diff --git a/.eslintrc.js b/.eslintrc.js index 15746edebf1..7a0080af03c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,7 +25,63 @@ module.exports = { "vue/require-prop-types": "off", "vue/require-default-prop": "off", "@typescript-eslint/no-non-null-assertion": "off", - "vue/max-attributes-per-line": "off" + "vue/max-attributes-per-line": "off", + "vue/multi-word-component-names": "off", + "vue/no-mutating-props": "warn", + "vue/no-unused-components": "warn", + "@typescript-eslint/ban-types": "warn", + "@typescript-eslint/no-var-requires": "warn", + "prefer-rest-params": "warn", + // Bug-catching rules: errors so the build fails when real bugs land. + "no-async-promise-executor": "error", + "no-case-declarations": "error", + // checkLoops off: intentional `while (true)` loops are allowed; constant + // conditions in `if`/ternaries are still caught. + "no-constant-condition": ["error", { "checkLoops": false }], + // allowEmptyCase: empty `case` grouping is intentional, not a fallthrough + // bug; a case with statements falling through is still caught. + "no-fallthrough": ["error", { "allowEmptyCase": true }], + "no-inner-declarations": "error", + "no-prototype-builtins": "error", + "no-unsafe-optional-chaining": "error", + "no-self-compare": "error", + "no-constructor-return": "error", + "no-template-curly-in-string": "error", + "array-callback-return": "error", + "vue/no-parsing-error": "error", + "vue/valid-next-tick": "error", + "vue/valid-template-root": "error", + // Likely-bug rules kept as warn: real signal, but too many existing hits + // (or occasional intentional uses) to fail CI on. + "eqeqeq": ["warn", "smart"], + "no-unused-expressions": ["warn", { "allowShortCircuit": true, "allowTernary": true, "allowTaggedTemplates": true }], + "no-return-assign": "warn", + "no-sequences": "warn", + // Style/code-smell rules: kept as warn so they don't block CI. + "no-empty": "warn", + "no-useless-catch": "warn", + "no-useless-escape": "warn", + "@typescript-eslint/no-namespace": "warn", + // Block plugin RCE via file:// URLs (CVE — see safeOpenExternal.ts). + // shell.openExternal must only be reached via safeOpenExternal so the + // http(s)-only protocol allowlist is enforced. + "no-restricted-syntax": [ + "error", + { + "selector": "MemberExpression[property.name='openExternal']", + "message": "Do not call shell.openExternal directly. Use safeOpenExternal() from @/background/lib/electron/safeOpenExternal so the URL protocol allowlist is enforced." + }, + { + // ssh2's generateKeyPairSync('ed25519') intermittently emits keys its + // own parseKey can't read (mscdex/ssh2#1390), causing flaky tests. + "selector": "CallExpression[callee.property.name='generateKeyPairSync'][arguments.0.value='ed25519']", + "message": "Do not use ssh2's generateKeyPairSync('ed25519') — it intermittently produces unparseable keys (mscdex/ssh2#1390). Use a static ed25519 fixture or ssh-keygen instead." + }, + { + "selector": "CallExpression[callee.name='generateKeyPairSync'][arguments.0.value='ed25519']", + "message": "Do not use ssh2's generateKeyPairSync('ed25519') — it intermittently produces unparseable keys (mscdex/ssh2#1390). Use a static ed25519 fixture or ssh-keygen instead." + } + ] }, "parser": "vue-eslint-parser", "parserOptions": { @@ -35,12 +91,45 @@ module.exports = { } }, "overrides": [ + { + // TypeScript handles undefined-identifier checking; eslint's no-undef + // produces false positives on type-only imports and contextBridge + // globals (platformInfo, etc.). Also off for .js since those interop + // with the same TS modules and globals. + "files": ["*.ts", "*.tsx", "*.vue", "*.js"], + "rules": { + "no-undef": "off" + } + }, { "files": [ "apps/**/tests/**/*.{j,t}s?(x)" ], "env": { "jest": true + }, + "rules": { + // Test fixtures embed shell scripts and config templates that + // legitimately contain `${...}` substitution syntax. + "no-template-curly-in-string": "off" + } + }, + { + // The single trusted entry point that wraps shell.openExternal. + "files": [ + "apps/studio/src/background/lib/electron/safeOpenExternal.ts" + ], + "rules": { + "no-restricted-syntax": "off" + } + }, + { + // Renders nothing by design; the empty template root is intentional. + "files": [ + "apps/studio/src/components/EmptyComponent.vue" + ], + "rules": { + "vue/valid-template-root": "off" } } ] diff --git a/.gitattributes b/.gitattributes index fbd4c27f0be..23782e5801c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,7 @@ dev/**/*.sql linguist-language=SQL *.surql linguist-generated + +# SSH test fixtures must not be CRLF-converted on Windows checkout - +# Pageant's OpenSSH key parser is strict and rejects CRLF-munged files. +apps/studio/tests/resources/ssh/pageant_test -text +apps/studio/tests/resources/ssh/pageant_test.pub -text diff --git a/.github/scripts/create_draft_release.js b/.github/scripts/create_draft_release.js deleted file mode 100644 index c7c2f96b490..00000000000 --- a/.github/scripts/create_draft_release.js +++ /dev/null @@ -1,46 +0,0 @@ - - -/** - * Create a draft release - * @param {Object} options - */ -module.exports = async({github, core}, owner, repo, tagName) => { - - const releases = await github.rest.repos.listReleases({ - owner, - repo - }); - - // NOTE(@day): for test releases - tagName = tagName.replace('test', 'v'); - - let uploadUrl; - let assetsUrl; - - const draftRelease = releases.data.find( - release => release.tag_name === tagName && release.draft - ); - - let finishedRelease = null - if (draftRelease) { - core.info(`Draft release with tag ${tagName} already exists.`); - finishedRelease = { data: draftRelease } - } else { - const newRelease = await github.rest.repos.createRelease({ - owner, - repo, - tag_name: tagName, - name: `Release ${tagName}`, - body: 'Description of the release', - draft: true, - prerelease: false, - }); - finishedRelease = newRelease - core.info(`Draft release created with tag ${tagName}: ${newRelease.data.html_url}`); - } - core.setOutput('upload_url', finishedRelease.data.upload_url); - core.setOutput('assets_url', finishedRelease.data.assets_url); - core.setOutput('id', finishedRelease.data.id) - core.setOutput('json', JSON.stringify(finishedRelease.data)) - -} diff --git a/.github/scripts/extract_channel.sh b/.github/scripts/extract_channel.sh index 407de1759dc..a383dc5c903 100755 --- a/.github/scripts/extract_channel.sh +++ b/.github/scripts/extract_channel.sh @@ -9,8 +9,9 @@ then exit 1 fi -# Extract version from package.json -VERSION=$(jq -r '.version' apps/studio/package.json) +# Version comes from $1 when given (release-published.yml passes the release +# tag minus the "v" prefix), otherwise from package.json (build-time usage). +VERSION="${1:-$(jq -r '.version' apps/studio/package.json)}" # Determine the release channel based on the version if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then diff --git a/.github/scripts/mirror_release_downloads.js b/.github/scripts/mirror_release_downloads.js new file mode 100644 index 00000000000..c66904202cb --- /dev/null +++ b/.github/scripts/mirror_release_downloads.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/* + * Mirror a published GitHub release into the downloads R2 bucket, and refresh + * the latest.json manifest the website resolves downloads from. + * + * Usage: node mirror_release_downloads.js + * + * Ordering matters: binaries upload first, the manifest last, so latest.json + * never references an object that isn't in the bucket yet. Uploads overwrite, + * so re-running (via workflow_dispatch) is safe and heals partial runs. + * + * Shells out to `gh` for release reads/downloads and `aws` for R2 uploads; + * both are preinstalled on GitHub runners. + * + * Required env: + * GH_TOKEN - GitHub token for release reads + * R2_BUCKET - target bucket name + * R2_ENDPOINT - S3-compatible endpoint URL + * PUBLIC_BASE_URL - public origin serving the bucket (custom domain) + * LATEST_TAG - tag GitHub reports as the latest release (resolved by + * the workflow before this script runs); latest.json is + * only written when it matches the mirrored tag + * AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY - R2 credentials + */ + +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const REPO = 'beekeeper-studio/beekeeper-studio' + +// Run a command, streaming its output to the log. +function run(cmd, args) { + execFileSync(cmd, args, { stdio: 'inherit' }) +} + +// Run a command and return its stdout as a string. +function capture(cmd, args) { + return execFileSync(cmd, args, { encoding: 'utf8' }).trim() +} + +// extension/arch/type must stay in lockstep with the website's matching logic +// (web repo: _assets/js/lib/github.js GithubAsset + controllers/download.js). +function assetEntry(asset, urlBase) { + const name = asset.name + const extension = name.split('.').pop() + + const arch = name.includes('arm64') || name.includes('aarch64') ? 'arm64' : 'x86_64' + + const type = extension === 'exe' && name.includes('portable') ? 'portable' : 'installer' + + return { + name, + size: asset.size, + content_type: asset.contentType, + extension, + arch, + type, + url: `${urlBase}/${encodeURIComponent(name)}`, + } +} + +function upload(file, dest, contentType, cacheControl) { + run('aws', [ + 's3', 'cp', file, dest, + '--endpoint-url', process.env.R2_ENDPOINT, + '--content-type', contentType, + '--cache-control', cacheControl, + ]) +} + +function main() { + const tag = process.argv[2] + if (!tag) { + console.error('usage: mirror_release_downloads.js ') + process.exit(1) + } + for (const name of ['GH_TOKEN', 'R2_BUCKET', 'R2_ENDPOINT', 'PUBLIC_BASE_URL', 'LATEST_TAG']) { + if (!process.env[name]) { + console.error(`missing required env: ${name}`) + process.exit(1) + } + } + + const { R2_BUCKET, PUBLIC_BASE_URL } = process.env + const prefix = `releases/${tag}` + + const release = JSON.parse(capture('gh', [ + 'release', 'view', tag, '--repo', REPO, + '--json', 'tagName,publishedAt,isDraft,isPrerelease,assets', + ])) + + if (release.isDraft) { + console.log(`${tag} is a draft; nothing to mirror.`) + return + } + + const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'mirror-release-')) + try { + console.log(`Found ${release.assets.length} assets on ${tag}:`) + for (const asset of release.assets) { + console.log(` - ${asset.name} (${asset.size} bytes)`) + } + + // Binaries never change once released - cache them hard. Each asset is + // downloaded, uploaded, and deleted individually: the log shows exactly + // how far a run got, and disk usage stays at one asset instead of the + // whole release. + release.assets.forEach((asset, i) => { + const progress = `[${i + 1}/${release.assets.length}] ${asset.name}` + const file = path.join(workdir, asset.name) + console.log(`${progress}: downloading`) + run('gh', ['release', 'download', tag, '--repo', REPO, '--dir', workdir, '--pattern', asset.name]) + console.log(`${progress}: uploading`) + upload( + file, + `s3://${R2_BUCKET}/${prefix}/${asset.name}`, + asset.contentType, + 'public, max-age=31536000, immutable' + ) + fs.rmSync(file) + console.log(`${progress}: done`) + }) + + // Only the release GitHub reports as latest may write the manifest. This + // one rule covers prereleases, re-published old versions, and + // near-simultaneous publishes: drafts and prereleases are never "latest", + // and whatever order concurrent runs finish in, the manifest converges on + // the canonical latest. The lookup itself happens in an earlier workflow + // step (so a bad API response fails the run before any downloads); this + // script only compares. + if (process.env.LATEST_TAG !== tag) { + console.log(`${tag} is not the latest release (${process.env.LATEST_TAG} is): assets mirrored, latest.json untouched.`) + return + } + + // electron-updater metadata (*.yml, *.blockmap) is mirrored above but + // excluded from the manifest - it is not a user-facing download. + const manifest = { + tag_name: release.tagName, + version: release.tagName.replace(/^v/, ''), + published_at: release.publishedAt, + assets: release.assets + .filter((asset) => !/\.(yml|blockmap)$/.test(asset.name)) + .map((asset) => assetEntry(asset, `${PUBLIC_BASE_URL}/${prefix}`)), + } + + const manifestFile = path.join(workdir, 'latest.json') + fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n') + upload(manifestFile, `s3://${R2_BUCKET}/latest.json`, 'application/json', 'public, max-age=60') + + console.log(`Mirrored ${tag} to ${prefix} and updated latest.json`) + } finally { + fs.rmSync(workdir, { recursive: true, force: true }) + } +} + +main() diff --git a/.github/scripts/notify_slack.sh b/.github/scripts/notify_slack.sh new file mode 100755 index 00000000000..6d8ebd27793 --- /dev/null +++ b/.github/scripts/notify_slack.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail + +# usage: notify_slack.sh +# env: SLACK_WEBHOOK - incoming webhook URL for the target channel +# +# Message is passed through jq so quoting/newlines can't break the payload. + +MESSAGE="${1:?usage: notify_slack.sh }" + +jq -n --arg text "$MESSAGE" '{text: $text}' \ + | curl -sf -X POST -H 'Content-Type: application/json' -d @- "$SLACK_WEBHOOK" diff --git a/.github/scripts/prepare_rpm_repo.sh b/.github/scripts/prepare_rpm_repo.sh new file mode 100755 index 00000000000..2260f45d47b --- /dev/null +++ b/.github/scripts/prepare_rpm_repo.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Prepare a signed RPM repository in a local directory. +# +# Pure local operation -- no network, no S3. Given a repo directory that already +# holds the existing repodata/ metadata (empty or absent on the first ever run) +# plus a list of new RPM files, this signs the new packages, drops them into +# their per-architecture subdirectories, and regenerates the repo metadata so it +# lists both the new packages and every previously published package. +# +# Existing packages are carried forward from the old metadata via +# --recycle-pkglist, so their .rpm files do NOT need to be present on disk. That +# is what lets the caller avoid downloading the (many GB and growing) back +# catalogue of packages on every release. +# +# Usage: prepare_rpm_repo.sh path/to/pkg1.rpm [path/to/pkg2.rpm ...] +# Env: GPG_KEY_ID GPG key id used to sign the packages and the metadata +set -euxo pipefail + +# Ensure required commands are installed +for cmd in createrepo_c gpg rpmsign rpm; do + if ! command -v "$cmd" &> /dev/null; then + echo "Error: $cmd is not installed. Please install it first." + exit 1 + fi +done + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 path/to/packages/*.rpm" + exit 1 +fi + +REPO_DIR="$1" +shift + +mkdir -p "$REPO_DIR/repodata" + +# List of the NEW packages published in this run. The already-published packages +# are picked up automatically from the existing metadata by --recycle-pkglist, so +# they never need to be listed (or present on disk) here. +PKGLIST=$(mktemp -t rpm-pkglist-XXXXXX) +trap 'rm -f "$PKGLIST"' EXIT + +# Process each RPM file +for RPM_FILE in "$@"; do + if [ ! -f "$RPM_FILE" ]; then + echo "Skipping invalid RPM: $RPM_FILE" + continue + fi + + echo "Processing RPM: $RPM_FILE" + + # Extract RPM architecture + RPM_ARCH=$(rpm -qp --queryformat "%{ARCH}" "$RPM_FILE") + + echo "Detected architecture: $RPM_ARCH" + + # Ensure architecture subdirectory exists + mkdir -p "$REPO_DIR/$RPM_ARCH" + + RPM_BASENAME=$(basename "$RPM_FILE") + + # Copy the RPM into the local repo + echo "Copying $RPM_FILE to repo..." + cp "$RPM_FILE" "$REPO_DIR/$RPM_ARCH/" + + # Sign the RPM with GPG + echo "Signing the RPM..." + rpmsign --addsign --key-id "$GPG_KEY_ID" "$REPO_DIR/$RPM_ARCH/$RPM_BASENAME" + + # Add the new package to the metadata pkglist (path is relative to the repo root) + echo "$RPM_ARCH/$RPM_BASENAME" >> "$PKGLIST" +done + +# Regenerate repository metadata (shared for all architectures). +# +# --update reuses the existing metadata for unchanged packages, so +# their .rpm files do not need to be re-read (or downloaded). +# --recycle-pkglist carries every already-published package forward from the old +# metadata, so nothing is dropped just because its file is not +# present locally. +# --pkglist adds the new packages published in this run (unioned with the +# recycled list above). +# --skip-stat stops createrepo_c from stat()-ing the (absent) existing +# package files during the cache lookup. +echo "Updating RPM repo metadata..." +createrepo_c --update --skip-stat --recycle-pkglist --pkglist "$PKGLIST" "$REPO_DIR/" + +# Sign the repository metadata +echo "Signing repomd.xml..." +gpg --detach-sign --armor --batch --yes --local-user "$GPG_KEY_ID" \ + --output "$REPO_DIR/repodata/repomd.xml.asc" "$REPO_DIR/repodata/repomd.xml" + +echo "Generating SHA256 checksum..." +pushd "$REPO_DIR/repodata" > /dev/null +sha256sum repomd.xml > repomd.xml.sha256 +popd > /dev/null + +echo "Repo prepared at $REPO_DIR" diff --git a/.github/scripts/publish_rpm.sh b/.github/scripts/publish_rpm.sh index 0b7e929699f..0b588f3f37d 100755 --- a/.github/scripts/publish_rpm.sh +++ b/.github/scripts/publish_rpm.sh @@ -1,9 +1,20 @@ #!/bin/bash +# Publish RPM package(s) to the R2-hosted yum repository. +# +# Three stages: +# 1. sync the existing repo metadata DOWN from R2 (metadata only, not packages) +# 2. prepare the repo locally -- sign + regenerate metadata (prepare_rpm_repo.sh) +# 3. sync the new packages and updated metadata UP to R2 +# +# The back catalogue of packages (many GB and growing) stays in R2 and is never +# downloaded onto the runner; only the metadata makes the round trip. set -euxo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # Ensure required commands are installed for cmd in aws createrepo_c gpg rpmsign rpm; do - if ! command -v $cmd &> /dev/null; then + if ! command -v "$cmd" &> /dev/null; then echo "Error: $cmd is not installed. Please install it first." exit 1 fi @@ -17,56 +28,28 @@ fi # Create a dynamic temporary directory for this run LOCAL_REPO_DIR=$(mktemp -d -t rpm-repo-XXXXXX) - -# Sync existing RPMs and metadata from R2 -echo "Syncing existing RPMs and metadata from R2..." -aws s3 sync "s3://$R2_BUCKET/" "$LOCAL_REPO_DIR/" --endpoint-url "$R2_ENDPOINT" - -# Process each RPM file -for RPM_FILE in "$@"; do - if [ ! -f "$RPM_FILE" ]; then - echo "Skipping invalid RPM: $RPM_FILE" - continue - fi - - echo "Processing RPM: $RPM_FILE" - - # Extract RPM architecture - RPM_ARCH=$(rpm -qp --queryformat "%{ARCH}" "$RPM_FILE") - - echo "Detected architecture: $RPM_ARCH" - - # Ensure architecture subdirectory exists - mkdir -p "$LOCAL_REPO_DIR/$RPM_ARCH" - - # Copy the RPM into the local repo - echo "Copying $RPM_FILE to repo..." - cp "$RPM_FILE" "$LOCAL_REPO_DIR/$RPM_ARCH/" - - # Sign the RPM with GPG - echo "Signing the RPM..." - rpmsign --addsign --key-id "$GPG_KEY_ID" "$LOCAL_REPO_DIR/$RPM_ARCH/$(basename "$RPM_FILE")" +trap 'rm -rf "$LOCAL_REPO_DIR"' EXIT + +# 1. Sync ONLY the existing repo metadata from R2 -- NOT the packages themselves. +echo "Syncing existing repo metadata from R2..." +aws s3 sync "s3://$R2_BUCKET/repodata/" "$LOCAL_REPO_DIR/repodata/" --endpoint-url "$R2_ENDPOINT" + +# 2. Sign the new packages and regenerate the repo metadata locally. +"$SCRIPT_DIR/prepare_rpm_repo.sh" "$LOCAL_REPO_DIR" "$@" + +# 3. Upload the new packages (existing ones already live in R2, untouched). Only +# the architecture dirs that received a new package exist locally, so this +# uploads just those. +for ARCH_DIR in "$LOCAL_REPO_DIR"/*/; do + ARCH=$(basename "$ARCH_DIR") + [ "$ARCH" = "repodata" ] && continue + echo "Uploading new $ARCH packages to R2..." + aws s3 sync "$ARCH_DIR" "s3://$R2_BUCKET/$ARCH/" --endpoint-url "$R2_ENDPOINT" done -# Regenerate repository metadata (shared for all architectures) -echo "Updating RPM repo metadata..." -createrepo_c --update "$LOCAL_REPO_DIR/" - -# Sign the repository metadata -echo "Signing repomd.xml..." -gpg --detach-sign --armor --batch --yes --local-user "$GPG_KEY_ID" \ - --output "$LOCAL_REPO_DIR/repodata/repomd.xml.asc" "$LOCAL_REPO_DIR/repodata/repomd.xml" - -echo "Generating SHA256 checksum..." -pushd "$LOCAL_REPO_DIR/repodata" > /dev/null -sha256sum repomd.xml > repomd.xml.sha256 -popd > /dev/null - -# Upload everything back to Cloudflare R2 -echo "Uploading updated RPMs and metadata to R2..." -aws s3 sync "$LOCAL_REPO_DIR/" "s3://$R2_BUCKET/" --endpoint-url "$R2_ENDPOINT" - -# Clean up -rm -rf "$LOCAL_REPO_DIR" +# Upload the regenerated metadata. --delete removes stale metadata files that the +# new repomd.xml no longer references (safe: repodata only holds generated files). +echo "Uploading updated repo metadata to R2..." +aws s3 sync "$LOCAL_REPO_DIR/repodata/" "s3://$R2_BUCKET/repodata/" --endpoint-url "$R2_ENDPOINT" --delete echo "All RPMs successfully uploaded, signed, and repo metadata updated!" diff --git a/.github/scripts/test_prepare_rpm_repo.sh b/.github/scripts/test_prepare_rpm_repo.sh new file mode 100755 index 00000000000..477eb483f97 --- /dev/null +++ b/.github/scripts/test_prepare_rpm_repo.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Self-contained test for prepare_rpm_repo.sh. +# +# Proves that publishing a new release keeps the previously published releases in +# the repo WITHOUT their .rpm files being present on disk -- the whole point of +# the incremental publish. +# +# No S3 and no network: prepare_rpm_repo.sh operates purely on local directories, +# so the test just hands it a directory directly. rpmsign/gpg are stubbed so no +# signing key is needed. +# +# Requires: createrepo_c, rpmbuild, rpm (Debian/Ubuntu: apt-get install createrepo-c rpm) +# Usage: bash .github/scripts/test_prepare_rpm_repo.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PREPARE="$SCRIPT_DIR/prepare_rpm_repo.sh" + +for cmd in createrepo_c rpmbuild rpm; do + command -v "$cmd" >/dev/null || { echo "Missing required tool: $cmd"; exit 1; } +done + +WORK=$(mktemp -d -t rpm-prepare-test-XXXXXX) +trap 'rm -rf "$WORK"' EXIT + +# --------------------------------------------------------------------------- +# 1. Build two tiny real RPMs (two separate releases). +# --------------------------------------------------------------------------- +build_rpm() { # version + local ver="$1" + cat > "$WORK/spec" </dev/null 2>&1 +} +build_rpm 5.1.0 +build_rpm 5.2.0 +RPMDIR="$WORK/rpmbuild/RPMS/x86_64" + +# --------------------------------------------------------------------------- +# 2. Stub the signing tools so no GPG key is required. +# --------------------------------------------------------------------------- +BIN="$WORK/bin"; mkdir -p "$BIN" +cat > "$BIN/rpmsign" <<'SIGN' +#!/bin/bash +echo "[stub rpmsign] $*" +SIGN +cat > "$BIN/gpg" <<'GPG' +#!/bin/bash +# emulate `--output FILE` by writing a dummy signature there +prev=""; out="" +for a in "$@"; do [ "$prev" = "--output" ] && out="$a"; prev="$a"; done +[ -n "$out" ] && echo "stub-signature" > "$out" +echo "[stub gpg] $*" +GPG +chmod +x "$BIN/rpmsign" "$BIN/gpg" + +# --------------------------------------------------------------------------- +# 3. Build the "existing repo" the sync-down step would leave on disk: only the +# repodata/ metadata for the already-published 5.1.0, and NOT its .rpm file. +# --------------------------------------------------------------------------- +REPO="$WORK/repo"; mkdir -p "$REPO/x86_64" +cp "$RPMDIR/beekeeper-studio-5.1.0-1.x86_64.rpm" "$REPO/x86_64/" +createrepo_c "$REPO" >/dev/null 2>&1 +# Drop the old package file: after a real `aws s3 sync ... repodata/` only the +# metadata is on disk, never the back catalogue of .rpm files. +rm -rf "$REPO/x86_64" + +# --------------------------------------------------------------------------- +# 4. Run the REAL prep script to publish 5.2.0 into that repo directory. +# --------------------------------------------------------------------------- +echo ">>> prepare_rpm_repo.sh: publishing 5.2.0 on top of existing 5.1.0 metadata..." +PATH="$BIN:$PATH" GPG_KEY_ID="STUBKEY" \ + bash "$PREPARE" "$REPO" "$RPMDIR/beekeeper-studio-5.2.0-1.x86_64.rpm" >/dev/null 2>&1 + +# --------------------------------------------------------------------------- +# 5. Assert the resulting metadata lists BOTH releases, each exactly once. +# --------------------------------------------------------------------------- +REF=$(grep -o 'repodata/[a-f0-9]*-primary\.xml\.gz' "$REPO/repodata/repomd.xml" | head -1) +LOCS=$(zcat "$REPO/$REF" | grep -o '>> Packages in the prepared repo metadata:" +echo "$LOCS" | sed 's/^/ /' + +expected=$(printf '%s\n' \ + "x86_64/beekeeper-studio-5.1.0-1.x86_64.rpm" \ + "x86_64/beekeeper-studio-5.2.0-1.x86_64.rpm" | sort) +count=$(echo "$LOCS" | grep -c .) + +echo +if [ "$LOCS" = "$expected" ] && [ "$count" -eq 2 ]; then + echo "PASS: prepared repo lists both releases (old 5.1.0 carried forward without its .rpm on disk)." + exit 0 +else + echo "FAIL: expected 2 releases:" + echo "$expected" | sed 's/^/ /' + exit 1 +fi diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 6faa32c95f2..00000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@beta - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - allowed_tools: Bash(yarn install),Bash(yarn electron:build --linux AppImage),Bash(yarn test:unit),Bash(yarn test:integration --run-in-band) - - diff --git a/.github/workflows/create_tag.yml b/.github/workflows/create_tag.yml deleted file mode 100644 index bdcc6f14ef7..00000000000 --- a/.github/workflows/create_tag.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Create A Tag (and thus a release) of Beekeeper Studio - -permissions: - contents: write - -on: - workflow_dispatch: - inputs: - version: - description: 'What version do you want to release (no v)? Check the latest version on releases first please. Beta example: x.x.x-beta.x' - required: true - default: 0.0.1 - type: string - ref: - description: What branch/github ref do you want to build the release from? - required: true - default: master - type: string - sure: - description: Are you REALLY sure you want to make a release? - required: true - type: boolean - - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - ref: ${{ github.event.inputs.ref }} - - - name: Set up Git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Validate Version Format - id: validate_version - shell: bash - env: - VERSION: ${{ github.event.inputs.version }} - run: | - # Ensure version does not start with 'v' - if [[ "$VERSION" =~ ^v ]]; then - echo "Error: Version should not start with 'v'" - exit 1 - fi - - # Validate version format - if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - CHANNEL="none" - elif [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta)\.[0-9]+$ ]]; then - CHANNEL="${BASH_REMATCH[2]}" - else - echo "Error: Invalid version format. Expected x.x.x or x.x.x-alpha.x or x.x.x-beta.x" - exit 1 - fi - - echo "Version is valid: $VERSION" - - - name: Tag name - id: identify_tag - env: - VERSION: ${{github.event.inputs.version}} - run: | - echo "tag_name=v$VERSION" >> $GITHUB_OUTPUT - - - name: Update version in package.json - env: - VERSION: ${{ github.event.inputs.version }} - run: | - # Update version in package.json - jq '.version = "'"$VERSION"'"' apps/studio/package.json > tmp.json && mv tmp.json apps/studio/package.json - - - name: Commit version change - run: | - git add apps/studio/package.json - git commit -m "Update version to ${{ github.event.inputs.version }}" - - - name: Fetch tags - run: git fetch --tags - - - name: Create tag - run: | - git tag "${{ steps.identify_tag.outputs.tag_name }}" - - - name: Push changes and tag - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - git push origin "${{ steps.identify_tag.outputs.tag_name }}" diff --git a/.github/workflows/delete-draft-releases.yml b/.github/workflows/delete-draft-releases.yml index 343ec60e118..557974a6fc1 100644 --- a/.github/workflows/delete-draft-releases.yml +++ b/.github/workflows/delete-draft-releases.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: Delete all draft releases - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const { data: releases } = await github.rest.repos.listReleases({ diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index a6a4ff9e962..8bcdb773145 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -16,10 +16,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13.7' # or any specific version cache: 'pip' # caching pip dependencies diff --git a/.github/workflows/e2e-smoke-test.yml b/.github/workflows/e2e-smoke-test.yml new file mode 100644 index 00000000000..9e474765ad3 --- /dev/null +++ b/.github/workflows/e2e-smoke-test.yml @@ -0,0 +1,129 @@ +name: E2E Smoke Test (Cross-Platform) + +permissions: + contents: read + +on: + pull_request: + paths-ignore: + - apps/sqltools/** + - docs/** + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-smoke: + name: E2E Smoke - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: Install Python 3.11 (non-Mac) + uses: actions/setup-python@v6 + with: + python-version: 3.11 + if: runner.os != 'macOS' + + - name: Install Python 3.11 (Mac) + if: runner.os == 'macOS' + run: | + brew install python@3.11 + echo "/opt/homebrew/opt/python@3.11/libexec/bin" >> $GITHUB_PATH + + - name: Install Node.js, NPM and Yarn + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: yarn + + # ImageOS identifies the runner image (e.g. ubuntu24, win22, macos15), so + # caches never cross runner image versions: node_modules holds native + # modules compiled for one exact image. + - name: Compute runner image cache id + shell: bash + run: echo "RUNNER_IMAGE_ID=${ImageOS:-unknown}" >> "$GITHUB_ENV" + + - name: Cache node_modules + id: cache-nm + uses: actions/cache@v5 + with: + path: | + node_modules + apps/studio/node_modules + apps/ui-kit/node_modules + apps/sqltools/node_modules + key: node-modules-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} + + - name: Install dependencies + if: steps.cache-nm.outputs.cache-hit != 'true' + uses: nick-fields/retry@v2 + with: + timeout_minutes: 20 + max_attempts: 3 + command: yarn install --frozen-lockfile --network-timeout 100000 + env: + HUSKY: "0" + npm_config_node_gyp: ${{ github.workspace }}/node_modules/node-gyp/bin/node-gyp.js + + - name: Install xvfb-maybe + run: npm install -g xvfb-maybe + + - name: Cache Playwright browsers + id: cache-playwright + uses: actions/cache@v5 + with: + path: | + ~/.cache/ms-playwright + ~/Library/Caches/ms-playwright + ~/AppData/Local/ms-playwright + key: playwright-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('yarn.lock') }} + + - name: Install Playwright browsers + if: steps.cache-playwright.outputs.cache-hit != 'true' + run: yarn playwright install chromium + + - name: Install Playwright system dependencies (Linux) + if: runner.os == 'Linux' + run: yarn playwright install-deps chromium + + - name: Build UI Kit + run: yarn lib:build + + - name: Build App + run: yarn workspace beekeeper-studio build + + - name: Run Tests (Linux/macOS) + if: runner.os != 'Windows' + shell: bash + run: | + xvfb-maybe -a -s "-screen 0 1024x768x24" -- bash -c ' + yarn workspace beekeeper-studio test:e2e:smoke + ' + env: + ELECTRON_ENABLE_LOGGING: 1 + ELECTRON_DISABLE_SANDBOX: 1 + ELECTRON_EXTRA_LAUNCH_ARGS: "--disable-gpu" + + - name: Run Tests (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + yarn workspace beekeeper-studio test:e2e:smoke + env: + ELECTRON_ENABLE_LOGGING: 1 + + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v6 + with: + name: test-results-${{ matrix.os }} + path: apps/studio/test-results + retention-days: 7 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 00000000000..0b4375a3c51 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,140 @@ +name: E2E Tests + +permissions: + contents: read + actions: write + +on: + push: + branches: + - master + paths-ignore: + - docs/** + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + prep: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + + - name: Install Node.js, NPM and Yarn + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + # ImageOS identifies the runner image (e.g. ubuntu22, ubuntu24), so caches + # never cross runner image versions: node_modules holds native modules + # compiled for one exact image. + - name: Compute runner image cache id + shell: bash + run: echo "RUNNER_IMAGE_ID=${ImageOS:-unknown}" >> "$GITHUB_ENV" + + - name: Cache node_modules + id: cache-nm + uses: actions/cache@v5 + with: + path: | + node_modules + apps/studio/node_modules + apps/ui-kit/node_modules + apps/sqltools/node_modules + key: node-modules-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} + + - name: Install dependencies + if: steps.cache-nm.outputs.cache-hit != 'true' + uses: nick-fields/retry@v2 + with: + timeout_minutes: 20 + max_attempts: 3 + command: yarn install --frozen-lockfile --network-timeout 100000 + env: + HUSKY: "0" + npm_config_node_gyp: ${{ github.workspace }}/node_modules/node-gyp/bin/node-gyp.js + + - name: Cache Playwright browsers + id: cache-playwright + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('yarn.lock') }} + + - name: Install Playwright browsers + if: steps.cache-playwright.outputs.cache-hit != 'true' + run: yarn playwright install chromium + + e2e-tests: + name: E2E Tests + runs-on: ubuntu-24.04 + needs: [prep] + + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: Install Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: 3.11 + + - name: Install Node.js, NPM and Yarn + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: Compute runner image cache id + shell: bash + run: echo "RUNNER_IMAGE_ID=${ImageOS:-unknown}" >> "$GITHUB_ENV" + + - name: Restore node_modules cache + uses: actions/cache/restore@v5 + with: + path: | + node_modules + apps/studio/node_modules + apps/ui-kit/node_modules + apps/sqltools/node_modules + key: node-modules-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} + fail-on-cache-miss: true + + - name: Restore Playwright browsers cache + uses: actions/cache/restore@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('yarn.lock') }} + fail-on-cache-miss: true + + - name: Install Playwright system dependencies + run: yarn playwright install-deps chromium + + - name: Build UI Kit + run: yarn lib:build + + - name: Build App + run: yarn workspace beekeeper-studio build + + - name: Start postgres container + run: docker compose up psql15 -d --wait + + - name: Run Tests + shell: bash + run: | + xvfb-run --auto-servernum --server-args "-screen 0 1600x1000x24" bash -c ' + yarn workspace beekeeper-studio test:e2e:ci + ' + env: + ELECTRON_ENABLE_LOGGING: 1 + ELECTRON_DISABLE_SANDBOX: 1 + ELECTRON_EXTRA_LAUNCH_ARGS: "--disable-gpu" + + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v6 + with: + name: test-results + path: apps/studio/test-results + retention-days: 7 diff --git a/.github/workflows/mongodb-kerberos-tests.yaml b/.github/workflows/mongodb-kerberos-tests.yaml new file mode 100644 index 00000000000..aec3c757b94 --- /dev/null +++ b/.github/workflows/mongodb-kerberos-tests.yaml @@ -0,0 +1,45 @@ +name: MongoDB Kerberos integration test + +permissions: + contents: read + +# Heavy multi-container job (Samba AD DC + MongoDB Enterprise + a dockerized Jest client), +# so it runs only when the MongoDB client/GSSAPI code or this suite changes, plus on demand. +# The companion spec self-skips in the normal integration matrix unless MONGODB_KERBEROS_TEST=1. +# +# The entire environment lives in Docker (see dev/docker_mongodb_kerberos/run.sh), so this +# job needs nothing on the runner except Docker -- it runs the exact same command a developer +# runs locally. +on: + push: + branches: + - master + paths: + - 'apps/studio/src-commercial/backend/lib/db/clients/mongodb.ts' + - 'apps/studio/tests/integration/lib/db/clients/mongodb-kerberos.spec.ts' + - 'dev/docker_mongodb_kerberos/**' + - '.github/workflows/mongodb-kerberos-tests.yaml' + pull_request: + paths: + - 'apps/studio/src-commercial/backend/lib/db/clients/mongodb.ts' + - 'apps/studio/tests/integration/lib/db/clients/mongodb-kerberos.spec.ts' + - 'dev/docker_mongodb_kerberos/**' + - '.github/workflows/mongodb-kerberos-tests.yaml' + workflow_dispatch: + +# Only the latest commit per ref matters; cancel superseded in-progress runs. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mongodb-kerberos: + runs-on: ubuntu-24.04 + # Tight enough that a stalled Kerberos handshake surfaces in minutes, not an hour. + timeout-minutes: 40 + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Run the dockerized Kerberos integration test + run: bash dev/docker_mongodb_kerberos/run.sh diff --git a/.github/workflows/rc-branch-prs.yml b/.github/workflows/rc-branch-prs.yml new file mode 100644 index 00000000000..27c9db02e00 --- /dev/null +++ b/.github/workflows/rc-branch-prs.yml @@ -0,0 +1,49 @@ +name: Open PRs for RC branches + +# Release candidate (rc-*) branches sometimes get fixes that we always want +# back in master. This checks every rc-* branch daily and opens a PR into +# master for any that contain commits master doesn't already have. +# +# The actual logic lives in bin/rc-prs/ so it can be run and tested locally: +# bin/rc-prs/sync-rc-prs.sh # do it for real +# DRY_RUN=1 bin/rc-prs/sync-rc-prs.sh # show what it would do + +permissions: + contents: read + pull-requests: write + +on: + schedule: + # Daily at 06:00 UTC + - cron: '0 6 * * *' + workflow_dispatch: + inputs: + dry_run: + description: "Dry run (report what would happen, don't open PRs)" + type: boolean + default: false + +jobs: + open-rc-prs: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Open PRs for rc-* branches with commits not in master + id: sync + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run && '1' || '' }} + run: bin/rc-prs/sync-rc-prs.sh + + - name: Report results + if: always() && steps.sync.outputs.checked_count != '' + run: | + echo "Checked ${{ steps.sync.outputs.checked_count }} rc-* branch(es)" + echo "Opened ${{ steps.sync.outputs.opened_count }} PR(s)" + echo "Skipped (PR already open): ${{ steps.sync.outputs.existing_count }}" + echo "Skipped (in sync): ${{ steps.sync.outputs.not_ahead_count }}" + echo "Errors: ${{ steps.sync.outputs.error_count }}" diff --git a/.github/workflows/release-published.yml b/.github/workflows/release-published.yml new file mode 100644 index 00000000000..289309fee48 --- /dev/null +++ b/.github/workflows/release-published.yml @@ -0,0 +1,257 @@ +name: Release - Publish Channels + +# Runs when a draft release is published (studio-publish.yml only builds and +# uploads assets to a draft). Everything user-facing happens here, gated on +# the manual publish click: +# - mirror release assets to the downloads R2 bucket + refresh latest.json +# (the manifest the website resolves downloads from) +# - publish deb/rpm to the apt/rpm repos +# - upload the snap to the store +# +# Slack: posts to #appevents when publishing starts and succeeds, and to +# #production-alerts when any job fails (secrets slack_appevents_webhook / +# slack_production_alerts_webhook, one incoming-webhook URL per channel). +# +# workflow_dispatch re-runs any of it idempotently for a given tag - use it to +# backfill the mirror or heal a partial run. The checkboxes select which +# publish paths run; a release event always runs all of them. + +permissions: + contents: read + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish (e.g. v5.4.2)" + required: true + mirror: + description: "Mirror downloads to R2 + refresh latest.json" + type: boolean + default: true + repositories: + description: "Publish deb/rpm repositories" + type: boolean + default: true + snap: + description: "Publish snap to the store" + type: boolean + default: true + +# Serialize near-simultaneous publishes. The mirror script's latest-release +# guard makes any completion order converge on GitHub's canonical latest. +concurrency: + group: release-published + cancel-in-progress: false + +env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + +jobs: + notify_start: + runs-on: ubuntu-24.04 + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + - name: Notify Slack (publishing started) + run: > + bash ./.github/scripts/notify_slack.sh + ":hourglass_flowing_sand: Publishing release \`$TAG\` — <$RUN_URL|workflow run>" + env: + SLACK_WEBHOOK: ${{ secrets.slack_appevents_webhook }} + + # Channel comes from the tag itself (v1.2.3 -> latest, v1.2.3-beta.1 -> beta) + # rather than the release's prerelease checkbox, so a mis-flagged publish + # can't ship a beta to stable channels. + identify_channel: + runs-on: ubuntu-24.04 + outputs: + channel: ${{ steps.extract_channel.outputs.channel }} + deb_codename: ${{ steps.extract_channel.outputs.deb_codename }} + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + - name: Extract channel from tag + id: extract_channel + run: bash ./.github/scripts/extract_channel.sh "${TAG#v}" + + mirror_downloads: + if: github.event_name == 'release' || inputs.mirror + runs-on: ubuntu-24.04 + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + # Resolved before mirroring so a bad API response fails the run early, + # and the mirror script itself only mirrors. latest.json is written only + # when the published tag matches this. + - name: Determine latest release + id: latest_release + run: | + latest=$(gh api repos/beekeeper-studio/beekeeper-studio/releases/latest --jq '.tag_name') + echo "Latest release: $latest" + echo "tag=$latest" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Mirror release assets to R2 + run: node ./.github/scripts/mirror_release_downloads.js "$TAG" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LATEST_TAG: ${{ steps.latest_release.outputs.tag }} + R2_BUCKET: beekeeper-downloads + R2_ENDPOINT: ${{ secrets.cloudflare_endpoint }} + PUBLIC_BASE_URL: https://downloads.beekeeperstudio.io + AWS_ACCESS_KEY_ID: "${{ secrets.cloudflare_key_id }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.cloudflare_secret_access_key }}" + AWS_DEFAULT_REGION: "us-east-1" + # 2025-01-15: AWS made changes to their clis that broke compatibility + # for third party services. + # This works around that. + # https://github.com/aws/aws-sdk-ruby/issues/3166 + AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED + AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED + + publish_repositories: + needs: identify_channel + if: (github.event_name == 'release' || inputs.repositories) && needs.identify_channel.outputs.channel == 'latest' + runs-on: ubuntu-24.04 + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.2 + + - name: Install dependencies for rpm deployment + run: sudo apt-get update && sudo apt-get install -y createrepo-c + + - name: Install deb-s3 from GitHub + run: | + curl -sL https://github.com/deb-s3/deb-s3/releases/download/0.11.8/deb-s3-0.11.8.gem -o ./deb-s3.gem + gem install -N ./deb-s3.gem + + - name: Import GPG key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@v3 + with: + gpg-private-key: ${{ secrets.gpg_key }} + + - name: Download release packages + run: | + rm -rf ./artifacts; mkdir artifacts + gh release download "$TAG" --repo beekeeper-studio/beekeeper-studio \ + --pattern '*.deb' --pattern '*.rpm' --dir ./artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish DEB to R2 + run: | + deb-s3 upload $(find ./artifacts -type f -name "*.deb") \ + --bucket=beekeeper-deb-repo \ + --codename=${{ needs.identify_channel.outputs.deb_codename }} \ + --endpoint=${{ secrets.cloudflare_endpoint }} \ + --lock \ + --sign=${{ steps.import_gpg.outputs.keyid }} \ + --preserve-versions + env: + AWS_ACCESS_KEY_ID: "${{ secrets.cloudflare_key_id }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.cloudflare_secret_access_key }}" + AWS_DEFAULT_REGION: "us-east-1" + # 2025-01-15: AWS made changes to their clis that broke compatibility + # for third party services. + # This works around that. + # https://github.com/aws/aws-sdk-ruby/issues/3166 + AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED + AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED + + - name: Publish RPM to R2 + # publish_rpm.sh syncs the repo metadata down, hands off to + # prepare_rpm_repo.sh to sign the new packages and rebuild the metadata, + # then syncs the new packages + metadata back up. The repo-building step + # has an offline test that needs no S3/credentials: + # bash .github/scripts/test_prepare_rpm_repo.sh + run: | + .github/scripts/publish_rpm.sh $(find ./artifacts -type f -name "*.rpm") + env: + GPG_KEY_ID: "${{ steps.import_gpg.outputs.keyid }}" + R2_BUCKET: "beekeeper-rpm-repo/repo" + R2_ENDPOINT: ${{ secrets.cloudflare_endpoint }} + AWS_ACCESS_KEY_ID: "${{ secrets.cloudflare_key_id }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.cloudflare_secret_access_key }}" + # 2025-01-15: AWS made changes to their clis that broke compatibility + # for third party services. + # This works around that. + # https://github.com/aws/aws-sdk-ruby/issues/3166 + AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED + AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED + + publish_snapcraft: + needs: identify_channel + if: github.event_name == 'release' || inputs.snap + runs-on: ubuntu-latest + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} + steps: + - name: Install Snapcraft + uses: samuelmeuli/action-snapcraft@v3 + + - name: Download release snaps + run: | + rm -rf ./artifacts; mkdir artifacts + gh release download "$TAG" --repo beekeeper-studio/beekeeper-studio \ + --pattern '*.snap' --dir ./artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Every published release goes to edge; the stable channel is promoted + # separately below only for the latest channel. + - name: Upload snaps to edge + run: | + snaps=$(find ./artifacts -type f -name "*.snap") + if [ -z "$snaps" ]; then + echo "No .snap files found in release assets" >&2 + exit 1 + fi + for snap in $snaps; do + echo "Uploading $snap to latest/edge" + snapcraft upload "$snap" --release latest/edge + done + + - name: Move release snap from edge to stable + if: needs.identify_channel.outputs.channel == 'latest' + continue-on-error: true + run: | + snapcraft promote beekeeper-studio --from-channel "latest/edge" --to-channel "latest/stable" --yes + + notify_result: + needs: [identify_channel, mirror_downloads, publish_repositories, publish_snapcraft] + if: always() + runs-on: ubuntu-24.04 + env: + RESULTS: "identify_channel:${{ needs.identify_channel.result }} mirror_downloads:${{ needs.mirror_downloads.result }} publish_repositories:${{ needs.publish_repositories.result }} publish_snapcraft:${{ needs.publish_snapcraft.result }}" + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: Notify Slack (publishing failed) + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: | + failed=$(tr ' ' '\n' <<<"$RESULTS" | grep -vE ':(success|skipped)$' | xargs echo) + bash ./.github/scripts/notify_slack.sh ":rotating_light: Release publishing FAILED for \`$TAG\` ($failed) — <$RUN_URL|workflow run>" + env: + SLACK_WEBHOOK: ${{ secrets.slack_production_alerts_webhook }} + + # skipped counts as success: publish_repositories is skipped on purpose + # for beta/alpha releases. + - name: Notify Slack (publishing succeeded) + if: ${{ !(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }} + run: > + bash ./.github/scripts/notify_slack.sh + ":white_check_mark: Release \`$TAG\` published — downloads mirror and channels updated. <$RUN_URL|workflow run>" + env: + SLACK_WEBHOOK: ${{ secrets.slack_appevents_webhook }} diff --git a/.github/workflows/sqlserver-kerberos-tests.yaml b/.github/workflows/sqlserver-kerberos-tests.yaml new file mode 100644 index 00000000000..aa34d38933f --- /dev/null +++ b/.github/workflows/sqlserver-kerberos-tests.yaml @@ -0,0 +1,44 @@ +name: SQL Server Kerberos integration test + +permissions: + contents: read + +# Heavy multi-container job (Samba AD DC + SQL Server on Linux + a dockerized Jest client), +# so it runs only when the integrated-auth code or this suite changes, plus on demand. The +# companion spec self-skips in the normal integration matrix unless SQLSERVER_KERBEROS_TEST=1. +# +# The entire environment lives in Docker (see dev/docker_sqlserver_kerberos/run.sh), so this +# job needs nothing on the runner except Docker -- it runs the exact same command a developer +# runs locally. +on: + # Always run on master so the suite is exercised on every merge, regardless of + # which files changed. PRs are path-filtered below to avoid spending the heavy + # multi-container job on unrelated changes. + push: + branches: + - master + pull_request: + paths: + - 'apps/studio/src/lib/db/clients/sqlserver.ts' + - 'apps/studio/src/lib/db/types.ts' + - 'apps/studio/tests/integration/lib/db/clients/sqlserver-kerberos.spec.ts' + - 'dev/docker_sqlserver_kerberos/**' + - '.github/workflows/sqlserver-kerberos-tests.yaml' + workflow_dispatch: + +# Only the latest commit per ref matters; cancel superseded in-progress runs. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + sqlserver-kerberos: + runs-on: ubuntu-24.04 + # Tight enough that a stalled Kerberos handshake surfaces in minutes, not an hour. + timeout-minutes: 40 + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: Run the dockerized Kerberos integration test + run: bash dev/docker_sqlserver_kerberos/run.sh diff --git a/.github/workflows/studio-build-non-production.yml b/.github/workflows/studio-build-non-production.yml index e792d7edf06..7b4c906c829 100644 --- a/.github/workflows/studio-build-non-production.yml +++ b/.github/workflows/studio-build-non-production.yml @@ -25,32 +25,33 @@ jobs: strategy: matrix: - os: [macos-14, ubuntu-22.04, windows-2022, ubuntu-arm64] + os: [macos-14, ubuntu-22.04, windows-2022, ubuntu-22.04-arm] steps: - name: Check out Git repository - uses: actions/checkout@v1 + uses: actions/checkout@v5 - name: Install Snapcraft - uses: samuelmeuli/action-snapcraft@v2 - if: "contains(matrix.os, 'ubuntu') && !contains(matrix.os, 'arm')" + uses: samuelmeuli/action-snapcraft@v3 + if: "contains(matrix.os, 'ubuntu')" - name: Install python 3.11 (not Mac) - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.11 if: "!startsWith(matrix.os, 'macos')" - name: Install flatpak tools - if: "contains(matrix.os, 'ubuntu') && !contains(matrix.os, 'arm')" + if: "contains(matrix.os, 'ubuntu')" run: | - sudo apt install -y flatpak flatpak-builder rpm + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder rpm flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo - name: Install fpm run: sudo gem install fpm -v 1.17.0 - if: "contains(matrix.os, 'ubuntu') && !contains(matrix.os, 'arm')" + if: "contains(matrix.os, 'ubuntu')" - name: Install python 3.11 (mac) if: "startsWith(matrix.os, 'macos')" @@ -84,20 +85,27 @@ jobs: fi - name: Install Node.js, NPM and Yarn - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' + # ImageOS identifies the runner image (e.g. ubuntu22, win22, macos14), so + # caches never cross runner image versions: node_modules holds native + # modules compiled for one exact image. + - name: Compute runner image cache id + shell: bash + run: echo "RUNNER_IMAGE_ID=${ImageOS:-unknown}" >> "$GITHUB_ENV" + - name: Cache node_modules id: cache-nm - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | node_modules apps/studio/node_modules apps/ui-kit/node_modules apps/sqltools/node_modules - key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} + key: node-modules-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} - name: Clean cache if: steps.cache-nm.outputs.cache-hit != 'true' @@ -128,7 +136,7 @@ jobs: USE_SYSTEM_FPM: true - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ matrix.os }} path: | @@ -147,10 +155,10 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v1 + uses: actions/checkout@v5 - name: Download DEB artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: ubuntu-22.04 path: ./deb-package diff --git a/.github/workflows/studio-publish.yml b/.github/workflows/studio-publish.yml index 551852a2f4a..e9d9323b51a 100644 --- a/.github/workflows/studio-publish.yml +++ b/.github/workflows/studio-publish.yml @@ -1,8 +1,9 @@ name: Studio - Build & Publish permissions: - contents: read + contents: write # for the softprops action actions: write + id-token: write on: push: @@ -14,42 +15,18 @@ jobs: create_draft_release: runs-on: "ubuntu-22.04" outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} - assets_url: ${{ steps.create_release.outputs.assets_url }} id: ${{ steps.create_release.outputs.id }} - json: ${{ steps.create_release.outputs.json }} - azure_client_id: ${{ steps.azure_creds.outputs.client_id }} - azure_tenant_id: ${{ steps.azure_creds.outputs.tenant_id }} - azure_keyvault_url: ${{ steps.azure_creds.outputs.keyvault_url }} steps: - name: Check out Git repository - uses: actions/checkout@v1 + uses: actions/checkout@v5 - # Requires us to use a github token with more power (to create drafts on another repo) - - name: Create or check draft release + - name: Create draft release id: create_release - uses: actions/github-script@v7 - env: - TAG_NAME: ${{ github.ref_name }} - OWNER: 'beekeeper-studio' - REPO: 'beekeeper-studio' + uses: softprops/action-gh-release@v3 with: - github-token: ${{ secrets.GH_DEPLOY_TOKEN }} - script: | - const script = require('./.github/scripts/create_draft_release.js') - await script({github, context, core}, process.env.OWNER, process.env.REPO, process.env.TAG_NAME) + draft: true + generate_release_notes: true - - name: Extract Azure credentials from existing secrets - id: azure_creds - env: - KEYVAULT_AUTH: "${{secrets.keyvault_auth}}" - run: | - CLIENT_ID=$(echo "$KEYVAULT_AUTH" | jq -r '.id') - TENANT_ID=$(echo "$KEYVAULT_AUTH" | jq -r '.tenant') - KEYVAULT_URL=$(echo "$KEYVAULT_AUTH" | jq -r '.url') - echo "client_id=$CLIENT_ID" >> $GITHUB_OUTPUT - echo "tenant_id=$TENANT_ID" >> $GITHUB_OUTPUT - echo "keyvault_url=$KEYVAULT_URL" >> $GITHUB_OUTPUT # electron-builder comes built in with channels -- latest, beta, alpha. # To support these for deb, rpm, and snap, we need to extract the channel from the package version # outputs: latest, beta, alpha @@ -61,7 +38,7 @@ jobs: deb_codename: ${{steps.extract_channel.outputs.deb_codename}} steps: - name: Check out Git repository - uses: actions/checkout@v1 + uses: actions/checkout@v5 - name: Extract Channel from package.json id: extract_channel run: bash ./.github/scripts/extract_channel.sh @@ -72,8 +49,6 @@ jobs: outputs: mac_x64_yml: ${{ steps.set_yaml.outputs.mac_x64_yml }} mac_arm64_yml: ${{ steps.set_yaml.outputs.mac_arm64_yml }} - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} strategy: fail-fast: false @@ -89,11 +64,12 @@ jobs: arch: x64 type: windows setup_python: true - - name: ubuntu-arm64 + - name: ubuntu-22.04-arm arch: arm64 type: linux setup_python: true - clean_zfs: true + setup_ruby: true + setup_flatpak: true - name: macos-14-large arch: x64 type: macos @@ -102,34 +78,52 @@ jobs: type: macos steps: - - name: Check out Git repository - uses: actions/checkout@v1 + # core24 snaps always run a full `snapcraft pack --use-lxd` build, so every + # Linux runner needs an initialised LXD (previously only arm64 required it). + - name: setup LXD + uses: canonical/setup-lxd@main + if: matrix.os.type == 'linux' - # Oh the hacks I put in place for the snap build - # Why does it fill up zfs? I don't understand what it does at all - - name: Free Up ZFS Space - run: | - # Force unmount any snapcraft-related ZFS datasets that are busy - sudo zfs list -H -o name | grep "snapcraft" | sort -r | xargs -I{} sudo zfs unmount -f {} 2>/dev/null || true - # Destroy datasets (clones) first, deepest children first - sudo zfs list -H -o name | grep "snapcraft" | sort -r | xargs -I{} sudo zfs destroy -rR {} 2>/dev/null || true - # Then destroy any remaining snapshots - sudo zfs list -H -o name -t snapshot | grep "snapcraft" | xargs -I{} sudo zfs destroy -rR {} 2>/dev/null || true - if: matrix.os.clean_zfs + - name: Check out Git repository + uses: actions/checkout@v5 - name: Install flatpak tools - if: matrix.os.type == 'linux' && matrix.os.arch != 'arm64' + if: matrix.os.setup_flatpak run: bash ./.github/scripts/install-build-deps.sh - - name: Install azuresigntool - run: 'dotnet tool install --global AzureSignTool --version 7.0.1' + - name: Set up Java (for jsign) if: matrix.os.type == 'windows' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' - - name: Azure Login + - name: Install jsign if: matrix.os.type == 'windows' - uses: azure/login@v2 - with: - creds: '{"clientId":"${{ needs.create_draft_release.outputs.azure_client_id }}","clientSecret":"${{ secrets.keyvault_auth_secret }}","subscriptionId":"${{ secrets.azure_subscription_id }}","tenantId":"${{ needs.create_draft_release.outputs.azure_tenant_id }}"}' + shell: powershell + env: + JSIGN_VERSION: '7.4' + JSIGN_SHA256: '2abf2ade9ea322acc2d60c24794eadc465ff9380938fca4c932d09e0b25f1c28' + run: | + $jar = "$env:RUNNER_TEMP\jsign.jar" + Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/$env:JSIGN_VERSION/jsign-$env:JSIGN_VERSION.jar" -OutFile $jar + $expected = ($env:JSIGN_SHA256 -replace '\s','').ToLower() + $actual = (Get-FileHash $jar -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + Write-Error "jsign checksum mismatch: expected $expected got $actual" + exit 1 + } + "JSIGN_JAR=$jar" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Decode Windows code signing certificate + if: matrix.os.type == 'windows' + shell: powershell + env: + WIN_CODESIGN_CERT: ${{ secrets.win_codesign_cert }} + run: | + $cert = "$env:RUNNER_TEMP\codesign-cert.pem" + [IO.File]::WriteAllBytes($cert, [Convert]::FromBase64String($env:WIN_CODESIGN_CERT)) + "WIN_CERT_FILE=$cert" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - uses: ruby/setup-ruby@v1 with: @@ -144,7 +138,7 @@ jobs: env: OWNER: 'beekeeper-studio' REPO: 'beekeeper-studio' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const fs = require('fs') @@ -168,7 +162,7 @@ jobs: - name: "Install python 3.11 (NOT Mac)" - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.11 if: matrix.os.setup_python @@ -222,35 +216,23 @@ jobs: fi - name: Install Node.js, NPM and Yarn - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - - name: Cache node_modules - id: cache-nm - uses: actions/cache@v4 - with: - path: | - node_modules - apps/studio/node_modules - apps/ui-kit/node_modules - apps/sqltools/node_modules - key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} - - name: Install Snapcraft uses: samuelmeuli/action-snapcraft@v3 - if: matrix.os.type == 'linux' && matrix.os.arch != 'arm64' - - - name: Clean cache - if: steps.cache-nm.outputs.cache-hit != 'true' - run: yarn cache clean --all + if: matrix.os.type == 'linux' + # No node_modules cache here, deliberately. Release builds always install + # and compile native modules from scratch so their glibc/libstdc++ + # requirements match this runner's OS. A cache seeded by a newer-OS job + # once shipped binaries that could not load on Ubuntu 22.04/Debian 12 (#4627). # FIXME (matthew) Windows needs retries. It sometimes fails to build # the native oracledb package. # But only sometimes. I cannot figure out why. # Someone should at some point. - name: yarn install (with retry) - if: steps.cache-nm.outputs.cache-hit != 'true' uses: nick-fields/retry@v2 with: timeout_minutes: 20 @@ -281,6 +263,18 @@ jobs: run: | echo "$DATA" | base64 --decode > ~/mac-certificate.p12 + # Authenticate just before signing so the access token (1h lifetime) is + # fresh. token_format: access_token outputs the token jsign uses as its + # KMS store password, so no gcloud token minting is needed in sign.js. + - name: Authenticate to Google Cloud + id: auth + if: matrix.os.type == 'windows' + uses: google-github-actions/auth@v3 + with: + token_format: access_token + workload_identity_provider: ${{ secrets.gcp_workload_identity_provider }} + service_account: ${{ secrets.gcp_service_account }} + # Oh hello future Matthew, why do we split out windows build? # well CSC_LINK gets picked up EVEN THOUGH YOU HAVE # A CUSTOM sign.js. Obviously a bug, but this is a workaround @@ -288,11 +282,13 @@ jobs: - name: Build & Publish (NT) if: matrix.os.type == 'windows' env: - KEYVAULT_URL: "${{ needs.create_draft_release.outputs.azure_keyvault_url }}" - KV_WIN_CERTIFICATE: "${{secrets.kv_win_certificate}}" + GCP_KMS_KEYRING: "${{ secrets.gcp_kms_keyring }}" + GCP_KMS_KEY: "${{ secrets.gcp_kms_key }}" + GCP_ACCESS_TOKEN: "${{ steps.auth.outputs.access_token }}" + # WIN_CERT_FILE and JSIGN_JAR are exported to $GITHUB_ENV by earlier steps PYTHON_PATH: "${{'$PYTHON_PATH' }}" PYTHONPATH: "${{ '$PYTHONPATH' }}" - GH_TOKEN: ${{ secrets.GH_DEPLOY_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} USE_SYSTEM_FPM: true SNAPCRAFT_BUILD_ENVIRONMENT: 'lxd' run: yarn run electron:build --publish always @@ -308,21 +304,20 @@ jobs: CSC_KEY_PASSWORD: ${{ secrets.mac_dev_pw }} PYTHON_PATH: "${{ matrix.os.type == 'macos' && steps.mac_python.outputs.python_path || '$PYTHON_PATH' }}" PYTHONPATH: "${{ matrix.os.type == 'macos' && steps.mac_python.outputs.python_path || '$PYTHONPATH' }}" - GH_TOKEN: ${{ secrets.GH_DEPLOY_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} USE_SYSTEM_FPM: true SNAPCRAFT_BUILD_ENVIRONMENT: 'lxd' run: yarn run electron:build --publish always - name: Delete latest-mac.yml if: matrix.os.type == 'macos' - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: RELEASE_ID: ${{needs.create_draft_release.outputs.id}} with: - github-token: ${{ secrets.GH_DEPLOY_TOKEN }} script: | const script = require('./.github/scripts/delete_latest_yml.js') - const assetsUrl = '${{ needs.create_draft_release.outputs.assets_url }}' + const assetsUrl = 'https://api.github.com/repos/beekeeper-studio/beekeeper-studio/releases/${{ needs.create_draft_release.outputs.id }}/assets' const channel = '${{needs.identify_channel.outputs.channel}}' await script({ github, core, context }, assetsUrl, channel) @@ -341,25 +336,15 @@ jobs: - name: Set yaml files id: set_yaml if: matrix.os.type == 'macos' - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: ARCH: ${{ matrix.os.arch }} CHANNEL: ${{needs.identify_channel.outputs.channel}} with: - github-token: ${{ secrets.GH_DEPLOY_TOKEN }} script: | const fs = require('fs') const content = fs.readFileSync(`apps/studio/dist_electron/latest-mac.yml`, 'utf8') core.setOutput(`mac_${process.env.ARCH}_yml`, content) - - name: Upload DEB/RPM artifacts - uses: actions/upload-artifact@v4 - if: matrix.os.type == 'linux' - with: - name: "${{matrix.os.type}}-${{matrix.os.arch}}" - path: | - apps/studio/dist_electron/*.deb - apps/studio/dist_electron/*.rpm - - name: Cleanup artifacts if: ${{!startsWith(matrix.os.name, 'windows')}} run: npx rimraf "apps/studio/dist_electron/!(*.exe|*.deb|*.rpm|*.AppImage|*.dmg|*.snap|*.yml|*.flatpak|*.aur)" @@ -374,15 +359,14 @@ jobs: needs: [release, create_draft_release, identify_channel] runs-on: ubuntu-latest steps: - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v6 - run: npm install js-yaml - name: Merge yml files - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: INTEL_YML: ${{ needs.release.outputs.mac_x64_yml }} ARM_YML: ${{ needs.release.outputs.mac_arm64_yml }} with: - github-token: ${{ secrets.GH_DEPLOY_TOKEN }} script: | const fs = require('fs'); const yaml = require('js-yaml'); @@ -414,11 +398,10 @@ jobs: const merge = mergeFiles(); fs.writeFileSync('mac.yml', merge, 'utf8'); - name: Upload fixed mac yml - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: RELEASE_ID: ${{ needs.create_draft_release.outputs.id }} with: - github-token: ${{ secrets.GH_DEPLOY_TOKEN }} script: | const fs = require('fs'); const releaseId = process.env.RELEASE_ID; @@ -437,88 +420,6 @@ jobs: 'content-type': 'application/octet-stream' } }); - publish_snapcraft: - # The release pushes to edge. We need to promote the edge release if we have a stable release - needs: [release, identify_channel] - runs-on: ubuntu-latest - if: needs.identify_channel.outputs.channel == 'latest' - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} - steps: - - name: Install Snapcraft - uses: samuelmeuli/action-snapcraft@v3 - - name: Move release snap from edge to stable - continue-on-error: true - run: | - snapcraft promote beekeeper-studio --from-channel "latest/edge" --to-channel "latest/stable" --yes - - publish_repositories: - needs: [release, create_draft_release, identify_channel] - runs-on: ubuntu-24.04 - steps: - - name: Check out Git repository - uses: actions/checkout@v1 - - - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.0.2 - - - name: Install dependencies for rpm deployment - run: sudo apt-get update && sudo apt-get install -y createrepo-c - - - name: Install deb-s3 from GitHub - run: | - curl -sL https://github.com/deb-s3/deb-s3/releases/download/0.11.8/deb-s3-0.11.8.gem -o ./deb-s3.gem - gem install -N ./deb-s3.gem - - - name: Import GPG key - id: import_gpg - uses: crazy-max/ghaction-import-gpg@v3 - with: - gpg-private-key: ${{ secrets.gpg_key }} - - - run: "rm -rf ./artifacts; mkdir artifacts" - - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - path: ./artifacts - - # Only publishing the DEB for the stable channel (for now) - - name: Publish DEB to R2 - if: needs.identify_channel.outputs.channel == 'latest' - run: | - deb-s3 upload $(find ./artifacts -type f -name "*.deb") \ - --bucket=beekeeper-deb-repo \ - --codename=${{needs.identify_channel.outputs.deb_codename}} \ - --endpoint=${{secrets.cloudflare_endpoint}} \ - --lock \ - --sign=${{steps.import_gpg.outputs.keyid}} \ - --preserve-versions - env: - AWS_ACCESS_KEY_ID: "${{secrets.cloudflare_key_id}}" - AWS_SECRET_ACCESS_KEY: "${{secrets.cloudflare_secret_access_key}}" - AWS_DEFAULT_REGION: "us-east-1" - # 2025-01-15: AWS made changes to their clis that broke compatibility - # for third party services. - # This works around that. - # https://github.com/aws/aws-sdk-ruby/issues/3166 - AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED - AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED - - - name: Publish RPM to R2 - if: needs.identify_channel.outputs.channel == 'latest' - run: | - .github/scripts/publish_rpm.sh $(find ./artifacts -type f -name "*.rpm") - env: - GPG_KEY_ID: "${{steps.import_gpg.outputs.keyid}}" - R2_BUCKET: "beekeeper-rpm-repo/repo" - R2_ENDPOINT: ${{secrets.cloudflare_endpoint}} - AWS_ACCESS_KEY_ID: "${{secrets.cloudflare_key_id}}" - AWS_SECRET_ACCESS_KEY: "${{secrets.cloudflare_secret_access_key}}" - # 2025-01-15: AWS made changes to their clis that broke compatibility - # for third party services. - # This works around that. - # https://github.com/aws/aws-sdk-ruby/issues/3166 - AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED - AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED + # deb/rpm repo publishing and snap store uploads moved to + # release-published.yml: they now run when the draft release is published, + # not at build time, so no channel ships before the manual publish click. diff --git a/.github/workflows/studio-snap-test.yml b/.github/workflows/studio-snap-test.yml new file mode 100644 index 00000000000..897eac96006 --- /dev/null +++ b/.github/workflows/studio-snap-test.yml @@ -0,0 +1,64 @@ +name: Studio - Snap build test + +# Temporary workflow to validate the core24 snapcraft migration on both Linux +# architectures without publishing. Safe to delete once the migration is merged. + +on: + push: + branches: + - claude/zen-ride-10fgtv + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + snap-build: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-22.04-arm] + + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: setup LXD + uses: canonical/setup-lxd@main + + - name: Install Snapcraft + uses: samuelmeuli/action-snapcraft@v3 + + - name: Install python 3.11 + uses: actions/setup-python@v6 + with: + python-version: 3.11 + + - name: Install Node.js, NPM and Yarn + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: Install dependencies + run: yarn install --frozen-lockfile --network-timeout 100000 + env: + npm_config_node_gyp: ${{ github.workspace }}/node_modules/node-gyp/bin/node-gyp.js + + - name: Build snap (no publish) + run: yarn run electron:build --publish never --linux snap + env: + SNAPCRAFT_BUILD_ENVIRONMENT: 'lxd' + USE_SYSTEM_FPM: true + + - name: Upload snap artifact + uses: actions/upload-artifact@v6 + with: + name: snap-${{ matrix.os }} + path: apps/studio/dist_electron/*.snap + if-no-files-found: error diff --git a/.github/workflows/studio-test.yml b/.github/workflows/studio-test.yml index ba5075d52cb..84cf8747187 100644 --- a/.github/workflows/studio-test.yml +++ b/.github/workflows/studio-test.yml @@ -17,6 +17,10 @@ on: paths-ignore: - apps/sqltools/** +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: prep: runs-on: ubuntu-24.04 @@ -29,27 +33,34 @@ jobs: version: "1.7" force: true - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - id: set-test-chunks name: Set Chunks run: echo "test-chunks=$(./bin/get-db-files-as-json.sh)" >> $GITHUB_OUTPUT - name: Install Node.js, NPM and Yarn - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' + # ImageOS identifies the runner image (e.g. ubuntu22, ubuntu24), so caches + # never cross runner image versions: node_modules holds native modules + # compiled for one exact image. + - name: Compute runner image cache id + shell: bash + run: echo "RUNNER_IMAGE_ID=${ImageOS:-unknown}" >> "$GITHUB_ENV" + - name: Cache node_modules id: cache-nm - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | node_modules apps/studio/node_modules apps/ui-kit/node_modules apps/sqltools/node_modules - key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} + key: node-modules-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} - name: Install dependencies if: steps.cache-nm.outputs.cache-hit != 'true' @@ -68,22 +79,26 @@ jobs: needs: [prep] steps: - name: Check out Git repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Node.js, NPM and Yarn - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' + - name: Compute runner image cache id + shell: bash + run: echo "RUNNER_IMAGE_ID=${ImageOS:-unknown}" >> "$GITHUB_ENV" + - name: Restore node_modules cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | node_modules apps/studio/node_modules apps/ui-kit/node_modules apps/sqltools/node_modules - key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} + key: node-modules-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} fail-on-cache-miss: true - name: Lint @@ -98,9 +113,18 @@ jobs: - name: Check for bad log imports run: bin/check-for-electron-log-imports.sh + - name: Check for direct console.log calls + run: bin/check-for-console-logs.sh + - name: Check for missing lodash imports run: bin/check-lodash.sh + - name: Check shell scripts (shellcheck) + run: bin/check-shell-scripts.sh + + - name: Build UI Kit + run: yarn lib:build + - name: Unit Tests run: yarn workspace beekeeper-studio run test:unit --ci --silent @@ -122,30 +146,40 @@ jobs: chunk: ${{ fromJson(needs.prep.outputs['test-chunks']) }} steps: - name: Check out Git repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Node.js, NPM and Yarn - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' + - name: Compute runner image cache id + shell: bash + run: echo "RUNNER_IMAGE_ID=${ImageOS:-unknown}" >> "$GITHUB_ENV" + - name: Restore node_modules cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | node_modules apps/studio/node_modules apps/ui-kit/node_modules apps/sqltools/node_modules - key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} + key: node-modules-${{ env.RUNNER_IMAGE_ID }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} fail-on-cache-miss: true - name: Install libaio (for oracle) + if: matrix.chunk[0] == 'oracle.spec.js' run: sudo apt install libaio-dev - name: Symlink libaio v1 (for oracle) + if: matrix.chunk[0] == 'oracle.spec.js' run: sudo ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 + - name: Install PostgreSQL 15 (for postgres-backup) + if: matrix.chunk[0] == 'postgres-backup.spec.ts' + run: sudo bash bin/get-postgres-15.sh + - name: Test uses: nick-fields/retry@v2 with: @@ -164,7 +198,7 @@ jobs: # uses: actions/checkout@v2 # - name: Install Node.js, NPM and Yarn - # uses: actions/setup-node@v3 + # uses: actions/setup-node@v6 # with: # node-version-file: '.nvmrc' # cache: yarn @@ -180,56 +214,3 @@ jobs: # max_attempts: 2 # on_retry_command: "docker ps -aq | xargs docker stop | xargs docker rm" # command: yarn workspace beekeeper-studio run test:codemirror --runInBand --ci - - e2e: - name: E2E tests - runs-on: ubuntu-24.04 - needs: [prep] - steps: - - name: Check out Git repository - uses: actions/checkout@v4 - - - name: Install Node.js, NPM and Yarn - uses: actions/setup-node@v3 - with: - node-version-file: '.nvmrc' - - - name: Restore node_modules cache - uses: actions/cache/restore@v4 - with: - path: | - node_modules - apps/studio/node_modules - apps/ui-kit/node_modules - apps/sqltools/node_modules - key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }} - fail-on-cache-miss: true - - - name: Start postgres container - run: docker compose up psql15 -d - - - name: Serve + Run Tests - run: | - xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" bash -c ' - yarn run electron:serve > server.log 2>&1 & - sleep 15 - cat server.log # (Optional: see if it launched correctly) - yarn workspace beekeeper-studio test:e2e:ci - ' - env: - ELECTRON_ENABLE_LOGGING: 1 - ELECTRON_DISABLE_SANDBOX: 1 - ELECTRON_EXTRA_LAUNCH_ARGS: "--disable-gpu" - - - # - name: Run E2E Tests - # run: yarn test:e2e:ci - # continue-on-error: true - - - name: Upload test results - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: test-results - path: apps/studio/test-results - retention-days: 30 diff --git a/.github/workflows/ui-kit-publish.yml b/.github/workflows/ui-kit-publish.yml index 9e4f4ccd226..90c50415ada 100644 --- a/.github/workflows/ui-kit-publish.yml +++ b/.github/workflows/ui-kit-publish.yml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out Git repository - uses: actions/checkout@v1 + uses: actions/checkout@v5 - name: Install Node.js, NPM and Yarn - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: yarn diff --git a/.github/workflows/update-readmes.yml b/.github/workflows/update-readmes.yml index d6d83357eb9..027112cc51f 100644 --- a/.github/workflows/update-readmes.yml +++ b/.github/workflows/update-readmes.yml @@ -16,18 +16,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 10 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - name: Check if supported_databases.md changed id: check_changes - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: | const { execSync } = require('child_process'); @@ -42,7 +42,7 @@ jobs: - name: Update README files if necessary if: steps.check_changes.outputs.changed == 'true' id: update_readme - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: | const fs = require('fs'); diff --git a/.github/workflows/windows-login-tests.yaml b/.github/workflows/windows-login-tests.yaml new file mode 100644 index 00000000000..50e004bdab6 --- /dev/null +++ b/.github/workflows/windows-login-tests.yaml @@ -0,0 +1,143 @@ +name: SQL Server Integrated Auth integration test + +permissions: + contents: read + +on: + pull_request: + paths: + - 'apps/studio/src/lib/db/clients/sqlserver.ts' + - 'apps/studio/src/lib/db/types.ts' + - 'apps/studio/package.json' + - 'apps/studio/tests/integration/lib/db/clients/sqlserver-winauth.spec.ts' + - '.github/workflows/windows-login-tests.yaml' + workflow_dispatch: + +jobs: + sqlserver-winauth: + runs-on: windows-2022 + # Tight enough that a stall surfaces in minutes, not in an hour of + # wall-clock. Bump it if install+test growth ever gets close. + timeout-minutes: 25 + defaults: + run: + shell: pwsh + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: Ensure SQL Server Express is installed + run: | + # Skip if a SQL Server instance is already installed on the runner. + # windows-2022 has historically shipped with SQL Server Express 2019 + # pre-installed; relying on that when it's there sidesteps chocolatey + # flakes entirely. + $existing = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server' -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -match 'MSSQL\d+\.' -and + (Test-Path (Join-Path $_.PSPath 'MSSQLServer\SuperSocketNetLib\Tcp')) + } | Select-Object -First 1 + if ($existing) { + Write-Host "Using pre-installed SQL Server instance: $($existing.Name)" + exit 0 + } + + Write-Host 'No pre-installed SQL Server found. Installing via chocolatey with retry.' + $maxAttempts = 3 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + Write-Host "::group::chocolatey install attempt $attempt of $maxAttempts" + # --limit-output dropped on purpose: we want full logs when it fails. + choco install sql-server-express -y --no-progress --ignore-checksums + $exit = $LASTEXITCODE + Write-Host '::endgroup::' + if ($exit -eq 0) { exit 0 } + if ($attempt -lt $maxAttempts) { + Write-Host "Chocolatey exit $exit. Backing off before retry." + Start-Sleep -Seconds 30 + } + } + throw "Failed to install SQL Server Express after $maxAttempts chocolatey attempts." + + - name: Enable TCP/IP on port 1433 and restart the instance + run: | + $base = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server' | + Where-Object { + $_.Name -like '*SQLEXPRESS*' -and + (Test-Path (Join-Path $_.PSPath 'MSSQLServer\SuperSocketNetLib\Tcp')) + } | Select-Object -First 1 + if (-not $base) { throw 'Could not locate the SQLEXPRESS registry key.' } + + $tcp = Join-Path $base.PSPath 'MSSQLServer\SuperSocketNetLib\Tcp' + Set-ItemProperty -Path $tcp -Name 'Enabled' -Value 1 + + # Wipe dynamic ports and pin TcpPort=1433 on every IP stanza so the + # probe (Server=localhost,1433) reliably lands on this instance. + Get-ChildItem $tcp | ForEach-Object { + Set-ItemProperty -Path $_.PSPath -Name 'TcpDynamicPorts' -Value '' + Set-ItemProperty -Path $_.PSPath -Name 'TcpPort' -Value '1433' + } + + Restart-Service 'MSSQL$SQLEXPRESS' -Force + + $deadline = (Get-Date).AddSeconds(90) + while ((Get-Date) -lt $deadline) { + if (Test-NetConnection -ComputerName localhost -Port 1433 -InformationLevel Quiet -WarningAction SilentlyContinue) { + Write-Host 'SQL Server is listening on 1433.' + break + } + Start-Sleep -Seconds 2 + } + + - name: Open Windows Firewall for TCP/1433 + run: | + # Connections by hostname (vs localhost) traverse the NIC and are + # subject to firewall rules. Loopback bypasses the firewall, so the + # localhost case works without this rule, but the hostname case in + # the Jest spec needs it. + New-NetFirewallRule -DisplayName 'SQL Server TCP 1433 (CI)' ` + -Direction Inbound -Action Allow -Protocol TCP -LocalPort 1433 | Out-Null + + - name: Verify integrated auth reaches SQL Server (pre-flight) + run: | + # The chocolatey package adds the installing user (runneradmin) as a + # sysadmin, so integrated auth over TCP should succeed without any + # further GRANT. If this step fails the Jest step will too, so fail + # early with a cleaner diagnostic. Exercise both hostnames the Jest + # spec will try. + sqlcmd -S "localhost,1433" -E -Q "SELECT SUSER_SNAME() AS loginName, @@VERSION AS v;" + if ($LASTEXITCODE -ne 0) { throw "Integrated auth pre-flight (localhost) failed (exit $LASTEXITCODE)." } + + $hn = [System.Net.Dns]::GetHostName() + Write-Host "Pre-flighting hostname connection to $hn,1433" + sqlcmd -S "$hn,1433" -E -Q "SELECT SUSER_SNAME() AS loginName, @@SERVERNAME AS server;" + if ($LASTEXITCODE -ne 0) { throw "Integrated auth pre-flight (hostname=$hn) failed (exit $LASTEXITCODE)." } + + - name: Install workspace dependencies + run: yarn install --frozen-lockfile + + - name: Run SQL Server integrated auth Jest spec + working-directory: apps/studio + env: + TEST_MODE: '1' + ELECTRON_RUN_AS_NODE: '1' + # --forceExit: msnodesqlv8 pool teardown can leave a native ODBC handle + # owned by the driver, which can keep Node from exiting cleanly even after + # all tests pass. --forceExit tells Jest to call process.exit() once the + # run completes. + # --testTimeout: global per-test ceiling so any legitimate hang fails in + # 90s instead of consuming the whole job budget. + run: yarn internal:integration --testPathPattern sqlserver-winauth --runInBand --ci --forceExit --testTimeout=90000 + + - name: Collect SQL Server ERRORLOGs on failure + if: failure() + run: | + Get-ChildItem 'C:\Program Files\Microsoft SQL Server\*\MSSQL\Log\ERRORLOG*' -ErrorAction SilentlyContinue | + ForEach-Object { + Write-Host "===== $($_.FullName) =====" + Get-Content $_.FullName -Tail 200 + } diff --git a/.gitignore b/.gitignore index fa2884651df..10925247175 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .turbo/ .idea/ .cache/ +.claude/ .vscode/ ./data/ .local diff --git a/CLAUDE.md b/CLAUDE.md index 9927a370efd..eeb467d40bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,24 @@ Test files are organized in `apps/studio/tests/`: 3. **Run tests**: `yarn test:unit` before committing 4. **Build**: `yarn bks:build` for production build +## UI Copy Style + +User-facing strings (button labels, tooltips, alerts, helper text, form hints) should be written as **neutral statements**, not first-person plural narration. + +- Don't use "we", "we'll", "we found", "we couldn't", "we didn't". Don't address the app as if it's a person reporting back. +- Prefer terse statements about state or behaviour: `Resolved /path/to/key`, `No ssh-agent found`, `Falls back to User from ~/.ssh/config`. +- Don't anthropomorphise: `The agent will be queried` is fine, `we'll ask the agent` is not. +- This applies to copy, not to code comments — internal comments may use "we" if it improves clarity. + +Examples: + +| Avoid | Prefer | +| ------------------------------------------------------- | ------------------------------------------------- | +| "We found your ssh-agent socket: /tmp/agent.123" | "ssh-agent socket: /tmp/agent.123" | +| "We couldn't find an ssh config at /home/u/.ssh/config" | "No ssh config at /home/u/.ssh/config" | +| "If blank, we use User from ~/.ssh/config" | "If blank, falls back to User from ~/.ssh/config" | +| "We'll resolve HostName from your config" | "Resolves HostName from the matching entry" | + ## Path Aliases (Vite/TypeScript) ```typescript @@ -156,7 +174,12 @@ Test files are organized in `apps/studio/tests/`: "@commercial" -> "./src-commercial" "@shared" -> "./src/shared" "assets" -> "./src/assets" -"@bksLogger" -> "./src/lib/log/rendererLogger" +"@bksLogger" -> resolved per-build: + - esbuild main+preload → "./src/lib/log/mainLogger" + - esbuild utility → "./src/lib/log/utilityLogger" + - vite renderer → "./src/lib/log/rendererLogger" + - jest → "./src/lib/log/mainLogger" + - tsc / IDE → "./src/lib/log/bksLogger.d.ts" (ambient declaration; no runtime file) ``` ## Database Support diff --git a/README-sw.md b/README-sw.md new file mode 100644 index 00000000000..51607216827 --- /dev/null +++ b/README-sw.md @@ -0,0 +1,261 @@ + +🌐 [EN](README.md) | [PT-BR](README.pt-br.md) | [ES](README-es.md) | [DE](README-de.md) | [FR](README-fr.md) | [EL](README-el.md) | [JA](README-ja.md) | [IT](README-it.md) | [KO](README-ko.md) | [ID](README-id.md) | [SW](README-sw.md) + +# Beekeeper Studio + +Beekeeper Studio ni kihariri cha SQL na kidhibiti cha kazidata (database manager) chenye jukwaa mtambuka (cross-platform), kinachopatikana kwa Linux, Mac, na Windows. + +[Pakua Beekeeper Studio](https://beekeeperstudio.io/get-community) + +Tunatoa binaries kwa MacOS, Windows, na Linux. + +[![image](https://user-images.githubusercontent.com/279769/203650152-4a34af1f-8a38-47cf-a273-d34d1c84feeb.png)](https://beekeeperstudio.io/get) + + +Beekeeper Studio ni bure kupakua na inatoa vipengele vingi bila malipo, bila usajili, bila kujiandikisha, na bila kadi ya benki. Programu inatoa baadhi ya vipengele vya premium kwa bei nafuu ya leseni. [Soma zaidi hapa](https://beekeeperstudio.io/pricing) + + +Sehemu kubwa ya code katika repo hii ni open source chini ya leseni ya GPLv3. Vipengele vya kulipia pia viko kwenye repo hii chini ya leseni ya kibiashara yenye source code inayopatikana. + +Michango kutoka kwa jamii (community contributions) inakaribishwa! + + +## Kazidata Zinazotumika (Supported Databases) + + + + +| Database | Support | Community | Paid Editions | Beekeeper Links | +| :------------------------------------------------------- | :--------------------------- | :-------: | :------: | -----------------------------------------: | +| [PostgreSQL](https://postgresql.org) | ⭐ Full Support | ✅ | ✅ | [Features](https://beekeeperstudio.io/db/postgres-client) | +| [MySQL](https://www.mysql.com/) | ⭐ Full Support | ✅ | ✅ | [Features](https://beekeeperstudio.io/db/mysql-client)| +| [SQLite](https://sqlite.org) | ⭐ Full Support | ✅ | ✅ | [Features](https://beekeeperstudio.io/db/sqlite-client), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/sqlite) | +| [SQL Server](https://www.microsoft.com/en-us/sql-server) | ⭐ Full Support | ✅ | ✅ | [Features](https://beekeeperstudio.io/db/sql-server-client) | +| [Amazon Redshift](https://aws.amazon.com/redshift/) | ⭐ Full Support | ✅ | ✅ | [Features](https://beekeeperstudio.io/db/redshift-client) | +| [CockroachDB](https://www.cockroachlabs.com/) | ⭐ Full Support | ✅ | ✅ | [Features](https://beekeeperstudio.io/db/cockroachdb-client), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/cockroachdb) | +| [MariaDB](https://mariadb.org/) | ⭐ Full Support | ✅ | ✅ | [Features](https://beekeeperstudio.io/db/mariadb-client) | +| [TiDB](https://pingcap.com/products/tidb/) | ⭐ Full Support | ✅ | ✅ | [Features](https://beekeeperstudio.io/db/tidb-client) | +| [Google BigQuery](https://cloud.google.com/bigquery) | ⭐ Full Support | ✅ | ✅ | [Features](https://beekeeperstudio.io/db/google-big-query-client), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/bigquery) | +| [Redis](https://redis.io/) | ⭐ Full Support | ✅ | ✅ | [Features](https://www.beekeeperstudio.io/db/redis-client/), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/redis) | +| [GreengageDB](https://greengagedb.org/) | ⭐ Full Support | ✅ | ✅ | [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/greengage) | +| [Oracle Database](https://www.oracle.com/database/) | ⭐ Full Support | | ✅ | [Features](https://beekeeperstudio.io/db/oracle-client), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/oracle) | +| [Cassandra](http://cassandra.apache.org/) | ⭐ Full Support | | ✅ | [Features](https://beekeeperstudio.io/db/cassandra-client) | +| [ScyllaDB](https://www.scylladb.com/) | ⭐ Full Support (via Cassandra driver) | | ✅ | Drop-in compatible with Cassandra | +| [Firebird](https://firebirdsql.org/) | ⭐ Full Support | | ✅ | [Features](https://beekeeperstudio.io/db/firebird-client), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/firebird) | +| [LibSQL](https://libsql.org/) | ⭐ Full Support | | ✅ | [Features](https://beekeeperstudio.io/db/libsql-client) | +| [ClickHouse](https://clickhouse.tech/) | ⭐ Full Support | | ✅ | [Features](https://www.beekeeperstudio.io/db/clickhouse-client/), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/clickhouse) | +| [DuckDB](https://duckdb.org/) | ⭐ Full Support | | ✅ | [Features](https://www.beekeeperstudio.io/db/duckdb-client/), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/duckdb) | +| [SQL Anywhere](https://www.sap.com/products/technology-platform/sql-anywhere.html) | ⭐ Full Support | | ✅ | [Features](https://www.beekeeperstudio.io/db/sql-anywhere-client/) | +| [MongoDB](https://www.mongodb.com/) | ⭐ Full Support | | ✅ | [Features](https://www.beekeeperstudio.io/db/mongodb-client/), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/mongodb) | +| [Trino](https://trino.io/) / [Presto](https://prestodb.io/) | ⭐ Full Support | | ✅ | [Features](https://www.beekeeperstudio.io/db/trino-client/), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/trino/) | +| [SurrealDB](https://surrealdb.com/) | ⭐ Full Support | | ✅ | [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/surrealdb) | +| [DynamoDB](https://aws.amazon.com/dynamodb/) | 🧪 Beta Support | | ✅ | [Features](https://www.beekeeperstudio.io/db/dynamodb-client/), [Docs](https://docs.beekeeperstudio.io/user_guide/connecting/dynamodb) | +| [Snowflake](https://www.snowflake.com/) | ⏳ Coming Soon | | ✅ | -- | + + + + + + +## Matoleo ya Beekeeper Studio (Editions) + +Beekeeper Studio ni pakua moja tu, ikiwa na updates ndani ya programu (in-app) kwa vipengele vya premium. + +Tungependa kufanya Beekeeper Studio kuwa bure kabisa kwa kila mtu, lakini kutengeneza software nzuri ni kazi ngumu na ya gharama. Tunaamini kuwa matoleo yetu ya kulipia yana bei ya haki, na tunatumaini wewe pia utakubaliana nasi. + +👉 [Linganisha Matoleo ya Beekeeper Studio](https://beekeeperstudio.io/pricing) + +## Vipengele vya Beekeeper Studio + +Sehemu bora zaidi: Ni laini 🍫, haraka 🏎, na kwa kweli utafurahia kuitumia 🥰 + +- Cross-platform kweli: Windows, MacOS, na Linux +- Kihariri cha SQL chenye autocomplete na syntax highlighting +- Interface yenye tabs ili uweze kufanya kazi nyingi kwa wakati mmoja +- Panga na chuja data za jedwali (table data) ili kupata unachohitaji hasa +- Njia za mkato za kibodi (keyboard shortcuts) zenye mantiki +- Hifadhi queries kwa matumizi ya baadaye +- Historia ya utekelezaji wa queries, ili uweze kupata ile query iliyofanya kazi siku 3 zilizopita +- Dark theme nzuri sana +- Import/Export +- Backup/Restore +- Angalia data kama JSON +- Na mengine mengi + +## Mtazamo Wetu wa UX + +Moja ya mambo yanayotukera kuhusu vihariri vingine vya SQL na vidhibiti vya kazidata vya open source ni kwamba vinachukua mtazamo wa "kutupa kila kitu ndani", vikiongeza vipengele vingi mno hadi interface inakuwa fujo na ngumu kutumia. Tulitaka mazingira ya SQL ya open source yenye muonekano mzuri, yenye nguvu lakini pia rahisi kutumia. Hatukuweza kupata, kwa hivyo tukatengeneza Beekeeper Studio! + +Kwa ujumla, dira yetu kuu ni kujenga software ambayo "inajisikia vizuri" unapoitumia. Hiyo inamaanisha, kwa kiwango cha chini kabisa, tunathamini Beekeeper kuwa haraka, rahisi kutumia, na ya kisasa. Ikiwa kipengele kipya kinaathiri dira hii, tunakiondoa. + + +## Kuunga Mkono Beekeeper Studio + +Tunapenda kufanya kazi kwenye Beekeeper Studio, na tungependa kuendelea kuikuza na kuiboresha milele. Kwa hilo tunahitaji msaada wako. + +Njia bora ya kuunga mkono Beekeeper Studio ni kununua [leseni](https://beekeeperstudio.io/pricing) ya kulipia. Kila ununuzi unasaidia moja kwa moja kazi yetu kwenye Beekeeper Studio. + +Ikiwa uko kwenye kampuni na unatumia Beekeeper Studio kwa kazi yako, labda unapaswa kumwomba bosi wako [akununulie leseni](https://beekeeperstudio.io/pricing). + +Ikiwa huwezi kumudu leseni, tafadhali tumia toleo la bure — kwa hilo ndio tunalitengeneza! + +Asante kwa msaada wako wa kudumu! + + +## Nyaraka (Documentation) + +Tembelea [docs.beekeeperstudio.io](https://docs.beekeeperstudio.io) kwa miongozo ya watumiaji, maswali yanayoulizwa mara kwa mara (FAQ), vidokezo vya utatuzi wa matatizo (troubleshooting), na zaidi. + +## Leseni + +Beekeeper Studio Community Edition (code iliyo kwenye repo hii) ina leseni ya GPLv3. + +Beekeeper Studio Ultimate Edition ina vipengele vya ziada na ina leseni ya [makubaliano ya kibiashara ya leseni ya mtumiaji wa mwisho (EULA)](https://beekeeperstudio.io/legal/commercial-eula/). + +Alama za biashara za Beekeeper Studio (majina na nembo/logos) sio open source. Angalia [miongozo yetu ya alama za biashara](https://beekeeperstudio.io/legal/trademark/) kwa maelezo zaidi. + +## Miongozo ya Alama za Biashara (Trademark Guidelines) + +Alama za biashara zinaweza kuwa ngumu kwa miradi ya open source, kwa hivyo tumepitisha seti ya miongozo ya kawaida kuhusu matumizi ya alama zetu ambayo ni ya kawaida katika miradi mingi ya open source. + +Ikiwa unatumia tu programu ya Beekeeper Studio, na hufanyi fork au kusambaza code ya Beekeeper Studio kwa njia yoyote, hizi labda hazikuhusu wewe. + +👉 [Miongozo ya Alama za Biashara ya Beekeeper Studio](https://beekeeperstudio.io/legal/trademark/) + +## Kuchangia (Contributing) kwenye Beekeeper Studio + +Tunapenda ushiriki *wowote* kutoka kwa jamii. Hata kama unalalamika kwa sababu hukupendi kitu fulani kwenye programu! + + +### Makubaliano ya Wachangiaji (Contributor Agreements) + +- Kujenga jamii yenye ushirikishwaji na ukaribishaji ni muhimu kwetu, hivyo tafadhali fuata [kanuni zetu za maadili](code_of_conduct.md) unaposhiriki kwenye mradi. + +- Kwa kuchangia kwenye mradi, unakubali masharti ya [miongozo yetu ya uchangiaji](CONTRIBUTING.md). + +### Kuchangia Bila Kuandika Code + +Tumekufunika, soma [mwongozo wetu wa dakika 10 wa kuchangia bila kuandika code](https://github.com/beekeeper-studio/beekeeper-studio/issues/287). + +### Ku-compile na Kuendesha Beekeeper Studio kwa Mtaa (Locally) + +Unataka kuandika code na kuboresha Beekeeper Studio? Kuweka mazingira ni rahisi kwenye Mac, Linux, au Windows. + +```bash +# Kwanza: Sakinisha NodeJS 20, NPM, na Yarn +# ... + +# 1. Fanya fork ya repo ya Beekeeper Studio (bofya kitufe cha fork juu kulia mwa skrini hii) +# 2. Clone fork yako: +git clone git@github.com:/beekeeper-studio.git beekeeper-studio +cd beekeeper-studio/ +yarn install # sakinisha dependencies + + +# Sasa unaweza kuanzisha programu: +yarn run electron:serve ## programu itaanza +``` + +**Ukipata `error:03000086:digital envelope routines::initialization error`, utahitaji ku-update openssl.** + +- Kwenye Ubuntu/Debian: +``` +sudo apt-get update +sudo apt-get upgrade openssl +``` + +- Kwenye CentOS/RHEL: +``` +sudo yum update openssl +``` + +- Kwenye macOS (kwa kutumia Homebrew): +``` +brew update +brew upgrade openssl +``` + +### Wapi kufanya mabadiliko? + +Repo hii sasa ni monorepo, tuna sehemu kadhaa zenye code, lakini kuna sehemu chache tu muhimu za kuingilia (entry points). + +Code yote ya programu iko kwenye `apps/studio`, na baadhi ya code inayoshirikiwa iko kwenye `shared/src`. Hii inashirikiwa na programu nyingine. + +Beekeeper Studio ina entry points mbili: +- `background.js` - hii ni code ya upande wa Electron inayodhibiti mambo ya asili (native) kama kuonyesha madirisha (windows). +- `main.js` - hii ndio entry point kwa programu ya Vue.js. Unaweza kufuata nyayo za components za Vue kutoka `App.vue` kupata screen unayohitaji. + +**Kwa kawaida tuna 'skrini' mbili:** +- ConnectionInterface - kuunganisha kwenye DB +- CoreInterface - kuingiliana na kazidata + +### Jinsi ya Kutuma Mabadiliko (Change)? + + +- Push mabadiliko yako kwenye repo yako na fungua Pull Request kutoka kwenye ukurasa wetu wa GitHub (ukurasa huu) +- Hakikisha unaandika maelezo kuhusu kinachofanya mabadiliko yako! Gif inakaribishwa kila wakati kwa mabadiliko ya kuonekana (visual). + +## Maelezo kwa Maintainers (wasomaji wa kawaida wanaweza kuruka hii) + +### Mambo ya Kuzingatia Wakati wa Ku-update Electron + +Hii kila wakati ni maumivu kamili na itavunja build mara 9 kati ya 10. + +Baadhi ya mambo unayopaswa kuzingatia unapo-update Electron: + +1. Je, inatumia toleo tofauti la node? Kwa mfano, Electron-18 inatumia node 14, 22 inatumia node 16. Kwa hivyo wote wanahitaji kusasishwa +2. Je, node-abi inahitaji kusasishwa ili iweze kuelewa toleo la Electron? Hii inatumika kwenye build kupata packages zilizokwisha-compile. Unahitaji ku-update hii kwenye root/package.json#resolutions +3. Je, baadhi ya APIs zimeondolewa au kupitwa na wakati (deprecated)? Hakikisha functions zote zinazoingiliana na Electron APIs bado zinafanya kazi, mambo kama - kuchagua faili, ku-maximize dirisha, kutekeleza query, n.k. + + +### Mchakato wa Kutoa Toleo (Release Process) + +1. Ongeza namba ya toleo (version) kwenye package.json +2. Badilisha `build/release-notes.md` na maelezo mapya ya toleo. Fuata muundo ulioko humo. + - endesha `git log ..HEAD --oneline | grep 'Merge pull'` kupata PRs zilizounganishwa (merged) +2. Commit +3. Push kwenye master +4. Tengeneza tag `git tag v`. Lazima ianze na 'v' +5. `git push origin ` + - Sasa subiri action ya build/publish kwenye Github ikamilike +6. Chapisha toleo jipya + - Nenda kwenye toleo jipya la 'draft' kwenye tab ya releases ya GitHub, hariri maelezo, chapisha + - Ingia kwenye snapcraft.io, buruta toleo lililopakiwa kwenda kwenye channel ya 'stable' kwa kila architecture. + +Hii pia inapaswa kuchapisha nyaraka (documentation) za hivi karibuni + +Baada ya Kutoa Toleo: +1. Nakili maelezo ya toleo kwenye blog post, chapisha kwenye tovuti +2. Tweet link +3. Shiriki kwenye LinkedIn +4. Tuma kwenye mailing list kwenye SendInBlue + + +## Shukrani Kubwa + +Beekeeper Studio isingekuwepo bila [Sqlectron-core](https://github.com/sqlectron/sqlectron-core), maktaba kuu za kazidata (core database libraries) za [mradi wa Sqlectron](https://github.com/sqlectron/sqlectron-gui). Beekeeper Studio ilianza kama fork ya majaribio ya repo hiyo. Shukrani kubwa kwa @maxcnunes na wengine wa jamii ya Sqlectron. + +Leseni asili ya sqlectron-core imejumuishwa hapa: + +``` +Copyright (c) 2015 The SQLECTRON Team + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` \ No newline at end of file diff --git a/apps/studio/.gitignore b/apps/studio/.gitignore index 3997beadf82..7014cfe49d7 100644 --- a/apps/studio/.gitignore +++ b/apps/studio/.gitignore @@ -1 +1,2 @@ -*.db \ No newline at end of file +*.db +*.duckdb diff --git a/apps/studio/build/release-notes.md b/apps/studio/build/release-notes.md index a1ce37c2dbc..6685015d1d7 100644 --- a/apps/studio/build/release-notes.md +++ b/apps/studio/build/release-notes.md @@ -1,77 +1,91 @@ -Using provided tag: v5.1.5 -Using tag: v5.1.5 -552aa7277 Merge pull request #2978 from therealrinku/fix/jsonb-boolean -58a83c918 Merge pull request #2971 from beekeeper-studio/fix/core-interface-split -d64de0113 Merge pull request #3013 from beekeeper-studio/fix/anywhere-form -6d11a7c59 Merge pull request #2992 from beekeeper-studio/feat/anywhere -b59774bda Merge pull request #3011 from beekeeper-studio/fix/vite-version -e608f0863 Merge pull request #3007 from beekeeper-studio/bugfix/2936_Copy-As-SQL -0e60794da Merge pull request #3008 from beekeeper-studio/bugfix/remove-uncalled-function -608534750 Merge pull request #2934 from thePeras/feat/streamer-mode -fe7c9dc22 Merge pull request #2946 from beekeeper-studio/feat/mongo-sql-editor -592cf2f49 Merge pull request #2996 from beekeeper-studio/fix/sqltexteditor-contextmenu -53130a8e9 Merge pull request #2998 from therealrinku/fix/quick-search-overflow -b65f2a4aa Merge pull request #2969 from beekeeper-studio/fix/status-bar -4358465af Merge pull request #2999 from beekeeper-studio/fix/config-keys -7de56e1e8 Merge pull request #2997 from beekeeper-studio/fix/mongo-ansi -1266f3de2 Merge pull request #2995 from beekeeper-studio/docs/ui-kit-sqltexteditor -bf9d7932c Merge pull request #2994 from beekeeper-studio/fix/vim-clipboard -30caebf0b Merge pull request #2988 from beekeeper-studio/fix/sqltexteditor-syntax-colors -157ceede9 Merge pull request #2989 from beekeeper-studio/fix/text-editor-vim -d27d7b0c7 Merge pull request #2979 from TomasSilv/development -645dbc8b1 Merge pull request #2975 from beekeeper-studio/dependabot/npm_and_yarn/apps/ui-kit/examples/react/vite-6.0.14 -521dd6e3f Merge pull request #2960 from beekeeper-studio/fix/sass -c87d34520 Merge pull request #2986 from beekeeper-studio/fix/null-license -ef50e9697 Merge pull request #2860 from beekeeper-studio/oracle-client-fixes -708273dbb Merge pull request #2944 from therealrinku/fix/clear-table-search -0b1aee048 Merge pull request #2902 from beekeeper-studio/dependabot/npm_and_yarn/yargs-parser-20.2.9 -2e308ad80 Merge pull request #2967 from beekeeper-studio/fix/mongo-shell -349226d42 Merge pull request #2955 from beekeeper-studio/fix/free-solarized -23cac27ce Merge pull request #2671 from beekeeper-studio/file-based-license -ba16498de Merge pull request #2977 from beekeeper-studio/unique-id -fcac97301 Merge pull request #2965 from beekeeper-studio/fix/json-viewer -ba70574c2 Merge pull request #2966 from beekeeper-studio/fix/sqltexteditor-race -c5aac62ce Merge pull request #2895 from Jvillani-fend/feature/ctrl+pg_tabChange -ecc34f785 Merge pull request #2928 from thePeras/fix/fk-icon-update -cd28f3585 Merge pull request #2849 from beekeeper-studio/feat/editable-json-viewer -222429b95 Merge pull request #2866 from beekeeper-studio/feat/result-table-json-viewer -1c383b216 Merge pull request #2799 from beekeeper-studio/feat/wrap-text -f043a9fc1 Merge pull request #2931 from beekeeper-studio/use-ui-kit-sqltexteditor -1d535392d Merge pull request #2802 from beekeeper-studio/feat/config-binary-encoding -595100adb Merge pull request #2800 from beekeeper-studio/fix/tabulator-errors -5e4722b92 Merge pull request #2957 from joaovicente3/fix-foreign-key-button -12807f0e2 Merge pull request #2949 from therealrinku/fix/raw-filter -b271b88cc Merge pull request #2875 from beekeeper-studio/dependabot/npm_and_yarn/babel/runtime-7.26.10 -c24f82b62 Merge pull request #2874 from beekeeper-studio/dependabot/npm_and_yarn/babel/helpers-7.26.10 -bb5ae4cc7 Merge pull request #2873 from beekeeper-studio/dependabot/npm_and_yarn/axios-1.8.2 -1fee44c81 Merge pull request #2932 from beekeeper-studio/dependabot/npm_and_yarn/apps/ui-kit/examples/react/vite-6.0.12 -92ddfda3c Merge pull request #2933 from beekeeper-studio/dependabot/npm_and_yarn/vite-5.4.15 -69f8e1b04 Merge pull request #2914 from beekeeper-studio/feat/mongo-editor -1dbbb4629 Merge pull request #2915 from beekeeper-studio/fix/config-plugin -5d4ef2658 Merge pull request #2926 from T-MSD/BugFix -b30c0572f Merge pull request #2938 from therealrinku/fix/excel-export-null-values -d263ac2f6 Merge pull request #2906 from joao-sntos/fix-bug-import-from-url -1b67e5bbe Merge pull request #2922 from rajitha-dassanayake/feat/2677 -d8c6c48e7 Merge pull request #2942 from therealrinku/fix/copy-all-shortcut -e2d22183d Merge pull request #2940 from thePeras/fix/refresh-panel -855dcf4ee Merge pull request #2917 from Daniel15/patch-1 -a0edb7928 Merge pull request #2930 from beekeeper-studio/update-ui-kits-doc -e71273810 Merge pull request #2929 from beekeeper-studio/fix/oracle-initial-sort -39c8474e7 Merge pull request #2924 from therealrinku/fix/highlight-selected-table -1eb5d68f9 Merge pull request #2896 from beekeeper-studio/fix/json-sidebar-fold-gutter -fe33fe001 Merge pull request #2881 from beekeeper-studio/feat/mongo-v2 -ec95b4895 Merge pull request #2904 from beekeeper-studio/fix/default-conn-colour -321e2ca59 Merge pull request #2843 from beekeeper-studio/feat/changed-sign-json-viewer -89079e5dd Merge pull request #2879 from RodrigoPerestrelo/fix-select-copy-relation-names -cc96326e3 Merge pull request #2905 from beekeeper-studio/update-ui-kit -7b48c48ed Merge pull request #2903 from beekeeper-studio/rathboma-patch-6 -820a0294d Merge pull request #2792 from beekeeper-studio/ui-kit-updates -aa9476d60 Merge pull request #2807 from beekeeper-studio/fix/index-form-validation -878853195 Merge pull request #2899 from therealrinku/fix/add-row -89df82507 Merge pull request #2876 from juratjan123/Hint_improve -7781ce1c0 Merge pull request #1929 from beekeeper-studio/config.ini -ecafaba7a Merge pull request #2483 from beekeeper-studio/feature/free-reminder -7f4f865ab Merge pull request #2641 from beekeeper-studio/feature/2379-Tab-History -90fa41fff Merge pull request #2878 from beekeeper-studio/fix/unsaved-conn-dupes -0ebb6526c Merge pull request #2880 from drochag/patch-1 -f63c884ae Merge pull request #2853 from beekeeper-studio/rc-51 +# Beekeeper Studio 5.9.0 + +This is a big one. Snowflake support — which we've been teasing for a couple of releases — has finally landed, bringing our fully-featured driver count up another notch. SQL Server gets proper integrated (Windows/Kerberos) authentication, and there's a pile of everyday-workflow upgrades: paste copied data straight in as new rows, pick enum values from a dropdown, rename things inline in the sidebar, and move queries, folders, and connections around from a single dialog. On top of that there's a long tail of bug fixes, SSH and packaging improvements, and a security fix worth calling out. + +## Highlights + +- **Snowflake support.** Our long-awaited Snowflake driver is here. Connect with username/password, SSO via browser, or multi-factor auth (authenticator code or Duo push), with token caching where your account allows it. You get full schema browsing, views and materialized views, primary/foreign keys, index create/drop, create-script (DDL) retrieval, table cloning, and transactional inserts/updates/deletes — plus streaming and cancellation for big queries. The connection form takes your account ID, default database, and warehouse, and there's a read-only mode for locked-down environments. + +- **SQL Server integrated (Windows/Kerberos) authentication.** Connect to your SQL Server using either integrated Windows auth, or Kerberos. Kerberos authentication works across all three operating systems (although it sometimes requires system-wide libraries installed). + + +## Notable Improvements + +- **Paste data as new rows.** Copy a block of data from anywhere and paste it into a table as new rows instead of overwriting the cells you've selected — the quick way to port a chunk of data between tables without setting up a full import. There's a dedicated keybinding, and backspace now clears a selected range as you'd expect. + +- **Pick enum values from a dropdown.** Enum columns now offer their valid values in a dropdown right in the table and result grids, so you don't have to remember or retype them. Works on PostgreSQL, CockroachDB, MySQL/MariaDB, DuckDB, and ClickHouse. + +- **One move-to dialog for everything.** The move-to modal now handles saved queries and (sub)folders as well as connections, so you can reorganize the whole sidebar from one consistent dialog instead of a scatter of context-menu entries. + +- **Inline renaming in the sidebar.** Rename queries, connections, and folders inline, right where they live. + +- **Plugin keyboard shortcuts.** Plugin menu items can now be bound to custom keyboard shortcuts via the config file. + +- **New "unix timestamp" query magic.** Convert a Unix timestamp (seconds, milliseconds, microseconds, or nanoseconds) to a readable date string, with timezone and ISO formatting options — e.g. `columnname__format__unixtime__ms__utc`. + +- **Smarter SSH config handling.** Previously-silent `~/.ssh/config` problems now surface as non-blocking warning toasts on connect/test (unparseable config, bad ownership/permissions, or a missing `IdentityFile` in agent mode). SSH tunnels now skip entries with a missing `IdentityFile`, and Beekeeper uses ssh-config's native `Match exec` handling instead of stripping Match blocks by hand. A new `[security] allowSshConfigMatchExec` option (default true, matching `ssh(1)`) lets you disable execution of `Match exec` directives. + +- **SSH tunneling for more engines.** Fixed SSH tunneling for ClickHouse and Firebird, and added bastion/jump-host support for MongoDB (forcing `directConnection` so it actually works). + +- **ClickHouse custom SSL certificates.** ClickHouse connections now support custom SSL certificates for encrypted connections. Thanks @mastercactapus! + +- **DynamoDB is now in beta.** DynamoDB moves from planned to beta support, with a new connection guide covering IAM auth and local-endpoint configuration. + +- **Sidebar remembers where you were.** The last-open sidebar tab persists across restarts, and the default sidebar tab is configurable. + +## Full Change List + +### New Features +- Snowflake driver: connections, schema browsing, views/materialized views, keys, indexes, DDL retrieval, table cloning, transactional writes, streaming and cancellation (#4323) +- SQL Server integrated (Windows/Kerberos) authentication, with Encrypt toggle and optional SPN override (#4416, #4430) +- Paste copied data as new rows, with a dedicated keybinding; backspace clears a selected range (#4450) +- Select enum values from a dropdown — PostgreSQL, CockroachDB, MySQL/MariaDB, DuckDB, ClickHouse (#4444) +- Unified move-to dialog for connections, saved queries, and (sub)folders (#4448) +- Inline renaming of queries, connections, and folders in the sidebar (#4250) +- Plugin menu items support configurable keyboard shortcuts (#3837) +- New unix-timestamp query magic (#4401). Thanks @Squidysquid1! +- ClickHouse custom SSL certificate support (#4343). Thanks @mastercactapus! +- DynamoDB beta support and connection docs (#4298) +- Persist last-open sidebar tab across restarts; configurable default sidebar selection (#4440, #4447) + +### Bug Fixes +- Restoring the edited text of a saved query was broken (#4443) +- Auto-refresh now works when making table alterations from the query editor (#4442) +- BigQuery NUMERIC/BIGNUMERIC (and other custom-type) values now display correctly (#4391) +- SQL Server autocomplete handles bracket-quoted `[identifiers]` correctly (#4437) +- Editing a PostgreSQL array of enums no longer throws a JSON error (#4238) +- Newly added columns no longer become invisible when dragged to reorder (#4418). Thanks @aanthoonyy! +- Team subfolders show their own name instead of the parent's (#4403) +- Query import clears the folder ID for correct personal-folder placement (#4406) +- Fixed two import bugs: a `generateColumnTypesFromFile` TypeError and a CSV error message (#4244) +- Paste a single value across all selected cells (#3972). Thanks @anabdsantos! +- Proper string escaping in the TableMenu copy actions (#4421). Thanks @Squidysquid1! +- Simplified delete context-menu wording (#4386) +- Reverted AppImage to the default runtime for AppImageLauncher compatibility + +### SSH +- Surface invalid/untrusted ssh config and missing identity files as non-blocking warning toasts (#4378 and related) +- Use ssh-config's native `Match exec` support instead of stripping Match blocks +- New `[security] allowSshConfigMatchExec` option to control execution of `Match exec` directives +- Skip SSH tunnel entries with a missing `IdentityFile` (#4368) +- Fixed SSH tunneling for MongoDB, ClickHouse, and Firebird (#4435) + +### Security +- Fixed a plugin-manifest path-traversal vulnerability (GHSA-3wfm-5rhc-mg5c) that could trigger arbitrary recursive directory deletion on uninstall; plugin IDs are now validated and must match their install directory (#4393) +- Misc dependency vulnerability fixes (#4392) + +### Packaging & Platform +- Migrated Snap builds to electron-builder's native core24 snapcraft config to fix broken snap builds (#4427) +- Switched Windows code signing from Azure to Google Cloud KMS (#4424) +- Upgraded electron-builder to 26.11.1 and configured the AppImage toolset (#4315), with follow-up toolset config cleanup (#4432) +- Fixed desktop-environment window association on Linux (#4429) + +### Internal / Tooling +- Migrated the TableSchemaValidation editor to the shared UI Kit text editor (#4327) +- Upgraded Vite to v8 (#4394) +- Upgraded the ERD library (#4364) +- Updated the Tabulator data-grid library (#4400) +- Structured plugin error classes/codes for better diagnostics (#3316) +- Extracted folder-tree logic into reusable utilities (#4408) +- Test/CI: pinned the ClickHouse Docker image to stop timeouts, static ed25519 SSH fixture, lightweight none-auth tunnel harness, dockerized Samba AD + SQL Server Kerberos stack, and regression tests for several utility bugs +- Dependency bumps: @babel/core, dompurify, fast-xml/builder, form-data, @grpc/grpc-js, protobufjs, qs, shell-quote, tmp, @tootallnate/once, typeorm, undici, vitest, ws diff --git a/apps/studio/build/win/sign.js b/apps/studio/build/win/sign.js index 1d138ecc712..4580546a440 100644 --- a/apps/studio/build/win/sign.js +++ b/apps/studio/build/win/sign.js @@ -6,29 +6,41 @@ function isEmpty(value) { exports.default = async function (configuration) { - const certificate = process.env.KV_WIN_CERTIFICATE; - const keyvaultUrl = process.env.KEYVAULT_URL; + // jsign --keystore: the KMS keyRing resource path + // (projects//locations//keyRings/) + const keyring = process.env.GCP_KMS_KEYRING; + // jsign --alias: the KMS key name, optionally with a /cryptoKeyVersions/ suffix + const key = process.env.GCP_KMS_KEY; + // jsign --certfile: KMS holds only the private key, so the public EV + // certificate chain (PEM/P7B) must be supplied separately + const certFile = process.env.WIN_CERT_FILE; + const jsignJar = process.env.JSIGN_JAR || 'jsign.jar'; // this way we don't have to sign EVERY build - if(isEmpty(certificate) || isEmpty(keyvaultUrl)) { - console.warn(`build/sign.js: Cannot sign exe, no KV_WIN_CERTIFICATE/KEYVAULT_URL provided for ${configuration.path}`); + if (isEmpty(keyring) || isEmpty(key) || isEmpty(certFile)) { + console.warn(`build/sign.js: Cannot sign exe, no GCP_KMS_KEYRING/GCP_KMS_KEY/WIN_CERT_FILE provided for ${configuration.path}`); return null; } - const timeserver = "http://timestamp.digicert.com" + // Sectigo's RFC3161 timestamp server with SHA-256, per their code signing docs. + const timeserver = "http://timestamp.sectigo.com" - // Updated to use Azure managed identity authentication via azure/login action - // This uses the token obtained from the azure/login step in the GitHub workflow - // The -kvm flag enables managed identity authentication (uses Azure CLI credentials) + // GCP OAuth access token produced by the google-github-actions/auth step + // (token_format: access_token). Valid for an hour, well within build time. + const token = process.env.GCP_ACCESS_TOKEN; + + // jsign signs via Google Cloud KMS (GOOGLECLOUD storetype). The private key + // never leaves KMS; only the public certificate chain is read from --certfile. const command = [ - 'azuresigntool.exe sign -fd sha384', - '-kvu', keyvaultUrl, - '-kvm', // Use managed identity / Azure CLI authentication - '-kvc', certificate, - "-tr", timeserver, - '-td', 'sha384', - '--max-degree-of-parallelism', '1', - '-v' + 'java', '-jar', `"${jsignJar}"`, + '--storetype', 'GOOGLECLOUD', + '--storepass', `"${token}"`, + '--keystore', keyring, + '--alias', key, + '--certfile', `"${certFile}"`, + '--tsaurl', timeserver, + '--tsmode', 'RFC3161', + '--alg', 'SHA-256', ] // throws an error if non-0 exit code, that's what we want. diff --git a/apps/studio/config-metadata.json b/apps/studio/config-metadata.json index 4ab33166b36..77e3aa068c1 100644 --- a/apps/studio/config-metadata.json +++ b/apps/studio/config-metadata.json @@ -9,6 +9,7 @@ { "key": "save", "label": "Save" }, { "key": "openInSqlEditor", "label": "Open in SQL Editor" }, { "key": "openQuickSearch", "label": "Open Quick Search" }, + { "key": "jsonViewerSidebar", "label": "Open Json Viewer" }, { "key": "copySelection", "label": "Copy Selection" }, { "key": "pasteSelection", "label": "Paste Selection" }, { "key": "cloneSelection", "label": "Clone Selection" }, @@ -56,10 +57,10 @@ "label": "Query Editor", "properties": [ { "key": "selectEditor", "label": "Focus Editor" }, - { "key": "submitTabQuery", "label": "Run All Queries" }, - { "key": "submitCurrentQuery", "label": "Run Current Query" }, - { "key": "submitQueryToFile", "label": "Run All Queries to File" }, - { "key": "submitCurrentQueryToFile", "label": "Run Current Query to File" }, + { "key": "primaryQueryAction", "label": "Run Only the Selected Query (Formerly Run All Queries)" }, + { "key": "secondaryQueryAction", "label": "Run All Queries (Formerly Run Select Queries)" }, + { "key": "primaryQueryToFileAction", "label": "Run Current Query to File" }, + { "key": "secondaryQueryToFileAction", "label": "Run All Queries to File" }, { "key": "selectNextResult", "label": "Select Next Result" }, { "key": "selectPreviousResult", "label": "Select Previous Result" }, { "key": "copyResultSelection", "label": "Copy Result Selection" }, @@ -79,7 +80,16 @@ { "key": "focusOnFilterInput", "label": "Focus Filter Input" }, { "key": "openEditorModal", "label": "Open Editor Modal" }, { "key": "firstPage", "label": "First Page" }, - { "key": "lastPage", "label": "Last Page" } + { "key": "lastPage", "label": "Last Page" }, + { "key": "pasteAsNewRows", "label": "Paste clipboard as new rows"}, + { "key": "nullSelection", "label": "Set selected cells to null"} + ] + }, + { + "key": "keybindings.resultTable", + "label": "Result Table", + "properties": [ + { "key": "openEditorModal", "label": "Open Editor Modal" } ] } ] diff --git a/apps/studio/default.config.ini b/apps/studio/default.config.ini index 31d09f7f852..565ac797a7d 100644 --- a/apps/studio/default.config.ini +++ b/apps/studio/default.config.ini @@ -1,8 +1,8 @@ [general] checkForUpdatesInterval = 86400000 ; 24 hours checkForUpdatesDisabled = false ; enable/disable automatic update checks -dataSyncInterval = 30000 ; 30 secs -workspaceSyncInterval = 5000 ; 5 seconds +workspaceSyncInterval = 10000 ; 5 seconds +downloadUserAgent = "Mozilla/5.0 (compatible; Beekeeper Studio) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0" [security] disconnectOnSuspend = false @@ -11,7 +11,19 @@ disconnectOnIdle = false lockMode = disabled ; disabled, pin, future: google, ldap, oauth; applies when connecting to any db idleThresholdSeconds = 300 ; time before user is considered 'idle' idleCheckIntervalSeconds = 30 ; time between idle checks +activityReportIntervalSeconds = 5 ; how often the renderer reports user input to back up idle detection +activityEvents[] = mousedown ; DOM events treated as user activity for idle detection +activityEvents[] = keydown minPinLength = 6 +; Loading SQLite runtime extensions executes arbitrary native code from the +; extension path. Off by default - users must explicitly opt in via their +; user.config.ini before any sqliteExtensionFile setting will be honoured. +allowRuntimeExtensions = false +; `Match exec` in ~/.ssh/config runs an arbitrary command to decide whether a +; block applies; it is evaluated by default to match ssh(1). Set to true to +; skip `Match exec` sections when reading ~/.ssh/config. Host blocks and +; non-exec Match rules (Match host, Match user, etc.) are still applied. +disableSshConfigMatchExec = false [ui.general] ; Controls the encoding format used to display binary data in the application. @@ -22,10 +34,30 @@ binaryEncoding = hex mainContentMinWidth = 200 ; Minimum width of main content area in pixels primarySidebarMinWidth = 150 ; Minimum width of primary sidebar area in pixels secondarySidebarMinWidth = 150 ; Minimum width of secondary sidebar area in pixels +defaultGlobalSidebarItem = tables ; Global sidebar tab selected on first connect, until the user switches. One of: tables, queries, history [ui.queryEditor] maxResults = 50000 defaultFormatter = bk-default +; primary and secondary query actions must be one of the following 2 options +; "submitCurrentQuery" will only run the active query +; "submitTabQuery" will run all queries or what is highlighted +primaryQueryAction=submitTabQuery +secondaryQueryAction=submitCurrentQuery + +[ui.queryEditor.autocomplete] +; Casing of completed keywords and built-in functions. +; preserve = follow the typed prefix: SEL -> SELECT, sel -> select; +; completing with no prefix (e.g. Ctrl+Space) inserts uppercase +; upper = always uppercase +; lower = always lowercase +keywordCasing = preserve +; When completed table/column names get quoted. +; auto = only names the database can't reference unquoted: special +; characters always; MixedCase only where unquoted names are +; case-folded (e.g. PostgreSQL) +; always = quote every completed name +quoteIdentifiers = auto [ui.tableTable] pageSize = 100 @@ -34,6 +66,8 @@ maxColumnWidth = 1000 minColumnWidth = 100 maxInitialWidth = 500 defaultColumnWidth = 125 +; The event that triggers cell edit mode ("click" or "doubleclick") +editTrigger = doubleclick [ui.tableTriggers] maxColumnWidth = 300 @@ -63,6 +97,14 @@ manualTransactionTimeout = 600000 ; 10 Minutes ; The amount of time before an automatic rollback to warn the user that it is about to happen autoRollbackWarningWindow = 60000 ; 1 Minute +; The quote character autocomplete wraps identifiers in when quoting is +; needed. 0 or -1 selects the database's convention (PostgreSQL/SQLite ", +; MySQL `, SQL Server [ ]). Only characters the database accepts as +; identifier quotes are honored: SQL Server accepts [ or ", SQLite accepts +; " or `; an unrecognized character falls back to the convention. Can be set +; per database, e.g. under [db.sqlserver]. +autocompleteQuoteCharacter = 0 + ; Parameter type configuration (https://github.com/sql-formatter-org/sql-formatter/blob/66c219b1c1329ac67d6a77dc58ce563e166c43f1/docs/paramTypes.md) [db.default.paramTypes] ; Positional param `?` @@ -99,6 +141,10 @@ maxConnections = 10 ; Allow skipping to last page of a table in the table view. This uses a count query so it could be expensive allowSkipToLastPage = true +[db.starrocks] +; Allow skipping to last page of a table in the table view. This uses a count query so it could be expensive +allowSkipToLastPage = true + [db.postgres] ; number of milliseconds to wait before timing out when connecting a new client. ; Reference https://node-postgres.com/apis/pool#new-pool @@ -171,6 +217,16 @@ numbered[] = '?' named[] = ':' named[] = '@' +[db.bedrock] +; Allow skipping to last page of a table in the table view. This uses a count query so it could be expensive +allowSkipToLastPage = true + +[db.bedrock.paramTypes] +positional = true +numbered[] = '?' +named[] = ':' +named[] = '@' + [db.sqlserver] ; Allow skipping to last page of a table in the table view. This uses a count query so it could be expensive allowSkipToLastPage = false @@ -228,6 +284,20 @@ allowSkipToLastPage = false ; number of milliseconds to wait before timeing out waiting for a new connection from the pool connectionTimeout = 15000 +[db.dynamodb] +; Allow skipping to last page of a table in the table view. This uses a count query so it could be expensive +allowSkipToLastPage = false +; Timeout in milliseconds for cursor fetch operations +cursorFetchTimeout = 30000 +; Number of items to sample when discovering non-key attributes for the +; column list. DynamoDB only declares types for indexed attributes, so we +; sample a few rows to surface the rest. +columnSampleSize = 50 + +[db.snowflake] +connectionTimeout = 60000 +idleTimeout = 60000 + [keybindings.general] refresh[] = f5 refresh[] = ctrlOrCmd+r @@ -235,10 +305,12 @@ addRow = ctrlOrCmd+n save = ctrlOrCmd+s openInSqlEditor = ctrlOrCmd+shift+s openQuickSearch = ctrlOrCmd+p +jsonViewerSidebar = copySelection = ctrlOrCmd+c pasteSelection = ctrlOrCmd+v cloneSelection = ctrlOrCmd+d -deleteSelection = delete +deleteSelection[] = delete +deleteSelection[] = ctrlOrCmd+backspace undo = ctrlOrCmd+z redo[] = ctrlOrCmd+shift+z redo[] = ctrlOrCmd+y @@ -276,12 +348,12 @@ altOpenInBackground = ctrlOrCmd+right [keybindings.queryEditor] selectEditor = ctrlOrCmd+l -submitTabQuery[] = ctrlOrCmd+enter -submitTabQuery[] = f5 -submitCurrentQuery[] = ctrlOrCmd+shift+enter -submitCurrentQuery[] = shift+f5 -submitQueryToFile = ctrlOrCmd+i -submitCurrentQueryToFile = ctrlOrCmd+shift+i +primaryQueryAction[] = ctrlOrCmd+enter +primaryQueryAction[] = f5 +secondaryQueryAction[] = ctrlOrCmd+shift+enter +secondaryQueryAction[] = shift+f5 +primaryQueryToFileAction = ctrlOrCmd+i +secondaryQueryToFileAction = ctrlOrCmd+shift+i selectNextResult = shift+up selectPreviousResult = shift+down copyResultSelection = ctrlOrCmd+c @@ -291,16 +363,41 @@ closeTableFilter = esc manualCommit = ctrlOrCmd+shift+c manualRollback = ctrlOrCmd+shift+r +[keybindings.resultTable] +openEditorModal = shift+enter + [keybindings.tableTable] nextPage = ctrlOrCmd+right previousPage = ctrlOrCmd+left focusOnFilterInput = ctrlOrCmd+f openEditorModal = shift+enter +pasteAsNewRows = ctrlOrCmd+shift+v +nullSelection = backspace firstPage = ctrlOrCmd+h lastPage = ctrlOrCmd+l +[keybindings.plugins.bks-ai-shell] +new-tab-dropdown-item = ctrlOrCmd+l + [azure] azSQLLoginScope = https://ossrdbms-aad.database.windows.net +; Change the behaviour of the SSO workflow, change at your own risk +ssoPrompt = 'select_account' + +[pluginSystem] +; Disable plugin system entirely +disabled = true + +; Disable all community plugin functionality (installing, fetching the plugin list from the registry, and loading) +communityDisabled = true + +; When disabled = true, only plugins listed here are allowed to be installed and loaded. +; Has no effect when disabled = false. +; Example: +; allow[] = bks-ai-shell +; allow[] = bks-er-diagram +allow[] = bks-ai-shell +allow[] = bks-er-diagram [plugins.bks-ai-shell] disabled = false diff --git a/apps/studio/deprecated.config.ini b/apps/studio/deprecated.config.ini new file mode 100644 index 00000000000..f729880cf4d --- /dev/null +++ b/apps/studio/deprecated.config.ini @@ -0,0 +1,5 @@ +[keybindings.queryEditor] +submitTabQuery = 'Replaced by primaryQueryAction' +submitCurrentQuery = 'Replaced by secondaryQueryAction' +submitQueryToFile = 'Replaced by primaryQueryToFileAction' +submitCurrentQueryToFile = 'Replaced by secondaryQueryToFileAction' diff --git a/apps/studio/e2e/helpers/launchElectron.ts b/apps/studio/e2e/helpers/launchElectron.ts new file mode 100644 index 00000000000..8f87d1f7650 --- /dev/null +++ b/apps/studio/e2e/helpers/launchElectron.ts @@ -0,0 +1,23 @@ +import { _electron as electron } from 'playwright'; + +export async function launchElectron() { + + const app = await electron.launch({ + args: ['dist/main.js'], + env: process.env + }); + + const win = await app.firstWindow(); + + await win.setViewportSize({ width: 1600, height: 1000 }); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + try { + await win.getByText("Don't show again", { exact: false }).click({ timeout: 500 }) + } catch(e) { + + } + + return app; +} diff --git a/apps/studio/e2e/pageActions/index.ts b/apps/studio/e2e/pageActions/index.ts index 01f87e6c39d..296e055881c 100644 --- a/apps/studio/e2e/pageActions/index.ts +++ b/apps/studio/e2e/pageActions/index.ts @@ -4,6 +4,7 @@ import { userActions as resultPaneActions } from './resultPanelActions'; import { userActions as tableSideBarActions } from './tableSideBarActions'; import { userActions as footerActions } from './footerActions'; import { userActions as toggleSideBarActions } from './toggleSideBarActions'; + export const userActions = (page) => { return { ...newDatabaseConnectionActions(page), diff --git a/apps/studio/e2e/tests/appLaunch.test.ts b/apps/studio/e2e/tests/appLaunch.test.ts new file mode 100644 index 00000000000..c8c8465346d --- /dev/null +++ b/apps/studio/e2e/tests/appLaunch.test.ts @@ -0,0 +1,20 @@ +import { test, expect, ElectronApplication, Page } from '@playwright/test'; +import { NewDatabaseConnection } from "../pageComponents/NewDatabaseConnection"; +import { launchElectron } from 'e2e/helpers/launchElectron'; + +let electronApp: ElectronApplication; +let window: Page; + +test.describe("App Launch", () => { + + test("opens the app", async () => { + electronApp = await launchElectron(); + window = await electronApp.firstWindow(); + const newDatabaseConnection = new NewDatabaseConnection(window); + + await expect(newDatabaseConnection.newConnectionDropdown).toBeVisible(); + + await electronApp.close(); + }); + +}); diff --git a/apps/studio/e2e/tests/contextMenu.test.ts b/apps/studio/e2e/tests/contextMenu.test.ts index d5d07ab2c98..5754a00f158 100644 --- a/apps/studio/e2e/tests/contextMenu.test.ts +++ b/apps/studio/e2e/tests/contextMenu.test.ts @@ -1,31 +1,23 @@ -import { _electron as electron } from 'playwright'; -import { test, expect, beforeEach, afterEach } from '@playwright/test'; +import { test, expect, ElectronApplication, Page } from '@playwright/test'; import { QueryTab } from '../pageComponents/QueryTab'; -import { Footer } from '../pageComponents/Footer'; -import { QueryResultPane } from '../pageComponents/QueryResultPane'; import { userActions } from "../pageActions/index"; import { POSTGRES_CONFIG } from './config/postgresDbConfig'; +import { launchElectron } from 'e2e/helpers/launchElectron'; const POSTGRES_QUERY = 'SELECT * FROM actor WHERE actor_id IN (1, 2);'; -let electronApp; -let window; -let queryTab; -let footer; -let resultPane; -let userAttemptsTo; +let electronApp: ElectronApplication; +let win: Page; +let queryTab: QueryTab; +let userAttemptsTo: any; test.describe("Using the context menu", () => { - beforeEach(async () => { - electronApp = await electron.launch({ - args: ['dist/main.js'], - }); - window = await electronApp.firstWindow(); - queryTab = new QueryTab(window); - resultPane = new QueryResultPane(window); - footer = new Footer(window); - userAttemptsTo = userActions(window); + test.beforeEach(async () => { + electronApp = await launchElectron(); + win = await electronApp.firstWindow(); + queryTab = new QueryTab(win); + userAttemptsTo = userActions(win); await userAttemptsTo.selectNewConnection(POSTGRES_CONFIG.connectionType); @@ -35,35 +27,22 @@ test.describe("Using the context menu", () => { await expect(queryTab.queryTabTextArea).toBeVisible(); }); - afterEach(async () => { + test.afterEach(async () => { if (electronApp) { await electronApp.close(); } }); test("paste a query using context menu", async () => { - // adding a default text to be asserted later - await window.evaluate((clipboardText) => navigator.clipboard.writeText(clipboardText), POSTGRES_QUERY); + // adding a default text to be asserted later + await win.evaluate((clipboardText) => window.main.writeTextToClipboard(clipboardText), POSTGRES_QUERY); - await queryTab.queryTabTextArea.click({ - button: 'right' - }); + await queryTab.queryTabTextArea.click({ + button: 'right' + }); - await window.getByRole('menuitem', { name: 'Paste' }).click(); - const queryTabText = await queryTab.queryTabTextArea.innerText(); - await expect(queryTabText).toContain(POSTGRES_QUERY); - }); - - test("paste a password using context menu", async () => { - // adding a default text to be asserted later - await window.evaluate((clipboardText) => navigator.clipboard.writeText(clipboardText), POSTGRES_QUERY); - - await queryTab.queryTabTextArea.click({ - button: 'right' - }); - - await window.getByRole('menuitem', { name: 'Paste' }).click(); - const queryTabText = await queryTab.queryTabTextArea.innerText(); - await expect(queryTabText).toContain(POSTGRES_QUERY); + await win.getByRole('menuitem', { name: 'Paste' }).click(); + const queryTabText = await queryTab.queryTabTextArea.innerText(); + expect(queryTabText).toContain(POSTGRES_QUERY); }); }); diff --git a/apps/studio/e2e/tests/copyResults.test.ts b/apps/studio/e2e/tests/copyResults.test.ts index fdba1ff4451..a797ab7701d 100644 --- a/apps/studio/e2e/tests/copyResults.test.ts +++ b/apps/studio/e2e/tests/copyResults.test.ts @@ -1,26 +1,24 @@ -import { _electron as electron } from 'playwright'; -import { test, expect, beforeEach, afterEach } from '@playwright/test'; +import { test, expect, ElectronApplication, Page } from '@playwright/test'; import { QueryTab } from '../pageComponents/QueryTab'; import { Footer } from '../pageComponents/Footer'; import { QueryResultPane } from '../pageComponents/QueryResultPane'; import { userActions } from "../pageActions/index"; import { POSTGRES_CONFIG } from './config/postgresDbConfig'; +import { launchElectron } from 'e2e/helpers/launchElectron'; const POSTGRES_QUERY = 'SELECT * FROM actor WHERE actor_id IN (1, 2);'; -let electronApp; -let window; -let queryTab; -let footer; -let resultPane; -let userAttemptsTo; +let electronApp: ElectronApplication; +let window: Page; +let queryTab: QueryTab; +let footer: Footer; +let resultPane: QueryResultPane; +let userAttemptsTo: any; test.describe("Copy Results Verifications", () => { - beforeEach(async () => { - electronApp = await electron.launch({ - args: ['dist/main.js'], - }); + test.beforeEach(async () => { + electronApp = await launchElectron(); window = await electronApp.firstWindow(); queryTab = new QueryTab(window); resultPane = new QueryResultPane(window); @@ -38,7 +36,7 @@ test.describe("Copy Results Verifications", () => { await expect(resultPane.resultSecondRow).toBeVisible(); }); - afterEach(async () => { + test.afterEach(async () => { if (electronApp) { await electronApp.close(); } diff --git a/apps/studio/e2e/tests/exportResults.test.ts b/apps/studio/e2e/tests/exportResults.test.ts index 3c24a3408be..8dd2f1c7e8d 100644 --- a/apps/studio/e2e/tests/exportResults.test.ts +++ b/apps/studio/e2e/tests/exportResults.test.ts @@ -1,27 +1,24 @@ -import { _electron as electron } from 'playwright'; -import { test, expect, beforeEach, afterEach } from '@playwright/test'; +import { test, expect, ElectronApplication, Page } from '@playwright/test'; import { QueryTab } from '../pageComponents/QueryTab'; import { Footer } from '../pageComponents/Footer'; import { QueryResultPane } from '../pageComponents/QueryResultPane'; import { userActions } from "../pageActions/index"; import { POSTGRES_CONFIG } from './config/postgresDbConfig'; +import { launchElectron } from 'e2e/helpers/launchElectron'; const POSTGRES_QUERY = 'SELECT * FROM actor WHERE actor_id IN (1, 2);'; - -let electronApp; -let window; -let queryTab; -let footer; -let resultPane; -let userAttemptsTo; +let electronApp: ElectronApplication; +let window: Page; +let queryTab: QueryTab; +let footer: Footer; +let resultPane: QueryResultPane; +let userAttemptsTo: any; test.describe("Export Results Verifications", () => { - beforeEach(async () => { - electronApp = await electron.launch({ - args: ['dist/main.js'] - }); + test.beforeEach(async () => { + electronApp = await launchElectron(); window = await electronApp.firstWindow(); queryTab = new QueryTab(window); resultPane = new QueryResultPane(window); @@ -39,7 +36,7 @@ test.describe("Export Results Verifications", () => { await expect(resultPane.resultSecondRow).toBeVisible(); }); - afterEach(async () => { + test.afterEach(async () => { if (electronApp) { await electronApp.close(); } diff --git a/apps/studio/e2e/tests/jsonSideBar.test.ts b/apps/studio/e2e/tests/jsonSideBar.test.ts index 1ca7ebfa8a9..a92b9178662 100644 --- a/apps/studio/e2e/tests/jsonSideBar.test.ts +++ b/apps/studio/e2e/tests/jsonSideBar.test.ts @@ -1,27 +1,22 @@ -import { _electron as electron } from 'playwright'; -import { test, expect, beforeEach, afterEach } from '@playwright/test'; -import { SideBarToggle } from '../pageComponents/SideBarToggle'; +import { test, expect, ElectronApplication, Page } from '@playwright/test'; import { QueryResultPane } from '../pageComponents/QueryResultPane'; import { QueryTab } from '../pageComponents/QueryTab'; import { userActions } from "../pageActions/index"; import { POSTGRES_CONFIG } from './config/postgresDbConfig'; +import { launchElectron } from 'e2e/helpers/launchElectron'; const POSTGRES_QUERY = 'SELECT * FROM actor WHERE actor_id IN (1, 2);'; - -let electronApp; -let window; -let sideBarToggle; -let queryTab; -let resultPane; -let userAttemptsTo; +let electronApp: ElectronApplication; +let window: Page; +let queryTab: QueryTab; +let resultPane: QueryResultPane; +let userAttemptsTo: any; test.describe("JSON Sidebar Verifications", () => { - beforeEach(async () => { - electronApp = await electron.launch({ - args: ['dist/main.js'] - }); + test.beforeEach(async () => { + electronApp = await launchElectron(); window = await electronApp.firstWindow(); queryTab = new QueryTab(window); resultPane = new QueryResultPane(window); @@ -38,21 +33,21 @@ test.describe("JSON Sidebar Verifications", () => { await expect(resultPane.resultSecondRow).toBeVisible(); }); - afterEach(async () => { + test.afterEach(async () => { if (electronApp) { await electronApp.close(); } }); test.skip("accessing the JSON sidebar", async () => { - - await userAttemptsTo.toggleLeftSideBar(); - - // need to deal with the free trial modal - // await window.getByText('Start Free Trial').click(); - // await window.getByRole('button', { name: 'more_vert' }).click(); - // need to create the JSON SideBar files, but since we won't be activating this test now... - const jsonSideBar = await window.locator('[contenteditable="true"][role="textbox"]'); - await expect(jsonSideBar).toBeVisible(); + + await userAttemptsTo.toggleLeftSideBar(); + + // need to deal with the free trial modal + // await window.getByText('Start Free Trial').click(); + // await window.getByRole('button', { name: 'more_vert' }).click(); + // need to create the JSON SideBar files, but since we won't be activating this test now... + const jsonSideBar = window.locator('[contenteditable="true"][role="textbox"]'); + await expect(jsonSideBar).toBeVisible(); }); }); diff --git a/apps/studio/e2e/tests/largeResultSet.test.ts b/apps/studio/e2e/tests/largeResultSet.test.ts new file mode 100644 index 00000000000..13639c7c353 --- /dev/null +++ b/apps/studio/e2e/tests/largeResultSet.test.ts @@ -0,0 +1,111 @@ +import { test, expect, ElectronApplication, Page } from '@playwright/test'; +import { launchElectron } from 'e2e/helpers/launchElectron'; +import { NewDatabaseConnection } from '../pageComponents/NewDatabaseConnection'; +import { QueryTab } from '../pageComponents/QueryTab'; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; + +/** + * Regression guard for issue #17 — "queries that output 500+ rows break the app". + * + * Tabulator only virtualises when it is given a definite height. The stacked + * results layout sizes each block by its content, so the table's "100%" used to + * resolve to auto and every row went into the DOM. Five thousand rows took ~30s + * to render and left the window unusable. + * + * The test connects to a SQLite file it builds itself and counts the rows + * Tabulator actually committed to the DOM. A virtualised table renders a + * screenful whatever the row count. + * + * The layout under test comes from RESULTS_LAYOUT and must already be saved in + * the app database, because there is no settings control reachable from this + * screen. To exercise the stacked path: + * + * node scripts/seedResultsLayout.js apps/studio/tmp/app.db stacked + * RESULTS_LAYOUT=stacked TEST_MODE=1 yarn playwright test e2e/tests/largeResultSet.test.ts + */ + +const LAYOUT = process.env.RESULTS_LAYOUT ?? 'tabs'; +const ROW_COUNT = 5000; +/** Generous enough to cover any screen; far below an unvirtualised render. */ +const VIRTUALISED_CEILING = 200; + +const dbPath = path.join(os.tmpdir(), `bks-large-result-${LAYOUT}.db`); + +let electronApp: ElectronApplication; +let window: Page; +let connection: NewDatabaseConnection; +let queryTab: QueryTab; + +async function connectToSqlite() { + await connection.newConnectionDropdown.selectOption('sqlite'); + await window.locator('#Database').fill(dbPath); + await connection.connectButton.click(); + await expect(queryTab.queryTabTextArea).toBeVisible({ timeout: 30000 }); +} + +async function runQuery(sql: string) { + await queryTab.queryTabTextArea.fill(sql); + await (await queryTab.tabRunQueryButton()).click(); +} + +test.describe('Large result sets', () => { + test.beforeAll(() => { + // A fresh file each run, so the fixture never drifts. + for (const suffix of ['', '-wal', '-shm']) { + fs.rmSync(dbPath + suffix, { force: true }); + } + }); + + test.beforeEach(async () => { + electronApp = await launchElectron(); + window = await electronApp.firstWindow(); + connection = new NewDatabaseConnection(window); + queryTab = new QueryTab(window); + }); + + test.afterEach(async () => { + if (electronApp) await electronApp.close(); + }); + + test(`Given ${ROW_COUNT} rows in the ${LAYOUT} layout, Tabulator renders only a screenful`, async () => { + await connectToSqlite(); + + // Build the fixture through the app itself, so the test needs no native + // sqlite binding of its own. + await runQuery(` + DROP TABLE IF EXISTS big; + CREATE TABLE big AS + WITH RECURSIVE counter(x) AS ( + SELECT 1 UNION ALL SELECT x + 1 FROM counter WHERE x < ${ROW_COUNT} + ) + SELECT + x AS id, + 'Subject ' || x AS name, + 'subject' || x || '@aperture.test' AS email, + 'Row note number ' || x || ' for testing large result sets' AS note, + x * 1.5 AS amount + FROM counter; + `); + await window.waitForTimeout(3000); + + await runQuery('SELECT * FROM big;'); + await expect(window.locator('.tabulator-row').first()).toBeVisible({ timeout: 60000 }); + // Let Tabulator settle its render pass before counting. + await window.waitForTimeout(3000); + + const renderedRows = await window.locator('.tabulator-row').count(); + if (process.env.RESULTS_SCREENSHOT) { + await window.screenshot({ path: process.env.RESULTS_SCREENSHOT }); + } + + expect( + renderedRows, + `${LAYOUT} layout put ${renderedRows} of ${ROW_COUNT} rows in the DOM — ` + + `Tabulator is not virtualising, so it has no definite height` + ).toBeLessThan(VIRTUALISED_CEILING); + + expect(renderedRows, 'the table should still render something').toBeGreaterThan(0); + }); +}); diff --git a/apps/studio/e2e/tests/newConnection.test.ts b/apps/studio/e2e/tests/newConnection.test.ts index 850728bfd3a..2488ffcabfe 100644 --- a/apps/studio/e2e/tests/newConnection.test.ts +++ b/apps/studio/e2e/tests/newConnection.test.ts @@ -1,20 +1,20 @@ -import { _electron as electron } from 'playwright'; -import { test, expect } from '@playwright/test'; +import { test, expect, ElectronApplication, Page } from '@playwright/test'; import { NewDatabaseConnection } from '../pageComponents/NewDatabaseConnection'; import { QueryTab } from '../pageComponents/QueryTab'; import { userActions } from "../pageActions/index"; import { POSTGRES_CONFIG } from './config/postgresDbConfig'; +import { launchElectron } from 'e2e/helpers/launchElectron'; -let electronApp; -let window; -let newDatabaseConnection; -let queryTab; -let userAttemptsTo; +let electronApp: ElectronApplication; +let window: Page; +let newDatabaseConnection: NewDatabaseConnection; +let queryTab: QueryTab; +let userAttemptsTo: any; test.describe('New Connection Tests', () => { test.beforeEach(async () => { - electronApp = await electron.launch({ args: ['dist/main.js'] }); + electronApp = await launchElectron(); window = await electronApp.firstWindow(); userAttemptsTo = userActions(window); newDatabaseConnection = new NewDatabaseConnection(window); @@ -28,7 +28,6 @@ test.describe('New Connection Tests', () => { }); test('Test a Postgres connection', async () => { - await userAttemptsTo.selectNewConnection(POSTGRES_CONFIG.connectionType); await userAttemptsTo.insertDatabaseDetails(POSTGRES_CONFIG); await userAttemptsTo.testDatabaseConnection(); diff --git a/apps/studio/e2e/tests/queryExecution.test.ts b/apps/studio/e2e/tests/queryExecution.test.ts index b6d9d68bda8..3e32d06ceb9 100644 --- a/apps/studio/e2e/tests/queryExecution.test.ts +++ b/apps/studio/e2e/tests/queryExecution.test.ts @@ -1,28 +1,28 @@ -import { _electron as electron } from 'playwright'; -import { test, expect, beforeEach, afterEach } from '@playwright/test'; +import { test, expect, ElectronApplication, Page } from '@playwright/test'; import { QueryTab } from '../pageComponents/QueryTab'; import { QueryResultPane } from '../pageComponents/QueryResultPane'; import { userActions } from "../pageActions/index"; import { POSTGRES_CONFIG } from './config/postgresDbConfig'; +import { launchElectron } from 'e2e/helpers/launchElectron'; -let electronApp; -let window; -let queryTab; -let resultPane; -let userAttemptsTo; +let electronApp: ElectronApplication; +let window: Page; +let queryTab: QueryTab; +let resultPane: QueryResultPane; +let userAttemptsTo: any; const testQueryPrefix = `SELECT * FROM actor`; test.describe("Postgres query execution", () => { - beforeEach(async () => { - electronApp = await electron.launch({ args: ['dist/main.js'] }); + test.beforeEach(async () => { + electronApp = await launchElectron(); window = await electronApp.firstWindow(); queryTab = new QueryTab(window); resultPane = new QueryResultPane(window); userAttemptsTo = userActions(window); }); - afterEach(async () => { + test.afterEach(async () => { if (electronApp) { await electronApp.close(); } diff --git a/apps/studio/e2e/tests/queryResultPane.test.ts b/apps/studio/e2e/tests/queryResultPane.test.ts index 7ad862fb86a..20800995ca4 100644 --- a/apps/studio/e2e/tests/queryResultPane.test.ts +++ b/apps/studio/e2e/tests/queryResultPane.test.ts @@ -1,30 +1,29 @@ -import { _electron as electron } from 'playwright'; -import { test, expect, beforeEach, afterEach } from '@playwright/test'; +import { test, expect, ElectronApplication, Page } from '@playwright/test'; import { QueryTab } from '../pageComponents/QueryTab'; import { QueryResultPane } from '../pageComponents/QueryResultPane'; import { userActions } from "../pageActions/index"; import { POSTGRES_CONFIG } from './config/postgresDbConfig'; +import { launchElectron } from 'e2e/helpers/launchElectron'; const POSTGRES_QUERY = 'SELECT * FROM actor WHERE actor_id IN (1, 2);'; - -let electronApp; -let window; -let queryTab; -let resultPane; -let userAttemptsTo; +let electronApp: ElectronApplication; +let window: Page; +let queryTab: QueryTab; +let resultPane: QueryResultPane; +let userAttemptsTo: any; test.describe("Result Pane Verifications", () => { - beforeEach(async () => { - electronApp = await electron.launch({ args: ['dist/main.js'] }); + test.beforeEach(async () => { + electronApp = await launchElectron(); window = await electronApp.firstWindow(); queryTab = new QueryTab(window); resultPane = new QueryResultPane(window); userAttemptsTo = userActions(window); }); - afterEach(async () => { + test.afterEach(async () => { if (electronApp) { await electronApp.close(); } @@ -56,15 +55,15 @@ test.describe("Result Pane Verifications", () => { await userAttemptsTo.runQuery(); await expect(resultPane.resultSecondRow).toBeVisible(); - // clicking twice due to a bug (will be reported) + // clicking twice due to a bug (will be reported) await userAttemptsTo.clickOnFirstColumnHeader(); const cellValueBeforeReordering = await resultPane.firstItemAndFirstColumn.textContent() - await expect(await resultPane.firstItemAndFirstColumn).toBeVisible(); + await expect(resultPane.firstItemAndFirstColumn).toBeVisible(); await userAttemptsTo.clickOnFirstColumnHeader(); const cellValueAfterReordering = await resultPane.firstItemAndFirstColumn.textContent(); await expect(resultPane.resultFirstRow).toBeVisible(); - await expect(cellValueBeforeReordering).not.toBe(cellValueAfterReordering); + expect(cellValueBeforeReordering).not.toBe(cellValueAfterReordering); }); }); diff --git a/apps/studio/e2e/tests/savedQueryRestore.test.ts b/apps/studio/e2e/tests/savedQueryRestore.test.ts new file mode 100644 index 00000000000..86c65200fa8 --- /dev/null +++ b/apps/studio/e2e/tests/savedQueryRestore.test.ts @@ -0,0 +1,72 @@ +import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test'; +import * as path from 'path'; +import * as os from 'os'; + +async function launch(): Promise<{ app: ElectronApplication; win: Page }> { + const app = await electron.launch({ + args: ['dist/main.js'], + env: { ...process.env, TEST_MODE: '1' }, + }); + const win = await app.firstWindow(); + await win.setViewportSize({ width: 1600, height: 1000 }); + await win.waitForTimeout(1500); + try { + await win.getByText("Don't show again", { exact: false }).click({ timeout: 800 }); + } catch (e) { /* no dialog */ } + return { app, win }; +} + +test('restores in-progress edits to a SAVED query after relaunch', async () => { + const dbFile = path.join(os.tmpdir(), `bks-restore-${Date.now()}.db`); + const dbName = path.basename(dbFile); + const SAVED = 'select 1 as original_saved_text;'; + const EDITED = 'select 999 as restored_after_relaunch;'; + + // ---------- First launch: connect, save a query, edit it, let it autosave ---------- + let { app, win } = await launch(); + + await win.getByLabel('Connection Type').selectOption('sqlite'); + await win.locator('#Database').fill(dbFile); + await win.getByRole('button', { name: 'Connect' }).click(); + + const editor = win.locator('#tab-0').getByRole('textbox'); + await expect(editor).toBeVisible({ timeout: 30000 }); + await editor.click(); + await editor.fill(SAVED); + + // Save as a favorite/saved query (Ctrl+S opens the save modal) + await win.keyboard.press('Control+s'); + const titleInput = win.locator('input[name="title"]'); + await expect(titleInput).toBeVisible({ timeout: 10000 }); + await titleInput.fill('RestoreTestQuery'); + await win.locator('form button[type="submit"].btn-primary').first().click(); + await win.waitForTimeout(1500); // let the save settle + + // Now edit the saved query and wait past the 1s autosave debounce + await editor.click(); + await editor.fill(EDITED); + await expect(editor).toContainText('restored_after_relaunch'); + await win.waitForTimeout(2500); + + await app.close(); + + // ---------- Second launch: reconnect to the same DB, expect the edits restored ---------- + ({ app, win } = await launch()); + + // Recent connections connect on double-click; the item shows the db file name + const recent = win.locator('.recent-connection-list').getByText(dbName, { exact: false }).first(); + await expect(recent).toBeVisible({ timeout: 15000 }); + await recent.dblclick(); + + const editor2 = win.locator('#tab-0').getByRole('textbox'); + await expect(editor2).toBeVisible({ timeout: 30000 }); + + // The fix: the editor should show the EDITED text (restored from unsavedQueryText), + // not revert to the SAVED text. + await expect(editor2).toContainText('restored_after_relaunch', { timeout: 15000 }); + await expect(editor2).not.toContainText('original_saved_text'); + + await win.screenshot({ path: 'e2e/savedQueryRestore.png', fullPage: false }); + + await app.close(); +}); diff --git a/apps/studio/e2e/tests/tableCreation.test.ts b/apps/studio/e2e/tests/tableCreation.test.ts index 53fdb0e00e0..e9a673acefc 100644 --- a/apps/studio/e2e/tests/tableCreation.test.ts +++ b/apps/studio/e2e/tests/tableCreation.test.ts @@ -1,26 +1,20 @@ -import { _electron as electron } from 'playwright'; -import { test, expect, beforeEach, afterEach } from '@playwright/test'; -import { QueryTab } from '../pageComponents/QueryTab'; -import { QueryResultPane } from '../pageComponents/QueryResultPane'; +import { test, expect, ElectronApplication, Page } from '@playwright/test'; import { TablesSideBar } from '../pageComponents/TablesSideBar'; import { POSTGRES_CONFIG } from './config/postgresDbConfig'; import { userActions } from "../pageActions/index"; +import { launchElectron } from 'e2e/helpers/launchElectron'; -let electronApp; -let window; -let queryTab; -let resultPane; -let userAttemptsTo; -let tablesSideBar; -let newTableName; +let electronApp: ElectronApplication; +let window: Page; +let tablesSideBar: TablesSideBar; +let newTableName: string; +let userAttemptsTo: any; test.describe("Table creation", () => { - beforeEach(async () => { - electronApp = await electron.launch({ args: ['dist/main.js'] }); + test.beforeEach(async () => { + electronApp = await launchElectron(); window = await electronApp.firstWindow(); - queryTab = new QueryTab(window); - resultPane = new QueryResultPane(window); tablesSideBar = new TablesSideBar(window); userAttemptsTo = userActions(window); @@ -29,7 +23,7 @@ test.describe("Table creation", () => { await userAttemptsTo.connectWithDatabase(); }); - afterEach(async () => { + test.afterEach(async () => { if (!electronApp) return; const dropTableQuery = `DROP TABLE ${newTableName};` const newQueryIndex = '1'; diff --git a/apps/studio/e2e/tests/tableSideBar.test.ts b/apps/studio/e2e/tests/tableSideBar.test.ts index 0551ae8d31b..8b3833a7283 100644 --- a/apps/studio/e2e/tests/tableSideBar.test.ts +++ b/apps/studio/e2e/tests/tableSideBar.test.ts @@ -1,28 +1,21 @@ -import { _electron as electron } from 'playwright'; -import { test, expect, beforeEach, afterEach } from '@playwright/test'; -import { NewDatabaseConnection } from '../pageComponents/NewDatabaseConnection'; -import { QueryTab } from '../pageComponents/QueryTab'; -import { QueryResultPane } from '../pageComponents/QueryResultPane'; +import { test, expect, ElectronApplication, Page } from '@playwright/test'; import { TablesSideBar } from '../pageComponents/TablesSideBar'; import { POSTGRES_CONFIG } from './config/postgresDbConfig'; import { userActions } from "../pageActions/index"; +import { launchElectron } from 'e2e/helpers/launchElectron'; + +let electronApp: ElectronApplication; +let window: Page; +let userAttemptsTo: any; +let tablesSideBar: TablesSideBar; +let newTableName: string; -let electronApp; -let window; -let queryTab; -let resultPane; -let userAttemptsTo; -let newDatabaseConnection; -let tablesSideBar; -let newTableName; test.describe("Table creation", () => { + test.setTimeout(60_000); // 60 seconds - beforeEach(async () => { - electronApp = await electron.launch({ args: ['dist/main.js'] }); + test.beforeEach(async () => { + electronApp = await launchElectron(); window = await electronApp.firstWindow(); - newDatabaseConnection = new NewDatabaseConnection(window); - queryTab = new QueryTab(window); - resultPane = new QueryResultPane(window); tablesSideBar = new TablesSideBar(window); userAttemptsTo = userActions(window); @@ -31,7 +24,7 @@ test.describe("Table creation", () => { await userAttemptsTo.connectWithDatabase(); }); - afterEach(async () => { + test.afterEach(async () => { if (!electronApp) return; const dropTableQuery = `DROP TABLE ${newTableName};` const newQueryIndex = '1'; @@ -43,7 +36,6 @@ test.describe("Table creation", () => { }); test("create a table and verify that the columns are visible in the sidebar", async () => { - test.setTimeout(60_000); // 60 seconds newTableName = `automated_test_table_${Date.now()}`; const columnName = `test_number_${newTableName}`; const CREATE_TABLE_QUERY = `CREATE TABLE ${newTableName} ( diff --git a/apps/studio/electron-builder-config-test.js b/apps/studio/electron-builder-config-test.js index b5a34d52393..205a6b50514 100644 --- a/apps/studio/electron-builder-config-test.js +++ b/apps/studio/electron-builder-config-test.js @@ -124,6 +124,7 @@ module.exports = { target: [ 'appImage' ], + syncDesktopName: true, desktop: { 'StartupWMClass': 'beekeeper-studio' }, diff --git a/apps/studio/electron-builder-config.js b/apps/studio/electron-builder-config.js index 97625fafd86..bd684085594 100644 --- a/apps/studio/electron-builder-config.js +++ b/apps/studio/electron-builder-config.js @@ -37,7 +37,11 @@ module.exports = { ], afterPack: "./build/afterPack.js", asarUnpack: [ - 'package.json' + 'package.json', + // msnodesqlv8 ships a native ODBC addon used for SQL Server integrated + // (SSPI/Kerberos) auth. prebuild-install drops the binary under build/Release + // and/or prebuilds depending on platform, so unpack both. + '**/msnodesqlv8/**/*.node' ], extraResources: [ { @@ -71,7 +75,7 @@ module.exports = { { from: ".", to: ".", - filter: ["user.config.ini", "system.config.ini", "default.config.ini"], + filter: ["user.config.ini", "system.config.ini", "default.config.ini", "deprecated.config.ini"], }, { from: "node_modules/ws", @@ -172,6 +176,11 @@ module.exports = { 'flatpak', 'pacman' ], + // Align the installed .desktop filename with the WM_CLASS Electron reports at + // runtime (derived from desktopName in package.json) so desktop environments + // associate running windows with the launcher entry. Both resolve to + // beekeeper-studio.desktop / StartupWMClass=beekeeper-studio. + syncDesktopName: true, desktop: { entry: { 'StartupWMClass': 'beekeeper-studio', @@ -210,16 +219,46 @@ module.exports = { publish: [ 'github' ], fpm: rpmFpmOptions, }, - snap: { - base: 'core22', + snapcraft: { + base: 'core24', + // Only attach the built .snap to the GitHub release here. Pushing to the + // snap store is done as a separate final step (see publish_snapcraft in + // studio-publish.yml) so it can be retried without rebuilding when + // credentials are stale or the store is unavailable. publish: [ - 'github', - 'snapStore' + 'github' ], - environment: { - "ELECTRON_SNAP": "true" - }, - plugs: ["default", "ssh-keys", "removable-media", "mount-observe"] + core24: { + // Build the core24 snap in an isolated LXD container. CI provisions LXD + // via canonical/setup-lxd on every Linux runner. + useLXD: true, + environment: { + "ELECTRON_SNAP": "true" + }, + // core24 drops browser-support from its default plug set. It must be + // declared so Chromium can use /dev/shm under strict confinement. + // Use the plain interface (not allow-sandbox: true) — the privileged + // form is denied auto-connection by snapd, so it would stay disconnected + // on both sideloaded and store installs. electron-builder appends + // --no-sandbox automatically when allow-sandbox isn't set, matching the + // previous core22 behaviour. + plugs: [ + "default", + "ssh-keys", + "removable-media", + "mount-observe", + "browser-support" + ], + // Bundle fonts so non-Latin text and emoji render correctly. "default" + // keeps electron-builder's standard stage packages. + stagePackages: [ + "default", + "fonts-noto", + "fonts-noto-cjk", + "fonts-noto-color-emoji", + "fonts-liberation" + ] + } }, win: { icon: './public/icons/png/512x512.png', diff --git a/apps/studio/esbuild.mjs b/apps/studio/esbuild.mjs index 6a10370c4d2..b2a96805fbd 100755 --- a/apps/studio/esbuild.mjs +++ b/apps/studio/esbuild.mjs @@ -35,7 +35,7 @@ const externals = ['better-sqlite3', 'sqlite3', 'oracledb', '@electron/remote', "@google-cloud/bigquery", 'pg-query-stream', 'electron', '@duckdb/node-api', '@mongosh/browser-runtime-electron', '@mongosh/service-provider-node-driver', - 'mongodb-client-encryption', 'sqlanywhere', 'ws', 'kerberos', + 'mongodb-client-encryption', 'sqlanywhere', 'ws', 'kerberos', 'msnodesqlv8', ...ensureInstalled, ] @@ -43,17 +43,27 @@ let electron = null /** @type {fs.FSWatcher[]} */ const configWatchers = {} +// Debounced because the main and utility builds run as separate esbuild +// contexts (different @bksLogger alias each); their onEnd hooks both +// call this, and 500ms is enough to coalesce their finish into one +// electron restart. const restartElectron = _.debounce(() => { if (electron) { + // Windows has no real signals, so this process exits with code 1 and a + // null signal — the same shape as a crash. Mark it so the exit handler + // can tell a deliberate restart from a real one. + electron.restarting = true process.kill(electron.pid, 'SIGINT') } // start electron again - electron = spawn(electronBin, ['.'], { stdio: 'inherit' }) - electron.on('exit', (code, signal) => { + const child = spawn(electronBin, ['.'], { stdio: 'inherit' }) + child.on('exit', (code, signal) => { console.log('electron exited', code, signal) + if (child.restarting) return if (!signal) process.exit() }) - console.log('spawned electron, pid: ', electron.pid) + electron = child + console.log('spawned electron, pid: ', child.pid) }, 500) @@ -99,18 +109,37 @@ const commonArgs = { } } - const mainArgs = { - ...commonArgs, - entryPoints: ['src-commercial/entrypoints/main.ts', 'src-commercial/entrypoints/utility.ts', 'src-commercial/entrypoints/preload.ts'], - plugins: [getElectronPlugin("Main")] - } +// `@bksLogger` resolves to a different file per build so each process +// gets a logger flavored for its electron-log entry point — main+preload +// share electron-log/main (the IPC sink for renderer messages), utility +// runs electron-log/node. The ambient declaration in src/lib/log/ +// bksLogger.d.ts keeps the IDE / tsc happy with a base-Logger type. +const aliasFor = (loggerFile) => ({ + '@bksLogger': path.resolve('./src/lib/log/' + loggerFile), +}) - if(isWatching) { - const main = await esbuild.context(mainArgs) - Promise.all([main.watch()]) - } else { - Promise.all([ - esbuild.build(mainArgs), - ]) - } +const mainArgs = { + ...commonArgs, + entryPoints: ['src-commercial/entrypoints/main.ts', 'src-commercial/entrypoints/preload.ts'], + alias: aliasFor('mainLogger.ts'), + plugins: [getElectronPlugin("Main")] +} + +const utilityArgs = { + ...commonArgs, + entryPoints: ['src-commercial/entrypoints/utility.ts'], + alias: aliasFor('utilityLogger.ts'), + plugins: [getElectronPlugin("Utility")] +} + +if(isWatching) { + const main = await esbuild.context(mainArgs) + const utility = await esbuild.context(utilityArgs) + await Promise.all([main.watch(), utility.watch()]) +} else { + await Promise.all([ + esbuild.build(mainArgs), + esbuild.build(utilityArgs), + ]) +} // launch electron diff --git a/apps/studio/jest.config.js b/apps/studio/jest.config.js index 9a596eff372..cf05ce5748d 100644 --- a/apps/studio/jest.config.js +++ b/apps/studio/jest.config.js @@ -25,11 +25,16 @@ module.exports = { // support the same @ -> src alias mapping in source code moduleNameMapper: { '^@libsql/core/(.*)': resolve(__dirname, '../../node_modules/@libsql/core/lib-cjs/$1'), + '^@marimo-team/codemirror-languageserver$': + '/tests/__mocks__/marimo-codemirror-languageserver.js', + '^@beekeeperstudio/ui-kit$': + '/tests/__mocks__/beekeeperstudio-ui-kit.js', '^@/(.*)$': '/src/$1', '^@shared(.*)$': '/src/shared/$1', '^@commercial(.*)$': '/src-commercial/$1', - '^@bksLogger$': '/src/lib/log/bksLogger.ts', + '^@bksLogger$': '/src/lib/log/mainLogger.ts', '^@tests(.*)$': '/tests/$1', + '^@beekeeperstudio/ui-kit$': resolve(__dirname, '../ui-kit/lib/index.ts'), }, // serializer for snapshots snapshotSerializers: [ diff --git a/apps/studio/jest.integration.config.js b/apps/studio/jest.integration.config.js index 382c7b82914..ac20ad914ac 100644 --- a/apps/studio/jest.integration.config.js +++ b/apps/studio/jest.integration.config.js @@ -12,7 +12,10 @@ const config = { // just to keep config.ts happy in debug mode localStorage: {} }, - testPathIgnorePatterns: ["/codemirror/"] + testPathIgnorePatterns: [ + "/codemirror/", + "/tests/integration/macos/", + ] } module.exports = config diff --git a/apps/studio/jest.integration.macos.config.js b/apps/studio/jest.integration.macos.config.js new file mode 100644 index 00000000000..ca03aedbfdf --- /dev/null +++ b/apps/studio/jest.integration.macos.config.js @@ -0,0 +1,10 @@ +// eslint-disable-next-line +var integrationConfig = require('./jest.integration.config') + +const config = { + ...integrationConfig, + testMatch: ["**/tests/integration/macos/**/*.spec.[jt]s?(x)"], + testPathIgnorePatterns: ["/codemirror/"], +} + +module.exports = config diff --git a/apps/studio/package.json b/apps/studio/package.json index f47d127b353..bc2ffe55df6 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -1,7 +1,8 @@ { "name": "beekeeper-studio", - "version": "6.0.2", + "version": "6.0.5", "private": true, + "desktopName": "beekeeper-studio.desktop", "description": "SqlWolf - SQL query editor and database UI for Mac, Windows, and Linux", "author": { "name": "SqlWolf", @@ -15,32 +16,36 @@ "test:codemirror": "cross-env TEST_MODE=1 ELECTRON_RUN_AS_NODE=1 yarn electron ../../node_modules/jest/bin/jest.js --config ./jest.codemirror.config.js", "test:ci": "cross-env TEST_MODE=1 ELECTRON_RUN_AS_NODE=1 yarn electron ../../node_modules/jest/bin/jest.js --config ./jest.ci.config.js", "test:unit": "cross-env TEST_MODE=1 ELECTRON_RUN_AS_NODE=1 yarn electron ../../node_modules/jest/bin/jest.js --config ./jest.config.js", - "test:e2e": "xvfb-maybe yarn playwright test --config=playwright.config.ts", - "test:e2e:ci": "xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' yarn playwright test --config=playwright.ci.config.ts", - "lint": "eslint", + "test:e2e": "cross-env TEST_MODE=1 yarn playwright test --config=playwright.config.ts", + "test:e2e:ci": "cross-env TEST_MODE=1 yarn playwright test --config=playwright.ci.config.ts", + "test:e2e:smoke": "cross-env TEST_MODE=1 yarn playwright test --config=playwright.smoke.config.ts", + "lint": "eslint src src-commercial tests --ext .ts,.tsx,.vue,.js", "electron:build": "yarn build && yarn electron-builder --config ./electron-builder-config.js", - "electron:serve": "concurrently -c blue,green -n esbuild,vite \"yarn dev:esbuild\" \"yarn dev:vite\"", + "electron:serve": "concurrently --kill-others-on-fail -c blue,green -n esbuild,vite \"yarn dev:esbuild\" \"yarn dev:vite\"", "config:build": "CLI_MODE=1 tsx ./src/config/typesGenerator.ts", "postinstall": "electron-builder install-app-deps", "internal:integration": "cross-env TEST_MODE=1 ELECTRON_RUN_AS_NODE=1 yarn electron ../../node_modules/jest/bin/jest.js --config ./jest.integration.config.js", "test:integration": "../../bin/integration-tests.sh", - "dev:esbuild": "./esbuild.mjs watch", + "test:integration:macos": "cross-env TEST_MODE=1 ELECTRON_RUN_AS_NODE=1 yarn electron ../../node_modules/jest/bin/jest.js --config ./jest.integration.macos.config.js", + "dev:esbuild": "node ./esbuild.mjs watch", "dev:vite": "vite dev" }, "main": "dist/main.js", "dependencies": { - "@aws-sdk/client-redshift": "^3.1000.0", - "@aws-sdk/client-redshift-serverless": "^3.1000.0", - "@aws-sdk/credential-providers": "^3.1000.0", - "@aws-sdk/rds-signer": "^3.1000.0", - "@aws-sdk/shared-ini-file-loader": "^3.374.0", + "@aws-sdk/client-dynamodb": "^3.1031.0", + "@aws-sdk/client-redshift": "^3.1028.0", + "@aws-sdk/client-redshift-serverless": "^3.1028.0", + "@aws-sdk/credential-providers": "^3.1028.0", + "@aws-sdk/lib-dynamodb": "^3.1031.0", + "@aws-sdk/rds-signer": "^3.1028.0", + "@smithy/shared-ini-file-loader": "^4.4.8", "@azure/identity": "^4.13.1", "@azure/keyvault-secrets": "^4.10.0", "@azure/msal-node": "^2.12.0", - "@babel/core": "^7.29.0", + "@babel/core": "^7.29.6", "@babel/plugin-transform-class-static-block": "^7.26.0", - "@beekeeperstudio/bks-ai-shell": "^3.0.8", - "@beekeeperstudio/bks-er-diagram": "^1.0.7", + "@beekeeperstudio/bks-ai-shell": "^3.3.1", + "@beekeeperstudio/bks-er-diagram": "^1.1.2", "@beekeeperstudio/plugin": "^1.6.0", "@cleverbrush/async": "^1.1.10", "@cleverbrush/deep": "^1.1.10", @@ -50,19 +55,19 @@ "@electron/remote": "^2.0.10", "@google-cloud/bigquery": "^6.2.0", "@leeoniya/ufuzzy": "^1.0.19", - "@libsql/knex-libsql": "^0.1.0", - "@mongosh/browser-runtime-electron": "^3.29.1", - "@mongosh/service-provider-node-driver": "^3.18.1", + "@mongosh/browser-runtime-electron": "^5.3.4", + "@mongosh/service-provider-node-driver": "^5.0.8", "@octokit/rest": "^21.1.1", "@pdanpdan/vue-keyboard-trap": "^1.0.19", "@queryleaf/lib": "^0.2.3", "@redis/client": "^5.8.2", - "@surrealdb/codemirror": "^1.0.0-beta.21", + "@surrealdb/codemirror": "^1.0.4", "@types/ini": "^4.1.0", "@types/semver": "^7.7.0", "@uiw/codemirror-theme-monokai": "^4.23.10", "ansi-to-html": "^0.7.2", - "axios": "^1.13.5", + "aws4": "^1.13.2", + "axios": "^1.18.0", "axios-retry": "^3.2.4", "base64-url": "^2.3.3", "bcryptjs": "^3.0.2", @@ -71,6 +76,7 @@ "bytes": "^3.1.0", "cassandra-driver": "^4.6.4", "cassandra-knex": "beekeeper-studio/cassandra-knex#1bac17636e3451f0f7aaa62fb92c8a9539f5ee4a", + "@beekeeperstudio/knex-snowflake-dialect": "^0.4.4", "class-validator": "0.14.1", "codemirror": "^5.63.1", "concurrently": "^8.2.2", @@ -78,7 +84,7 @@ "core-js": "^3", "dateformat": "^3.0.3", "diff-match-patch": "^1.0.5", - "dompurify": "^3.3.2", + "dompurify": "^3.4.13", "driver.js": "^1.3.6", "electron-devtools-installer": "^3.2.1", "electron-log": "^5.1.5", @@ -96,15 +102,14 @@ "knex": "^2.4.1", "knex-firebird-dialect": "1.4.6", "libsql": "^0.5.22", - "lodash": "^4.17.23", + "lodash": "^4.18.1", "markdown-table": "^3.0.2", "marked": "^15.0.7", "material-icons": "^1.13.12", "mkdirp": "^1.0.4", - "mock-aws-s3": "^4.0.2", "module-alias": "^2.2.3", "mongodb": "^6.12.0", - "mssql": "^11.0.1", + "mssql": "^12.2.1", "mysql2": "~3.11.2", "node-firebird": "^1.1.9", "nodejs-file-downloader": "^4.13.0", @@ -114,7 +119,6 @@ "pg": "^8.11.3", "pg-cursor": "^2.5.2", "pg-hstore": "^2.3.3", - "pluralize": "^8.0.0", "popper.js": "^1.15.0", "portal-vue": "^2.1.7", "portfinder": "^1.0.26", @@ -126,20 +130,21 @@ "scrollyfills": "^1.0.0", "semver": "^7.7.2", "simple-encryptor": "^3.0.0", + "snowflake-sdk": "^2.4.0", "source-map-support": "^0.5.21", "split.js": "^1.6.5", - "sql-formatter": "15.6.10", - "sql-query-identifier": "^2.9.0", + "sql-formatter": "15.7.3", + "sql-query-identifier": "^3.2.0", "sqlanywhere": "beekeeper-studio/node-sqlanywhere#54d1ef2052ccdfe963f0956a3e4d024ee6f1fd8d", - "ssh-config": "^5.1.0", + "ssh-config": "5.2.0", "ssh2": "^1.14.0", - "surrealdb": "^1.3.2", - "tabulator-tables": "beekeeper-studio/tabulator#f9d3c0cdf0933a9a1bc5056b773c02a68a309fa1", + "surrealdb": "^2.0.3", + "tabulator-tables": "^6.5.2", "tinyduration": "^3.2.4", - "trino-client": "^0.2.7", + "trino-client": "^0.2.9", "typeface-roboto": "^0.0.75", "typeface-source-code-pro": "^1.1.3", - "typeorm": "^0.3.26", + "typeorm": "^0.3.31", "username": "^5.1.0", "v-hotkey": "^0.8.0", "v-tooltip": "^2.1.3", @@ -153,15 +158,18 @@ "vue2-datepicker": "^3.11.1", "vuedraggable": "^2.24.2", "vuex": "^3.1.1", - "vuex-persist": "^2.0.1", - "ws": "^8.18.3", + "ws": "^8.20.1", "xel": "beekeeper-studio/xel", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "yargs-parser": "^21.0.0" }, + "optionalDependencies": { + "kerberos": "^2.0.1", + "msnodesqlv8": "^5.1.5" + }, "devDependencies": { "@aws-sdk/types": "^3.127.0", - "@babel/core": "^7.29.0", + "@babel/core": "^7.29.6", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-transform-private-methods": "^7.24.7", "@playwright/test": "^1.44.0", @@ -181,45 +189,43 @@ "@types/oracledb": "^6.5.3", "@types/papaparse": "^5.2.5", "@types/pg": "^8.11.3", - "@types/pluralize": "^0.0.30", "@types/semver": "^7.7.0", "@types/sql-formatter": "^4.0.0", - "@types/tabulator-tables": "^6.2.0", + "@types/tabulator-tables": "^6.3.4", "@types/tmp": "^0.2.6", - "@typescript-eslint/eslint-plugin": "^4.18.0", - "@typescript-eslint/parser": "^4.18.0", - "@vitejs/plugin-vue2": "^2.3.1", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "@vue/babel-preset-app": "^5.0.8", "@vue/test-utils": "^1.3.0", "@vue/vue2-jest": "^29.2.6", - "babel-eslint": "^10.0.1", "babel-jest": "^29.7.0", "concurrently": "^8.2.2", "cross-env": "^7.0.3", - "dts-gen": "0.7.4", - "electron": "39.5.2", - "electron-builder": "26.7.0", - "esbuild": "^0.21.2", + "dts-gen": "^0.10.9", + "electron": "39.8.10", + "electron-builder": "^26.11.1", + "esbuild": "^0.25.12", "esbuild-node-externals": "^1.13.1", - "eslint": "^6.7.2", - "eslint-plugin-vue": "^6.2.2", + "eslint": "^8.57.1", + "eslint-plugin-vue": "^9.33.0", "execa": "npm:@esm2cjs/execa", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-serializer-vue": "^3.1.0", "jest-transform-stub": "^2.0.0", "jest-watch-typeahead": "^2.2.2", + "jose": "^5.9.0", "jszip": "^3.10.1", "node-abi": "^3.65.0", - "node-gyp": "^10.1.0", "sass": "~1.77.1", "sass-embedded": "^1.77.1", - "testcontainers": "~10.25.0", - "tmp": "^0.2.4", + "testcontainers": "~11.14.0", + "tmp": "^0.2.7", "ts-jest": "^29.1.5", "tsx": "^4.20.3", "typescript": "~5.8.3", - "vite": "~5.4.21", + "vite": "^8.0.8", + "vite-ng-plugin-vue2": "^1.0.0", "vite-plugin-commonjs": "^0.10.1", "vue-template-compiler": "^2.7.16", "xvfb-maybe": "^0.2.1" diff --git a/apps/studio/playwright.ci.config.ts b/apps/studio/playwright.ci.config.ts index b20eed1c7eb..311fadec40f 100644 --- a/apps/studio/playwright.ci.config.ts +++ b/apps/studio/playwright.ci.config.ts @@ -2,14 +2,16 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './e2e', + testIgnore: '**/appLaunch.test.ts', timeout: 60000, expect: { timeout: 30000, }, fullyParallel: false, workers: 1, + retries: 3, use: { - actionTimeout: 10000, + actionTimeout: 30000, trace: 'on-first-retry', screenshot: 'only-on-failure', } diff --git a/apps/studio/playwright.config.ts b/apps/studio/playwright.config.ts index a5dcfe5a587..08031e88d75 100644 --- a/apps/studio/playwright.config.ts +++ b/apps/studio/playwright.config.ts @@ -7,7 +7,6 @@ export default defineConfig({ timeout: 30000, }, fullyParallel: true, - workers: 3, use: { actionTimeout: 10000, trace: 'on-first-retry', diff --git a/apps/studio/playwright.smoke.config.ts b/apps/studio/playwright.smoke.config.ts new file mode 100644 index 00000000000..fb650d66fb2 --- /dev/null +++ b/apps/studio/playwright.smoke.config.ts @@ -0,0 +1,6 @@ +import config from './playwright.ci.config' + +config.testMatch = /.*appLaunch\.test\.ts/ +config.testIgnore = undefined + +export default config diff --git a/apps/studio/scripts/seedResultsLayout.js b/apps/studio/scripts/seedResultsLayout.js new file mode 100644 index 00000000000..d2874580e59 --- /dev/null +++ b/apps/studio/scripts/seedResultsLayout.js @@ -0,0 +1,35 @@ +/** + * Sets queryResultsLayout in an app database, so an e2e run can exercise a + * specific results layout. There is no settings control reachable from the + * query screen, and the renderer's Vuex store is not exposed in a production + * build, so the value has to be in place before the app starts. + * + * better-sqlite3 is built against Electron's ABI, so run this under Electron: + * + * ELECTRON_RUN_AS_NODE=1 ./node_modules/electron/dist/electron \ + * apps/studio/scripts/seedResultsLayout.js apps/studio/tmp/app.db stacked + */ +const Database = require('better-sqlite3'); + +const [dbPath, layout] = process.argv.slice(2); + +if (!dbPath || !['tabs', 'stacked'].includes(layout)) { + console.error('usage: seedResultsLayout.js '); + process.exit(1); +} + +const db = new Database(dbPath); +const existing = db.prepare("SELECT id FROM user_setting WHERE key = 'queryResultsLayout'").get(); + +if (existing) { + db.prepare('UPDATE user_setting SET userValue = ? WHERE id = ?').run(layout, existing.id); +} else { + db.prepare(` + INSERT INTO user_setting (section, key, userValue, defaultValue, valueType, createdAt, updatedAt, version) + VALUES (NULL, 'queryResultsLayout', ?, 'tabs', 0, datetime('now'), datetime('now'), 0) + `).run(layout); +} + +const saved = db.prepare("SELECT userValue FROM user_setting WHERE key = 'queryResultsLayout'").get(); +console.log('queryResultsLayout =', saved.userValue); +db.close(); diff --git a/apps/studio/snowflake-docs-show-columns.md b/apps/studio/snowflake-docs-show-columns.md deleted file mode 100644 index 66c2977dee5..00000000000 --- a/apps/studio/snowflake-docs-show-columns.md +++ /dev/null @@ -1,294 +0,0 @@ -Lists the columns in the tables or views for which you have access privileges. This command can be used to list the columns for a specified table/view/schema/database (or the current schema/database for the session), or your entire account. - -See also: - -[DESCRIBE TABLE](https://docs.snowflake.com/en/sql-reference/sql/desc-table) - -[COLUMNS view](https://docs.snowflake.com/en/sql-reference/info-schema/columns) (Information Schema) - -## Syntax[¶](https://docs.snowflake.com/en/sql-reference/sql/show-columns#syntax "Link to this heading") - -``` -SHOW COLUMNS [ LIKE '' ] - [ IN { ACCOUNT | DATABASE [ ] | SCHEMA [ ] | TABLE | [ TABLE ] | VIEW | [ VIEW ] } | APPLICATION | APPLICATION PACKAGE ] - -``` - -## Parameters[¶](https://docs.snowflake.com/en/sql-reference/sql/show-columns#parameters "Link to this heading") - -`LIKE ''` - -Filters the command output by object name. The filter uses case-insensitive pattern matching, with support for SQL wildcard characters (`%` and `_`). - -For example, the following patterns return the same results: - -`... LIKE '%testing%' ...` - -`... LIKE '%TESTING%' ...` - -`IN { ACCOUNT | DATABASE [ ] | SCHEMA [ ] | TABLE | [ TABLE ] | VIEW | [ VIEW ] | APPLICATION   | APPLICATION PACKAGE }` - -Specifies the scope of the command, which determines whether the command lists records only for the current/specified database, schema, table, or view, or across your entire account: - -If you specify the keyword `ACCOUNT`, then the command retrieves records for all schemas in all databases of the current account. - -If you specify the keyword `DATABASE`, then: - -- If you specify a `_db_name_`, then the command retrieves records for all schemas of the specified database. - -- If you do not specify a `_db_name_`, then: - - - If there is a current database, then the command retrieves records for all schemas in the current database. - - - If there is no current database, then the command retrieves records for all databases and schemas in the account. - - -If you specify the keyword `SCHEMA`, then: - -- If you specify a qualified schema name (e.g. `my_database.my_schema`), then the command retrieves records for the specified database and schema. - -- If you specify an unqualified `_schema_name_`, then: - - - If there is a current database, then the command retrieves records for the specified schema in the current database. - - - If there is no current database, then the command displays the error `SQL compilation error: Object does not exist, or operation cannot be performed`. - -- If you do not specify a `_schema_name_`, then: - - - If there is a current database, then: - - - If there is a current schema, then the command retrieves records for the current schema in the current database. - - - If there is no current schema, then the command retrieves records for all schemas in the current database. - - - If there is no current database, then the command retrieves records for all databases and all schemas in the account. - - -If you specify the keyword `TABLE` without a `table_name`, then: - -- If there is a current database, then: - - - If there is a current schema, then the command retrieves records for the current schema in the current database. - - - If there is no current schema, then the command retrieves records for all schemas in the current database. - -- If there is no current database, then the command retrieves records for all databases and all schemas in the account. - - -If you specify a `` (with or without the keyword `TABLE`), then: - -- If you specify a fully-qualified `` (e.g. `my_database_name.my_schema_name.my_table_name`), then the command retrieves all records for the specified table. - -- If you specify a schema-qualified `` (e.g. `my_schema_name.my_table_name`), then: - - - If a current database exists, then the command retrieves all records for the specified table. - - - If no current database exists, then the command displays an error similar to `Cannot perform SHOW . This session does not have a current database...`. - -- If you specify an unqualified ``, then: - - - If a current database and current schema exist, then the command retrieves records for the specified table in the current schema of the current database. - - - If no current database exists or no current schema exists, then the command displays an error similar to: `SQL compilation error: does not exist or not authorized.`. - - -If you specify the keyword `VIEW` or a view name, the rules for views parallel the rules for tables. - -If you specify the keywords `APPLICATION` or `APPLICATION PACKAGE`, records for the specified Snowflake Native App Framework application or application package are returned. - -Default: Depends on whether the session currently has a database in use: - -> - Database: `DATABASE` is the default (i.e. the command returns the objects you have privileges to view in the database). -> -> - No database: `ACCOUNT` is the default (i.e. the command returns the objects you have privileges to view in your account). -> - -## Usage notes[¶](https://docs.snowflake.com/en/sql-reference/sql/show-columns#usage-notes "Link to this heading") - -- If you use the keyword `VIEW` and specify a view name, the view may be a materialized view or a non-materialized view. - - -- The command doesn’t require a running warehouse to execute. - -- The command only returns objects for which the current user’s current role has been granted at least one access privilege. - -- The MANAGE GRANTS access privilege implicitly allows its holder to see every object in the account. By default, only the account administrator (users with the ACCOUNTADMIN role) and security administrator (users with the SECURITYADMIN role) have the MANAGE GRANTS privilege. - - -- To post-process the output of this command, you can use the [RESULT\_SCAN](https://docs.snowflake.com/en/sql-reference/functions/result_scan) function, which treats the output as a table that can be queried. - - -- The command returns a maximum of ten thousand records for the specified object type, as dictated by the access privileges for the role used to execute the command. Any records above the ten thousand records limit aren’t returned, even with a filter applied. - - To view results for which more than ten thousand records exist, query the corresponding view (if one exists) in the [Snowflake Information Schema](https://docs.snowflake.com/en/sql-reference/info-schema). - - -## Output[¶](https://docs.snowflake.com/en/sql-reference/sql/show-columns#output "Link to this heading") - -The command output provides column properties and metadata in the following columns: - -| -Column - - | - -Description - - | -| --- | --- | -| - -`table_name` - - | - -Name of the table the columns belong to. - - | -| - -`schema_name` - - | - -Schema for the table. - - | -| - -`column_name` - - | - -Name of the column. - - | -| - -`data_type` - - | - -Column data type and applicable properties, such as length, precision, scale, nullable, etc.; note that character and numeric columns display their generic data type rather than their defined data type (i.e. TEXT for all character types, FIXED for all fixed-point numeric types, and REAL for all floating-point numeric types). - - | -| - -`null?` - - | - -Whether the column can contain NULL values. - - | -| - -`default` - - | - -Default value, if any, defined for the column. - - | -| - -`kind` - - | - -Not applicable for columns (always displays COLUMN as the value). - - | -| - -`expression` - - | | -| - -`comment` - - | - -Comment, if any, for the column. - - | -| - -`database_name` - - | - -Database for the table. - - | -| - -`autoincrement` - - | - -Auto-increment start and increment values, if any, for the column. If the column has the NOORDER property, the value includes `NOORDER` (for example, `IDENTITY START 1 INCREMENT 1 NOORDER`). Otherwise, the value includes `ORDER`. - - | -| - -`SchemaEvolutionRecord` - - | - -Records information about the latest triggered Schema Evolution for a given table column. This column contains the following subfields: - -- EvolutionType: The type of the triggered schema evolution (ADD\_COLUMN or DROP\_NOT\_NULL). - -- EvolutionMode: The triggering ingestion mechanism (COPY or SNOWPIPE). - -- FileName: The file name that triggered the evolution. - -- TriggeringTime: The approximate time when the column was evolved. - -- QueryId or PipeID: A unique identifier of the triggering query or pipe (QUERY ID for COPY or PIPE ID for SNOWPIPE). - - - | - -## Examples[¶](https://docs.snowflake.com/en/sql-reference/sql/show-columns#examples "Link to this heading") - -``` -create or replace table dt_test (n1 number default 5, n2_int integer default n1+5, n3_bigint bigint autoincrement, n4_dec decimal identity (1,10), - f1 float, f2_double double, f3_real real, - s1 string, s2_var varchar, s3_char char, s4_text text, - b1 binary, b2_var varbinary, - bool1 boolean, - d1 date, - t1 time, - ts1 timestamp, ts2_ltz timestamp_ltz, ts3_ntz timestamp_ntz, ts4_tz timestamp_tz); - -show columns in table dt_test; - -+------------+-------------+-------------+---------------------------------------------------------------------------------------+-------+----------------+--------+------------+---------+---------------+-------------------------------+ -| table_name | schema_name | column_name | data_type | null? | default | kind | expression | comment | database_name | autoincrement | -|------------+-------------+-------------+---------------------------------------------------------------------------------------+-------+----------------+--------+------------+---------+---------------+-------------------------------| -| DT_TEST | PUBLIC | N1 | {"type":"FIXED","precision":38,"scale":0,"nullable":true} | true | 5 | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | N2_INT | {"type":"FIXED","precision":38,"scale":0,"nullable":true} | true | DT_TEST.N1 + 5 | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | N3_BIGINT | {"type":"FIXED","precision":38,"scale":0,"nullable":true} | true | | COLUMN | | | TEST1 | IDENTITY START 1 INCREMENT 1 | -| DT_TEST | PUBLIC | N4_DEC | {"type":"FIXED","precision":38,"scale":0,"nullable":true} | true | | COLUMN | | | TEST1 | IDENTITY START 1 INCREMENT 10 | -| DT_TEST | PUBLIC | F1 | {"type":"REAL","nullable":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | F2_DOUBLE | {"type":"REAL","nullable":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | F3_REAL | {"type":"REAL","nullable":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | S1 | {"type":"TEXT","length":16777216,"byteLength":16777216,"nullable":true,"fixed":false} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | S2_VAR | {"type":"TEXT","length":16777216,"byteLength":16777216,"nullable":true,"fixed":false} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | S3_CHAR | {"type":"TEXT","length":1,"byteLength":4,"nullable":true,"fixed":false} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | S4_TEXT | {"type":"TEXT","length":16777216,"byteLength":16777216,"nullable":true,"fixed":false} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | B1 | {"type":"BINARY","length":8388608,"byteLength":8388608,"nullable":true,"fixed":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | B2_VAR | {"type":"BINARY","length":8388608,"byteLength":8388608,"nullable":true,"fixed":false} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | BOOL1 | {"type":"BOOLEAN","nullable":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | D1 | {"type":"DATE","nullable":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | T1 | {"type":"TIME","precision":0,"scale":9,"nullable":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | TS1 | {"type":"TIMESTAMP_LTZ","precision":0,"scale":9,"nullable":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | TS2_LTZ | {"type":"TIMESTAMP_LTZ","precision":0,"scale":9,"nullable":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | TS3_NTZ | {"type":"TIMESTAMP_NTZ","precision":0,"scale":9,"nullable":true} | true | | COLUMN | | | TEST1 | | -| DT_TEST | PUBLIC | TS4_TZ | {"type":"TIMESTAMP_TZ","precision":0,"scale":9,"nullable":true} | true | | COLUMN | | | TEST1 | | -+------------+-------------+-------------+---------------------------------------------------------------------------------------+-------+----------------+--------+------------+---------+---------------+-------------------------------+ - -``` \ No newline at end of file diff --git a/apps/studio/src-commercial/backend/handlers/awsHandlers.ts b/apps/studio/src-commercial/backend/handlers/awsHandlers.ts index 34d79f96d24..0c410cbc856 100644 --- a/apps/studio/src-commercial/backend/handlers/awsHandlers.ts +++ b/apps/studio/src-commercial/backend/handlers/awsHandlers.ts @@ -1,15 +1,28 @@ import { spawn } from "child_process"; +import path from "path"; import rawLog from '@bksLogger' +import { extraToolSearchDirs } from "./cliHandlers"; export interface IAwsHandlers { - "aws/getProfiles": () => Promise; + "aws/getProfiles": (args: { cliPath?: string }) => Promise; } export const AwsHandlers: IAwsHandlers = { - "aws/getProfiles": async function () { + "aws/getProfiles": async function ({ cliPath }: { cliPath?: string } = {}) { + // Use the binary the user selected/discovered (an absolute path), not + // whatever bare `aws` happens to be first on PATH — that could be a + // different, older CLI. shell:false to match the rest of the spawn + // surface (see cliAuth.exploit.spec.ts). In practice cliPath is always + // an absolute path (CommonIam's watcher returns early when cliPath is + // empty), but extend PATH defensively so the bare-`aws` fallback works + // the same way `cli/which` does on GUI-launched macOS/Linux. + const awsBin = cliPath || "aws"; + const env = { ...process.env }; + env.PATH = [...extraToolSearchDirs, env.PATH].filter(Boolean).join(path.delimiter); return new Promise((resolve, reject) => { - const proc = spawn("aws", ["configure", "list-profiles"], { - shell: true, + const proc = spawn(awsBin, ["configure", "list-profiles"], { + shell: false, + env, }); let stdout = ""; @@ -32,7 +45,9 @@ export const AwsHandlers: IAwsHandlers = { const profiles = stdout.trim().split("\n"); resolve(profiles); } else { - rawLog.error(`AWS CLI failed with code ${code}`); + // Expected when the CLI is too old for `list-profiles` (v1 < 1.16.146) + // or no profiles are configured; the form falls back to manual entry. + rawLog.warn(`aws configure list-profiles failed (code ${code}); falling back to manual profile entry`); reject( `aws failed (code ${code})\nSTDERR: ${stderr}\nSTDOUT: ${stdout}` ); diff --git a/apps/studio/src-commercial/backend/handlers/backupHandlers.ts b/apps/studio/src-commercial/backend/handlers/backupHandlers.ts index f9b01427298..e2a13c66d87 100644 --- a/apps/studio/src-commercial/backend/handlers/backupHandlers.ts +++ b/apps/studio/src-commercial/backend/handlers/backupHandlers.ts @@ -1,15 +1,13 @@ import { Command } from "@/lib/db/models"; import { spawn } from "child_process"; import { state } from "@/handlers/handlerState"; -import platformInfo from "@/common/platform_info"; -const errorMessages = { +export const errorMessages = { nonZero: 'Command returned non-zero exit code' } export interface IBackupHandlers { 'backup/runCommand': ({ command, sId }: { command: Command, sId: string }) => Promise, - 'backup/whichDumpTool': ({ toolName }: { toolName: string }) => Promise, 'backup/cancelCommand': ({ sId }: { sId: string }) => Promise } @@ -17,8 +15,9 @@ export const BackupHandlers: IBackupHandlers = { 'backup/runCommand': async function({ command, sId }: { command: Command, sId: string }) { if (command.isSql) { // Execute SQL command on connection - return new Promise(async (resolve, reject) => { - (await state(sId).connection.query(`${command.mainCommand} ${command.options ? command.options.join(' ') : ''}`, null)).execute() + const sqlQuery = await state(sId).connection.query(`${command.mainCommand} ${command.options ? command.options.join(' ') : ''}`, null); + return new Promise((resolve, reject) => { + sqlQuery.execute() .catch((reason) => { state(sId).port.postMessage({ type: 'backupNotif', @@ -41,7 +40,7 @@ export const BackupHandlers: IBackupHandlers = { }) } else { state(sId).backupProc = spawn(command.mainCommand, command.options, { - shell: true, + shell: false, env: command.env }); @@ -96,37 +95,6 @@ export const BackupHandlers: IBackupHandlers = { }) } }, - 'backup/whichDumpTool': async function({ toolName }: { toolName: string }) { - const command = platformInfo.isWindows ? 'where' : 'which'; - - return new Promise((resolve, reject) => { - const proc = spawn(command, [toolName], { shell: true }); - - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (chunk) => { - stdout += chunk.toString(); - }); - - proc.stderr.on('data', (chunk) => { - stderr += chunk.toString(); - }); - - proc.on('error', (err) => { - reject(err); - }); - - proc.on('close', (code) => { - if (code === 0) { - const path = stdout.trim().split('\n')[0]; // pick first result - resolve(path); - } else { - reject(`whichTool failed (code ${code})\nSTDERR: ${stderr}\nSTDOUT: ${stdout}`); - } - }); - }); - }, 'backup/cancelCommand': async function({ sId }: { sId: string }) { if (state(sId).backupProc) { return state(sId).backupProc.kill(); diff --git a/apps/studio/src-commercial/backend/handlers/cliHandlers.ts b/apps/studio/src-commercial/backend/handlers/cliHandlers.ts new file mode 100644 index 00000000000..7d09c83e43c --- /dev/null +++ b/apps/studio/src-commercial/backend/handlers/cliHandlers.ts @@ -0,0 +1,62 @@ +import { spawn } from "child_process"; +import path from "path"; +import platformInfo from "@/common/platform_info"; + +// Directories to search for CLI tools in addition to the inherited PATH. +// GUI apps frequently don't inherit a normal shell PATH — on macOS launchd +// gives a minimal one, and on Linux some packagings (AppImage, Snap) strip it +// further. Explicitly prepend the common executable directories so brew / +// system-installed tools (az, aws, pg_dump, mysqldump, ...) are found, and so +// `which` returns the stable symlink (not the version-pinned Cellar target). +// Duplicates with whatever is already in process.env.PATH are harmless. +export const extraToolSearchDirs: string[] = (() => { + if (platformInfo.isWindows) return []; + const system = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']; + return platformInfo.isMac ? ['/opt/homebrew/bin', ...system] : system; +})(); + +export interface ICliHandlers { + 'cli/which': ({ toolName }: { toolName: string }) => Promise, +} + +export const CliHandlers: ICliHandlers = { + 'cli/which': async function({ toolName }: { toolName: string }) { + const command = platformInfo.isWindows ? 'where' : 'which'; + + // Pass the environment explicitly so we can prepend Homebrew bin dirs. + const env = { ...process.env }; + if (env.Path && platformInfo.isWindows) { + env.Path = [...extraToolSearchDirs, env.Path].filter(Boolean).join(path.delimiter); + } else { + env.PATH = [...extraToolSearchDirs, env.PATH].filter(Boolean).join(path.delimiter); + } + + return new Promise((resolve, reject) => { + const proc = spawn(command, [toolName], { shell: false, env }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + proc.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + proc.on('error', (err) => { + reject(err); + }); + + proc.on('close', (code) => { + if (code === 0) { + const result = stdout.trim().split(/\r?\n/)[0]; // pick first match + resolve(result); + } else { + reject(`cli/which failed (code ${code})\nSTDERR: ${stderr}\nSTDOUT: ${stdout}`); + } + }); + }); + }, +} diff --git a/apps/studio/src-commercial/backend/handlers/connHandlers.ts b/apps/studio/src-commercial/backend/handlers/connHandlers.ts index f87d938a59c..fa02b6b56e6 100644 --- a/apps/studio/src-commercial/backend/handlers/connHandlers.ts +++ b/apps/studio/src-commercial/backend/handlers/connHandlers.ts @@ -1,6 +1,6 @@ import { UserSetting } from "@/common/appdb/models/user_setting"; import { IConnection } from "@/common/interfaces/IConnection"; -import { DatabaseFilterOptions, ExtendedTableColumn, FilterOptions, NgQueryResult, OrderBy, PrimaryKeyColumn, Routine, SchemaFilterOptions, StreamResults, SupportedFeatures, TableChanges, TableColumn, TableFilter, TableIndex, TableInsert, TableOrView, TablePartition, TableProperties, TableResult, TableTrigger, TableUpdateResult } from "@/lib/db/models"; +import { DatabaseFilterOptions, ExtendedTableColumn, FieldDescriptor, FieldEditData, FilterOptions, NgQueryResult, OrderBy, PrimaryKeyColumn, Routine, SchemaFilterOptions, StreamResults, SupportedFeatures, TableChanges, TableColumn, TableFilter, TableIndex, TableInsert, TableOrView, TablePartition, TableProperties, TableResult, TableTrigger, TableUpdateResult } from "@/lib/db/models"; import { DatabaseElement, IDbConnectionServerConfig } from "@/lib/db/types"; import { AlterPartitionsSpec, AlterTableSpec, CreateTableSpec, dialectFor, IndexAlterations, RelationAlterations, TableKey } from "@shared/lib/dialects/models"; import { checkConnection, errorMessages, getDriverHandler, state } from "@/handlers/handlerState"; @@ -17,7 +17,7 @@ import { waitPromise } from "@/common/utils"; export interface IConnectionHandlers { // Connection management from the store ************************************** 'conn/create': ({ config, auth, osUser, sId }: {config: IConnection, auth?: { input: string; mode: "pin" }, osUser: string, sId: string }) => Promise, - 'conn/test': ({ config, osUser, sId }: { config: IConnection, osUser: string, sId: string }) => Promise, + 'conn/test': ({ config, osUser, sId }: { config: IConnection, osUser: string, sId: string }) => Promise, 'conn/changeDatabase': ({ newDatabase, sId }: { newDatabase: string, sId: string }) => Promise, 'conn/clearConnection': ({ sId }: { sId: string}) => Promise, 'conn/getServerConfig': ({ sId }: { sId: string }) => Promise, @@ -52,6 +52,7 @@ export interface IConnectionHandlers { 'conn/listTablePartitions': ({ table, schema, sId }: { table: string, schema?: string, sId: string }) => Promise, 'conn/executeCommand': ({ commandText, sId }: { commandText: string, sId: string }) => Promise, 'conn/query': ({ queryText, options, tabId, hasActiveTransaction, sId }: { queryText: string, options?: any, tabId: number, hasActiveTransaction: boolean, sId: string }) => Promise, + 'conn/getResultEditData': ({ queryText, fields, sId }: { queryText: string, fields: FieldDescriptor[], sId: string }) => Promise, 'conn/getCompletions': ({ cmd, sId }: { cmd: string, sId: string }) => Promise, 'conn/getShellPrompt': ({ sId }: { sId: string }) => Promise, 'conn/executeQuery': ({ queryText, options, sId }: { queryText: string, options: any, sId: string }) => Promise, @@ -69,7 +70,7 @@ export interface IConnectionHandlers { 'conn/getTableCreateScript': ({ table, schema, sId }: { table: string, schema?: string, sId: string }) => Promise, 'conn/getViewCreateScript': ({ view, schema, sId }: { view: string, schema?: string, sId: string }) => Promise, 'conn/getMaterializedViewCreateScript': ({ view, schema, sId }: { view: string, schema?: string, sId: string }) => Promise, - 'conn/getRoutineCreateScript': ({ routine, type, schema, sId }: { routine: string, type: string, schema?: string, sId: string }) => Promise, + 'conn/getRoutineCreateScript': ({ routine, type, schema, id, sId }: { routine: string, type: string, schema?: string, id?: string, sId: string }) => Promise, 'conn/createTable': ({ table }: { table: CreateTableSpec }) => Promise, 'conn/getCollectionValidation': ({ collection, sId }: { collection: string, sId: string }) => Promise, 'conn/setCollectionValidation': ({ params, sId }: { params: any, sId: string }) => Promise, @@ -85,7 +86,7 @@ export interface IConnectionHandlers { 'conn/alterPartitionSql': ({ changes, sId }: { changes: AlterPartitionsSpec, sId: string }) => Promise, 'conn/alterPartition': ({ changes, sId }: { changes: AlterPartitionsSpec, sId: string }) => Promise, 'conn/applyChangesSql': ({ changes, sId }: { changes: TableChanges, sId: string }) => Promise, - 'conn/applyChanges': ({ changes, sId }: { changes: TableChanges, sId: string }) => Promise, + 'conn/applyChanges': ({ changes, tabId, sId }: { changes: TableChanges, tabId?: number, sId: string }) => Promise, 'conn/setTableDescription': ({ table, description, schema, sId }: { table: string, description: string, schema?: string, sId: string }) => Promise, 'conn/setElementName': ({ elementName, newElementName, typeOfElement, schema, sId }: { elementName: string, newElementName: string, typeOfElement: DatabaseElement, schema?: string, sId: string }) => Promise, 'conn/dropElement': ({ elementName, typeOfElement, schema, sId }: { elementName: string, typeOfElement: DatabaseElement, schema?: string, sId: string }) => Promise, @@ -221,8 +222,10 @@ export const ConnHandlers: IConnectionHandlers = { state(sId).connectionAbortController = abortController; await server?.createConnection(config.defaultDatabase || undefined).connect(abortController.signal); abortController.abort(); + const sshConfigWarnings = server.getServerConfig()?.sshConfigWarnings || []; server.disconnect(); state(sId).connectionAbortController = null; + return sshConfigWarnings; }, 'conn/changeDatabase': async function({ newDatabase, sId }: { newDatabase: string, sId: string }) { @@ -350,6 +353,11 @@ export const ConnHandlers: IConnectionHandlers = { return id; }, + 'conn/getResultEditData': async function({ queryText, fields, sId }: { queryText: string, fields: FieldDescriptor[], sId: string }) { + checkConnection(sId); + return await state(sId).connection.getResultEditData(queryText, fields); + }, + 'conn/getCompletions': async function({ cmd, sId }: { cmd: string, sId: string }) { checkConnection(sId); return await state(sId).connection.getCompletions(cmd); @@ -420,9 +428,9 @@ export const ConnHandlers: IConnectionHandlers = { return await state(sId).connection.getMaterializedViewCreateScript(view, schema); }, - 'conn/getRoutineCreateScript': async function({ routine, type, schema, sId }: { routine: string, type: string, schema?: string, sId: string }) { + 'conn/getRoutineCreateScript': async function({ routine, type, schema, id, sId }: { routine: string, type: string, schema?: string, id?: string, sId: string }) { checkConnection(sId); - return await state(sId).connection.getRoutineCreateScript(routine, type, schema); + return await state(sId).connection.getRoutineCreateScript(routine, type, schema, id); }, 'conn/createTable': async function({ table, sId }: { table: CreateTableSpec, sId: string }) { @@ -485,9 +493,9 @@ export const ConnHandlers: IConnectionHandlers = { return state(sId).connection.applyChangesSql(changes); }, - 'conn/applyChanges': async function({ changes, sId }: { changes: TableChanges, sId: string }) { + 'conn/applyChanges': async function({ changes, tabId, sId }: { changes: TableChanges, tabId?: number, sId: string }) { checkConnection(sId); - return await state(sId).connection.applyChanges(changes); + return await state(sId).connection.applyChanges(changes, tabId); }, 'conn/setTableDescription': async function({ table, description, schema, sId }: { table: string, description: string, schema?: string, sId: string }) { @@ -563,7 +571,7 @@ export const ConnHandlers: IConnectionHandlers = { 'conn/azureGetAccountName': async function({ authId }: { authId: number }) { if (!authId) { throw new Error("authId is required"); - }; + } const cache = await TokenCache.findOneBy({id: authId}) if (!cache) return null return cache.name @@ -598,6 +606,7 @@ export const ConnHandlers: IConnectionHandlers = { 'conn/releaseConnection': async function({ tabId, sId }: { tabId: number, sId: string }) { checkConnection(sId); await state(sId).connection.releaseConnection(tabId); + clearTransactionTimeout(sId, tabId); }, 'conn/startTransaction': async function({ tabId, sId }: { tabId: number, sId: string }) { diff --git a/apps/studio/src-commercial/backend/handlers/handlers.ts b/apps/studio/src-commercial/backend/handlers/handlers.ts index be76a239313..71ae72811af 100644 --- a/apps/studio/src-commercial/backend/handlers/handlers.ts +++ b/apps/studio/src-commercial/backend/handlers/handlers.ts @@ -2,15 +2,17 @@ import { IFileHandlers } from "@/handlers/fileHandlers"; import { IGeneratorHandlers } from "@/handlers/generatorHandlers"; import { IQueryHandlers } from "@/handlers/queryHandlers"; import { ITempHandlers } from "@/handlers/tempHandlers"; -import { IAzureVaultHandlers } from "@/handlers/azureVaultHandlers"; +import { IVaultHandlers } from "@/handlers/vaultHandlers"; // commercial import { IConnectionHandlers } from "./connHandlers"; import { IExportHandlers } from "./exportHandlers"; import { IImportHandlers } from "./importHandlers"; import { IBackupHandlers } from "./backupHandlers"; +import { ICliHandlers } from "./cliHandlers"; import { IEnumHandlers } from "./enumHandlers"; import { IAwsHandlers } from "./awsHandlers"; +import { IWorkspaceHandlers } from "@/handlers/workspaceHandlers"; export interface Handlers extends IConnectionHandlers, @@ -19,8 +21,10 @@ export interface Handlers IImportHandlers, IExportHandlers, IBackupHandlers, + ICliHandlers, IFileHandlers, IEnumHandlers, ITempHandlers, IAwsHandlers, - IAzureVaultHandlers {} + IWorkspaceHandlers, + IVaultHandlers {} diff --git a/apps/studio/src/handlers/pluginHandlers.ts b/apps/studio/src-commercial/backend/handlers/pluginHandlers.ts similarity index 89% rename from apps/studio/src/handlers/pluginHandlers.ts rename to apps/studio/src-commercial/backend/handlers/pluginHandlers.ts index ae226d4aaf4..1f6142e146e 100644 --- a/apps/studio/src/handlers/pluginHandlers.ts +++ b/apps/studio/src-commercial/backend/handlers/pluginHandlers.ts @@ -1,10 +1,10 @@ import { EncryptedPluginData } from "@/common/appdb/models/EncryptedPluginData"; import { PluginData } from "@/common/appdb/models/PluginData"; -import { Manifest, PluginContext, PluginManager, PluginRegistryEntry, PluginRepository } from "@/services/plugin"; -import { PluginTimeoutError } from "@/services/plugin/errors"; +import { Manifest, PluginSnapshot, PluginManager, PluginRegistryEntry, PluginRepository } from "@/services/plugin"; +import { PluginSystemError } from "@/lib/errors"; interface IPluginHandlers { - "plugin/plugins": () => Promise + "plugin/plugins": () => Promise "plugin/entries": ({ clearCache }: { clearCache: boolean }) => Promise<{ official: PluginRegistryEntry[], community: PluginRegistryEntry[] }> "plugin/repository": ({ id }: { id: string }) => Promise "plugin/install": ({ id }: { id: string }) => Promise @@ -37,13 +37,18 @@ export const PluginHandlers: (pluginManager: PluginManager) => IPluginHandlers = resolve(); } else if (duration > 30_000) { clearInterval(interval); - reject(new PluginTimeoutError("Plugin initialization timed out")); + reject( + new PluginSystemError( + "INIT_TIMEOUT", + "Plugin initialization timed out" + ) + ); } }, 100); }); }, "plugin/plugins": async () => { - return pluginManager.getPlugins(); + return await pluginManager.getPlugins(); }, "plugin/entries": async ({ clearCache }) => { if (clearCache) { diff --git a/apps/studio/src-commercial/backend/lib/connection-provider.ts b/apps/studio/src-commercial/backend/lib/connection-provider.ts index 4b6d9f03dbc..4ec959ee469 100644 --- a/apps/studio/src-commercial/backend/lib/connection-provider.ts +++ b/apps/studio/src-commercial/backend/lib/connection-provider.ts @@ -1,39 +1,107 @@ import { IGroupedUserSettings } from '@/common/appdb/models/user_setting' import { IConnection } from '@/common/interfaces/IConnection' import { IDbConnectionPublicServer } from '@/lib/db/serverTypes' -import { IDbConnectionServerConfig } from '@/lib/db/types' +import { IDbConnectionServerConfig, IDbConnectionServerSSHConfig } from '@/lib/db/types' import { createServer } from './db/server' import { readSshConfig } from '@/lib/ssh/sshConfigReader' +import fs from 'fs' + +// In Automatic mode ssh tries each IdentityFile and skips missing ones; surface +// that so the user knows a configured key was not used. Only relevant when the +// keys are actually consumed (agent mode). +function missingIdentityFileWarnings(identityFiles?: string[]): string[] { + return (identityFiles || []) + .filter((p) => !fs.existsSync(p)) + .map((p) => `IdentityFile not found and skipped: ${p}`) +} export default { convertConfig(config: IConnection, osUsername: string, settings: IGroupedUserSettings): IDbConnectionServerConfig { const sqliteExtension = settings?.sqliteExtensionFile?.value || undefined - const ssh = config.sshEnabled ? { + const ssh: IDbConnectionServerSSHConfig | null = config.sshEnabled ? { host: config.sshHost ? config.sshHost.trim() : null, port: config.sshPort, user: config.sshUsername ? config.sshUsername.trim() : null, password: config.sshMode === 'userpass' ? config.sshPassword : null, privateKey: config.sshMode === 'keyfile' ? config.sshKeyfile : null, passphrase: config.sshMode === 'keyfile' ? config.sshKeyfilePassword : null, - bastionHost: config.sshBastionHost, + bastionHost: config.sshBastionHost ? config.sshBastionHost.trim() : null, + bastionPort: config.sshBastionHostPort, + bastionUser: config.sshBastionUsername ? config.sshBastionUsername.trim() : null, + bastionPassword: config.sshBastionMode === 'userpass' ? config.sshBastionPassword : null, + bastionPrivateKey: config.sshBastionMode === 'keyfile' ? config.sshBastionKeyfile : null, + bastionPassphrase: config.sshBastionMode === 'keyfile' ? config.sshBastionKeyfilePassword : null, + bastionMode: config.sshBastionMode, useAgent: config.sshMode == 'agent', keepaliveInterval: config.sshKeepaliveInterval, } : null - if (ssh && config.sshMode === 'agent' && config.sshHost) { - const fileConfig = readSshConfig(config.sshHost.trim()) - if (fileConfig.port && !ssh.port) { - ssh.port = fileConfig.port + // Non-fatal ~/.ssh/config issues to surface to the user (invalid/untrusted + // config, missing IdentityFile). Deduped and attached to the result. + const sshConfigWarnings: string[] = [] + + // Resolve aliases via ~/.ssh/config for all modes: HostName, Port, and + // User are filled in when the user typed an alias and left fields blank. + // The chosen authentication mode is never overridden — only Automatic + // mode pulls credentials from ~/.ssh/config (IdentityFile / IdentitiesOnly). + if (ssh && config.sshHost) { + const fileConfig = readSshConfig( + config.sshHost.trim(), + undefined, + config.sshUsername ? config.sshUsername.trim() : undefined + ) + if (fileConfig.warnings) { + sshConfigWarnings.push(...fileConfig.warnings.map((w) => w.message)) } - if (fileConfig.identityFile) { - ssh.privateKey = fileConfig.identityFile + if (config.sshMode === 'agent') { + sshConfigWarnings.push(...missingIdentityFileWarnings(fileConfig.identityFiles)) } if (fileConfig.host) { ssh.host = fileConfig.host } + if (fileConfig.port && !ssh.port) { + ssh.port = fileConfig.port + } if (fileConfig.user && !ssh.user) { ssh.user = fileConfig.user } + if (config.sshMode === 'agent') { + if (fileConfig.identityFile && !ssh.privateKey) { + ssh.privateKey = fileConfig.identityFile + } + ssh.identityFiles = fileConfig.identityFiles + ssh.identitiesOnly = fileConfig.identitiesOnly === true + } + } + + if (ssh && config.sshBastionHost) { + const fileConfig = readSshConfig( + config.sshBastionHost.trim(), + undefined, + config.sshBastionUsername ? config.sshBastionUsername.trim() : undefined + ) + if (fileConfig.warnings) { + sshConfigWarnings.push(...fileConfig.warnings.map((w) => w.message)) + } + if (config.sshBastionMode === 'agent') { + sshConfigWarnings.push(...missingIdentityFileWarnings(fileConfig.identityFiles)) + } + if (fileConfig.host) { + ssh.bastionHost = fileConfig.host + } + if (fileConfig.port && !ssh.bastionPort) { + ssh.bastionPort = fileConfig.port + } + if (fileConfig.user && !ssh.bastionUser) { + ssh.bastionUser = fileConfig.user + } + if (config.sshBastionMode === 'agent') { + if (fileConfig.identityFile && !ssh.bastionPrivateKey) { + ssh.bastionPrivateKey = fileConfig.identityFile + } + ssh.bastionIdentityFiles = fileConfig.identityFiles + ssh.bastionIdentitiesOnly = fileConfig.identitiesOnly === true + } } return { @@ -57,6 +125,8 @@ export default { sslKeyFile: config.sslKeyFile, sslRejectUnauthorized: config.sslRejectUnauthorized, trustServerCertificate: config.trustServerCertificate, + windowsAuthEnabled: config.windowsAuthEnabled, + sqlServerOptions: config.sqlServerOptions, instantClientLocation: settings?.oracleInstantClient?.stringValue || undefined, oracleConfigLocation: settings?.oracleConfigLocation?.stringValue || undefined, options: config.options, @@ -70,7 +140,10 @@ export default { libsqlOptions: config.libsqlOptions, sqlAnywhereOptions: config.sqlAnywhereOptions, surrealDbOptions: config.surrealDbOptions, - runtimeExtensions: sqliteExtension ? sqliteExtension as string[] : [] + snowflakeOptions: config.snowflakeOptions, + dynamoDbOptions: config.dynamoDbOptions, + runtimeExtensions: sqliteExtension ? sqliteExtension as string[] : [], + sshConfigWarnings: sshConfigWarnings.length ? Array.from(new Set(sshConfigWarnings)) : undefined, } }, diff --git a/apps/studio/src-commercial/backend/lib/db/client.ts b/apps/studio/src-commercial/backend/lib/db/client.ts index aab8817c5b9..d05ca1a62a3 100644 --- a/apps/studio/src-commercial/backend/lib/db/client.ts +++ b/apps/studio/src-commercial/backend/lib/db/client.ts @@ -6,6 +6,7 @@ import { SQLServerClient } from '@/lib/db/clients/sqlserver'; import { SqliteClient } from '@/lib/db/clients/sqlite'; import { MariaDBClient } from '@/lib/db/clients/mariadb'; import { TiDBClient } from '@/lib/db/clients/tidb'; +import { StarRocksClient } from '@/lib/db/clients/starrocks'; import { RedshiftClient } from '@/lib/db/clients/redshift'; import { CockroachClient } from '@/lib/db/clients/cockroach'; import { GreengageClient } from '@/lib/db/clients/greengage'; @@ -14,6 +15,7 @@ import { IDbConnectionServer } from "@/lib/db/backendTypes"; import { FirebirdClient } from "./clients/firebird"; import { OracleClient } from "./clients/oracle"; import { CassandraClient } from "./clients/cassandra"; +import { ScyllaDBClient } from "./clients/scylladb"; import { LibSQLClient } from "./clients/libsql"; import { DuckDBClient } from "./clients/duckdb"; import { ClickHouseClient } from "./clients/clickhouse"; @@ -22,6 +24,9 @@ import { SQLAnywhereClient } from "./clients/anywhere"; import { TrinoClient } from "./clients/trino"; import { SurrealDBClient } from "./clients/surrealdb"; import { RedisClient } from '@/lib/db/clients/redis'; +import { BedrockClient } from '@/lib/db/clients/bedrock'; +import { DynamoDBClient } from "./clients/dynamodb"; +import { SnowflakeClient } from "./clients/snowflake"; const clients = new Map([ ['mysql', MysqlClient], @@ -31,12 +36,14 @@ const clients = new Map([ ['redshift', RedshiftClient], ['mariadb', MariaDBClient], ['tidb', TiDBClient], + ['starrocks', StarRocksClient], ['cockroachdb', CockroachClient], ['greengage', GreengageClient], ['bigquery', BigQueryClient], ['firebird', FirebirdClient], ['oracle', OracleClient], ['cassandra', CassandraClient], + ['scylladb', ScyllaDBClient], ['libsql', LibSQLClient], ['duckdb', DuckDBClient], ['clickhouse', ClickHouseClient], @@ -44,7 +51,10 @@ const clients = new Map([ ['sqlanywhere', SQLAnywhereClient], ['trino', TrinoClient], ['surrealdb', SurrealDBClient], - ['redis', RedisClient] + ['redis', RedisClient], + ['bedrock', BedrockClient], + ['dynamodb', DynamoDBClient], + ['snowflake', SnowflakeClient] ], ); diff --git a/apps/studio/src-commercial/backend/lib/db/clients/anywhere.ts b/apps/studio/src-commercial/backend/lib/db/clients/anywhere.ts index 7a3b5183d52..792b6c0d7ea 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/anywhere.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/anywhere.ts @@ -13,6 +13,7 @@ import { SqlAnywhereConn, SqlAnywherePool } from './anywhere/SqlAnywherePool'; import _ from 'lodash'; import { joinFilters } from '@/common/utils'; import { SqlAnywhereChangeBuilder } from '@/shared/lib/sql/change_builder/SqlAnywhereChangeBuilder'; +import { SqlAnywhereCursor } from './anywhere/SqlAnywhereCursor'; const D = SqlAnywhereData; const log = rawLog.scope('sql-anywhere'); @@ -123,7 +124,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { } async listTables(filter?: FilterOptions): Promise { - const schemaFilter = buildSchemaFilter(filter, 'table_schema'); + const schemaFilter = buildSchemaFilter(filter, 'table_schema', (s) => this.wrapIdentifier(s)); const sql = ` SELECT t.table_name, @@ -145,7 +146,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { } async listViews(filter?: FilterOptions): Promise { - const schemaFilter = buildSchemaFilter(filter, 'table_schema'); + const schemaFilter = buildSchemaFilter(filter, 'table_schema', (s) => this.wrapIdentifier(s)); const sql = ` SELECT t.table_name, @@ -167,13 +168,13 @@ export class SQLAnywhereClient extends BasicDatabaseClient { } async listRoutines(filter?: FilterOptions): Promise { - const schemaFilter = buildSchemaFilter(filter, 'u.user_name'); + const schemaFilter = buildSchemaFilter(filter, 'u.user_name', (s) => this.wrapIdentifier(s)); const sql = ` - SELECT + SELECT p.proc_id AS id, u.user_name AS routine_schema, p.proc_name AS name, - CASE + CASE WHEN ( SELECT TOP 1 d.domain_name FROM SYS.SYSPROCPARM pp @@ -220,7 +221,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { const { rows } = await this.driverExecuteSingle(sql); const paramsResult = await this.driverExecuteSingle(paramsSQL); const grouped = _.groupBy(paramsResult.rows, 'specific_name'); - + return rows.map((row) => { const params = grouped[row.id] || []; return { @@ -274,13 +275,13 @@ export class SQLAnywhereClient extends BasicDatabaseClient { if (schema) clauses.push(`u.user_name = ${D.escapeString(schema, true)}`); const clause = clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : ''; const sql = ` - SELECT + SELECT u.user_name AS table_schema, t.table_name AS table_name, c.column_name AS column_name, c.column_id + 1 AS ordinal_position, c."default" AS column_default, - CASE + CASE WHEN c.nulls = 'Y' THEN 'YES' ELSE 'NO' END AS is_nullable, @@ -321,7 +322,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { async listTableTriggers(table: string, schema?: string): Promise { schema = schema || await this.defaultSchema(); const sql = ` - SELECT + SELECT COALESCE(tr.trigger_name, 'Trigger_' || tr.trigger_id) AS name, tr.event AS event, tr.trigger_time AS timing, @@ -335,11 +336,11 @@ export class SQLAnywhereClient extends BasicDatabaseClient { AND u.user_name = ${D.escapeString(schema, true)} ORDER BY tr.trigger_id `; - + const { rows } = await this.driverExecuteSingle(sql); - + if (!rows || rows.length === 0) return []; - + return rows.map(row => { // Determine the manipulation type (INSERT, UPDATE, DELETE, UPDATE OF columns) from the event let manipulation = ''; @@ -348,9 +349,9 @@ export class SQLAnywhereClient extends BasicDatabaseClient { case 'U': manipulation = 'UPDATE'; break; case 'D': manipulation = 'DELETE'; break; case 'C': manipulation = 'UPDATE OF COLUMNS'; break; - default: manipulation = row.event || ''; + default: manipulation = row.event || ''; } - + // Determine the timing (BEFORE, AFTER, INSTEAD OF) let timing = 'BEFORE'; switch(row.timing) { @@ -359,7 +360,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { case 'I': timing = 'INSTEAD OF'; break; default: timing = 'BEFORE'; } - + return { name: row.name, timing: timing, @@ -371,20 +372,20 @@ export class SQLAnywhereClient extends BasicDatabaseClient { }; }); } - + async listTableIndexes(table: string, schema?: string): Promise { schema = schema || await this.defaultSchema(); const sql = ` - SELECT + SELECT ROW_NUMBER() OVER (ORDER BY iname) AS id, iname AS name, icreator AS schema_name, tname AS table_name, - CASE + CASE WHEN indextype = 'Primary Key' THEN 'Y' ELSE 'N' END AS is_primary, - CASE + CASE WHEN indextype LIKE '%Unique%' THEN 'Y' ELSE 'N' END AS is_unique, @@ -395,9 +396,9 @@ export class SQLAnywhereClient extends BasicDatabaseClient { `; const { rows } = await this.driverExecuteSingle(sql); - + if (!rows || rows.length === 0) return []; - + return rows.map(row => { // Parse the column information from colnames string // The format is typically "column1 ASC, column2 DESC, ..." @@ -409,7 +410,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { order: orderType || 'ASC' }; }); - + return { id: row.id, name: row.name, @@ -424,13 +425,13 @@ export class SQLAnywhereClient extends BasicDatabaseClient { async listSchemas(filter?: SchemaFilterOptions): Promise { const sql = ` - SELECT + SELECT user_name AS schema_name FROM SYSUSER WHERE user_name NOT IN ('SYS', 'rs_systabgroup') ORDER BY user_name `; - + const { rows } = await this.driverExecuteSingle(sql); return rows.map(row => row.schema_name); } @@ -448,7 +449,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { WHERE t_for.table_name = ${D.escapeString(table, true)} AND u_for.user_name = ${D.escapeString(schema, true)} `; - + const { rows } = await this.driverExecuteSingle(sql); return rows.map(row => row.referenced_table); } @@ -641,7 +642,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { async executeQuery(queryText: string, options?: any): Promise { const data = await this.driverExecuteMultiple(queryText, options); - const commands = this.identifyCommands(queryText).map((item) => item.type); + const commands = this.identifyCommands(queryText); return data.map((result, idx) => { const fields = result.rows && result.rows.length ? Object.keys(result.rows[0]).map((k) => ({ @@ -649,8 +650,10 @@ export class SQLAnywhereClient extends BasicDatabaseClient { id: k })) : undefined; + const command = commands[idx] return { - command: commands[idx], + command: command?.type, + text: command?.text, rows: result.rows, fields, rowCount: result.rows?.length || 0, @@ -683,20 +686,20 @@ export class SQLAnywhereClient extends BasicDatabaseClient { const triggers = await this.listTableTriggers(table, schema); const indexes = await this.listTableIndexes(table, schema); const relations = await this.getTableKeys(table, schema); - + // Get table size using sa_table_page_usage procedure let size = 0; let indexSize = 0; - + try { const sizeQuery = ` - SELECT + SELECT PROPERTY('PageSize') * TablePages AS size, PROPERTY('PageSize') * IndexPages AS index_size FROM sa_table_page_usage() WHERE TableName = ${D.escapeString(table, true)} `; - + const sizeResult = await this.driverExecuteSingle(sizeQuery); if (sizeResult.rows && sizeResult.rows.length > 0) { size = Number(sizeResult.rows[0].size) || 0; @@ -720,7 +723,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { } async listMaterializedViews(filter?: FilterOptions): Promise { - const schemaFilter = buildSchemaFilter(filter, 'table_schema'); + const schemaFilter = buildSchemaFilter(filter, 'table_schema', (s) => this.wrapIdentifier(s)); const sql = ` SELECT t.table_name, @@ -743,10 +746,10 @@ export class SQLAnywhereClient extends BasicDatabaseClient { async getPrimaryKeys(table: string, schema?: string): Promise { log.debug('finding primary keys for', table, schema); - + schema = schema || 'dbo'; const sql = ` - SELECT + SELECT c.column_name AS COLUMN_NAME, CAST(ROW_NUMBER() OVER (ORDER BY c.column_id) AS INTEGER) AS ORDINAL_POSITION FROM SYS.SYSTABLE t @@ -757,7 +760,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { AND c.pkey = 'Y' ORDER BY c.column_id `; - + const { rows } = await this.driverExecuteSingle(sql); if (!rows || rows.length === 0) return []; @@ -804,7 +807,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { async createDatabase(databaseName: string, charset?: string, collation?: string): Promise { // Create the database const sql = await this.createDatabaseSQL(databaseName, charset, collation); - + try { await this.driverExecuteSingle(sql); return databaseName; @@ -816,33 +819,33 @@ export class SQLAnywhereClient extends BasicDatabaseClient { async createDatabaseSQL(databaseName?: string, charset?: string, collation?: string): Promise { databaseName = databaseName || 'newdatabase'; - + // Build the database file path - SQL Anywhere requires a file path // We'll create it in the user's home directory as a default const dbFilePath = `~/sql_anywhere/${databaseName}.db`; - + // Build the SQL command let sql = `CREATE DATABASE '${dbFilePath}'`; - + // Add character set if specified if (charset) { sql += ` CHAR SET '${charset}'`; } - + // Add collation if specified if (collation) { sql += ` NCHAR COLLATION '${collation}'`; } - + return sql; } async getTableCreateScript(table: string, schema?: string): Promise { // Use multiple simpler queries instead of a single complex one - + // Get table info const tableInfoSql = ` - SELECT + SELECT u.user_name, t.table_name, t.table_id @@ -851,19 +854,19 @@ export class SQLAnywhereClient extends BasicDatabaseClient { WHERE t.table_name = ${D.escapeString(table, true)} AND u.user_name = ${D.escapeString(schema, true)} `; - + const tableInfo = await this.driverExecuteSingle(tableInfoSql); if (!tableInfo.rows || tableInfo.rows.length === 0) { return ''; } - + const userName = tableInfo.rows[0].user_name; const tableName = tableInfo.rows[0].table_name; const tableId = tableInfo.rows[0].table_id; - + // Get column info const columnsSql = ` - SELECT + SELECT c.column_name, d.domain_name, c.width, @@ -877,13 +880,13 @@ export class SQLAnywhereClient extends BasicDatabaseClient { WHERE c.table_id = ${tableId} ORDER BY c.column_id ASC `; - + const columnsResult = await this.driverExecuteSingle(columnsSql); const columns = columnsResult.rows; - + // Get foreign keys const fkSql = ` - SELECT + SELECT c_for.column_name AS foreign_column, u_pri.user_name AS primary_schema, t_pri.table_name AS primary_table, @@ -898,22 +901,22 @@ export class SQLAnywhereClient extends BasicDatabaseClient { WHERE fk.foreign_table_id = ${tableId} AND ic_for.sequence = ic_pri.sequence `; - + const fkResult = await this.driverExecuteSingle(fkSql); const foreignKeys = fkResult.rows; - + // Build the CREATE TABLE statement - + // Start with the table name let createSql = `CREATE TABLE ${userName}.${tableName} (\n`; - + // Add column definitions const columnDefs = columns.map(c => { - let dataType = c.domain_name.toLowerCase(); - + const dataType = c.domain_name.toLowerCase(); + // Start with column name let def = ` ${c.column_name} `; - + // Handle data types correctly based on SQL Anywhere syntax switch (dataType) { case 'char': @@ -923,7 +926,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { // Character/binary types need width def += `${dataType}(${c.width})`; break; - + case 'numeric': case 'decimal': // These types can have precision and scale @@ -933,7 +936,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { def += dataType; } break; - + case 'long varchar': case 'text': case 'long binary': @@ -941,42 +944,42 @@ export class SQLAnywhereClient extends BasicDatabaseClient { // These types don't take parameters def += dataType; break; - + default: // For int, bigint, smallint, tinyint, bit, etc. - no parameters def += dataType; } - + // Add nullability def += c.nulls === 'N' ? ' NOT NULL' : ' NULL'; - + // Add default if exists if (c.default !== null) { def += ` DEFAULT ${c.default}`; } - + return def; }); - + createSql += columnDefs.join(',\n'); - + // Add primary key if any const pkColumns = columns.filter(c => c.pkey === 'Y').map(c => c.column_name); if (pkColumns.length > 0) { createSql += ',\n PRIMARY KEY (' + pkColumns.join(', ') + ')'; } - + // Add foreign keys if any if (foreignKeys.length > 0) { - const fkDefs = foreignKeys.map(fk => + const fkDefs = foreignKeys.map(fk => ` FOREIGN KEY (${fk.foreign_column}) REFERENCES ${fk.primary_schema}.${fk.primary_table}(${fk.primary_column})` ); createSql += ',\n' + fkDefs.join(',\n'); } - + // Close the statement createSql += '\n);'; - + return createSql; } async getViewCreateScript(view: string, schema?: string): Promise { @@ -1165,12 +1168,10 @@ export class SQLAnywhereClient extends BasicDatabaseClient { schema = schema ?? await this.defaultSchema(); const columns = await this.listTableColumns(table, schema); const rowCount = await this.getTableLength(table, schema); - + const conn = await this.pool.connect(); - - // Import SqlAnywhereCursor - const { SqlAnywhereCursor } = await import('./anywhere/SqlAnywhereCursor'); - + + return { totalRows: Number(rowCount), columns, @@ -1184,7 +1185,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { }; } - queryStream(query: string, chunkSize: number): Promise { + queryStream(_query: string, _chunkSize: number): Promise { throw new Error('Method not implemented.'); } @@ -1217,7 +1218,7 @@ export class SQLAnywhereClient extends BasicDatabaseClient { const runQuery = async (connection: SqlAnywhereConn) => { const queries = this.identifyCommands(q); const results: SQLAnywhereResult[] = []; - for (let query of queries) { + for (const query of queries) { log.info('EXECUTING QUERY: ', query.text); const result = await connection.query(query.text, autoCommit); log.info('RECEIVED RESULT: ', result); @@ -1253,14 +1254,6 @@ export class SQLAnywhereClient extends BasicDatabaseClient { } } - private identifyCommands(queryText: string) { - try { - return identify(queryText, { strict: false, dialect: 'mssql' }); - } catch (err) { - return []; - } - } - protected parseTableColumn(column: any): BksField { return { name: column.column_name, diff --git a/apps/studio/src-commercial/backend/lib/db/clients/anywhere/SqlAnywhereCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/anywhere/SqlAnywhereCursor.ts index ba71f69aec0..027a7945e05 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/anywhere/SqlAnywhereCursor.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/anywhere/SqlAnywhereCursor.ts @@ -1,4 +1,4 @@ -import { BeeCursor, OrderBy, TableFilter } from "@/lib/db/models"; +import { BeeCursor, OrderBy, TableColumn, TableFilter } from "@/lib/db/models"; import { SqlAnywhereConn } from "./SqlAnywherePool"; import rawLog from '@bksLogger'; import { SQLAnywhereClient } from "../anywhere"; @@ -28,6 +28,11 @@ export class SqlAnywhereCursor extends BeeCursor { this.client = client; } + // We don't support query streaming so we don't need the columns getter + get columns(): TableColumn[] | null { + return null; + } + async start(): Promise { log.info('Starting cursor'); this.cursorPos = 0; @@ -37,7 +42,7 @@ export class SqlAnywhereCursor extends BeeCursor { try { const offset = this.cursorPos * this.chunkSize; const limit = this.chunkSize; - + // Generate SQL for paginated query const sql = await this.client.selectTopSql( this.options.table, @@ -48,16 +53,16 @@ export class SqlAnywhereCursor extends BeeCursor { this.options.schema, ['*'] ); - + // Execute the query const result = await this.conn.query(sql); this.cursorPos++; - + // If no results, return empty array if (!result || result.length === 0) { return []; } - + // Convert rows to array format expected by BeeCursor return result.map(row => Object.values(row)); } catch (err) { diff --git a/apps/studio/src-commercial/backend/lib/db/clients/cassandra.ts b/apps/studio/src-commercial/backend/lib/db/clients/cassandra.ts index 1ed77c32223..8c82447ee63 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/cassandra.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/cassandra.ts @@ -17,6 +17,7 @@ import { dataTypesToMatchTypeCode, CassandraData as D } from "@shared/lib/dialec import { CassandraCursor } from "./cassandra/CassandraCursor"; import { IDbConnectionServer } from "@/lib/db/backendTypes"; import _ from "lodash"; +import { IdentifyResult } from "sql-query-identifier/lib/defines"; const log = rawLog.scope("cassandra"); const logger = () => log; @@ -83,6 +84,16 @@ export class CassandraClient extends BasicDatabaseClient { }); } + async disconnect(): Promise { + await super.disconnect(); + // cassandra-driver keeps control connections and reconnect timers alive + // until shutdown() is called, which prevents Node from exiting. + if (this.client) { + await this.client.shutdown(); + this.client = null; + } + } + getBuilder(table: string, _schema?: string): ChangeBuilderBase { return new CassandraChangeBuilder(table, []) } @@ -242,7 +253,7 @@ export class CassandraClient extends BasicDatabaseClient { } async executeQuery(queryText: string, options?: any): Promise { - const commands = this.identifyCommands(queryText).map((item) => item.type); + const commands = this.identifyCommands(queryText); const data = await this.driverExecuteSingle(queryText, options); return [this.parseRowQueryResult(data, commands[0])]; @@ -581,14 +592,6 @@ export class CassandraClient extends BasicDatabaseClient { }; } - private identifyCommands(queryText) { - try { - return identify(queryText); - } catch (err) { - return []; - } - } - private parseFields(fields, _row) { return fields.map((field) => { field.dataType = dataTypesToMatchTypeCode[field?.type?.code] || 'user-defined' @@ -598,20 +601,21 @@ export class CassandraClient extends BasicDatabaseClient { } - private parseRowQueryResult(data, command) { + private parseRowQueryResult(data: CassandraResult, command: IdentifyResult) { // Fallback in case the identifier could not recognize the command - const isSelect = command ? command === 'SELECT' : Array.isArray(data.rows); - const { columns, rows, rowLength } = data + const isSelect = command ? command?.type === 'SELECT' : Array.isArray(data.rows); + const { columns, rows, length } = data const fields = isSelect ? this.parseFields(columns, rows[0]) : [] return { - command: command || (isSelect && 'SELECT'), + command: command?.type || (isSelect && 'SELECT'), + text: command?.text, rows: this.parseRows(rows, columns) || [], fields: fields, // FIXME not sure what this is, this causes the query to fail. .isPaged() is not defined. // isPaged: data.isPaged(), - rowCount: isSelect ? (rowLength || 0) : undefined, - affectedRows: !isSelect && !isNaN(rowLength) ? rowLength : undefined, + rowCount: isSelect ? (length || 0) : undefined, + affectedRows: !isSelect && !isNaN(length) ? length : undefined, }; } @@ -807,11 +811,24 @@ export class CassandraClient extends BasicDatabaseClient { const value = row[key]; const typeCode = typeByColumn[key].code; - if (typeCode == cassandra.types.dataTypes.list) { + if (typeCode == cassandra.types.dataTypes.list || typeCode == cassandra.types.dataTypes.set) { row[key] = value?.map((v) => this.convertValueByType(v, typeByColumn[key].info.code)); return; } + if (typeCode == cassandra.types.dataTypes.map) { + const [keyType, valueType] = typeByColumn[key].info; + const converted = {}; + if (value) { + Object.entries(value).forEach(([k, v]) => { + const convertedKey = this.convertValueByType(k, keyType.code); + converted[convertedKey as string] = this.convertValueByType(v, valueType.code); + }); + } + row[key] = converted; + return; + } + row[key] = this.convertValueByType(value, typeCode); }); return row; diff --git a/apps/studio/src-commercial/backend/lib/db/clients/cassandra/CassandraCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/cassandra/CassandraCursor.ts index 2023fa16230..3ad779e3c87 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/cassandra/CassandraCursor.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/cassandra/CassandraCursor.ts @@ -1,4 +1,4 @@ -import { BeeCursor } from "@/lib/db/models"; +import { BeeCursor, TableColumn } from "@/lib/db/models"; import rawLog from '@bksLogger' import { waitFor } from "@/lib/db/clients/base/wait" @@ -22,6 +22,11 @@ export class CassandraCursor extends BeeCursor { } + // We don't support query streaming so we don't need the columns getter + get columns(): TableColumn[] | null { + return null; + } + start(): Promise { // eslint-disable-next-line @typescript-eslint/no-this-alias const classThis = this diff --git a/apps/studio/src-commercial/backend/lib/db/clients/clickhouse.ts b/apps/studio/src-commercial/backend/lib/db/clients/clickhouse.ts index 1faf3ba0a0b..e62cbbf9e78 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/clickhouse.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/clickhouse.ts @@ -39,6 +39,7 @@ import { TableUpdateResult, } from "@/lib/db/models"; import { ClickHouseData } from "@shared/lib/dialects/clickhouse"; +import { parseClickHouseEnumValues } from "@/lib/db/clients/enumParsers"; import _ from "lodash"; import { createCancelablePromise, @@ -63,6 +64,9 @@ import { errors } from "@/lib/errors"; import { IDbConnectionServer } from "@/lib/db/backendTypes"; import { ChangeBuilderBase } from "@shared/lib/sql/change_builder/ChangeBuilderBase"; import { ClickHouseCursor } from "./clickhouse/ClickHouseCursor"; +import { readFileSync } from 'fs'; +import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config"; +import https from 'https' interface JSONResult { statement: IdentifyResult; @@ -120,7 +124,7 @@ const clickhouseContext = { const knex = knexlib({ client: ClickhouseKnexClient }); const RE_NULLABLE = /^Nullable\((.*)\)$/; -const RE_SELECT_FORMAT = /^\s*SELECT.+FORMAT\s+(\w+)\s*;?$/i; +const RE_SELECT_FORMAT = /^\s*SELECT.+FORMAT\s+(\w+)\s*;?$/is; export class ClickHouseClient extends BasicDatabaseClient { version: string; @@ -140,15 +144,26 @@ export class ClickHouseClient extends BasicDatabaseClient { if (this.server.config.url) { url = this.server.config.url + // Route the user-provided URL through the SSH tunnel's local endpoint. + if (this.server.sshTunnel) { + const urlObj = new URL(url); + urlObj.hostname = this.server.config.localHost; + urlObj.port = this.server.config.localPort.toString(); + url = urlObj.toString(); + } } else { const urlObj = new URL('http://example.com/'); - urlObj.hostname = this.server.config.host; - urlObj.port = this.server.config.port.toString(); + urlObj.hostname = this.server.sshTunnel + ? this.server.config.localHost + : this.server.config.host; + urlObj.port = (this.server.sshTunnel + ? this.server.config.localPort + : this.server.config.port).toString(); urlObj.protocol = this.server.config.ssl ? 'https:' : 'http:'; url = urlObj.toString(); } - this.client = createClient({ + const config: NodeClickHouseClientConfigOptions = { url, username: this.server.config.user, password: this.server.config.password, @@ -158,7 +173,34 @@ export class ClickHouseClient extends BasicDatabaseClient { default_format: "JSONCompact", }, request_timeout: 120_000, // 2 minutes - }); + }; + if (this.server.config.ssl) { + let hasCerts = false; + // ClickHouse client supports both one-way and mutual TLS authentication. If sslCertFile and sslKeyFile are provided, we will use mutual TLS, otherwise we will use one-way TLS. + if (this.server.config.sslCaFile) { + hasCerts = true; + if (this.server.config.sslCertFile && this.server.config.sslKeyFile) { + config.tls = { + ca_cert: readFileSync(this.server.config.sslCaFile), + cert: readFileSync(this.server.config.sslCertFile), + key: readFileSync(this.server.config.sslKeyFile), + }; + } else { + config.tls = { + ca_cert: readFileSync(this.server.config.sslCaFile), + }; + } + } + + // Beekeeper's default behavior is to disable verification unless certificates are provided. + if (!hasCerts || !this.server.config.sslRejectUnauthorized) { + config.http_agent = new https.Agent({ + rejectUnauthorized: false + }); + } + } + + this.client = createClient(config); const result = await this.driverExecuteSingle( "SELECT version() AS version" ); @@ -231,6 +273,7 @@ export class ClickHouseClient extends BasicDatabaseClient { comment: row.comment, primaryKey: row.is_in_primary_key === 1, nullable: RE_NULLABLE.test(row.type), + enumValues: parseClickHouseEnumValues(row.type), bksField: this.parseTableColumn(row), }; }); @@ -502,7 +545,7 @@ export class ClickHouseClient extends BasicDatabaseClient { private async updateValues(updates: TableUpdate[]) { log.info("Applying updates", updates); - let results: TableUpdateResult[] = []; + const results: TableUpdateResult[] = []; const updateQueries = buildUpdateQueries(this.knex, updates); for (const query of updateQueries) { @@ -687,7 +730,7 @@ export class ClickHouseClient extends BasicDatabaseClient { } async query(queryText: string): Promise { - let queryId = uuidv4(); + const queryId = uuidv4(); const cancelable = createCancelablePromise(errors.CANCELED_BY_USER); return { execute: async (): Promise => { @@ -745,6 +788,7 @@ export class ClickHouseClient extends BasicDatabaseClient { fields: [], affectedRows: 0, // TODO (azmi): implement affectedRows command: result.statement.type, + text: result.statement.text, rows: [], rowCount: 0, }); @@ -756,6 +800,7 @@ export class ClickHouseClient extends BasicDatabaseClient { fields: [{ id: "c0", name: "Result" }], affectedRows: 0, // TODO (azmi): implement affectedRows command: result.statement.type, + text: result.statement.text, rows: [{ c0: data }], rowCount: 1, }); @@ -776,6 +821,7 @@ export class ClickHouseClient extends BasicDatabaseClient { fields, affectedRows: 0, // TODO we can get this somewhere i feel like?? command: result.statement.type, + text: result.statement.text, rows, rowCount: rows.length, }); @@ -817,7 +863,7 @@ export class ClickHouseClient extends BasicDatabaseClient { columns = data.meta; } else { const result = await this.client.exec({ - query, + query: statement.text, query_params: options.params, query_id: options.queryId, @@ -1044,21 +1090,22 @@ export class ClickHouseClient extends BasicDatabaseClient { return { totalRows, columns, cursor }; } - async queryStream(query: string, chunkSize: number): Promise { - const cursorOpts = { - query, - params: [], - client: this.client, - chunkSize - } - - const { columns, totalRows } = await this.getColumnsAndTotalRows(query); - - return { - totalRows, - columns, - cursor: new ClickHouseCursor(cursorOpts) - } + async queryStream(_query: string, _chunkSize: number): Promise { + // const cursorOpts = { + // query, + // params: [], + // client: this.client, + // chunkSize + // } + + // const { columns, totalRows } = await this.getColumnsAndTotalRows(query); + + // return { + // totalRows, + // columns, + // cursor: new ClickHouseCursor(cursorOpts) + // } + throw new Error("Query Streaming is not currently supported for clickhouse") } wrapIdentifier(value: string): string { @@ -1068,7 +1115,7 @@ export class ClickHouseClient extends BasicDatabaseClient { static buildFilterString(filters: TableFilter[], columns = []) { let fullFilterString = ""; let filterString = ""; - let filterParams = {}; + const filterParams = {}; let paramCounter = 0; if (filters && _.isArray(filters) && filters.length > 0) { diff --git a/apps/studio/src-commercial/backend/lib/db/clients/clickhouse/ClickHouseCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/clickhouse/ClickHouseCursor.ts index 63942f37f91..fa37f05b483 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/clickhouse/ClickHouseCursor.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/clickhouse/ClickHouseCursor.ts @@ -1,4 +1,4 @@ -import { BeeCursor } from "@/lib/db/models"; +import { BeeCursor, TableColumn } from "@/lib/db/models"; import rawlog from "@bksLogger"; import type { ClickHouseClient, Row, StreamReadable } from "@clickhouse/client"; import { uuidv4 } from "@/lib/uuid"; @@ -29,6 +29,11 @@ export class ClickHouseCursor extends BeeCursor { this.client = options.client; } + // We don't support query streaming so we don't need the columns getter + get columns(): TableColumn[] | null { + return null; + } + async start(): Promise { log.info("Starting cursor"); diff --git a/apps/studio/src-commercial/backend/lib/db/clients/duckdb.ts b/apps/studio/src-commercial/backend/lib/db/clients/duckdb.ts index 326eb004eb5..3bbca9f5540 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/duckdb.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/duckdb.ts @@ -10,6 +10,7 @@ import { buildDeleteQueries, buildSchemaFilter, } from "@/lib/db/clients/utils"; +import { parseQuotedEnumValues } from "@/lib/db/clients/enumParsers"; import knexlib, { Knex } from "knex"; import _ from "lodash"; import rawLog from "@bksLogger"; @@ -203,6 +204,7 @@ export class DuckDBClient extends BasicDatabaseClient { // We only use one connection to be able to read and write at the same time // https://duckdb.org/docs/connect/concurrency#handling-concurrency connectionInstance: Connection; + _defaultSchema: string = "main"; transcoders = [DuckDBBinaryTranscoder]; constructor(server: IDbConnectionServer, database: IDbConnectionDatabase) { @@ -300,7 +302,13 @@ export class DuckDBClient extends BasicDatabaseClient { return obj; }); - return { fields, rows, rowCount: result.rowCount }; + return { + fields, + rows, + rowCount: result.rowCount, + text: result.statement.text, + command: result.statement.type + }; }); } @@ -442,6 +450,7 @@ export class DuckDBClient extends BasicDatabaseClient { defaultValue: row.column_default as string, hasDefault: !_.isNil(row.column_default), comment: row.comment as string, + enumValues: parseQuotedEnumValues(row.data_type as string), bksField: this.parseTableColumn({ name: row.column_name as string, type: row.data_type as string, @@ -461,7 +470,7 @@ export class DuckDBClient extends BasicDatabaseClient { async listTableIndexes( table: string, - schema?: string + schema: string = this._defaultSchema ): Promise { const { rows } = await this.driverExecuteSingle( ` @@ -478,7 +487,7 @@ export class DuckDBClient extends BasicDatabaseClient { WHERE table_name = ? AND schema_name = ? `, - { params: [table, schema || await this.defaultSchema()] } + { params: [table, schema] } ); return rows.map((row) => ({ @@ -520,7 +529,7 @@ export class DuckDBClient extends BasicDatabaseClient { } async listSchemas(filter?: SchemaFilterOptions): Promise { - const filterQuery = buildSchemaFilter(filter); + const filterQuery = buildSchemaFilter(filter, 'schema_name', (s) => this.wrapIdentifier(s)); const { rows } = await this.driverExecuteSingle(` SELECT DISTINCT schema_name FROM information_schema.schemata @@ -529,7 +538,7 @@ export class DuckDBClient extends BasicDatabaseClient { return rows.map((row) => row.schema_name as string); } - async getTableReferences(table: string, schema: string): Promise { + async getTableReferences(table: string, schema: string = this._defaultSchema): Promise { const { rows } = await this.driverExecuteSingle(` WITH cte AS ( SELECT rc.unique_constraint_name AS unique_constraint_name @@ -546,13 +555,11 @@ export class DuckDBClient extends BasicDatabaseClient { FROM cte JOIN information_schema.key_column_usage kc ON cte.unique_constraint_name = kc.constraint_name - `, { params: [schema || await this.defaultSchema(), table] }); + `, { params: [schema, table] }); return rows.map((row) => row.table_name as string); } - async getOutgoingKeys(table: string, schema?: string): Promise { - const defaultSchema = schema || await this.defaultSchema(); - + async getOutgoingKeys(table: string, schema: string = this._defaultSchema): Promise { // Query to get outgoing foreign keys (from this table to other tables) const { rows } = await this.driverExecuteSingle( ` @@ -585,15 +592,13 @@ export class DuckDBClient extends BasicDatabaseClient { AND rc.constraint_name = kcu1.constraint_name ORDER BY from_schema, from_table, kcu1.constraint_name, from_column `, - { params: [defaultSchema, table] } + { params: [schema, table] } ); return this.groupTableKeys(rows); } - async getIncomingKeys(table: string, schema?: string): Promise { - const defaultSchema = schema || await this.defaultSchema(); - + async getIncomingKeys(table: string, schema: string = this._defaultSchema): Promise { // Query to get incoming foreign keys (from other tables to this table) const { rows } = await this.driverExecuteSingle( ` @@ -626,7 +631,7 @@ export class DuckDBClient extends BasicDatabaseClient { AND rc.constraint_name = kcu1.constraint_name ORDER BY from_schema, from_table, kcu1.constraint_name, from_column `, - { params: [defaultSchema, table] } + { params: [schema, table] } ); return this.groupTableKeys(rows); @@ -672,14 +677,14 @@ export class DuckDBClient extends BasicDatabaseClient { } async defaultSchema(): Promise { - return "main"; + return this._defaultSchema; } protected async rawExecuteQuery( q: string, options: any ): Promise { - const queries = identify(q, { strict: false }); + const queries = this.identifyCommands(q); const params = options.params; const results: DuckDBResult[] = []; const conn: Connection = options.connection || this.connectionInstance; @@ -691,13 +696,11 @@ export class DuckDBClient extends BasicDatabaseClient { try { const statement = await conn.prepare(query.text); - let result: DuckDBMaterializedResult; - if (params) { const { values, types } = this.buildStatementBindArgs(params) statement.bind(values, types); } - result = await statement.run(); + const result: DuckDBMaterializedResult = await statement.run(); const columnNames = result.columnNames(); const columnTypes = result.columnTypes(); @@ -738,14 +741,14 @@ export class DuckDBClient extends BasicDatabaseClient { return { values, types } } - async getPrimaryKey(table: string, schema: string): Promise { + async getPrimaryKey(table: string, schema: string = this._defaultSchema): Promise { const keys = await this.getPrimaryKeys(table, schema); return keys.length === 1 ? keys[0].columnName : null; } async getPrimaryKeys( table: string, - schema: string + schema: string = this._defaultSchema ): Promise { const keys: PrimaryKeyColumn[] = []; @@ -812,7 +815,7 @@ export class DuckDBClient extends BasicDatabaseClient { throw new Error("Method not implemented."); } - async getTableCreateScript(table: string, schema: string): Promise { + async getTableCreateScript(table: string, schema: string = this._defaultSchema): Promise { const { rows } = await this.driverExecuteSingle( ` SELECT sql @@ -825,7 +828,7 @@ export class DuckDBClient extends BasicDatabaseClient { return rows[0].sql as string; } - async getViewCreateScript(view: string, schema: string): Promise { + async getViewCreateScript(view: string, schema: string = this._defaultSchema): Promise { const { rows } = await this.driverExecuteSingle( ` SELECT sql @@ -881,7 +884,7 @@ export class DuckDBClient extends BasicDatabaseClient { async setTableDescription( table: string, description: string, - schema: string + schema: string = this._defaultSchema ): Promise { await this.driverExecuteSingle( `COMMENT ON TABLE ${this.wrapIdentifier(schema)}.${this.wrapIdentifier( @@ -993,7 +996,7 @@ export class DuckDBClient extends BasicDatabaseClient { async dropElement( elementName: string, typeOfElement: DatabaseElement, - schema: string + schema: string = this._defaultSchema ): Promise { let query: string; @@ -1014,7 +1017,7 @@ export class DuckDBClient extends BasicDatabaseClient { await this.driverExecuteSingle(`${query} ${schema}.${elementName}`); } - async truncateAllTables(schema: string): Promise { + async truncateAllTables(schema: string = this._defaultSchema): Promise { const tables = await this.listTables({ schema }); for (const table of tables) { await this.truncateElement(table.name, DatabaseElement.TABLE, schema); @@ -1027,7 +1030,7 @@ export class DuckDBClient extends BasicDatabaseClient { limit: number, orderBy: OrderBy[], filters: string | TableFilter[], - schema: string, + schema: string = this._defaultSchema, selects?: string[] ): Promise { const query = await this.selectTopSql( @@ -1058,7 +1061,7 @@ export class DuckDBClient extends BasicDatabaseClient { limit: number, orderBy: OrderBy[], filters: string | TableFilter[], - schema: string, + schema: string = this._defaultSchema, selects?: string[] ): Promise { const columns = await this.listTableColumns(table); @@ -1081,7 +1084,7 @@ export class DuckDBClient extends BasicDatabaseClient { orderBy: OrderBy[], filters: string | TableFilter[], chunkSize: number, - schema: string + schema: string = this._defaultSchema ): Promise { const query = await this.selectTopSql(table, null, null, orderBy, filters, schema); const columns = await this.listTableColumns(table, schema); @@ -1096,30 +1099,12 @@ export class DuckDBClient extends BasicDatabaseClient { ): Promise { const cursor = new DuckDBCursor(this.connectionInstance, query, chunkSize); - const { columns, totalRows } = await this.getColumnsAndTotalRows(query); - return { - totalRows, - columns, cursor } } - async getColumnsAndTotalRows(query: string): Promise { - const [result] = await this.executeQuery(query) - const {fields, rowCount: totalRows} = result - const columns = fields.map(f => ({ - columnName: f.name, - dataType: f.dataType - })) - - return { - columns, - totalRows - } - } - - async getTableLength(table: string, schema: string): Promise { + async getTableLength(table: string, schema: string = this._defaultSchema): Promise { const { countQuery, params } = buildSelectTopQuery( table, undefined, @@ -1139,7 +1124,7 @@ export class DuckDBClient extends BasicDatabaseClient { async duplicateTable( tableName: string, duplicateTableName: string, - schema: string + schema: string = this._defaultSchema ): Promise { const query = await this.duplicateTableSql(tableName, duplicateTableName, schema); await this.driverExecuteSingle(query); diff --git a/apps/studio/src-commercial/backend/lib/db/clients/duckdb/DuckDBCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/duckdb/DuckDBCursor.ts index 1f53aea5f5e..590ca91b209 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/duckdb/DuckDBCursor.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/duckdb/DuckDBCursor.ts @@ -1,12 +1,13 @@ -import { BeeCursor } from "@/lib/db/models"; +import { BeeCursor, TableColumn } from "@/lib/db/models"; import rawLog from "@bksLogger"; -import { DuckDBResult, DuckDBConnection } from "@duckdb/node-api"; +import { DuckDBResult, DuckDBConnection, DuckDBValue } from "@duckdb/node-api"; const log = rawLog.scope("DuckDBCursor"); export class DuckDBCursor extends BeeCursor { private stream: DuckDBResult; private rowBuffer: any[][] = []; + private fields: string[]; constructor( private connection: DuckDBConnection, @@ -17,9 +18,18 @@ export class DuckDBCursor extends BeeCursor { super(chunkSize); } + get columns(): TableColumn[] | null { + if (!this.fields) return null; + return this.fields.map((f, i) => ({ + columnName: f, + dataType: this.stream.columnType(i).toString() + })) + } + async start() { log.info("Starting cursor"); this.stream = await this.connection.stream(this.query); + this.fields = this.stream.columnNames(); } async read(): Promise { diff --git a/apps/studio/src-commercial/backend/lib/db/clients/dynamodb.ts b/apps/studio/src-commercial/backend/lib/db/clients/dynamodb.ts new file mode 100644 index 00000000000..acb0130bcfc --- /dev/null +++ b/apps/studio/src-commercial/backend/lib/db/clients/dynamodb.ts @@ -0,0 +1,938 @@ +import _ from 'lodash'; +import rawLog from '@bksLogger'; +import { + DynamoDBClient as AWSDynamoDBClient, + CreateTableCommand, + DeleteTableCommand, + DescribeTableCommand, + ListTablesCommand, + UpdateTableCommand, + DescribeTimeToLiveCommand, + TableDescription, + AttributeDefinition, + KeySchemaElement, + GlobalSecondaryIndexDescription, + LocalSecondaryIndexDescription, +} from '@aws-sdk/client-dynamodb'; +import { + DynamoDBDocumentClient, + BatchWriteCommand, + ExecuteStatementCommand, + ScanCommand, + UpdateCommand, +} from '@aws-sdk/lib-dynamodb'; + +import { IDbConnectionServer } from '@/lib/db/backendTypes'; +import { BasicDatabaseClient, ExecutionContext, QueryLogOptions } from '@/lib/db/clients/BasicDatabaseClient'; +import { DatabaseElement, IDbConnectionDatabase, IamAuthType } from '@/lib/db/types'; +import { + BksField, + BksFieldType, + CancelableQuery, + ExtendedTableColumn, + NgQueryResult, + OrderBy, + PrimaryKeyColumn, + Routine, + StreamResults, + SupportedFeatures, + TableChanges, + TableColumn, + TableDelete, + TableFilter, + TableIndex, + TableInsert, + TableOrView, + TableProperties, + TableResult, + TableTrigger, + TableUpdate, + TableUpdateResult, +} from '@/lib/db/models'; +import { CreateTableSpec, IndexAlterations, TableKey } from '@/shared/lib/dialects/models'; +import { ChangeBuilderBase } from '@/shared/lib/sql/change_builder/ChangeBuilderBase'; +import { DynamoDBChangeBuilder } from '@/shared/lib/sql/change_builder/DynamoDBChangeBuilder'; +import { DynamoDBData, dynamoTypeLabel } from '@/shared/lib/dialects/dynamodb'; +import { resolveAWSCredentials } from '@/lib/db/clients/utils'; +import { createCancelablePromise } from '@/common/utils'; +import { errors } from '@/lib/errors'; +import { DynamoDBCursor } from './dynamodb/DynamoDBCursor'; +import BksConfig from '@/common/bksConfig'; +import { identify } from 'sql-query-identifier'; + +const log = rawLog.scope('dynamodb'); + +interface DynamoQueryResult { + columns: { name: string }[]; + rows: Record[]; + arrayMode: boolean; +} + +const dynamoContext = { + getExecutionContext(): ExecutionContext { + return null; + }, + logQuery(_q: string, _opts: QueryLogOptions, _ctx: ExecutionContext): Promise { + return null; + }, +}; + +// Rough inference of a BksField type from a JS value pulled out of DynamoDB. +function inferTypeFromValue(value: any): string { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'boolean') return 'BOOL'; + if (typeof value === 'number') return 'N'; + if (typeof value === 'string') return 'S'; + if (value instanceof Uint8Array || Buffer.isBuffer?.(value)) return 'B'; + if (Array.isArray(value)) return 'L'; + if (value instanceof Set) { + // Determine Set type based on first element + const first = value.values().next().value; + if (first === undefined) return 'SS'; // Empty set defaults to string set + if (typeof first === 'number') return 'NS'; + if (first instanceof Uint8Array || Buffer.isBuffer?.(first)) return 'BS'; + return 'SS'; + } + if (typeof value === 'object') return 'M'; + return 'UNKNOWN'; +} + +export class DynamoDBClient extends BasicDatabaseClient { + raw: AWSDynamoDBClient; + doc: DynamoDBDocumentClient; + region: string; + endpoint: string | undefined; + + constructor(server: IDbConnectionServer, database: IDbConnectionDatabase) { + super(null, dynamoContext, server, database); + this.readOnlyMode = server?.config?.readOnlyMode; + this.dialect = 'dynamodb'; + } + + async applyChangesSql(_changes: TableChanges): Promise { + throw new Error('Copy to SQL is not supported for DynamoDB connections.'); + } + + async connect(): Promise { + await super.connect(); + + const iam = this.server.config.iamAuthOptions || {}; + this.region = iam.awsRegion || 'us-east-1'; + this.endpoint = this.server.config.dynamoDbOptions?.endpoint || undefined; + + // When using AWS CLI auth type, the credential-providers chain handles profile + // resolution automatically. For key auth we build an explicit credentials block. + const clientConfig: ConstructorParameters[0] = { + region: this.region, + }; + if (this.endpoint) clientConfig.endpoint = this.endpoint; + + if (iam.authType === IamAuthType.Key) { + if (!iam.accessKeyId || typeof iam.accessKeyId !== 'string' || !iam.accessKeyId.trim()) { + throw new Error('DynamoDB IAM key authentication requires a non-empty Access Key ID.'); + } + if (!iam.secretAccessKey || typeof iam.secretAccessKey !== 'string' || !iam.secretAccessKey.trim()) { + throw new Error('DynamoDB IAM key authentication requires a non-empty Secret Access Key.'); + } + clientConfig.credentials = { + accessKeyId: iam.accessKeyId, + secretAccessKey: iam.secretAccessKey, + }; + } else if (iam.authType === IamAuthType.File || iam.authType === IamAuthType.CLI) { + clientConfig.credentials = await resolveAWSCredentials(iam); + } else if (this.endpoint) { + // Local endpoint without auth — DynamoDB Local accepts dummy credentials but + // the SDK still requires something. Provide placeholders so connect() works. + clientConfig.credentials = { + accessKeyId: iam.accessKeyId || 'local', + secretAccessKey: iam.secretAccessKey || 'local', + }; + } else { + // No auth type specified and no endpoint — this is likely a misconfiguration + throw new Error('DynamoDB connection requires either an authentication type (IAM Key, File, or CLI) or a local endpoint.'); + } + + try { + this.raw = new AWSDynamoDBClient(clientConfig); + this.doc = DynamoDBDocumentClient.from(this.raw, { + marshallOptions: { removeUndefinedValues: true, convertClassInstanceToMap: true }, + }); + + // ping — a ListTables call is the cheapest validation; empty result is fine. + await this.raw.send(new ListTablesCommand({ Limit: 1 })); + } catch (err) { + // Clean up clients on connection failure + if (this.doc) this.doc.destroy(); + this.raw = null; + this.doc = null; + throw err; + } + } + + async disconnect(): Promise { + if (this.doc) this.doc.destroy(); + this.raw = null; + this.doc = null; + await super.disconnect(); + } + + async versionString(): Promise { + return this.endpoint ? `DynamoDB Local (${this.endpoint})` : `Amazon DynamoDB (${this.region})`; + } + + async supportedFeatures(): Promise { + return { + customRoutines: false, + comments: false, + properties: true, + partitions: false, + editPartitions: false, + backups: false, + backDirFormat: false, + restore: false, + indexNullsNotDistinct: false, + transactions: true, + filterTypes: ['standard'], + }; + } + + async defaultSchema(): Promise { + return null; + } + + async listCharsets(): Promise { + return []; + } + + async getDefaultCharset(): Promise { + return null; + } + + async listCollations(): Promise { + return []; + } + + async listDatabases(): Promise { + return [this.region]; + } + + async listTables(): Promise { + const names: string[] = []; + let ExclusiveStartTableName: string | undefined = undefined; + do { + const result = await this.raw.send(new ListTablesCommand({ + Limit: 100, + ExclusiveStartTableName, + })); + names.push(...(result.TableNames || [])); + ExclusiveStartTableName = result.LastEvaluatedTableName; + } while (ExclusiveStartTableName); + + return names.map((n) => ({ name: n, entityType: 'table', schema: null } as TableOrView)); + } + + async listViews(): Promise { + return []; + } + + async listMaterializedViews(): Promise { + return []; + } + + async listMaterializedViewColumns(): Promise { + return []; + } + + async listRoutines(): Promise { + return []; + } + + async listTableTriggers(): Promise { + return []; + } + + async listSchemas(): Promise { + return []; + } + + async getTableReferences(): Promise { + return []; + } + + async getOutgoingKeys(): Promise { + return []; + } + + async getIncomingKeys(): Promise { + return []; + } + + private async describeTable(table: string): Promise { + const result = await this.raw.send(new DescribeTableCommand({ TableName: table })); + return result.Table; + } + + async listTableColumns(table?: string): Promise { + if (!table) { + // Called without table → iterate. Most callers (TableList, etc.) pass a table, + // so this branch is only hit from bulk schema loads. + const tables = await this.listTables(); + const all: ExtendedTableColumn[] = []; + for (const t of tables) { + try { + all.push(...(await this.listTableColumns(t.name))); + } catch (err) { + log.warn(`Failed to list columns for table ${t.name}:`, err); + // Continue with other tables rather than failing the entire operation + } + } + return all; + } + + const desc = await this.describeTable(table); + const keyNames = new Set((desc.KeySchema || []).map((k) => k.AttributeName)); + const declared: Map = new Map( + (desc.AttributeDefinitions || []).map((a) => [a.AttributeName, a.AttributeType]) + ); + + // Sample items so we can surface non-key attributes too — DynamoDB only declares + // types for indexed attributes, so the schema is otherwise implicit. + const sample = await this.doc.send(new ScanCommand({ + TableName: table, + Limit: BksConfig.db.dynamodb.columnSampleSize, + ConsistentRead: false, + })); + const discovered: Map = new Map(); + (sample.Items || []).forEach((item) => { + Object.entries(item).forEach(([k, v]) => { + if (!discovered.has(k)) discovered.set(k, inferTypeFromValue(v)); + }); + }); + + // Merge: declared attributes first (preserving order), then discovered. + const names = new Set(); + const ordered: string[] = []; + for (const name of declared.keys()) { + if (!names.has(name)) { + names.add(name); + ordered.push(name); + } + } + for (const name of discovered.keys()) { + if (!names.has(name)) { + names.add(name); + ordered.push(name); + } + } + + return ordered.map((name, idx) => ({ + ordinalPosition: idx, + schemaName: null, + tableName: table, + columnName: name, + dataType: dynamoTypeLabel(declared.get(name) || discovered.get(name)), + primaryKey: keyNames.has(name), + hasDefault: false, + nullable: !keyNames.has(name), + bksField: { name, bksType: 'UNKNOWN' as BksFieldType }, + } as ExtendedTableColumn)); + } + + async listTableIndexes(table: string): Promise { + const desc = await this.describeTable(table); + const result: TableIndex[] = []; + + const toColumns = (keys: KeySchemaElement[] | undefined) => + (keys || []).map((k) => ({ + name: k.AttributeName, + order: k.KeyType === 'RANGE' ? ('ASC' as const) : ('ASC' as const), + })); + + // The primary index on KeySchema. + if (desc.KeySchema && desc.KeySchema.length) { + result.push({ + id: 'primary', + table, + schema: null, + name: 'PRIMARY', + columns: toColumns(desc.KeySchema), + unique: true, + primary: true, + }); + } + + (desc.GlobalSecondaryIndexes || []).forEach((gsi: GlobalSecondaryIndexDescription) => { + result.push({ + id: gsi.IndexName, + table, + schema: null, + name: gsi.IndexName, + columns: toColumns(gsi.KeySchema), + unique: false, + primary: false, + }); + }); + + (desc.LocalSecondaryIndexes || []).forEach((lsi: LocalSecondaryIndexDescription) => { + result.push({ + id: lsi.IndexName, + table, + schema: null, + name: lsi.IndexName, + columns: toColumns(lsi.KeySchema), + unique: false, + primary: false, + }); + }); + + return result; + } + + async getTableProperties(table: string): Promise { + const desc = await this.describeTable(table); + const indexes = await this.listTableIndexes(table); + let ttl: string | undefined; + try { + const ttlDesc = await this.raw.send(new DescribeTimeToLiveCommand({ TableName: table })); + const ttlStatus = ttlDesc.TimeToLiveDescription?.TimeToLiveStatus; + if (ttlStatus && ttlStatus !== 'DISABLED') { + ttl = `${ttlStatus} (${ttlDesc.TimeToLiveDescription?.AttributeName})`; + } + } catch { + // TTL may not be readable on local DynamoDB — ignore. + } + + return { + description: [ + `Status: ${desc.TableStatus}`, + `Item count: ${desc.ItemCount ?? 'unknown'}`, + `Billing: ${desc.BillingModeSummary?.BillingMode || 'PROVISIONED'}`, + ttl ? `TTL: ${ttl}` : null, + ].filter(Boolean).join(' \u2022 '), + size: desc.TableSizeBytes, + indexes, + relations: [], + triggers: [], + partitions: [], + }; + } + + async getPrimaryKeys(table: string): Promise { + const desc = await this.describeTable(table); + return (desc.KeySchema || []).map((k, i) => ({ columnName: k.AttributeName, position: i })); + } + + async getPrimaryKey(table: string): Promise { + const desc = await this.describeTable(table); + return desc.KeySchema?.find((k) => k.KeyType === 'HASH')?.AttributeName || null; + } + + async getTableLength(table: string): Promise { + const desc = await this.describeTable(table); + return desc.ItemCount || 0; + } + + // Map a subset of the BKS TableFilter vocabulary to a DynamoDB FilterExpression. + // Field names and values both round-trip through ExpressionAttributeNames / + // ExpressionAttributeValues, so user-supplied strings are never interpolated + // into the expression directly — the SDK treats them as opaque parameters. + // The only piece interpolated as a token is the boolean joiner (`AND`/`OR`), + // which we normalize defensively in case a malformed `op` slips through. + private buildFilter(filters: TableFilter[]) { + if (!filters?.length) return { expr: undefined, names: undefined, values: undefined }; + const names: Record = {}; + const values: Record = {}; + const parts: string[] = []; + + filters.forEach((f, idx) => { + const nk = `#f${idx}`; + names[nk] = f.field; + const vk = `:v${idx}`; + let clause: string | null = null; + + switch (f.type) { + case '=': + values[vk] = f.value; + clause = `${nk} = ${vk}`; + break; + case '!=': + values[vk] = f.value; + clause = `${nk} <> ${vk}`; + break; + case '<': + case '<=': + case '>': + case '>=': + values[vk] = f.value; + clause = `${nk} ${f.type} ${vk}`; + break; + case 'in': { + const vals = Array.isArray(f.value) ? f.value : [f.value]; + const keys = vals.map((v, i) => { + const k = `${vk}_${i}`; + values[k] = v; + return k; + }); + clause = `${nk} IN (${keys.join(', ')})`; + break; + } + case 'is': + clause = `attribute_not_exists(${nk})`; + break; + case 'is not': + clause = `attribute_exists(${nk})`; + break; + case 'like': + values[vk] = String(f.value || '').replace(/%/g, ''); + clause = `contains(${nk}, ${vk})`; + break; + case 'not like': + values[vk] = String(f.value || '').replace(/%/g, ''); + clause = `NOT contains(${nk}, ${vk})`; + break; + default: + log.warn('DynamoDB: unsupported filter type', f.type); + } + + if (clause) { + const op = f.op === 'OR' ? 'OR' : 'AND'; + const joiner = parts.length === 0 ? '' : ` ${op} `; + parts.push(`${joiner}${clause}`); + } + }); + + if (!parts.length) return { expr: undefined, names: undefined, values: undefined }; + return { + expr: parts.join(''), + names, + // Omit the values map if there are no placeholders — e.g. `attribute_exists` + // filters — otherwise the SDK rejects the empty object. + values: Object.keys(values).length ? values : undefined, + }; + } + + async selectTop( + table: string, + offset: number, + limit: number, + _orderBy: OrderBy[], + filters: string | TableFilter[], + _schema?: string, + selects?: string[] + ): Promise { + const filterInput = Array.isArray(filters) ? filters : []; + const { expr, names, values } = this.buildFilter(filterInput); + + // Build projection expression if specific columns were requested. Attribute + // names have to round-trip through ExpressionAttributeNames to cover reserved + // words (Name, Size, etc.). + let projection: string | undefined; + const projNames: Record = names ? { ...names } : {}; + if (selects && selects.length && !selects.includes('*')) { + const tokens = selects.map((s, i) => { + const k = `#p${i}`; + projNames[k] = s; + return k; + }); + projection = tokens.join(', '); + } + + const items: any[] = []; + let lastKey: Record | undefined; + const target = (offset || 0) + (limit || 0); + + // Scan page-by-page. DynamoDB has no SQL-style offset and no server-side + // ORDER BY for Scan, so `orderBy` is ignored here (the dialect disables + // column sorting in the table view to avoid full table scans). We stop as + // soon as we have enough rows to satisfy offset+limit. + do { + try { + const result = await this.doc.send(new ScanCommand({ + TableName: table, + FilterExpression: expr, + ExpressionAttributeNames: Object.keys(projNames).length ? projNames : undefined, + ExpressionAttributeValues: values, + ProjectionExpression: projection, + ExclusiveStartKey: lastKey, + })); + items.push(...(result.Items || [])); + lastKey = result.LastEvaluatedKey; + if (target > 0 && items.length >= target) break; + } catch (err) { + log.error('DynamoDB selectTop scan error:', err); + throw new Error(`Failed to scan table ${table}: ${err.message}`); + } + } while (lastKey); + + const rows = offset > 0 + ? items.slice(offset, offset + (limit || items.length)) + : items.slice(0, limit || items.length); + + const fields: BksField[] = rows.length + ? Object.keys(rows[0]).map((k) => ({ name: k, bksType: 'UNKNOWN' as BksFieldType })) + : []; + + return { result: rows, fields }; + } + + async selectTopSql( + table: string, + _offset: number, + _limit: number, + _orderBy: OrderBy[], + _filters: string | TableFilter[], + _schema?: string, + selects?: string[] + ): Promise { + // Render an approximate PartiQL statement for the query editor display. + const cols = selects && selects.length && !selects.includes('*') + ? selects.map((s) => this.wrapIdentifier(s)).join(', ') + : '*'; + return `SELECT ${cols} FROM ${this.wrapIdentifier(table)}`; + } + + async selectTopStream( + table: string, + _orderBy: OrderBy[], + filters: string | TableFilter[], + chunkSize: number, + _schema?: string + ): Promise { + const filterInput = Array.isArray(filters) ? filters : []; + const { expr, names, values } = this.buildFilter(filterInput); + const columns = await this.listTableColumns(table); + const columnNames = columns.map((c) => c.columnName); + + const cursor = new DynamoDBCursor({ + kind: 'scan', + client: this.doc, + table, + filterExpression: expr, + expressionAttributeNames: names, + expressionAttributeValues: values, + chunkSize, + }, columnNames); + + return { + totalRows: await this.getTableLength(table), + columns, + cursor, + }; + } + + async getQuerySelectTop(table: string, _limit: number): Promise { + return `SELECT * FROM ${this.wrapIdentifier(table)}`; + } + + private ensureWritable(): void { + if (this.readOnlyMode) { + throw new Error('Write action(s) not allowed in Read-Only Mode.'); + } + } + + async executeQuery(queryText: string, _options?: any): Promise { + // Use sql-query-identifier with native DynamoDB/PartiQL dialect support (v2.11.0+) + const identified = this.identifyCommands(queryText); + const results: NgQueryResult[] = []; + + for (const stmt of identified) { + const statement = stmt.text.trim(); + if (!statement) continue; + + const isMutation = stmt.executionType === 'MODIFICATION'; + if (this.readOnlyMode && isMutation) { + throw new Error('Write action(s) not allowed in Read-Only Mode.'); + } + try { + const items: any[] = []; + let nextToken: string | undefined; + do { + const result = await this.doc.send(new ExecuteStatementCommand({ + Statement: statement, + NextToken: nextToken, + })); + items.push(...(result.Items || [])); + nextToken = result.NextToken; + } while (nextToken); + + const fieldNames = items.length + ? _.uniq(_.flatten(_.take(items, 20).map((r) => Object.keys(r)))) + : []; + results.push({ + rows: items, + rowCount: items.length, + affectedRows: isMutation ? items.length : 0, + fields: fieldNames.map((n) => ({ name: n, id: n })), + command: stmt.type, + text: statement, + }); + } catch (err) { + log.error('PartiQL execution error', err); + throw err; + } + } + + return results; + } + + async executeCommand(text: string): Promise { + return this.executeQuery(text); + } + + async query(queryText: string, _tabId?: number, _options?: any): Promise { + const cancelable = createCancelablePromise(errors.CANCELED_BY_USER); + let canceling = false; + return { + execute: async () => { + try { + return await Promise.race([cancelable.wait(), this.executeQuery(queryText)]); + } catch (err: any) { + if (canceling) { + canceling = false; + err.sqlectronError = 'CANCELED_BY_USER'; + } + throw err; + } finally { + cancelable.discard(); + } + }, + cancel: async () => { + canceling = true; + cancelable.cancel(); + }, + }; + } + + async queryStream(query: string, chunkSize: number): Promise { + // Peek one row to discover columns FIRST + const preview = await this.doc.send(new ExecuteStatementCommand({ + Statement: query, + Limit: 1, + })); + const cols: TableColumn[] = (preview.Items && preview.Items[0]) + ? Object.keys(preview.Items[0]).map((k) => ({ columnName: k, dataType: 'S' })) + : []; + const columnNames = cols.map((c) => c.columnName); + + // Now create cursor with discovered columns + const cursor = new DynamoDBCursor({ + kind: 'partiql', + client: this.doc, + statement: query, + chunkSize, + }, columnNames); + + return { totalRows: 0, columns: cols, cursor }; + } + + async executeApplyChanges(changes: TableChanges): Promise { + if ((changes.inserts?.length || changes.updates?.length || changes.deletes?.length)) { + this.ensureWritable(); + } + const results: TableUpdateResult[] = []; + try { + if (changes.inserts?.length) await this.insertRows(changes.inserts); + if (changes.updates?.length) results.push(...await this.updateValues(changes.updates)); + if (changes.deletes?.length) await this.deleteRows(changes.deletes); + } catch (err: any) { + log.error('DynamoDB apply changes failed', err); + throw new Error(`Failed to apply changes: ${err.message}`); + } + return results; + } + + async insertRows(inserts: TableInsert[]): Promise { + for (const ins of inserts) { + if (!ins.table) throw new Error('Missing table name for insert'); + const rows = ins.data || []; + // BatchWriteItem has a 25-item limit per request. + for (const chunk of _.chunk(rows, 25)) { + await this.doc.send(new BatchWriteCommand({ + RequestItems: { + [ins.table]: chunk.map((row) => ({ PutRequest: { Item: row } })), + }, + })); + } + } + } + + async updateValues(updates: TableUpdate[]): Promise { + const out: TableUpdateResult[] = []; + for (const upd of updates) { + if (!upd.table) throw new Error('Missing table name for update'); + if (!upd.primaryKeys?.length) throw new Error(`Missing primary keys for update in ${upd.table}`); + const key: Record = {}; + upd.primaryKeys.forEach((pk) => { key[pk.column] = pk.value; }); + + const result = await this.doc.send(new UpdateCommand({ + TableName: upd.table, + Key: key, + UpdateExpression: 'SET #c = :v', + ExpressionAttributeNames: { '#c': upd.column }, + ExpressionAttributeValues: { ':v': upd.value }, + ReturnValues: 'ALL_NEW', + })); + out.push(result.Attributes); + } + return out; + } + + async deleteRows(deletes: TableDelete[]): Promise { + // Group deletes by table so we can use BatchWriteItem where possible. + const byTable = _.groupBy(deletes, (d) => d.table); + for (const [table, list] of Object.entries(byTable)) { + for (const chunk of _.chunk(list, 25)) { + await this.doc.send(new BatchWriteCommand({ + RequestItems: { + [table]: chunk.map((del) => { + const key: Record = {}; + del.primaryKeys.forEach((pk) => { key[pk.column] = pk.value; }); + return { DeleteRequest: { Key: key } }; + }), + }, + })); + } + } + } + + async createTable(spec: CreateTableSpec): Promise { + this.ensureWritable(); + // Minimal create — a partition key named "id" of type S. Users needing richer + // schemas should use the AWS console; we just make the "New Table" action work. + await this.raw.send(new CreateTableCommand({ + TableName: spec.table, + AttributeDefinitions: [{ AttributeName: 'id', AttributeType: 'S' }], + KeySchema: [{ AttributeName: 'id', KeyType: 'HASH' }], + BillingMode: 'PAY_PER_REQUEST', + })); + } + + async dropElement(elementName: string, typeOfElement: DatabaseElement): Promise { + this.ensureWritable(); + if (typeOfElement !== DatabaseElement.TABLE) { + throw new Error(`DynamoDB does not support dropping ${typeOfElement}`); + } + await this.raw.send(new DeleteTableCommand({ TableName: elementName })); + } + + async alterIndex(changes: IndexAlterations): Promise { + this.ensureWritable(); + const desc = await this.describeTable(changes.table); + const existingAttrs = new Map( + (desc.AttributeDefinitions || []).map((a) => [a.AttributeName, a]) + ); + const updates: any[] = []; + + for (const add of changes.additions || []) { + if (!add.columns?.length) continue; + const keySchema: KeySchemaElement[] = add.columns.map((c, i) => ({ + AttributeName: c.name, + KeyType: i === 0 ? 'HASH' : 'RANGE', + })); + // Any referenced attribute must exist in AttributeDefinitions. + for (const c of add.columns) { + if (!existingAttrs.has(c.name)) { + existingAttrs.set(c.name, { AttributeName: c.name, AttributeType: 'S' }); + } + } + updates.push({ + Create: { + IndexName: add.name, + KeySchema: keySchema, + Projection: { ProjectionType: 'ALL' }, + }, + }); + } + + for (const drop of changes.drops || []) { + updates.push({ Delete: { IndexName: drop.name } }); + } + + // Each UpdateTable call can only process one GSI change, so iterate. + for (const update of updates) { + await this.raw.send(new UpdateTableCommand({ + TableName: changes.table, + AttributeDefinitions: Array.from(existingAttrs.values()), + GlobalSecondaryIndexUpdates: [update], + })); + } + } + + // -------------------- unsupported operations --------------------- + getBuilder(table: string, schema?: string): ChangeBuilderBase { + return new DynamoDBChangeBuilder(table, schema); + } + + async createDatabase(): Promise { + throw new Error('DynamoDB does not support creating databases'); + } + + async createDatabaseSQL(): Promise { + throw new Error('DynamoDB does not support generating SQL'); + } + + async getTableCreateScript(): Promise { + throw new Error('DynamoDB does not expose CREATE TABLE SQL'); + } + + async getViewCreateScript(): Promise { + throw new Error('DynamoDB does not support views'); + } + + async getMaterializedViewCreateScript(): Promise { + throw new Error('DynamoDB does not support materialized views'); + } + + async getRoutineCreateScript(): Promise { + throw new Error('DynamoDB does not support routines'); + } + + async setElementName(): Promise { + throw new Error('DynamoDB does not support renaming tables'); + } + + async setElementNameSql(): Promise { + throw new Error('DynamoDB does not support renaming tables'); + } + + async setTableDescription(): Promise { + throw new Error('DynamoDB does not support table descriptions'); + } + + async truncateElement(): Promise { + throw new Error('DynamoDB does not support truncation — drop and recreate instead'); + } + + async truncateElementSql(): Promise { + throw new Error('DynamoDB does not support truncation'); + } + + async truncateAllTables(): Promise { + throw new Error('DynamoDB does not support truncation'); + } + + async duplicateTable(): Promise { + throw new Error('DynamoDB does not support duplicating tables'); + } + + async duplicateTableSql(): Promise { + throw new Error('DynamoDB does not support duplicating tables'); + } + + async getInsertQuery(): Promise { + throw new Error('DynamoDB does not support generating SQL'); + } + + wrapIdentifier(value: string): string { + return DynamoDBData.wrapIdentifier!(value); + } + + protected parseTableColumn(column: { field: string }): BksField { + return { name: column.field, bksType: 'UNKNOWN' as BksFieldType }; + } + + protected async rawExecuteQuery(_q: string, _options: any): Promise { + // Not used — executeQuery goes through the PartiQL SDK directly. + throw new Error('DynamoDB does not support rawExecuteQuery'); + } +} diff --git a/apps/studio/src-commercial/backend/lib/db/clients/dynamodb/DynamoDBCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/dynamodb/DynamoDBCursor.ts new file mode 100644 index 00000000000..98f4448262c --- /dev/null +++ b/apps/studio/src-commercial/backend/lib/db/clients/dynamodb/DynamoDBCursor.ts @@ -0,0 +1,108 @@ +import { BeeCursor } from "@/lib/db/models"; +import { DynamoDBDocumentClient, ScanCommand, ExecuteStatementCommand } from "@aws-sdk/lib-dynamodb"; +import rawLog from '@bksLogger'; +import BksConfig from '@/common/bksConfig'; + +const log = rawLog.scope('dynamodb-cursor'); + +interface ScanCursorOptions { + kind: 'scan'; + client: DynamoDBDocumentClient; + table: string; + filterExpression?: string; + expressionAttributeNames?: Record; + expressionAttributeValues?: Record; + chunkSize: number; +} + +interface PartiQLCursorOptions { + kind: 'partiql'; + client: DynamoDBDocumentClient; + statement: string; + chunkSize: number; +} + +export type DynamoDBCursorOptions = ScanCursorOptions | PartiQLCursorOptions; + +// Streams items out of DynamoDB using LastEvaluatedKey / NextToken pagination. +// Returned rows are arrays (positional) keyed off the discovered column order — +// the caller is responsible for passing `columns` to selectTopStream so ordering +// matches what Tabulator expects. +export class DynamoDBCursor extends BeeCursor { + private readonly options: DynamoDBCursorOptions; + private lastEvaluatedKey: Record | undefined; + private nextToken: string | undefined; + private exhausted = false; + private buffer: any[] = []; + private columns: string[] = []; + + constructor(options: DynamoDBCursorOptions, columns: string[]) { + super(options.chunkSize); + this.options = options; + this.columns = columns; + } + + async start(): Promise { + // Lazily paginated; nothing to do upfront. + } + + private async fetchNextPage(): Promise { + if (this.exhausted) return; + + const fetchWithTimeout = async (promise: Promise): Promise => { + const timeoutMs = BksConfig.db.dynamodb.cursorFetchTimeout; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('DynamoDB cursor fetch timed out')), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]); + }; + + try { + if (this.options.kind === 'scan') { + const result = await fetchWithTimeout(this.options.client.send(new ScanCommand({ + TableName: this.options.table, + FilterExpression: this.options.filterExpression, + ExpressionAttributeNames: this.options.expressionAttributeNames, + ExpressionAttributeValues: this.options.expressionAttributeValues, + ExclusiveStartKey: this.lastEvaluatedKey, + Limit: this.chunkSize, + }))); + this.buffer.push(...(result.Items || [])); + this.lastEvaluatedKey = result.LastEvaluatedKey; + if (!this.lastEvaluatedKey) { + this.exhausted = true; + } + } else { + const result = await fetchWithTimeout(this.options.client.send(new ExecuteStatementCommand({ + Statement: this.options.statement, + NextToken: this.nextToken, + Limit: this.chunkSize, + }))); + this.buffer.push(...(result.Items || [])); + this.nextToken = result.NextToken; + if (!this.nextToken) { + this.exhausted = true; + } + } + } catch (err) { + log.error('DynamoDB cursor fetch error:', err); + this.exhausted = true; + throw err; + } + } + + async read(): Promise { + while (this.buffer.length < this.chunkSize && !this.exhausted) { + await this.fetchNextPage(); + } + if (this.buffer.length === 0) return []; + + const take = this.buffer.splice(0, this.chunkSize); + return take.map((item) => this.columns.map((c) => item[c])); + } + + async cancel(): Promise { + this.exhausted = true; + this.buffer = []; + } +} diff --git a/apps/studio/src-commercial/backend/lib/db/clients/firebird.ts b/apps/studio/src-commercial/backend/lib/db/clients/firebird.ts index c476dbbc963..dbe9c275217 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/firebird.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/firebird.ts @@ -126,15 +126,6 @@ const FIELD_TYPE_QUERY = ( END `; -function identifyCommands(queryText: string) { - try { - return identify(queryText, { strict: false, dialect: "generic" }); - } catch (err) { - log.error(err); - return []; - } -} - function buildFilterString(filters: TableFilter[], columns = []) { let filterString = ""; let filterParams = []; @@ -263,9 +254,17 @@ export class FirebirdClient extends BasicDatabaseClient { await super.connect(); + // Route through the SSH tunnel's local endpoint when a tunnel is active. + const host = this.server.sshTunnel + ? this.server.config.localHost + : this.server.config.host; + const port = this.server.sshTunnel + ? this.server.config.localPort + : this.server.config.port; + const config = { - host: this.server.config.host, - port: this.server.config.port, + host, + port, user: this.server.config.user, password: this.server.config.password, database: this.database.database, @@ -291,8 +290,8 @@ export class FirebirdClient extends BasicDatabaseClient { + async executeApplyChanges(changes: TableChanges, tabId?: number): Promise { let results = []; - const connection = await this.pool.getConnection(); - const transaction = await connection.transaction(); - try { + + const run = async (connection: Connection | Transaction) => { if (changes.inserts) { for (const command of buildInsertQueries(this.knex, changes.inserts)) { - await transaction.query(command); + await connection.query(command); } } if (changes.updates) { - results = await this.updateValues(transaction, changes.updates); + results = await this.updateValues(connection, changes.updates); } if (changes.deletes) { for (const command of buildDeleteQueries(this.knex, changes.deletes)) { - await transaction.query(command); + await connection.query(command); } } - await transaction.commit(); - return results; - } catch (ex) { - log.error("query exception: ", ex); - await transaction.rollback(); - throw ex; - } finally { - await connection.release() } + + if (tabId) { + const conn = this.peekConnection(tabId).transaction; + await run(conn); + } else { + const connection = await this.pool.getConnection(); + const transaction = await connection.transaction(); + + try { + await run(transaction); + await transaction.commit(); + } catch (ex) { + log.error("query exception: ", ex); + await transaction.rollback(); + throw ex; + } finally { + await connection.release() + } + } + return results; } async updateValues( @@ -1132,6 +1142,7 @@ export class FirebirdClient extends BasicDatabaseClient { - const queries = identifyCommands(queryText); + const queries = this.identifyCommands(queryText); const params = options.params ?? []; const results: FirebirdResult[] = []; diff --git a/apps/studio/src-commercial/backend/lib/db/clients/firebird/FirebirdCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/firebird/FirebirdCursor.ts index c74622c7ae1..2817ef60f09 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/firebird/FirebirdCursor.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/firebird/FirebirdCursor.ts @@ -1,4 +1,4 @@ -import { BeeCursor, OrderBy, TableFilter } from "@/lib/db/models"; +import { BeeCursor, OrderBy, TableColumn, TableFilter } from "@/lib/db/models"; import rawLog from "@bksLogger"; import { Connection } from "./NodeFirebirdWrapper"; import Firebird from "node-firebird"; @@ -24,6 +24,11 @@ export class FirebirdCursor extends BeeCursor { this.init(options); } + // We don't support query streaming so we don't need the columns getter + get columns(): TableColumn[] | null { + return null + } + async init(options: FirebirdCursorOptions) { log.info("Initializing connection"); diff --git a/apps/studio/src-commercial/backend/lib/db/clients/firebird/NodeFirebirdWrapper.ts b/apps/studio/src-commercial/backend/lib/db/clients/firebird/NodeFirebirdWrapper.ts index 3cff243e39c..4b8fcf7ccc2 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/firebird/NodeFirebirdWrapper.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/firebird/NodeFirebirdWrapper.ts @@ -83,21 +83,21 @@ export class Connection { }); } - query(query: string, params?: any[], rowAsArray?: boolean): Promise { - return new Promise(async (resolve, reject) => { - const database = this.database; - // Firebird requires a transaction to parse blob columns, so we create it here so we use the - // same transaction for every cell that needs to be parsed. - const transaction: Firebird.Transaction = await new Promise((resolve, reject) => { - this.database.transaction(Firebird.ISOLATION_READ_COMMITTED, (err, transaction) => { - if (err) { - reject(err); - return; - } - resolve(transaction) - }) - }); + async query(query: string, params?: any[], rowAsArray?: boolean): Promise { + const database = this.database; + // Firebird requires a transaction to parse blob columns, so we create it here so we use the + // same transaction for every cell that needs to be parsed. + const transaction: Firebird.Transaction = await new Promise((resolve, reject) => { + this.database.transaction(Firebird.ISOLATION_READ_COMMITTED, (err, transaction) => { + if (err) { + reject(err); + return; + } + resolve(transaction) + }) + }); + return new Promise((resolve, reject) => { function callback( err: any, result: any[], @@ -114,8 +114,8 @@ export class Connection { if (!Array.isArray(result)) result = [result]; const arrBlob = [] // for blob columns - result.map((value) => { - Object.keys(value).map((c) => { + result.forEach((value) => { + Object.keys(value).forEach((c) => { if (_.isFunction(value[c])) { // create a promise for every blob and run the parsing function value[c] = new Promise((resBlob, rejBlob) => { @@ -198,7 +198,7 @@ export class Transaction { constructor(private transaction: Firebird.Transaction) {} query(query: string, params?: any[], rowAsArray?: boolean): Promise { - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.transaction; function callback( @@ -217,8 +217,8 @@ export class Transaction { if (!Array.isArray(result)) result = [result]; const arrBlob = []; // for blob columns - result.map((value) => { - Object.keys(value).map((c) => { + result.forEach((value) => { + Object.keys(value).forEach((c) => { if (_.isFunction(value[c])) { value[c] = new Promise((resBlob, rejBlob) => { value[c](transaction, (error, name, event, row) => { diff --git a/apps/studio/src-commercial/backend/lib/db/clients/libsql.ts b/apps/studio/src-commercial/backend/lib/db/clients/libsql.ts index ef25fabfa13..507b6a76014 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/libsql.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/libsql.ts @@ -1,7 +1,7 @@ import _ from "lodash"; import rawLog from "@bksLogger"; import { SqliteClient, SqliteResult } from "@/lib/db/clients/sqlite"; -import Client_Libsql from "@libsql/knex-libsql"; +import Client_Libsql from "@shared/lib/knex-libsql"; import { BasicDatabaseClient } from "@/lib/db/clients/BasicDatabaseClient"; import Database from "libsql"; import { LibSQLCursor, LibSQLCursorOptions } from "./libsql/LibSQLCursor"; diff --git a/apps/studio/src-commercial/backend/lib/db/clients/mongodb.ts b/apps/studio/src-commercial/backend/lib/db/clients/mongodb.ts index 1b4631913a8..a6a79c04c8e 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/mongodb.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/mongodb.ts @@ -2,7 +2,6 @@ import { IDbConnectionServer } from "@/lib/db/backendTypes"; import { BasicDatabaseClient, ExecutionContext, QueryLogOptions } from "@/lib/db/clients/BasicDatabaseClient"; import { DatabaseElement, IDbConnectionDatabase } from "@/lib/db/types"; import { AggregationCursor, Collection, Db, Document, MongoClient, ObjectId } from 'mongodb'; -import { identify } from 'sql-query-identifier'; import rawLog from '@bksLogger'; import { BksField, BksFieldType, CancelableQuery, ExtendedTableColumn, NgQueryResult, OrderBy, PrimaryKeyColumn, Routine, SchemaFilterOptions, StreamResults, SupportedFeatures, TableChanges, TableColumn, TableDelete, TableFilter, TableIndex, TableInsert, TableOrView, TableProperties, TableResult, TableTrigger, TableUpdate, TableUpdateResult } from "@/lib/db/models"; import { CreateTableSpec, IndexAlterations, TableKey } from "@/shared/lib/dialects/models"; @@ -15,8 +14,10 @@ import { errors } from "@/lib/errors"; import EventEmitter from "events"; import { ChangeBuilderBase } from "@/shared/lib/sql/change_builder/ChangeBuilderBase"; import { QueryLeaf } from '@queryleaf/lib' +import { LicenseKey } from "@/common/appdb/models/LicenseKey"; +import platformInfo from "@/common/platform_info"; import { MongoDBCursor } from './mongodb/MongoDBCursor'; -import { wrapIdentifier } from "@/lib/db/clients/postgresql"; +import { wrapIdentifier } from "@/lib/db/clients/postgresql"; import knexlib from 'knex' const knex = knexlib({ client: 'pg' }) @@ -29,6 +30,55 @@ interface QueryResult { arrayMode: boolean; } +const DEFAULT_MONGO_PORT = 27017; + +// Extract the host/port a MongoDB URL points at so an SSH tunnel can forward to it. +// Returns null for URLs the standard URL parser can't handle (e.g. multi-host +// seed lists or mongodb+srv), where SSH tunnelling isn't supported anyway. +export function parseMongoHost(url: string): { host: string; port: number } | null { + try { + const parsed = new URL(url); + if (!parsed.hostname) return null; + return { + host: parsed.hostname, + port: parsed.port ? parseInt(parsed.port, 10) : DEFAULT_MONGO_PORT, + }; + } catch { + return null; + } +} + +// Rewrite a MongoDB URL's host:port to point at the local end of an SSH tunnel. +// Forces directConnection so the driver talks to the tunnelled node directly +// instead of running topology discovery and trying to reach the server's +// self-reported address (which isn't routable through the tunnel). +export function rewriteMongoUrlHost(url: string, localHost: string, localPort: number): string { + try { + const parsed = new URL(url); + parsed.host = `${localHost}:${localPort}`; + if (!parsed.searchParams.has('directConnection')) { + parsed.searchParams.set('directConnection', 'true'); + } + return parsed.toString(); + } catch { + return url; + } +} + +// Detect whether a MongoDB connection URL requests GSSAPI (Kerberos) auth. +// Reads the authMechanism query param, falling back to a regex when the URL +// can't be parsed (multi-host seed lists) since the query string still applies. +export function urlUsesGssapi(url: string): boolean { + if (!url) return false; + try { + const mechanism = new URL(url).searchParams.get('authMechanism'); + if (mechanism) return mechanism.toUpperCase() === 'GSSAPI'; + } catch { + // fall through to the regex below + } + return /[?&]authMechanism=GSSAPI/i.test(url); +} + const mongoContext = { getExecutionContext(): ExecutionContext { return null; @@ -46,12 +96,46 @@ export class MongoDBClient extends BasicDatabaseClient { constructor(server: IDbConnectionServer, database: IDbConnectionDatabase) { super(knex, mongoContext, server, database); + this.dialect = 'psql'; } async connect(): Promise { + // Kerberos (GSSAPI) auth is an Enterprise feature. The Mongo form is URL-only, + // so there's no field to gate -- detect it from the connection URL and fail fast + // before opening the SSH tunnel or hitting the network. Skipped under testMode so the + // integration suite can exercise the real GSSAPI path (mirrors checkAllowReadOnly). + if (urlUsesGssapi(this.server.config.url) && !platformInfo.testMode) { + const status = await LicenseKey.getLicenseStatus(); + if (!status.isUltimate) { + throw new Error("Kerberos (GSSAPI) authentication requires a Beekeeper Studio Enterprise license."); + } + } + + // The MongoDB form only collects a connection URL, so config.host/config.port + // are never populated. The SSH tunnel forwards a local port to config.host:config.port, + // so derive them from the URL before the base class opens the tunnel. + if (this.server.config.ssh && !this.server.sshTunnel) { + const target = parseMongoHost(this.server.config.url); + if (target) { + this.server.config.host = target.host; + this.server.config.port = target.port; + } + } + await super.connect(); - this.conn = new MongoClient(this.server.config.url); + let url = this.server.config.url; + + // Route the connection through the SSH tunnel's local endpoint. + if (this.server.sshTunnel) { + url = rewriteMongoUrlHost( + url, + this.server.config.localHost, + this.server.config.localPort + ); + } + + this.conn = new MongoClient(url); this.conn.on('connectionCreated', (event) => { log.debug('Pool connection %d acquired on %s', event.connectionId, event.address); @@ -170,7 +254,7 @@ export class MongoDBClient extends BasicDatabaseClient { // TODO (@day): convert 1, -1 to ASC and DESC return indexes.map((index) => ({ - table, + table, columns: Object.entries(index.key).map((key) => ({ name: key[0], order: this.convertOrder(key[1])})), name: index.name, unique: index.unique, @@ -235,14 +319,14 @@ export class MongoDBClient extends BasicDatabaseClient { override async setElementName(elementName: string, newElementName: string, typeOfElement:DatabaseElement): Promise { const db = this.conn.db(this.db); - if (typeOfElement == DatabaseElement.TABLE) { + if (typeOfElement === DatabaseElement.TABLE) { try { // Check if target name already exists const targetCollections = await db.listCollections({ name: newElementName }).toArray(); if (targetCollections.length > 0) { throw new Error(`Target collection ${newElementName} already exists`); } - + // Perform the rename operation await db.collection(elementName).rename(newElementName); } catch (err) { @@ -315,7 +399,7 @@ export class MongoDBClient extends BasicDatabaseClient { const convertedOrders = orderBy.length > 0 ? orderBy.reduce((all, ord) => ({ ...all, - [ord.field]: ord.dir.toLowerCase() === 'asc' ? 1 : -1 + [ord.field]: ord.dir.toLowerCase() === 'asc' ? 1 : -1 }), {} as any) : null; const convertedFilters = !_.isString() && filters.length > 0 ? this.convertFilters(filters as TableFilter[]) : {}; let convertedSelects = null; @@ -389,7 +473,7 @@ export class MongoDBClient extends BasicDatabaseClient { return { name: column, bksType }; }) } - + async getPrimaryKeys(_table: string, _schema?: string): Promise { return [{ @@ -438,42 +522,42 @@ export class MongoDBClient extends BasicDatabaseClient { async insertRows(inserts: TableInsert[], connection: Db) { const errors = []; - + for (const insert of inserts) { try { if (!insert.table) { throw new Error("Missing table name for insert operation"); } - + const collection = connection.collection(insert.table); await collection.insertMany(insert.data); - + log.debug(`Inserted ${insert.data.length} documents into ${insert.table}`); } catch (err) { log.error(`Error inserting into ${insert.table}:`, err); errors.push(`Failed to insert into ${insert.table}: ${err.message}`); } } - + if (errors.length > 0) { throw new Error(errors.join("; ")); } } async updateValues(updates: TableUpdate[], connection: Db) { - let results = []; + const results = []; const errors = []; - + for (const update of updates) { try { if (!update.table) { throw new Error("Missing table name for update operation"); } - + if (!update.primaryKeys || update.primaryKeys.length === 0) { throw new Error(`No primary key provided for update in table ${update.table}`); } - + // Safely convert ObjectId string to actual ObjectId let idValue = update.primaryKeys[0].value; try { @@ -484,12 +568,12 @@ export class MongoDBClient extends BasicDatabaseClient { log.error(`Error converting ObjectId ${idValue}:`, err); // Continue with the original value if conversion fails } - + const filter = { _id: idValue }; - + // Handle value conversion for special types if needed - let fieldValue = update.value; - + const fieldValue = update.value; + // Create the update document const updateDoc = { $set: { @@ -504,15 +588,15 @@ export class MongoDBClient extends BasicDatabaseClient { const result = await collection.findOneAndUpdate( filter, updateDoc, - { + { returnDocument: 'after', } ); - + if (!result) { throw new Error(`Failed to update document with _id ${idValue} in ${update.table}`); } - + results.push(result); log.debug(`Updated document in ${update.table} with _id ${idValue}, column: ${update.column}`); } catch (err) { @@ -521,27 +605,27 @@ export class MongoDBClient extends BasicDatabaseClient { errors.push(`Failed to update ${update.table}: ${err.message}`); } } - + if (errors.length > 0) { throw new Error(errors.join("; ")); } - + return results; } async deleteRows(deletes: TableDelete[], connection: Db) { const errors = []; - + for (const del of deletes) { try { if (!del.table) { throw new Error("Missing table name for delete operation"); } - + if (!del.primaryKeys || del.primaryKeys.length === 0) { throw new Error(`No primary key provided for delete in table ${del.table}`); } - + // Safely convert ObjectId string to actual ObjectId if needed let idValue = del.primaryKeys[0].value; try { @@ -559,7 +643,7 @@ export class MongoDBClient extends BasicDatabaseClient { const result = await collection.deleteOne({ _id: idValue }); - + if (result.deletedCount === 0) { log.warn(`Failed to delete document with _id ${idValue} in ${del.table}`); } else { @@ -571,7 +655,7 @@ export class MongoDBClient extends BasicDatabaseClient { errors.push(`Failed to delete from ${del.table}: ${err.message}`); } } - + if (errors.length > 0) { throw new Error(errors.join("; ")); } @@ -580,37 +664,37 @@ export class MongoDBClient extends BasicDatabaseClient { override async alterIndex(changes: IndexAlterations): Promise { const errors = []; const db = this.conn.db(this.db); - + try { // Verify collection exists const collections = await db.listCollections({ name: changes.table }).toArray(); if (collections.length === 0) { throw new Error(`Collection ${changes.table} does not exist`); } - + const collection = db.collection(changes.table); // Process index additions - for (let addition of changes.additions) { + for (const addition of changes.additions) { try { // Convert column order specifications to MongoDB format const indexSpec = addition.columns.reduce((obj, col) => ({ ...obj, [col.name]: this.convertOrder(col.order) }), {}); - + // Prepare index options const indexOptions: any = { name: addition.name, }; - + // Add unique option if specified if (addition.unique) { indexOptions.unique = true; } - + log.debug(`Creating index ${addition.name} on ${changes.table} with spec:`, indexSpec); - + // Create the index await collection.createIndex(indexSpec, indexOptions); log.debug(`Successfully created index ${addition.name} on ${changes.table}`); @@ -622,7 +706,7 @@ export class MongoDBClient extends BasicDatabaseClient { } // Process index drops - for (let drop of changes.drops) { + for (const drop of changes.drops) { try { log.debug(`Dropping index ${drop.name} from ${changes.table}`); await collection.dropIndex(drop.name); @@ -633,7 +717,7 @@ export class MongoDBClient extends BasicDatabaseClient { errors.push(errorMsg); } } - + // If any errors occurred, throw a combined error if (errors.length > 0) { throw new Error(errors.join('; ')); @@ -699,12 +783,12 @@ export class MongoDBClient extends BasicDatabaseClient { } async executeQuery(queryText: string, _options?: any): Promise { - const queries = this.identifyCommands(queryText).map((q) => q.text); - let results = []; + const queries = this.identifyCommands(queryText); + const results = []; for (let i = 0; i < queries.length; i++) { const query = queries[i]; - const r = await this.queryLeaf.execute(query); + const r = await this.queryLeaf.execute(query.text); let fields = []; if (r) { let f = []; @@ -735,27 +819,20 @@ export class MongoDBClient extends BasicDatabaseClient { rowCount: r?.length ?? 0, affectedRows, fields, - command: query + command: query?.type, + text: query?.text }) } return results; } - private identifyCommands(queryText: string) { - try { - return identify(queryText, { strict: false, dialect: 'psql' }); - } catch (err) { - return []; - } - } - async executeCommand(commandText: string, _options?: any): Promise { - let results: NgQueryResult[] = []; + const results: NgQueryResult[] = []; const listener = { onPrint: (value): void => { - value.map((v) => { + value.forEach((v) => { results.push({ output: v.printable }) @@ -899,7 +976,6 @@ export class MongoDBClient extends BasicDatabaseClient { }; return { - totalRows: 0,// need to figure this out columns, cursor: new MongoDBCursor(cursorOpts) }; @@ -912,15 +988,9 @@ export class MongoDBClient extends BasicDatabaseClient { chunkSize }; - const { columns, totalRows } = await this.getColumnsAndTotalRows(query); - return { - totalRows, - columns, cursor: new MongoDBCursor(cursorOpts) } - log.error('MongoDB does not support querying'); - return null; } wrapIdentifier(_value: string): string { @@ -944,16 +1014,16 @@ export class MongoDBClient extends BasicDatabaseClient { async getCollectionValidation(collectionName: string): Promise { try { const db = this.conn.db(this.db); - + // Run listCollections with the filter to get the specified collection info const collections = await db.listCollections({ name: collectionName }, { nameOnly: false }).toArray(); - + if (collections.length === 0) { throw new Error(`Collection ${collectionName} not found`); } - + const collectionInfo = collections[0]; - + // Return the validation information if it exists return { validator: collectionInfo.options?.validator || null, @@ -965,7 +1035,7 @@ export class MongoDBClient extends BasicDatabaseClient { throw err; } } - + async setCollectionValidation(params: { collection: string, validationLevel: 'off' | 'strict' | 'moderate', @@ -974,7 +1044,7 @@ export class MongoDBClient extends BasicDatabaseClient { }): Promise { try { const db = this.conn.db(this.db); - + // Create the validator command const command = { collMod: params.collection, @@ -982,10 +1052,10 @@ export class MongoDBClient extends BasicDatabaseClient { validationLevel: params.validationLevel, validationAction: params.validationAction }; - + // Run the command to modify the collection await db.command(command); - + log.debug(`Updated validation for collection ${params.collection}`); } catch (err) { log.error(`Error setting collection validation for ${params.collection}:`, err); @@ -1008,7 +1078,7 @@ export class MongoDBClient extends BasicDatabaseClient { return order; } } - + private async getCollectionCols(collection: Collection) { // Take the last 10 docs from a collection and hope that's an accurate representation of the whole collection lol return await collection.aggregate( diff --git a/apps/studio/src-commercial/backend/lib/db/clients/mongodb/MongoDBCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/mongodb/MongoDBCursor.ts index 89825342b98..32c827ca6ab 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/mongodb/MongoDBCursor.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/mongodb/MongoDBCursor.ts @@ -1,6 +1,7 @@ -import { BeeCursor } from "@/lib/db/models"; +import { BeeCursor, TableColumn } from "@/lib/db/models"; import { CursorResult, QueryLeaf } from "@queryleaf/lib"; import { AggregationCursor } from "mongodb"; +import _ from "lodash"; interface CursorOptions { query?: string, @@ -13,12 +14,21 @@ export class MongoDBCursor extends BeeCursor { private readonly options: CursorOptions; private cursor: CursorResult; private error?: Error; + private fields?: string[]; constructor(options: CursorOptions) { super(options.chunkSize); this.options = options; } + get columns(): TableColumn[] | null { + if (!this.fields) return null; + return this.fields.map((f) => ({ + columnName: f, + dataType: 'unknown' + })) + } + private handleError(error: Error) { this.error = error; } @@ -27,7 +37,6 @@ export class MongoDBCursor extends BeeCursor { if (this.options.queryLeaf) { this.cursor = await this.options.queryLeaf.executeCursor(this.options.query, { batchSize: this.chunkSize }); } else if (this.options.cursor) { - // @ts-expect-error stupid peer dependencies this.cursor = this.options.cursor; } else { throw new Error('You need either a cursor or a queryleaf instance to be passed to the cursor'); @@ -42,10 +51,14 @@ export class MongoDBCursor extends BeeCursor { } if (await this.cursor.hasNext()) { - // we have to call this to trigger mongo to fetch the next batch + // we have to call this to trigger mongo to fetch the next batch const firstDoc = await this.cursor.next(); const rest = this.cursor.readBufferedDocuments(); + if (!this.fields) { + this.fields = _.uniq(_.flatten(_.takeRight(rest, 10).map((obj) => Object.keys(obj)))); + } + return [firstDoc, ...rest].map((v) => Object.values(v)); } return []; @@ -53,5 +66,5 @@ export class MongoDBCursor extends BeeCursor { async cancel(): Promise { await this.cursor.close(); } - + } diff --git a/apps/studio/src-commercial/backend/lib/db/clients/oracle.ts b/apps/studio/src-commercial/backend/lib/db/clients/oracle.ts index 87ede944ee8..baef5426478 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/oracle.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/oracle.ts @@ -42,7 +42,6 @@ import { import rawLog from '@bksLogger' import { createCancelablePromise, joinFilters } from '@/common/utils'; import { errors } from '@/lib/errors'; -import { identify as rawIdentify } from 'sql-query-identifier' import { IdentifyResult } from 'sql-query-identifier/lib/defines'; import platformInfo from '@/common/platform_info'; import { OracleCursor } from './oracle/OracleCursor'; @@ -51,6 +50,7 @@ import { ChangeBuilderBase } from '@shared/lib/sql/change_builder/ChangeBuilderB import { IDbConnectionServer } from '@/lib/db/backendTypes'; import { GenericBinaryTranscoder } from '@/lib/db/serialization/transcoders'; import Client_Oracledb from '@shared/lib/knex-oracledb'; +import fs from 'fs'; const log = rawLog.scope('oracle') @@ -59,6 +59,12 @@ oracle.fetchAsString = [oracle.CLOB] oracle.fetchAsBuffer = [oracle.BLOB] let oracleInitialized = false +let oracleInitConfigDir: string | null = null + +export function _resetOracleStateForTesting() { + oracleInitialized = false + oracleInitConfigDir = null +} export class OracleClient extends BasicDatabaseClient { pool: oracle.Pool; @@ -77,6 +83,7 @@ export class OracleClient extends BasicDatabaseClient { + async executeApplyChanges(changes: TableChanges, tabId?: number): Promise { const insertQueries = buildInsertQueries(this.knex, changes.inserts) const updateQueries = buildUpdateQueries(this.knex, changes.updates) const deleteQueries = buildDeleteQueries(this.knex, changes.deletes) @@ -238,7 +245,7 @@ export class OracleClient extends BasicDatabaseClient = { libDir: cliLocation } if (configLocation) { - payload['configDir'] = configLocation + payload.configDir = configLocation } log.debug("initializing oracle client with", payload) - oracle.initOracleClient(payload) + try { + oracle.initOracleClient(payload) + } catch (err) { + // initOracleClient can only be called once per process — even if it + // fails, a second call will crash the native addon (DPI-1050). + // Mark it as initialized so we never call it again. + oracleInitialized = true + oracleInitConfigDir = configLocation || null + throw new Error(`Failed to initialize Oracle client: ${err.message}`) + } oracleInitialized = true + oracleInitConfigDir = configLocation || null } else { if (!cliLocation) { log.warn("Oracle is connecting using THIN mode -- some functionality might not be supported. Provide a path to the Oracle Instant client for full functionality") } + if (cliLocation && oracleInitialized && configLocation && configLocation !== oracleInitConfigDir) { + throw new Error( + `Oracle configuration directory cannot be changed after the client has been initialized. ` + + `Current: "${oracleInitConfigDir || '(none)'}", requested: "${configLocation}". ` + + `Please restart Beekeeper Studio to use a different configuration directory.` + ) + } } const connectionMethod = this.server.config.options?.connectionMethod || 'manual' @@ -809,7 +841,9 @@ export class OracleClient extends BasicDatabaseClient { - const { columns, totalRows } = await this.getColumnsAndTotalRows(query) return { - totalRows, - columns, cursor: new OracleCursor(this.pool, query, [], chunkSize) } } - async executeQuery(query: string): Promise { - const results = await this.driverExecuteMultiple(query) + async executeQuery(query: string, options?: any): Promise { + const results = await this.driverExecuteMultiple(query, options) return this.parseResults(results) } @@ -1027,7 +1058,8 @@ export class OracleClient extends BasicDatabaseClient f.id) || [] return { - command: result.info.text, + command: result.info.type, + text: result.info.text, rowCount: result.result.rows?.length || 0, affectedRows: result.result.rowsAffected || 0, rows: result.result.rows?.map((r: any) => _.zipObject(fieldIds, r)) || [], @@ -1070,7 +1102,7 @@ export class OracleClient extends BasicDatabaseClient { const realQueries: string[] = _.isArray(query) ? query : [query] - const infos = _.flatMap(realQueries.map((q) => this.identify(q))) + const infos = _.flatMap(realQueries.map((q) => this.identifyCommands(q))) // TODO - use `executeMany` if no SELECT queries are present // const hasListing = !!infos.find((i) => ['LISTING', 'UNKNOWN'].includes(i.executionType)) const hasReserved = this.reservedConnections.has(options?.tabId); @@ -1132,10 +1164,6 @@ export class OracleClient extends BasicDatabaseClient { let bksType: BksFieldType = 'UNKNOWN'; diff --git a/apps/studio/src-commercial/backend/lib/db/clients/oracle/OracleCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/oracle/OracleCursor.ts index f35b90ea4cf..25bbc0f9595 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/oracle/OracleCursor.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/oracle/OracleCursor.ts @@ -1,5 +1,5 @@ -import { BeeCursor } from "@/lib/db/models"; -import oracle from 'oracledb' +import { BeeCursor, TableColumn } from "@/lib/db/models"; +import oracle, { Metadata } from 'oracledb' import rawLog from '@bksLogger' import { waitFor } from "@/lib/db/clients/base/wait"; @@ -13,6 +13,7 @@ export class OracleCursor extends BeeCursor { private bufferReady = false private end = false private error?: Error + private fields: Metadata[]; constructor( private pool: oracle.Pool, @@ -24,6 +25,14 @@ export class OracleCursor extends BeeCursor { } + get columns(): TableColumn[] | null { + if (!this.fields) return null; + return this.fields.map((f) => ({ + columnName: f.name, + dataType: f.dbTypeName + })) + } + async start(): Promise { this.conn = await this.pool.getConnection() log.info("Oracle cursor start", this.query, this.params) @@ -58,7 +67,7 @@ export class OracleCursor extends BeeCursor { } private handleMetadata(data: any) { - console.log('handling metadata', data) + this.fields = data; } private async handleEnd() { diff --git a/apps/studio/src-commercial/backend/lib/db/clients/scylladb.ts b/apps/studio/src-commercial/backend/lib/db/clients/scylladb.ts new file mode 100644 index 00000000000..2609a3fcb2a --- /dev/null +++ b/apps/studio/src-commercial/backend/lib/db/clients/scylladb.ts @@ -0,0 +1,5 @@ +import { CassandraClient } from "./cassandra"; + +export class ScyllaDBClient extends CassandraClient { + +} diff --git a/apps/studio/src-commercial/backend/lib/db/clients/snowflake.ts b/apps/studio/src-commercial/backend/lib/db/clients/snowflake.ts new file mode 100644 index 00000000000..de28e8916f9 --- /dev/null +++ b/apps/studio/src-commercial/backend/lib/db/clients/snowflake.ts @@ -0,0 +1,1087 @@ +import { IDbConnectionServer } from "@/lib/db/backendTypes"; +import { BasicDatabaseClient, ExecutionContext, QueryLogOptions } from "@/lib/db/clients/BasicDatabaseClient"; +import { DatabaseElement, IDbConnectionDatabase, SnowflakeAuthType } from "@/lib/db/types"; +import * as snowflake from "snowflake-sdk"; +import { Connection, ConnectionOptions, Pool, PoolOptions } from "snowflake-sdk" +import BksConfig from "@/common/bksConfig"; +import rawLog from '@bksLogger' +import { BksField, CancelableQuery, DatabaseFilterOptions, ExtendedTableColumn, FieldDescriptor, FilterOptions, NgQueryResult, OrderBy, PrimaryKeyColumn, Routine, SchemaFilterOptions, StreamResults, SupportedFeatures, TableChanges, TableColumn, TableDelete, TableFilter, TableIndex, TableInsert, TableOrView, TableProperties, TableResult, TableTrigger, TableUpdate, TableUpdateResult } from "@/lib/db/models"; +import { buildDeleteQueries, buildInsertQueries, buildSchemaFilter, buildSelectQueriesFromUpdates, buildSelectTopQuery, buildUpdateQueries, errorMessages, escapeString } from "@/lib/db/clients/utils"; +import _ from "lodash"; +import { TableKey } from "@/shared/lib/dialects/models"; +import { IdentifyResult } from "sql-query-identifier/lib/defines"; +import { createCancelablePromise } from "@/common/utils"; +import { errors } from "@/lib/errors"; +import { SnowflakeDialect } from "@beekeeperstudio/knex-snowflake-dialect" +import knexlib from 'knex'; +import { ChangeBuilderBase } from "@/shared/lib/sql/change_builder/ChangeBuilderBase"; +import { SnowflakeChangeBuilder } from "@/shared/lib/sql/change_builder/SnowflakeChangeBuilder"; +import { SnowflakeCursor } from "./snowflake/SnowflakeCursor"; +import { IndexColumn } from "@beekeeperstudio/plugin"; + +const log = rawLog.scope('snowflake') + +interface SnowflakeResult { + columns: { name: string, type?: string | number | any }[] + rows: Record[]; + arrayMode: boolean; + rowCount: number; + affectedCount: number; +} + +const snowflakeContext = { + getExecutionContext(): ExecutionContext { + return null; + }, + logQuery(_query: string, _options: QueryLogOptions, _context: ExecutionContext): Promise { + return null; + } +}; + +type RawSnowflakeStatement = snowflake.RowStatement | snowflake.FileAndStageBindStatement; + +type SnowflakeStatement = { + rawStatement: RawSnowflakeStatement, + queryId: Promise +}; + +export interface VersionInfo { + version?: string +} + +export class SnowflakeClient extends BasicDatabaseClient { + + pool: Pool; + version: VersionInfo; + _defaultSchema: string; + + constructor(server: IDbConnectionServer, database: IDbConnectionDatabase) { + super(null, snowflakeContext, server, database); + this.dialect = "snowflake"; + this.readOnlyMode = server?.config?.readOnlyMode || false; + + this.knex = knexlib({ + client: SnowflakeDialect + }) + + snowflake.configure({ + logLevel: "OFF" + }) + } + + // Snowflake supports basically an infinite number of collations so idk how we want to handle this + async listCharsets(): Promise { + return []; + } + + async getDefaultCharset(): Promise { + return 'UTF8'; + } + + async listCollations(_charset: string): Promise { + return []; + } + + async connect(): Promise { + if (!this.server && !this.database) { + return; + } + + await super.connect(); + + const config = await this.configDatabase(this.server, this.database); + const poolConfig = this.configPool(); + + this.pool = snowflake.createPool(config, poolConfig); + + this.version = await this.getVersion(); + this._defaultSchema = await this.getSchema(); + log.info("DEFAULT SCHEMA: ", this._defaultSchema); + } + + async disconnect(): Promise { + await super.disconnect(); + await this.pool.clear(); + } + + async defaultSchema(): Promise { + return this._defaultSchema; + } + + private async configDatabase(server: IDbConnectionServer, database: IDbConnectionDatabase): Promise { + + if (!database.database) { + throw new Error('Please enter a default database'); + } + + if (!server.config.snowflakeOptions.defaultWarehouse) { + throw new Error('Please enter a default warehouse'); + } + + const config: ConnectionOptions = { + authenticator: 'SNOWFLAKE', + account: server.config.snowflakeOptions.accountId, + database: database.database, + warehouse: server.config.snowflakeOptions.defaultWarehouse, + }; + + if ([SnowflakeAuthType.MFACode, SnowflakeAuthType.MFANotif, SnowflakeAuthType.Default].includes(server.config.snowflakeOptions?.authType)) { + config.username = server.config.user; + config.password = server.config.password; + } + + if ([SnowflakeAuthType.MFACode, SnowflakeAuthType.MFANotif].includes(server.config.snowflakeOptions?.authType)) { + config.authenticator = 'USERNAME_PASSWORD_MFA'; + config.clientRequestMFAToken = true; + } + + if (server.config.snowflakeOptions?.authType === SnowflakeAuthType.MFACode) { + config.passcode = server.config.snowflakeOptions?.passcode; + } + + if (server.config.snowflakeOptions?.authType === SnowflakeAuthType.Browser) { + config.authenticator = 'EXTERNALBROWSER'; + config.clientStoreTemporaryCredential = true; + } + + return config; + } + + private configPool(): PoolOptions { + return { + max: BksConfig.db.snowflake.maxConnections, + autostart: true, + evictionRunIntervalMillis: BksConfig.db.snowflake.idleTimeout, + idleTimeoutMillis: BksConfig.db.snowflake.idleTimeout, + acquireTimeoutMillis: BksConfig.db.snowflake.connectionTimeout + } + } + + async versionString(): Promise { + return this.version.version; + } + + private async getVersion(): Promise { + const result = await this.driverExecuteSingle('SELECT current_version() AS versionString;'); + + const versionString = result.rows[0]?.VERSIONSTRING; + + return { + version: versionString + } + } + + getBuilder(table: string, schema?: string): ChangeBuilderBase { + return new SnowflakeChangeBuilder(table, schema); + } + + async supportedFeatures(): Promise { + return { + customRoutines: true, + comments: true, + properties: true, + partitions: false, + editPartitions: false, + backups: false, + backDirFormat: false, + restore: false, + indexNullsNotDistinct: false, + transactions: true, + filterTypes: ['standard', 'ilike'] + } + } + + async listDatabases(_filter?: DatabaseFilterOptions): Promise { + // doing it this way should ensure we don't need a database selected + const sql = ` + SHOW DATABASES; + `; + + const data = await this.driverExecuteSingle(sql); + + return data.rows.map((row) => row.name); + } + + async listTables(filter?: FilterOptions): Promise { + const schemaFilter = buildSchemaFilter(filter, 'TABLE_SCHEMA', this.wrapIdentifier); + + const sql = ` + SELECT + TABLE_SCHEMA as schema, + TABLE_NAME as name, + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_TYPE NOT LIKE '%VIEW%' + ${schemaFilter ? `AND ${schemaFilter}` : ''} + ORDER BY TABLE_SCHEMA, TABLE_NAME + `; + + const data = await this.driverExecuteSingle(sql); + + return data.rows.map((row) => ({ + name: row.NAME, + schema: row.SCHEMA + } as TableOrView)); + } + + async listViews(_filter?: FilterOptions): Promise { + const sql = ` + SHOW VIEWS IN DATABASE + `; + + const data = await this.driverExecuteSingle(sql); + + return data.rows + .filter((row) => row.is_materialized === 'false') + .map((row) => ({ + name: row.name, + schema: row.schema_name + } as TableOrView)); + } + + // TODO (@day): this may be annoying lmao + async listRoutines(_filter?: FilterOptions): Promise { + return []; + } + + async listMaterializedViews(_filter?: FilterOptions): Promise { + const sql = ` + SHOW MATERIALIZED VIEWS IN DATABASE + `; + + try { + const data = await this.driverExecuteSingle(sql); + + return data.rows + .map((row) => ({ + name: row.name, + schema: row.schema_name + } as TableOrView)); + } catch (e) { + // I believe this might throw if you don't have enterprise edition + log.warn('Could not list mat views: ', e); + return []; + } + } + + async listMaterializedViewColumns(table: string, schema?: string): Promise { + return await this.listTableColumns(table, schema); + } + + async listTableColumns(table: string, schema: string = this._defaultSchema): Promise { + const ident = this.wrapTable(table, schema); + const sql = ` + DESCRIBE TABLE ${ident} + `; + + const data = await this.driverExecuteSingle(sql); + + return data.rows.map((row: any, ind) => ({ + schemaName: schema, + tableName: table, + columnName: row.name, + dataType: row.type, + nullable: row['null?'] === 'Y', // wtf snowflake + defaultValue: row.default, + ordinalPosition: ind, + hasDefault: !_.isNil(row.default), + array: row.type === 'ARRAY', + comment: row.comment, + generated: row.kind === 'VIRTUAL', + generationExpression: row.expression, + bksField: this.parseTableColumn(row) + })) + } + + // afaik this doesn't exist in snowflake, unless we want to shove tasks and streams into here + async listTableTriggers(_table: string, _schema?: string): Promise { + return []; + } + + async listTableIndexes(table: string, schema?: string): Promise { + const ident = this.wrapTable(table, schema); + const sql = ` + SHOW INDEXES IN TABLE ${ident} + `; + + const data = await this.driverExecuteSingle(sql); + const reg = /^SYS_INDEX_.*_PRIMARY$/; + + const result = data.rows.map((r) => { + const columns: IndexColumn[] = r.columns.slice(1, -1).split(',').map((c) => { + return { + name: c.trim(), + order: 'ASC' // apparently all indexes in snowflake are ascending + } + }); + + const item: TableIndex = { + id: r.name, + name: r.name, + columns, + table: r.table, + schema: r.schema_name, + unique: r.is_unique === 'Y', + primary: reg.test(r.name) + } + + return item; + }) + + return result; + } + + async listSchemas(filter?: SchemaFilterOptions): Promise { + const schemaFilter = buildSchemaFilter(filter, 'SCHEMA_NAME'); + + const sql = ` + SELECT + SCHEMA_NAME + FROM INFORMATION_SCHEMA.SCHEMATA + ${schemaFilter ? `WHERE ${schemaFilter}` : ''} + ORDER BY SCHEMA_NAME + `; + + const data = await this.driverExecuteSingle(sql); + + return data.rows.map((row) => row.SCHEMA_NAME); + } + + async getOutgoingKeys(table: string, schema?: string): Promise { + const sql = ` + SHOW IMPORTED KEYS IN TABLE ${this.wrapIdentifier(schema)}.${this.wrapIdentifier(table)}; + `; + + const data = await this.driverExecuteSingle(sql); + + const groupedKeys = _.groupBy(data.rows, 'fk_name'); + + return Object.keys(groupedKeys).map(constraintName => { + const keyParts = groupedKeys[constraintName]; + + const firstPart = keyParts[0]; + + const key: TableKey = { + constraintName: firstPart.fk_name, + toTable: firstPart.pk_table_name, + toSchema: firstPart.pk_schema_name, + fromTable: firstPart.fk_table_name, + fromSchema: firstPart.fk_schema_name, + onUpdate: firstPart.update_rule, + onDelete: firstPart.delete_rule, + + toColumn: firstPart.pk_column_name, + fromColumn: firstPart.fk_column_name, + isComposite: false + }; + + if (keyParts.length > 1) { + key.toColumn = keyParts.map(p => p.pk_column_name); + key.fromColumn = keyParts.map(p => p.fk_column_name); + key.isComposite = true; + } + + return key; + }); + } + + async getPrimaryKey(table: string, schema?: string): Promise { + const keys = await this.getPrimaryKeys(table, schema); + return keys.length === 1 ? keys[0].columnName : null; + } + + async getPrimaryKeys(table: string, schema?: string): Promise { + const sql = ` + SHOW PRIMARY KEYS IN TABLE ${this.wrapIdentifier(schema)}.${this.wrapIdentifier(table)} + `; + + const data = await this.driverExecuteSingle(sql); + + if (data?.rows) { + return data.rows.map((r) => ({ + columnName: r.column_name, + position: r.key_sequence + })) + } else { + return [] + } + } + + async getIncomingKeys(table: string, schema?: string): Promise { + const sql = ` + SHOW EXPORTED KEYS IN TABLE ${this.wrapIdentifier(schema)}.${this.wrapIdentifier(table)}; + `; + + const data = await this.driverExecuteSingle(sql); + + const groupedKeys = _.groupBy(data.rows, 'fk_name'); + + return Object.keys(groupedKeys).map(constraintName => { + const keyParts = groupedKeys[constraintName]; + + const firstPart = keyParts[0]; + + const key: TableKey = { + constraintName: firstPart.fk_name, + toTable: firstPart.pk_table_name, + toSchema: firstPart.pk_schema_name, + fromTable: firstPart.fk_table_name, + fromSchema: firstPart.fk_schema_name, + onUpdate: firstPart.update_rule, + onDelete: firstPart.delete_rule, + + toColumn: firstPart.pk_column_name, + fromColumn: firstPart.fk_column_name, + isComposite: false + }; + + if (keyParts.length > 1) { + key.toColumn = keyParts.map(p => p.pk_column_name); + key.fromColumn = keyParts.map(p => p.fk_column_name); + key.isComposite = true; + } + + return key; + }); + } + + async query(queryText: string, tabId: number, _options?: any): Promise { + let stmt: snowflake.RowStatement | snowflake.FileAndStageBindStatement = null; + const hasReserved = this.reservedConnections.has(tabId); + const conn = hasReserved ? this.peekConnection(tabId) : await this.pool.acquire(); + const cancelable = createCancelablePromise(errors.CANCELED_BY_USER); + + return { + execute: async(): Promise => { + log.info('RUNNING: ', queryText); + const commands = this.identifyCommands(queryText); + if (await this.checkAllowReadOnly() && this.violatesReadOnly(commands)) { + throw new Error(errorMessages.readOnly); + } + + const queries: { queryId: string, command: IdentifyResult }[] = []; + for (const command of commands) { + if (cancelable.canceled) return []; + + const result = await this.runToStatement(command.text, { connection: conn, arrayMode: true }) + stmt = result.rawStatement; + + const queryId = await Promise.race([ + cancelable.wait(), + result.queryId + ]); + + if (queryId) { + queries.push({ queryId, command }); + } + } + + const results: NgQueryResult[] = [] + for (const query of queries) { + const result = await this.getQueryResultFromId(query.queryId, conn, true, (stmt, rows) => { + const command = query.command; + const columns: FieldDescriptor[] = stmt.getColumns()?.map((v, idx) => ({ + name: v.getName(), + id: `c${idx}`, + dataType: v.getType() + })) ?? []; + const updatedRows = stmt.getNumUpdatedRows(); + + const fieldIds = columns.map(c => c.id); + const result: NgQueryResult = { + command: command?.type, + text: command?.text, + rows: rows?.map(r => _.zipObject(fieldIds, r)) ?? [], + fields: columns, + rowCount: stmt.getNumRows(), + affectedRows: updatedRows > 0 ? updatedRows : 0 + } + return result; + }); + + results.push(result); + } + + if (!hasReserved) { + this.pool.release(conn); + } + + return results; + }, + cancel: async(): Promise => { + if (!stmt) { + throw new Error('No statement to cancel'); + } + + stmt.cancel(); + cancelable.cancel(); + } + } + } + + + async executeQuery(queryText: string, options?: any): Promise { + const data = await this.driverExecuteMultiple(queryText, options); + + const commands = this.identifyCommands(queryText); + + return data.map((r, idx) => { + const command = commands[idx]; + return { + command: command?.type, + text: command?.text, + rows: r.rows, + rowCount: r.rowCount, + affectedRows: r.affectedCount, + fields: r.columns.map((c) => ({ + name: c.name, + id: c.name, + dataType: c.type + })) + } + }); + } + + async executeApplyChanges(changes: TableChanges, tabId?: number): Promise { + let results: TableUpdateResult[] = []; + + const run = async (connection: Connection) => { + if (changes.inserts) { + await this.insertRows(changes.inserts, connection); + } + + if (changes.updates) { + results = await this.updateValues(changes.updates, connection); + } + + if (changes.deletes) { + await this.deleteRows(changes.deletes, connection); + } + } + + if (tabId) { + const conn = this.peekConnection(tabId); + await run(conn); + } else { + await this.runWithTransaction(run); + } + + return results; + } + + async getTableLength(table?: string, schema?: string): Promise { + const sql = ` + SHOW TABLES LIKE :1 IN SCHEMA ${this.wrapIdentifier(schema)} + `; + + const params = [table]; + const data = await this.driverExecuteSingle(sql, { params }); + + if (data?.rows && data?.rows.length > 0) { + const numRecords = data?.rows[0]?.rows; + return Number(numRecords); + } + + return 0; + } + + async selectTopSql(table: string, offset: number, limit: number, orderBy: OrderBy[], filters: string | TableFilter[], schema?: string, selects?: string[]): Promise { + const columns = await this.listTableColumns(table, schema); + const { query, params } = buildSelectTopQuery( + table, + offset, + limit, + orderBy, + filters, + "total", + columns, + selects, + schema, + this.wrapIdentifier + ); + + return this.knex.raw(query, params).toQuery(); + } + + async selectTop(table: string, offset: number, limit: number, orderBy: OrderBy[], filters: string | TableFilter[], schema?: string, selects?: string[]): Promise { + const columns = await this.listTableColumns(table, schema); + const queries = buildSelectTopQuery( + table, + offset, + limit, + orderBy, + filters, + "total", + columns, + selects, + schema, + this.wrapIdentifier + ); + const { query, params } = queries; + const result = await this.driverExecuteSingle(query, { params }); + const fields = columns.map((v) => v.bksField).filter((v) => selects && selects.length > 0 ? selects.includes(v.name) : true); + const rows = await this.serializeQueryResult(result, fields); + return { + result: rows, fields + } + } + + async selectTopStream(table: string, orderBy: OrderBy[], filters: string | TableFilter[], chunkSize: number, schema?: string): Promise { + const columns = await this.listTableColumns(table, schema); + const queries = buildSelectTopQuery( + table, + null, + null, + orderBy, + filters, + "total", + columns, + ['*'], + schema, + this.wrapIdentifier + ); + const rowCount = await this.driverExecuteSingle(queries.countQuery, { params: queries.params }); + + const cursorOptions = { + query: queries.query, + params: queries.params, + pool: this.pool, + chunkSize + }; + + return { + totalRows: Number(rowCount.rows[0].total), + columns, + cursor: new SnowflakeCursor(cursorOptions) + } + } + + async queryStream(query: string, chunkSize: number): Promise { + const options = { + query, + params: [], + pool: this.pool, + chunkSize + }; + + const cursor = new SnowflakeCursor(options); + + const { columns, totalRows } = await this.getColumnsAndTotalRows(query); + + return { + totalRows, + columns, + cursor + }; + } + + async getQuerySelectTop(table: string, limit: number, schema?: string): Promise { + return `SELECT * FROM ${this.wrapIdentifier(schema)}.${this.wrapIdentifier(table)} LIMIT ${limit}`; + } + + async getTableProperties(table: string, schema?: string): Promise { + const permissionWarnings: string[] = []; + + const sql = ` + SHOW TABLES LIKE :1 IN SCHEMA ${this.wrapIdentifier(schema)} + ` + + const params = [table] + + const detailsPromise = this.driverExecuteSingle(sql, { params }).catch(err => { + log.warn('Unable to fetch table size/description (likely due to insufficient permissions):', err.message) + permissionWarnings.push('Unable to retrieve table size and description due to insufficient permissions') + return { rows: [{ comment: null, bytes: 0, search_optimization_bytes: 0, owner: null }]}; + }) + + + const relationsPromise = this.getTableKeys(table, schema).catch(err => { + log.warn('Unable to fetch table relations (likely due to insufficient permissions):', err.message) + permissionWarnings.push('Unable to retrieve table relations due to insufficient permissions') + return [] + }) + + const indexesPromise = this.listTableIndexes(table, schema).catch(err => { + log.warn('Unable to fetch table indexes (likely due to insufficient permissions):', err.message) + permissionWarnings.push('Unable to retrieve table indexes due to insufficient permissions') + return [] + }); + + const [ + details, + relations, + indexes + ] = await Promise.all([ + detailsPromise, + relationsPromise, + indexesPromise + ]); + + const props = details.rows.length > 0 ? details.rows[0] : {}; + return { + description: props.comment, + indexSize: Number(props.search_optimization_bytes || 0), // this conceptually matches but idk if its the same thing + size: Number(props.bytes || 0), + indexes, + relations, + triggers: [], + partitions: [], + owner: props.owner, + permissionWarnings: permissionWarnings.length > 0 ? permissionWarnings : undefined + } + } + + async getTableCreateScript(table: string, schema?: string): Promise { + // TODO (@day): I'm worried about escaping here + const sql = ` + SELECT GET_DDL('TABLE', :1, TRUE) AS SCRIPT + `; + const params = [`${schema}.${table}`]; + + const data = await this.driverExecuteSingle(sql, { params }); + + return data.rows[0].SCRIPT; + } + + async getViewCreateScript(view: string, schema?: string): Promise { + // TODO (@day): I'm worried about escaping here + const sql = ` + SELECT GET_DDL('VIEW', :1, TRUE) AS SCRIPT + `; + const params = [`${schema}.${view}`]; + + const data = await this.driverExecuteSingle(sql, { params }); + + return data.rows[0].SCRIPT; + } + + async getRoutineCreateScript(_routine: string, _type: string, _schema?: string): Promise { + return []; + } + + async createDatabase(_databaseName: string, _charset: string, _collation: string): Promise { + return ''; + } + + async createDatabaseSQL(): Promise { + return ''; + } + + async setTableDescription(table: string, description: string, schema?: string): Promise { + const identifier = this.wrapTable(table, schema); + const comment = escapeString(description); + const sql = ` + COMMENT ON TABLE ${identifier} IS '${comment}' + `; + await this.driverExecuteSingle(sql); + // TODO (@day): get this from table properties and return + return ''; + } + + async setElementNameSql(elementName: string, newElementName: string, typeOfElement: DatabaseElement, schema?: string): Promise { + newElementName = this.wrapIdentifier(newElementName); + if ([DatabaseElement.TABLE, DatabaseElement.VIEW, DatabaseElement["MATERIALIZED-VIEW"]].includes(typeOfElement)) { + elementName = this.wrapTable(elementName, schema); + } else { + elementName = this.wrapIdentifier(elementName); + } + + let alterType: string = typeOfElement; + if (typeOfElement === DatabaseElement["MATERIALIZED-VIEW"]){ + alterType = "MATERIALIZED VIEW" + } + + return `ALTER ${alterType} ${elementName} RENAME TO ${newElementName}`; + } + + async dropElement(elementName: string, typeOfElement: DatabaseElement, schema?: string): Promise { + if ([DatabaseElement.TABLE, DatabaseElement.VIEW, DatabaseElement["MATERIALIZED-VIEW"]].includes(typeOfElement)) { + elementName = this.wrapTable(elementName, schema); + } else { + elementName = this.wrapIdentifier(elementName); + } + + let dropType: string = typeOfElement; + if (typeOfElement === DatabaseElement["MATERIALIZED-VIEW"]){ + dropType = "MATERIALIZED VIEW" + } + + const sql = `DROP ${dropType} ${elementName}`; + + await this.driverExecuteSingle(sql); + } + + async truncateElementSql(elementName: string, typeOfElement: DatabaseElement, schema?: string): Promise { + if ([DatabaseElement.TABLE, DatabaseElement.VIEW, DatabaseElement["MATERIALIZED-VIEW"]].includes(typeOfElement)) { + elementName = this.wrapTable(elementName, schema); + } else { + elementName = this.wrapIdentifier(elementName); + } + + let truncateType: string = typeOfElement; + if (typeOfElement === DatabaseElement["MATERIALIZED-VIEW"]){ + truncateType = "MATERIALIZED VIEW" + } + + return `TRUNCATE ${truncateType} ${elementName}`; + } + + async truncateAllTables(_schema?: string): Promise { + // this isn't used anywhere afaik + } + + async duplicateTable(tableName: string, duplicateTableName: string, schema?: string): Promise { + const sql = await this.duplicateTableSql(tableName, duplicateTableName, schema); + + await this.driverExecuteSingle(sql); + } + + async duplicateTableSql(tableName: string, duplicateTableName: string, schema: string = this._defaultSchema): Promise { + const sql = ` + CREATE TABLE ${this.wrapIdentifier(schema)}.${this.wrapIdentifier(duplicateTableName)} + CLONE ${this.wrapIdentifier(schema)}.${this.wrapIdentifier(tableName)} + `; + + return sql; + } + + async runWithConnection(run: (conn: Connection) => Promise): Promise { + const connection = await this.pool.acquire(); + try { + return await run(connection); + } finally { + await this.pool.release(connection); + } + } + + private async runWithTransaction(run: (conn: Connection) => Promise): Promise { + return await this.runWithConnection(async (connection) => { + await this.driverExecuteSingle('BEGIN TRANSACTION', { connection }); + try { + const result = await run(connection); + await this.driverExecuteSingle('COMMIT', { connection }); + return result + } catch(ex) { + log.warn("Error in transaction - rolling back ", ex.message); + await this.driverExecuteSingle('ROLLBACK', { connection }); + throw ex; + } + }) + + } + + private async getQueryResultFromId(queryId: string, conn: Connection, rowResults: boolean, formatResults: (stmt: RawSnowflakeStatement, rows: any[]) => T): Promise { + const snowflakeStmt = await conn.getResultsFromQueryId({ + queryId, + rowMode: rowResults ? 'array' : 'object_with_renamed_duplicated_columns' + }); + return await new Promise((resolve, reject) => { + try { + const stream = snowflakeStmt.streamRows(); + const rows: any[] = []; + + stream.on('error', err => { + reject(err) + }) + + stream.on('data', row => { + rows.push(row); + }) + + stream.on('end', () => { + const result = formatResults(snowflakeStmt, rows); + resolve(result); + }) + } catch (e) { + reject(e); + } + }) + } + + private async runToStatement(query: string, options: { + params?: snowflake.Bind[], + connection: Connection, + arrayMode?: boolean, + }): Promise { + if (!options.connection) { + throw new Error('Connection required to run to statement'); + } + let stmt: RawSnowflakeStatement; + + const queryId = new Promise((resolve, reject) => { + options.connection.execute({ + sqlText: query, + binds: options.params, + asyncExec: true, + streamResult: true, + parameters: { + MULTI_STATEMENT_COUNT: 1 + }, + rowMode: options.arrayMode ? 'array' : 'object_with_renamed_duplicated_columns', + complete: (err, stmt) => { + if (err) { + log.error(err.message); + return reject(err); + } + + const queryId = stmt.getQueryId(); + resolve(queryId); + } + }); + }); + + return { + rawStatement: stmt, + queryId + }; + } + + protected async rawExecuteQuery(q: string, options?: { + params?: snowflake.Bind[], + connection?: Connection, + tabId?: number, + arrayMode?: boolean, + multiple: boolean, + statements: IdentifyResult[] + }): Promise { + log.info('RUNNING QUERY (using ident statements): ', q) + const hasReserved = this.reservedConnections.has(options?.tabId); + let conn: Connection = null + + if (hasReserved) { + conn = this.peekConnection(options?.tabId); + } else if (options?.connection) { + conn = options?.connection; + } else { + conn = await this.pool.acquire(); + } + + const queries: string[] = []; + + for (const statement of options.statements) { + const result = await this.runToStatement(statement.text, { + params: options.params, + connection: conn, + arrayMode: options.arrayMode, + }); + + const queryId = await result.queryId; + queries.push(queryId); + } + + const results = await Promise.all(queries.map(async (queryId) => { + return await this.getQueryResultFromId(queryId, conn, options.arrayMode, (stmt, rows) => { + const columns = stmt.getColumns()?.map((v) => ({ + name: v.getName(), + type: v.getType() + })) ?? []; + + const result: SnowflakeResult = { + columns, + rows, + arrayMode: options.arrayMode, + rowCount: stmt.getNumRows(), + affectedCount: stmt.getNumUpdatedRows() + }; + return result; + }) + })) + + if (!hasReserved && !options?.connection) { + this.pool.release(conn); + } + + return results; + } + + async reserveConnection(tabId: number) { + this.throwIfHasConnection(tabId); + + if (this.reservedConnections.size >= BksConfig.db.snowflake.maxReservedConnections) { + throw new Error(errorMessages.maxReservedConnections); + } + + const conn = await this.pool.acquire(); + this.pushConnection(tabId, conn); + } + + async releaseConnection(tabId: number) { + const conn = this.popConnection(tabId); + if (conn) { + await this.pool.release(conn); + } + } + + async startTransaction(tabId: number) { + const conn = this.peekConnection(tabId); + await this.driverExecuteSingle('BEGIN TRANSACTION;', { connection: conn }); + } + + async commitTransaction(tabId: number) { + const conn = this.peekConnection(tabId); + await this.driverExecuteSingle('COMMIT;', { connection: conn }); + } + + async rollbackTransaction(tabId: number) { + const conn = this.peekConnection(tabId); + await this.driverExecuteSingle('ROLLBACK;', { connection: conn }); + } + + wrapIdentifier(value: string): string { + if (!value || value === '*') return value; + return `"${value.replaceAll(/"/g, '""')}"`; + } + + protected parseTableColumn(column: any): BksField { + return { + name: column.name, + bksType: "UNKNOWN" + } + } + + private async insertRows(rawInserts: TableInsert[], connection: Connection) { + await this.driverExecuteMultiple(buildInsertQueries(this.knex, rawInserts).join(";"), { connection }); + } + + private async updateValues(rawUpdates: TableUpdate[], connection: Connection): Promise { + await this.driverExecuteMultiple(buildUpdateQueries(this.knex, rawUpdates).join(";"), { connection }); + + const data = await this.driverExecuteMultiple(buildSelectQueriesFromUpdates(this.knex, rawUpdates).join(";"), { connection }); + + const results = data.map((r) => r.rows[0]); + + return results; + } + + private async deleteRows(deletes: TableDelete[], connection) { + await this.driverExecuteMultiple(buildDeleteQueries(this.knex, deletes).join(";"), { connection }); + } + + // This is never used + async getTableReferences(_table: string, _schema?: string): Promise { + return [] + } + + protected wrapTable(table: string, schema?: string) { + if (!schema) return this.wrapIdentifier(table); + return `${this.wrapIdentifier(schema)}.${this.wrapIdentifier(table)}`; + } + + private async getSchema() { + const sql = ` + SELECT COALESCE( + CURRENT_SCHEMA(), + ( + SELECT SCHEMA_NAME + FROM INFORMATION_SCHEMA.SCHEMATA + WHERE SCHEMA_NAME <> 'INFORMATION_SCHEMA' + AND CATALOG_NAME = CURRENT_DATABASE() + ORDER BY SCHEMA_NAME + LIMIT 1 + ) + ) AS schema; + `; + + const data = await this.driverExecuteSingle(sql); + return data.rows[0].SCHEMA; + } +} diff --git a/apps/studio/src-commercial/backend/lib/db/clients/snowflake/SnowflakeCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/snowflake/SnowflakeCursor.ts new file mode 100644 index 00000000000..f65c315d390 --- /dev/null +++ b/apps/studio/src-commercial/backend/lib/db/clients/snowflake/SnowflakeCursor.ts @@ -0,0 +1,86 @@ +import { BeeCursor, TableColumn } from "@/lib/db/models"; +import { Bind, Connection, Pool, RowStatement, SnowflakeError } from "snowflake-sdk"; + +interface CursorOptions { + query: string, + params: Bind[], + pool: Pool, + chunkSize: number, +} + +export class SnowflakeCursor extends BeeCursor { + private readonly options: CursorOptions; + private stmt: RowStatement; + private error?: SnowflakeError; + private cursorPos: number = 0; + private rowBuffer: any[][] = []; + private conn: Connection; + + constructor(options: CursorOptions) { + super(options.chunkSize); + this.options = options; + } + + get columns(): TableColumn[] | null { + if (!this.stmt) return null + return this.stmt.getColumns()?.map((v) => ({ + columnName: v.getName(), + dataType: v.getType() + })); + } + + async start() { + this.conn = await this.options.pool.acquire(); + return await new Promise((resolve, reject) => { + // idk if we really need to set this here, but I don't think it hurts anything? + this.stmt = this.conn.execute({ + sqlText: this.options.query, + binds: this.options.params, + rowMode: 'array', + streamResult: true, + complete: (err, stmt) => { + if (err) { + reject(err.message); + } + + this.stmt = stmt; + resolve(); + } + }); + }) + } + + async read(): Promise { + return new Promise((resolve, reject) => { + if (this.error) return reject(this.error.message); + if (!this.stmt) { + return reject("You need to call start first"); + } + + this.stmt.streamRows({ + start: this.cursorPos, + end: this.cursorPos + this.chunkSize + }) + .on('error', (err) => { + reject(err); + }) + .on('data', (row) =>{ + this.rowBuffer.push(row); + }) + .on('end', () => { + const result = this.rowBuffer; + this.rowBuffer = []; + resolve(result); + }) + }) + } + + async cancel(): Promise { + await new Promise((resolve) => { + this.stmt.cancel(() => { + resolve(); + }); + }); + await this.options.pool.release(this.conn); + } +} diff --git a/apps/studio/src-commercial/backend/lib/db/clients/surrealdb.ts b/apps/studio/src-commercial/backend/lib/db/clients/surrealdb.ts index 75fd07fcd80..7bc210519da 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/surrealdb.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/surrealdb.ts @@ -1,4 +1,4 @@ -import { AnyAuth, ConnectionStatus, RecordId, StringRecordId, Token } from "surrealdb"; +import { ProvidedAuth, RecordId } from "surrealdb"; import { SupportedFeatures, FilterOptions, TableOrView, Routine, TableColumn, ExtendedTableColumn, TableTrigger, TableIndex, SchemaFilterOptions, NgQueryResult, DatabaseFilterOptions, TableProperties, PrimaryKeyColumn, OrderBy, TableFilter, TableResult, StreamResults, BksField, CancelableQuery, BksFieldType, TableChanges, TableUpdateResult, TableInsert, TableUpdate, TableDelete } from "@/lib/db/models"; import { TableKey } from "@/shared/lib/dialects/models"; import { _baseTest } from "@playwright/test"; @@ -87,21 +87,21 @@ export class SurrealDBClient extends BasicDatabaseClient { this.pool = new SurrealPool(this.connectionString, { namespace: this.database.namespace, database: this.db, - auth: config, + authentication: config, reconnect: true, versionCheck: false }); // Test the pool const conn = await this.pool.connect(); - if (conn.status === ConnectionStatus.Disconnected || conn.status === ConnectionStatus.Error) { + if (conn.status === "disconnected") { throw new Error('Error connecting to database'); } await conn.release() } - configDatabase(): AnyAuth | Token { + configDatabase(): ProvidedAuth { const { user, password, @@ -181,7 +181,7 @@ export class SurrealDBClient extends BasicDatabaseClient { const conn = await this.pool.connect(); try { const result = await conn.version(); - return result || 'Unknown'; + return result?.version || 'Unknown'; } catch (error) { log.error('Failed to get version: ', error); } finally { @@ -253,7 +253,7 @@ export class SurrealDBClient extends BasicDatabaseClient { const parentFields = []; // This means it's a schemaless table, so we'll have to guess - if (!tableFields || tableFields.length == 0) { + if (!tableFields || tableFields.length === 0) { const results = await this.driverExecuteSingle(`SELECT * FROM ${table} LIMIT 10`); const existingFields = new Set(); results.rows.forEach((row) => { @@ -265,10 +265,10 @@ export class SurrealDBClient extends BasicDatabaseClient { } if (value instanceof RecordId) { - if (value.tb === table) { + if (value.table.name === table) { type = 'string' } else { - type = `record<${value.tb}>` + type = `record<${value.table.name}>` } } @@ -455,7 +455,7 @@ export class SurrealDBClient extends BasicDatabaseClient { return keys; } - async query(queryText: string, options?: any): Promise { + async query(queryText: string, _tabId?: number, options?: any): Promise { return { execute: async(): Promise => { return await this.executeQuery(queryText, options) @@ -643,42 +643,8 @@ export class SurrealDBClient extends BasicDatabaseClient { } async queryStream(query: string, chunkSize: number): Promise { - // For query streaming, we need to estimate total rows and columns - // This is a simplified implementation - const cursor = new SurrealDBCursor({ - query, - conn: this.pool, - chunkSize - }); - - // Try to get a sample to determine columns - let columns: TableColumn[] = []; - let totalRows = 0; - - try { - // Execute a small sample to get column info - const sampleQuery = query.includes('LIMIT') ? query : `${query} LIMIT 1`; - const sampleResult = await this.driverExecuteSingle(sampleQuery); - - if (sampleResult.columns) { - columns = sampleResult.columns.map(col => ({ - columnName: col.name, - dataType: 'unknown', - tableName: '' - })); - } - - // For total rows, we'd need to run a count query, but that's complex - // for arbitrary queries, so we'll set it to -1 to indicate unknown - totalRows = -1; - } catch (error) { - log.warn('Could not determine columns for query stream:', error); - } - return { - totalRows, - columns, - cursor + cursor: new SurrealDBCursor({ query, conn: this.pool, chunkSize }), }; } @@ -756,7 +722,7 @@ export class SurrealDBClient extends BasicDatabaseClient { } async executeApplyChanges(changes: TableChanges): Promise { - let results: TableUpdateResult[] = []; + const results: TableUpdateResult[] = []; const sql = ['BEGIN']; let allBindings = {}; @@ -813,31 +779,31 @@ export class SurrealDBClient extends BasicDatabaseClient { return results; } - setTableDescription(table: string, description: string, schema?: string): Promise { + setTableDescription(_table: string, _description: string, _schema?: string): Promise { throw new Error("Method not implemented."); } - setElementNameSql(elementName: string, newElementName: string, typeOfElement: DatabaseElement, schema?: string): Promise { + setElementNameSql(_elementName: string, _newElementName: string, _typeOfElement: DatabaseElement, _schema?: string): Promise { throw new Error("Method not implemented."); } - dropElement(elementName: string, typeOfElement: DatabaseElement, schema?: string): Promise { + dropElement(_elementName: string, _typeOfElement: DatabaseElement, _schema?: string): Promise { throw new Error("Method not implemented."); } - truncateElementSql(elementName: string, typeOfElement: DatabaseElement, schema?: string): Promise { + truncateElementSql(_elementName: string, _typeOfElement: DatabaseElement, _schema?: string): Promise { throw new Error("Method not implemented."); } - truncateAllTables(schema?: string): Promise { + truncateAllTables(_schema?: string): Promise { throw new Error("Method not implemented."); } - duplicateTable(tableName: string, duplicateTableName: string, schema?: string): Promise { + duplicateTable(_tableName: string, _duplicateTableName: string, _schema?: string): Promise { throw new Error("Method not implemented."); } - duplicateTableSql(tableName: string, duplicateTableName: string, schema?: string): Promise { + duplicateTableSql(_tableName: string, _duplicateTableName: string, _schema?: string): Promise { throw new Error("Method not implemented."); } diff --git a/apps/studio/src-commercial/backend/lib/db/clients/surrealdb/SurrealDBCursor.ts b/apps/studio/src-commercial/backend/lib/db/clients/surrealdb/SurrealDBCursor.ts index 4d1277addcb..13eaefc4530 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/surrealdb/SurrealDBCursor.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/surrealdb/SurrealDBCursor.ts @@ -1,5 +1,6 @@ -import { BeeCursor } from "@/lib/db/models"; +import { BeeCursor, TableColumn } from "@/lib/db/models"; import rawLog from '@bksLogger'; +import { Frame } from "surrealdb"; import { SurrealConn, SurrealPool } from "./SurrealDBPool"; const log = rawLog.scope('surrealdb/cursor'); @@ -12,83 +13,87 @@ interface CursorOptions { export class SurrealDBCursor extends BeeCursor { private readonly options: CursorOptions; - private offset = 0; - private hasMoreData = true; - private error?: Error; private client?: SurrealConn; + private iterator?: AsyncIterator>; + private _columns: TableColumn[] | null = null; + private error?: Error; + private done = false; constructor(options: CursorOptions) { super(options.chunkSize); this.options = options; } + get columns(): TableColumn[] | null { + return this._columns; + } + async start(): Promise { - // SurrealDB doesn't have native cursor support, so we'll simulate it - // by tracking offset and using LIMIT/START clauses this.client = await this.options.conn.connect(); - this.offset = 0; - this.hasMoreData = true; + const query = this.client.query(this.options.query); + this.iterator = query.stream()[Symbol.asyncIterator](); } async read(): Promise { - if (this.error) { - throw this.error; - } - - if (!this.hasMoreData) { - return []; - } + if (this.error) throw this.error; + if (this.done || !this.iterator) return []; + const rows: any[][] = []; try { - // Modify the query to add LIMIT and START clauses - const paginatedQuery = this.addPaginationToQuery(this.options.query, this.offset, this.chunkSize); - - const result = await this.client.query(paginatedQuery); - - // Extract rows from the result - const queryResult = result[0]; - const rows = Array.isArray(queryResult) ? queryResult : [queryResult]; - - // Convert to array format (similar to other cursor implementations) - const arrayRows: any[][] = rows.map(row => { - if (typeof row === 'object' && row !== null) { - return Object.values(row); + while (rows.length < this.chunkSize) { + const next = await this.iterator.next(); + if (next.done) { + this.done = true; + break; } - return [row]; - }); - - // Update pagination state - this.offset += arrayRows.length; - this.hasMoreData = arrayRows.length === this.chunkSize; - - return arrayRows; - } catch (error) { - this.error = error as Error; - throw error; + const frame = next.value; + if (frame.isValue()) { + const value = frame.value; + if (!this._columns) { + this._columns = this.getColumns(value); + } + rows.push(this.toRow(value)); + } else if (frame.isError()) { + frame.throw(); + } + } + } catch (err) { + this.error = err as Error; + throw err; } + + return rows; } async cancel(): Promise { - // SurrealDB doesn't have built-in query cancellation - // We'll just mark as cancelled - this.hasMoreData = false; - this.client?.release(); + this.done = true; + try { + await this.iterator?.return?.(); + } catch (err) { + log.warn('Error closing SurrealDB stream iterator', err); + } + try { + await this.client?.release(); + } catch (err) { + log.warn('Error releasing SurrealDB connection', err); + } log.debug('SurrealDB cursor cancelled'); } - private addPaginationToQuery(query: string, offset: number, limit: number): string { - // Remove existing LIMIT and START clauses if they exist - const cleanQuery = query.replace(/\s+LIMIT\s+\d+/gi, '').replace(/\s+START\s+\d+/gi, ''); - - // Add our pagination - let paginatedQuery = cleanQuery; - if (limit > 0) { - paginatedQuery += ` LIMIT ${limit}`; - if (offset > 0) { - paginatedQuery += ` START ${offset}`; - } + private getColumns(value: unknown): TableColumn[] { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return Object.keys(value as Record).map((name) => ({ + columnName: name, + dataType: 'unknown', + })); } + return [{ columnName: 'value', dataType: 'unknown' }]; + } - return paginatedQuery; + private toRow(value: unknown): any[] { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return Object.values(value as Record); + } + return [value]; } } diff --git a/apps/studio/src-commercial/backend/lib/db/clients/surrealdb/SurrealDBPool.ts b/apps/studio/src-commercial/backend/lib/db/clients/surrealdb/SurrealDBPool.ts index 90d1b8fc61b..1ad3188a7d6 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/surrealdb/SurrealDBPool.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/surrealdb/SurrealDBPool.ts @@ -1,4 +1,4 @@ -import Surreal, { AnyAuth, ConnectionStatus, ConnectOptions, Token } from "surrealdb"; +import { Surreal, AnyAuth, ConnectOptions, Token } from "surrealdb"; import rawLog from "@bksLogger"; import { uuidv4 } from "@/lib/uuid"; import ws from "ws"; @@ -42,10 +42,10 @@ export class SurrealPool { this.database = _.pick(config, "namespace", "database"); config = _.omit(config, "namespace", "database") - if (typeof config.auth !== 'string') { - this.auth = config.auth; - } else { - this.token = config.auth + if (typeof config.authentication !== 'string' && typeof config.authentication !== 'function') { + this.auth = config.authentication; + } else if (typeof config.authentication === 'string') { + this.token = config.authentication } config = _.omit(config, "auth") this.config = config; @@ -64,17 +64,11 @@ export class SurrealPool { if (this.pool.length < this.maxSize) { const newConn = new SurrealConn(this); log.info('Acquiring new connection', newConn.id); - log.info('CONFIG: ', this.config) await newConn.connect(this.connectionString, this.config); - log.info("Connected") - newConn.info await newConn.use(this.database); - log.info("Used", this.database) if (this.auth) { - log.info("Signing in", this.auth) await newConn.signin(this.auth); } else { - log.info("Authenticating: ", this.token) await newConn.authenticate(this.token) } await newConn.ready; @@ -86,7 +80,6 @@ export class SurrealPool { return new Promise((resolve, reject) => { log.info('Waiting for new connection to be available'); - let timeout: NodeJS.Timeout; const interval = setInterval(() => { for (const p of this.pool) { if (!this.inUse.has(p.id)) { @@ -99,7 +92,7 @@ export class SurrealPool { } } }, 100); - timeout = setTimeout(() => { + const timeout: NodeJS.Timeout = setTimeout(() => { clearInterval(interval); reject("Timed out waiting for new connection to be available from SurrealDBPool"); }, BksConfig.db.surrealdb.connectionTimeout) @@ -110,7 +103,7 @@ export class SurrealPool { const index = this.pool.findIndex((c) => c.id === conn.id); if (index > -1) { // just in case so we don't leave a dangling connection - if (conn.status != ConnectionStatus.Disconnected) { + if (conn.status != "disconnected") { await conn.close(); } this.pool.splice(index, 1) diff --git a/apps/studio/src-commercial/backend/lib/db/clients/trino.ts b/apps/studio/src-commercial/backend/lib/db/clients/trino.ts index 46e4f111b48..2eec8273170 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/trino.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/trino.ts @@ -304,7 +304,7 @@ export class TrinoClient extends BasicDatabaseClient { async listTables(filter?: FilterOptions): Promise { log.info('filters in listTables', filter) - const schemaFilter = buildSchemaFilter(filter, 'table_schema') + const schemaFilter = buildSchemaFilter(filter, 'table_schema', (s) => this.wrapIdentifier(s)) const whereClause = schemaFilter ? `WHERE ${schemaFilter}` : '' const sql = `select * from ${this.wrapIdentifier(this.db)}.information_schema.tables ${whereClause}` const result = await this.driverExecuteSingle(sql) @@ -436,14 +436,22 @@ export class TrinoClient extends BasicDatabaseClient { try { // The trino query parser doesn't particularly like semicolons. Who can blame it? const result: AsyncIterableIterator = await this.client.query(sql.trim().replace(/;$/, '')) - + let columns: ResultColumn[] = [] const rows: any[] = [] - + for await (const r of result) { + // The trino-client iterator doesn't throw on query failure - it + // yields the error response as a normal value, so without this + // check a failed query looks like a successful 0-row result. + if (r.error) { + const { errorName, message } = r.error + throw new Error(errorName ? `${errorName}: ${message}` : message) + } + const { data: resultData, columns: resultColumns } = r columns = resultColumns - + if (resultData) rows.push(...resultData) } @@ -455,7 +463,7 @@ export class TrinoClient extends BasicDatabaseClient { queryId: '' } } - + return { columns, rows: this.rowsToObject(columns, rows), @@ -699,7 +707,7 @@ export class TrinoClient extends BasicDatabaseClient { const paginatedSQL = this.buildPaginatedQuery(wrappedTable, filterString, wrappedSelects, rowNumberOrderClause, usePagination, safeOffset, safeLimit) const fullSql = this.buildPaginatedQuery(TrinoData.wrapIdentifier(table), fullFilterString, wrappedSelects, rowNumberOrderClause, usePagination, safeOffset, safeLimit) - + return { query: paginatedSQL, fullQuery: fullSql, @@ -711,7 +719,7 @@ export class TrinoClient extends BasicDatabaseClient { buildPaginatedQuery(tableRef: string, filter: string, wrappedSelects: string, rowNumberOrderClause: string, usePagination: boolean, safeOffset: number, safeLimit: number): string { return ` WITH ranked AS ( - SELECT + SELECT ${wrappedSelects}, ROW_NUMBER() OVER (${rowNumberOrderClause}) AS rownum FROM ${this.wrapIdentifier(this.db)}.${tableRef} @@ -721,7 +729,7 @@ export class TrinoClient extends BasicDatabaseClient { FROM ranked ${usePagination ? `WHERE rownum > ${safeOffset} AND rownum <= ${safeOffset + safeLimit}` : ""} ` - } + } protected violatesReadOnly(statements: IdentifyResult[], options: any = {}) { return ( diff --git a/apps/studio/src-commercial/backend/lib/import/formats/csv.ts b/apps/studio/src-commercial/backend/lib/import/formats/csv.ts index 733e6b5213c..75e2cb507ca 100644 --- a/apps/studio/src-commercial/backend/lib/import/formats/csv.ts +++ b/apps/studio/src-commercial/backend/lib/import/formats/csv.ts @@ -22,16 +22,11 @@ export default class extends Import { if (errors && errors.length > 0) { this.logger().error('csv file read error', errors) - if (Array.isArray(errors)) { - const errorSet = new Set() - errors.forEach(e => { - errorSet.add(e?.message || e) - }) - this.error = Array.from(errorSet).join(', '); - parser.abort(); - } - // I don't think this should be able to happen, we shall see - this.error = errors.join(', '); + const errorSet = new Set() + errors.forEach(e => { + errorSet.add(e?.message || String(e)) + }) + this.error = Array.from(errorSet).join(', '); parser.abort(); } diff --git a/apps/studio/src-commercial/backend/plugin-system/modules/BundledPluginModule.ts b/apps/studio/src-commercial/backend/plugin-system/modules/BundledPluginModule.ts index 4d8b7d462e8..1bb4b7e8560 100644 --- a/apps/studio/src-commercial/backend/plugin-system/modules/BundledPluginModule.ts +++ b/apps/studio/src-commercial/backend/plugin-system/modules/BundledPluginModule.ts @@ -1,9 +1,12 @@ import path from "path"; import fs from "fs"; +import semver from "semver"; import rawLog from "@bksLogger"; import platformInfo from "@/common/platform_info"; import globals from "@/common/globals"; import { Module, type ModuleOptions } from "@/services/plugin/Module"; +import type PluginManager from "@/services/plugin/PluginManager"; +import type { Manifest } from "@/services/plugin/types"; const log = rawLog.scope("BundledPluginModule"); @@ -27,77 +30,117 @@ export class BundledPluginModule extends Module { } private async installBundledPlugins() { - for (const plugin of globals.plugins.ensureInstalled) { + this.makePluginsDir(); + + for (const { pkg } of globals.plugins.ensureInstalled) { try { - await this.ensureInstall(plugin); + await this.ensureInstalled(pkg); } catch (e) { - log.error(`Error installing plugin ${plugin}`, e); + log.error(`Error installing plugin ${pkg}`, e); } } } + private makePluginsDir() { + const pluginsDirectory = this.manager.fileManager.options.pluginsDirectory; + if (!fs.existsSync(pluginsDirectory)) { + fs.mkdirSync(pluginsDirectory, { recursive: true }); + } + } + /** - * Install a plugin from a given path if it is not already installed. + * Install a bundled plugin, or update it if the bundled copy is newer. * * @param pkg Package name (e.g., "@beekeeperstudio/bks-ai-shell") */ - private async ensureInstall(pkg: string) { + private async ensureInstalled(pkg: string) { log.info(`Resolving ${pkg}`); - const pluginPath = BundledPluginModule.resolve(pkg); - const pluginsDirectory = this.manager.fileManager.options.pluginsDirectory; + const plugin = new BundledPlugin(this.manager, pkg); - if (!fs.existsSync(pluginsDirectory)) { - fs.mkdirSync(pluginsDirectory, { recursive: true }); + if (plugin.isUninstalledByUser()) { + // Uninstalled by the user, so don't bring it back. + return; } - const manifestPath = path.join(pluginPath, "manifest.json"); - if (!fs.existsSync(manifestPath)) { - throw new Error(`Plugin not found at ${pluginPath}`); + if (!plugin.isInstalled()) { + return await plugin.install(); } - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - const pluginId = manifest.id; - - // Have installed before? - if (this.manager.pluginSettings[pluginId]) { - log.info( - `Plugin "${pluginId}" is previously installed, skipping.` - ); - return; + if (plugin.isUpdateAvailable()) { + await plugin.update(); } + } +} - const dst = path.join(pluginsDirectory, pluginId); - if (fs.existsSync(dst)) { - // This must be set, otherwise the plugin will be copied again - await this.manager.setPluginAutoUpdateEnabled(pluginId, true); - log.info( - `Plugin "${pluginId}" installation directory already exists on disk.` - ); - return; - } +export class BundledPlugin { + private readonly sourceManifest: Manifest; + private readonly sourceDir: string; + private readonly targetDir: string; + + /** @param pkg Package name (e.g., "@beekeeperstudio/bks-ai-shell") */ + constructor(private readonly manager: PluginManager, pkg: string) { + const sourceDir = BundledPlugin.resolve(pkg); - log.info(`Installing plugin ${pluginId}`); - fs.cpSync(pluginPath, dst, { recursive: true }); + const rawManifest = fs.readFileSync( + path.join(sourceDir, "manifest.json"), + "utf-8" + ); - // This must be set, otherwise the plugin will be copied again - await this.manager.setPluginAutoUpdateEnabled(pluginId, true); + this.sourceManifest = JSON.parse(rawManifest); + this.sourceDir = sourceDir; + this.targetDir = manager.fileManager.getDirectoryOf(this.sourceManifest); } - /** - * Resolve a bundled plugin path. - * - * @param pkg Package name (e.g., "@beekeeperstudio/bks-ai-shell") - * @returns The resolved path to the plugin directory - */ - static resolve(pkg: string): string { - if (platformInfo.env.production) { - // Production: use extraResources location - return path.join(platformInfo.resourcesPath, "bundled_plugins", pkg); - } + static resolve(pkg: string) { + return platformInfo.env.production + ? path.join(platformInfo.resourcesPath, "bundled_plugins", pkg) + : path.dirname(require.resolve(`${pkg}/manifest.json`)); + } + + isInstalled(): boolean { + return fs.existsSync(this.targetDir); + } + + isUninstalledByUser(): boolean { + return ( + !this.isInstalled() && + !!this.manager.pluginSettings[this.sourceManifest.id] + ); + } + + isUpdateAvailable(): boolean { + return semver.gt(this.getBundledVersion(), this.getInstalledVersion()); + } + + private getBundledVersion() { + return semver.coerce(this.sourceManifest.version); + } + + private getInstalledVersion() { + const installedPath = path.join(this.targetDir, "manifest.json"); + const installed = JSON.parse(fs.readFileSync(installedPath, "utf-8")); + return semver.coerce(installed.version); + } + + async install() { + log.info(`Installing plugin ${this.sourceManifest.id}`); + + fs.cpSync(this.sourceDir, this.targetDir, { recursive: true }); + + // HACK: This must be set, otherwise the plugin will be copied again + await this.manager.setPluginAutoUpdateEnabled(this.sourceManifest.id, true); + } + + async update() { + log.info( + `Updating plugin ${this.sourceManifest.id} to v${this.sourceManifest.version}` + ); + + fs.rmSync(this.targetDir, { recursive: true, force: true }); + fs.cpSync(this.sourceDir, this.targetDir, { recursive: true }); - // Development: resolve from node_modules - const manifestPath = require.resolve(`${pkg}/manifest.json`); - return path.dirname(manifestPath); + // HACK: This must be set, otherwise the plugin will be copied again + await this.manager.setPluginAutoUpdateEnabled(this.sourceManifest.id, true); } } diff --git a/apps/studio/src-commercial/backend/plugin-system/modules/ConfigurationModule.ts b/apps/studio/src-commercial/backend/plugin-system/modules/ConfigurationModule.ts new file mode 100644 index 00000000000..e03dd64382d --- /dev/null +++ b/apps/studio/src-commercial/backend/plugin-system/modules/ConfigurationModule.ts @@ -0,0 +1,99 @@ +import _ from "lodash"; +import { PluginSnapshot } from "@/services/plugin"; +import { Module, ModuleOptions } from "@/services/plugin/Module"; +import { PluginSystemError } from "@/lib/errors"; +import { BksConfig } from "@/common/bksConfig/BksConfigProvider"; + +type ConfigurationOptions = { + config: BksConfig; +}; + +/** + * Handles plugin configuration via `config.ini`. + * + * Plugins can be configured via [pluginSystem] and [plugins.] sections. + * + * @example + * + * ```ts + * // Register the module + * const pluginManager = new PluginManager({ ... }); + * pluginManager.registerModule(ConfigurationModule.with({ config: bksConfig })); + * // Initialize the plugin manager + * pluginManager.initialize(); + * + * ``` + */ +export class ConfigurationModule extends Module { + constructor(private options: ConfigurationOptions & ModuleOptions) { + super(options); + + if (this.options.config.pluginSystem.disabled) { + this.manager.registry.communityDisabled = true; + this.manager.registry.officialDisabled = true; + } + + if (this.options.config.pluginSystem.communityDisabled) { + this.manager.registry.communityDisabled = true; + } + + this.hook("before-install-plugin", this.validatePluginInstall); + this.hook("plugin-snapshots", this.applyConfig); + } + + static with(options: ConfigurationOptions) { + return class extends ConfigurationModule { + constructor(baseOptions: ModuleOptions) { + super({ ...baseOptions, ...options }); + } + }; + } + + private validatePluginInstall(): void { + if (this.options.config.pluginSystem.disabled) { + throw new PluginSystemError("PLUGIN_SYSTEM_DISABLED"); + } + } + + private applyConfig(snapshots: PluginSnapshot[]): PluginSnapshot[] { + return snapshots.map((snapshot) => { + // Do not override disable state + if (snapshot.disableState.disabled) { + return snapshot; + } + + if (this.options.config.pluginSystem.disabled) { + if (this.options.config.pluginSystem.allow.includes(snapshot.manifest.id)) { + return snapshot; + } + + return { + ...snapshot, + disableState: { disabled: true, reason: "plugin-system-disabled" }, + }; + } + + if ( + snapshot.origin === "community" && + this.options.config.pluginSystem.communityDisabled + ) { + return { + ...snapshot, + disableState: { + disabled: true, + reason: "community-plugins-disabled", + }, + }; + } + + if (this.options.config.plugins?.[snapshot.manifest.id]?.disabled) { + return { + ...snapshot, + disableState: { disabled: true, reason: "disabled-by-config" }, + }; + } + + return snapshot; + }); + } +} diff --git a/apps/studio/src-commercial/backend/plugin-system/modules/index.ts b/apps/studio/src-commercial/backend/plugin-system/modules/index.ts index 0ec8c9efb08..51f52880196 100644 --- a/apps/studio/src-commercial/backend/plugin-system/modules/index.ts +++ b/apps/studio/src-commercial/backend/plugin-system/modules/index.ts @@ -1 +1,2 @@ +export { ConfigurationModule } from "./ConfigurationModule"; export { BundledPluginModule } from "./BundledPluginModule"; diff --git a/apps/studio/src-commercial/entrypoints/main.ts b/apps/studio/src-commercial/entrypoints/main.ts index fd8d9c1da6c..8a5b20f2a7c 100644 --- a/apps/studio/src-commercial/entrypoints/main.ts +++ b/apps/studio/src-commercial/entrypoints/main.ts @@ -11,7 +11,6 @@ import log from '@bksLogger' require('@electron/remote/main').initialize() log.info("initializing background") - import MenuHandler from '@/background/NativeMenuBuilder' import { IGroupedUserSettings, UserSetting } from '@/common/appdb/models/user_setting' import Connection from '@/common/appdb/Connection' @@ -29,11 +28,15 @@ import { manageUpdates } from '@/background/update_manager' import * as sms from 'source-map-support' import { initializeSecurity } from '@/backend/lib/security' import { initializeFileHelpers } from '@/backend/lib/FileHelpers' +import { safeOpenExternal } from '@/background/lib/electron/safeOpenExternal' if (platformInfo.env.development || platformInfo.env.test) { sms.install() } +log.transports.console.level = platformInfo.logLevel; +log.transports.file.level = platformInfo.logLevel; + function initUserDirectory(d: string) { if (!fs.existsSync(d)) { fs.mkdirSync(d, { recursive: true }) @@ -77,7 +80,7 @@ async function createUtilityProcess() { utilityProcess.on("message", (msg: UtilProcMessage) => { if (msg.type === 'openExternal') { - electron.shell.openExternal(msg.url) + safeOpenExternal(msg.url) } }) @@ -155,9 +158,7 @@ async function initBasics() { log.debug("managing updates") manageUpdates(settings.useBeta.valueAsBool) ipcMain.on(AppEvent.openExternally, (_e: electron.IpcMainEvent, args: any[]) => { - const url = args[0] - if (!url) return - electron.shell.openExternal(url) + safeOpenExternal(args?.[0]) }) return settings } diff --git a/apps/studio/src-commercial/entrypoints/preload.ts b/apps/studio/src-commercial/entrypoints/preload.ts index 152cd0c7d51..b343876b4e7 100644 --- a/apps/studio/src-commercial/entrypoints/preload.ts +++ b/apps/studio/src-commercial/entrypoints/preload.ts @@ -2,13 +2,11 @@ import { contextBridge, ipcRenderer, nativeImage } from 'electron'; import { AppEvent } from '@/common/AppEvent'; import path from 'path'; import fs from 'fs'; -import { SettingsPlugin } from '@/plugins/SettingsPlugin'; import { homedir } from 'os'; import tls, { SecureVersion } from 'tls'; import username from 'username'; import { execSync } from 'child_process'; import 'electron-log/preload'; -import pluralize from 'pluralize'; import type { SaveFileOptions } from '@/backend/lib/FileHelpers'; import type { NativePluginMenuItem } from '@/services/plugin/types'; @@ -46,6 +44,9 @@ export const api = { disableConnectionMenuItems(){ ipcRenderer.send("disable-connection-menu-items"); }, + sendUserActive() { + ipcRenderer.send("userActive"); + }, send(event: AppEvent, name: string, arg?: any) { if (!Object.values(AppEvent).includes(event)) return; ipcRenderer.send(event, name, arg) @@ -72,6 +73,7 @@ export const api = { ipcRenderer.send('install-update'); }, openExternally(link: string) { + // URL protocol is validated in the main process by safeOpenExternal. ipcRenderer.send(AppEvent.openExternally, [link]); }, resolve(toResolve: string) { @@ -103,7 +105,9 @@ export const api = { return electron.dialog.showSaveDialogSync(args); }, openLink(link: string) { - return electron.shell.openExternal(link); + // Route through the main process so safeOpenExternal validates the + // protocol — never call shell.openExternal directly from preload. + ipcRenderer.send(AppEvent.openExternally, [link]); }, onMaximize(func: any, sId: string) { ipcRenderer.on(`maximize-${sId}`, func); @@ -147,9 +151,6 @@ export const api = { readTextFromClipboard(): string { return electron.clipboard.readText(); }, - openPath(path: string) { - return electron.shell.openPath(path); - }, showItemInFolder(path: string) { electron.shell.showItemInFolder(path); }, @@ -177,9 +178,6 @@ export const api = { requestPorts() { ipcRenderer.invoke('requestPorts'); }, - pluralize(word: string, count?: number, inclusive?: boolean) { - return pluralize(word, count, inclusive); - }, fileHelpers: { save(options: SaveFileOptions) { return ipcRenderer.invoke('fileHelpers:save', options); diff --git a/apps/studio/src-commercial/entrypoints/renderer.ts b/apps/studio/src-commercial/entrypoints/renderer.ts index 3651399e339..03b7a438e0f 100644 --- a/apps/studio/src-commercial/entrypoints/renderer.ts +++ b/apps/studio/src-commercial/entrypoints/renderer.ts @@ -37,6 +37,7 @@ import { UtilityConnection } from '@/lib/utility/UtilityConnection' import { VueKeyboardTrapDirectivePlugin } from '@pdanpdan/vue-keyboard-trap'; import App from '@/App.vue' import { ForeignCacheTabulatorModule } from '@/plugins/ForeignCacheTabulatorModule' +import { PersistenceGuardTabulatorModule } from '@/plugins/PersistenceGuardTabulatorModule' import { WebPluginManager } from '@/services/plugin/web' import PluginStoreService from '@/services/plugin/web/PluginStoreService' import * as UIKit from '@beekeeperstudio/ui-kit' @@ -46,7 +47,13 @@ import ProductTourPlugin from '@/plugins/ProductTourPlugin' await window.main.requestPlatformInfo(); await window.main.requestBksConfigSource(); - rawLog.transports.console.level = "info" + // Main resolves BKS_LOG_LEVEL / DEBUG / NODE_ENV and hands the result + // back via platformInfo; apply it now so renderer messages above the + // renderer-side default (warn in prod, info in dev) start flowing. + const resolvedLevel = window.platformInfo.logLevel || 'warn' + rawLog.transports.console.level = resolvedLevel + if (rawLog.transports.ipc) rawLog.transports.ipc.level = resolvedLevel + const log = rawLog.scope("main.ts") log.info("starting logging") @@ -98,7 +105,7 @@ import ProductTourPlugin from '@/plugins/ProductTourPlugin' Tabulator.defaultOptions.layout = "fitDataFill"; Tabulator.defaultOptions.popupContainer = ".beekeeper-studio-wrapper"; Tabulator.defaultOptions.headerSortClickElement = 'icon'; - Tabulator.registerModule([HeaderSortTabulatorModule, KeyListenerTabulatorModule, ForeignCacheTabulatorModule]); + Tabulator.registerModule([HeaderSortTabulatorModule, KeyListenerTabulatorModule, ForeignCacheTabulatorModule, PersistenceGuardTabulatorModule]); // Tabulator.prototype.bindModules([EditModule]); (window as any).$ = $; @@ -180,6 +187,10 @@ import ProductTourPlugin from '@/plugins/ProductTourPlugin' const app = new Vue({ render: h => h(App), store, + mounted() { + VTooltip.options.defaultBoundariesElement = + document.querySelector(".beekeeper-studio-wrapper") as HTMLElement; + }, }) Vue.prototype.$util = utility; diff --git a/apps/studio/src-commercial/entrypoints/utility.ts b/apps/studio/src-commercial/entrypoints/utility.ts index dea24923225..72ab715e2dc 100644 --- a/apps/studio/src-commercial/entrypoints/utility.ts +++ b/apps/studio/src-commercial/entrypoints/utility.ts @@ -16,8 +16,9 @@ import { QueryHandlers } from '@/handlers/queryHandlers'; import { TabHistoryHandlers } from '@/handlers/tabHistoryHandlers' import { ExportHandlers } from '@commercial/backend/handlers/exportHandlers'; import { BackupHandlers } from '@commercial/backend/handlers/backupHandlers'; +import { CliHandlers } from '@commercial/backend/handlers/cliHandlers'; import { AwsHandlers } from '@commercial/backend/handlers/awsHandlers'; -import { AzureVaultHandlers } from '@/handlers/azureVaultHandlers'; +import { VaultHandlers } from '@/handlers/vaultHandlers'; import { ImportHandlers } from '@commercial/backend/handlers/importHandlers'; import { EnumHandlers } from '@commercial/backend/handlers/enumHandlers'; import { TempHandlers } from '@/handlers/tempHandlers'; @@ -25,13 +26,22 @@ import { DevHandlers } from '@/handlers/devHandlers'; import { FormatterPresetHandlers } from '@/handlers/formatterPresetHandlers'; import { LicenseHandlers } from '@/handlers/licenseHandlers'; import { LockHandlers } from '@/handlers/lockHandlers'; -import { PluginHandlers } from '@/handlers/pluginHandlers'; +import { PluginHandlers } from '@commercial/backend/handlers/pluginHandlers'; import { PluginManager } from '@/services/plugin'; import PluginFileManager from '@/services/plugin/PluginFileManager'; +import { DriverDepHandlers } from '@/handlers/driverDepHandlers'; +import { DriverDepManager, DriverDepFileManager, createDefaultRegistry } from '@/services/driverDeps'; +import type { DepPlatform, DepArch } from '@/services/driverDeps'; +import BksConfig from '@/common/bksConfig'; import _ from 'lodash'; -import { BundledPluginModule } from '@commercial/backend/plugin-system/modules/BundledPluginModule'; +import { + ConfigurationModule, + BundledPluginModule, +} from '@commercial/backend/plugin-system/modules'; +import { PluginErrorCode, PluginSystemErrorCode } from '@/lib/errors'; import * as sms from 'source-map-support' +import { WorkspaceHandlers } from '@/handlers/workspaceHandlers'; if (platformInfo.env.development || platformInfo.env.test) { sms.install() @@ -44,13 +54,26 @@ const pluginManager = new PluginManager({ pluginsDirectory: platformInfo.pluginsDirectory, }), }); +pluginManager.registerModule(ConfigurationModule.with({ config: BksConfig })); pluginManager.registerModule(BundledPluginModule); +const driverDepManager = new DriverDepManager({ + fileManager: new DriverDepFileManager({ + driverDepsDirectory: platformInfo.driverDepsDirectory, + userAgent: BksConfig.general.downloadUserAgent, + }), + registry: createDefaultRegistry(), + platform: platformInfo.platform as DepPlatform, + arch: (process.arch === 'x64' ? 'x64' : 'arm64') as DepArch, +}); + interface Reply { id: string, type: 'reply' | 'error', data?: any, error?: string + errorName?: "PluginSystemError" | "PluginError" | "Error" + errorCode?: PluginSystemErrorCode | PluginErrorCode stack?: string } @@ -62,16 +85,19 @@ export const handlers: Handlers = { ...ImportHandlers, ...AppDbHandlers, ...BackupHandlers, + ...CliHandlers, ...AwsHandlers, ...FileHandlers, ...EnumHandlers, ...TempHandlers, ...LicenseHandlers, ...PluginHandlers(pluginManager), + ...DriverDepHandlers(driverDepManager), ...TabHistoryHandlers, ...LockHandlers, ...FormatterPresetHandlers, - ...AzureVaultHandlers, + ...WorkspaceHandlers, + ...VaultHandlers, ...(platformInfo.isDevelopment && DevHandlers), }; @@ -112,7 +138,7 @@ process.parentPort.on('message', async ({ data, ports }) => { case 'close': log.info('REMOVING STATE FOR: ', sId); state(sId).port.close(); - removeState(sId); + await removeState(sId); break; default: log.error('UNRECOGNIZED MESSAGE TYPE RECEIVED FROM MAIN PROCESS'); @@ -135,13 +161,20 @@ async function runHandler(id: string, name: string, args: any) { replyArgs.type = 'error'; replyArgs.stack = e?.stack; replyArgs.error = e?.message ?? e; + replyArgs.errorName = e?.name; + replyArgs.errorCode = e?.code; log.error("HANDLER: ERROR", e) }) .finally(() => { try { state(args.sId).port.postMessage(replyArgs); } catch (e) { - log.error('ERROR SENDING MESSAGE: ', replyArgs, '\n\n\n ERROR: ', e) + log.error('ERROR SENDING MESSAGE: ', replyArgs, '\n\n\n ERROR: ', e?.message ?? e) + replyArgs.type = 'error'; + replyArgs.stack = e?.stack; + replyArgs.error = e?.message ?? 'Error sending message from utility process, this may be a bug. Please file an issue if this persists.' + delete replyArgs.data + state(args.sId).port.postMessage(replyArgs) } }); } else { @@ -177,5 +210,9 @@ async function init() { log.error("Error initializing plugin manager", e); }); + driverDepManager.initialize().catch((e) => { + log.error("Error initializing driver dep manager", e); + }); + process.parentPort.postMessage({ type: 'ready' }); } diff --git a/apps/studio/src/App.vue b/apps/studio/src/App.vue index 65c1be40e9c..f9a9e19ba27 100644 --- a/apps/studio/src/App.vue +++ b/apps/studio/src/App.vue @@ -1,9 +1,8 @@ @@ -86,6 +90,10 @@ import PluginManagerModal from '@/components/plugins/PluginManagerModal.vue' import KeyboardShortcutsModal from '@/components/common/modals/KeyboardShortcutsModal.vue' import PluginController from '@/components/plugins/PluginController.vue' import LockManager from "@/components/managers/LockManager.vue"; +import InputEphemeralModal from "@/components/common/modals/InputEphemeralModal.vue"; +import ShareModal from "@/components/common/modals/ShareModal.vue"; +import MoveItemModal from "@/components/common/modals/MoveItemModal.vue"; +import MoveFolderModal from "@/components/common/modals/MoveFolderModal.vue"; import rawLog from '@bksLogger' import { assignContextMenuToAllInputs } from './mixins/assignContextMenuToAllInputs' @@ -102,6 +110,7 @@ export default Vue.extend({ EnterLicenseModal, TrialExpiredModal, LicenseExpiredModal, LifetimeLicenseExpiredModal, WorkspaceCreateModal, WorkspaceRenameModal, WorkspaceDeleteModal, PluginManagerModal, ConfigurationWarningModal, PluginController, LockManager, KeyboardShortcutsModal, + InputEphemeralModal, ShareModal, MoveItemModal, MoveFolderModal, }, data() { return { @@ -169,7 +178,10 @@ export default Vue.extend({ this.interval = setInterval(this.notifyFreeTrial, globals.trialNotificationInterval) this.$store.dispatch('licenses/updateAll'); this.licenseInterval = setInterval( - () => this.$store.dispatch('licenses/updateAll'), + () => { + log.debug('license check - interval') + this.$store.dispatch('licenses/updateAll') + }, globals.licenseCheckInterval ) const query = querystring.parse(window.location.search, { parseBooleans: true }) @@ -260,7 +272,9 @@ export default Vue.extend({ }) - diff --git a/apps/studio/src/assets/styles/app/_layout.scss b/apps/studio/src/assets/styles/app/_layout.scss index ce4cdc09599..2c18ec666b3 100644 --- a/apps/studio/src/assets/styles/app/_layout.scss +++ b/apps/studio/src/assets/styles/app/_layout.scss @@ -30,7 +30,9 @@ i { font-weight: bold; text-transform: uppercase; font-size: 80%; - letter-spacing: 0.05rem; + // NOTE: This was probably ok when we still use Roboto as a font. + // Now we use system font. So at least in mac, this isn't good. + // letter-spacing: 0.05rem; } .noselect { @include noselect; @@ -134,7 +136,7 @@ x-buttons > x-button[menu].btn { margin-left: 0; } - .material-icons:last-child { + .material-icons:last-child:not(:first-child) { margin-left: $gutter-h * 0.65; } } @@ -360,8 +362,14 @@ input:not([type=checkbox]):not([type=radio]), select, textarea, .bk-form-input { select { -webkit-appearance: none; appearance: none; - background: url("data:image/svg+xml;utf8,") no-repeat scroll 98% 60% transparent !important; + background: url("data:image/svg+xml;utf8,") no-repeat scroll 98% 65% transparent !important; padding: 0 0.75rem; + &.auto-width { + // It should adjust when using `width: auto`, depending on the padding + background-position-x: calc(100% - 0.75rem) !important; + width: auto; + padding-right: 1.75rem; + } option { background: color.adjust($theme-bg, $lightness: 3%); color: $text; @@ -751,17 +759,25 @@ input[type=file] { .alert { display: flex; + align-items: flex-start; border-radius: 6px; padding: 0.75rem 1rem; background: rgba($theme-base, 0.035); margin: 0.5rem 0; font-size: 0.85rem; - line-height: 24px; + line-height: 1.2rem; > i { font-size: 18px; - line-height: 24px; + line-height: 1; margin-right: 1rem; } + code { + background: rgba($theme-base, 0.08); + margin: 0 0.25rem; + padding: 0.15rem 0.4rem; + border-radius: 3px; + font-size: 0.8rem; + } ul { margin: 0.35rem 0; padding-left: 1.5rem; @@ -778,14 +794,20 @@ input[type=file] { align-items: flex-start; justify-content: flex-start; flex-grow: 1; - line-height: 24px; + line-height: 1.2rem; } .alert-footer { display: flex; - line-height: 24px; + line-height: 1.2rem; height: 26px; // button height } } +.alert.alert-centered { + // Short, single-/two-line alerts look misaligned with the default + // align-items: flex-start; opt-in modifier that vertically centers the + // icon against the text. + align-items: center; +} .alert-danger { color: color.adjust($brand-danger, $lightness: 5%); } @@ -798,6 +820,18 @@ input[type=file] { .alert-success { color: color.adjust($brand-success, $lightness: 5%); } +.alert-small { + align-items: center; + padding: 0.4rem 0.6rem; + margin: 0.25rem 0; + line-height: 1.1rem; + > i { + margin-right: 0.5rem; + } + .alert-body { + align-items: center; + } +} // Dropdown // -------------------------- @@ -839,6 +873,9 @@ input[type=file] { max-width: 100%; overflow: hidden; } + &.vs--searching .vs__selected { + display: none; + } .vs__actions { padding: 0 } diff --git a/apps/studio/src/assets/styles/app/connection-interface.scss b/apps/studio/src/assets/styles/app/connection-interface.scss index 40165a851fc..f2fea85fa78 100644 --- a/apps/studio/src/assets/styles/app/connection-interface.scss +++ b/apps/studio/src/assets/styles/app/connection-interface.scss @@ -92,7 +92,7 @@ color: $text-dark; margin: 0 0 $gutter-h; } - .btn { + .save-actions .btn { margin-left: 0.5rem; } .form-group { @@ -164,6 +164,26 @@ margin-top: -$gutter-h; margin-bottom: -$gutter-h; } + &.bastion-host { + + .advanced-heading { + font-size: 0.831rem; + font-weight: 500; + } + + .btn-toggle { + margin-right: 0; + min-width: 1.7rem; + width: 1.7rem; + min-height: 1.7rem; + height: 1.7rem; + + .material-icons { + font-size: 1.2rem; + color: var(--text); + } + } + } } .advanced-heading { display: flex; diff --git a/apps/studio/src/assets/styles/app/core-interface.scss b/apps/studio/src/assets/styles/app/core-interface.scss index a8aad0cda0d..8be14cad90f 100644 --- a/apps/studio/src/assets/styles/app/core-interface.scss +++ b/apps/studio/src/assets/styles/app/core-interface.scss @@ -45,6 +45,10 @@ flex: 1 1 auto; min-width: 350px; } + &.main-content { + flex: 1 1 0; + min-width: 0; + } z-index: 1; min-width: 10rem; } diff --git a/apps/studio/src/assets/styles/app/core-tabs.scss b/apps/studio/src/assets/styles/app/core-tabs.scss index 6cdb0d0e262..5b1b11e2187 100644 --- a/apps/studio/src/assets/styles/app/core-tabs.scss +++ b/apps/studio/src/assets/styles/app/core-tabs.scss @@ -449,6 +449,9 @@ padding-left: 0.45rem; padding-right: 0.25rem; } + .filter-mode-spacer { + visibility: hidden; + } .multiple-filter { flex-grow: 1; & > * { @@ -565,4 +568,11 @@ .tab-upsell-wrapper { padding: 0rem 2rem 2rem; + + &.tab-upsell-wrapper--ai-shell { + padding: 0; + height: 100%; + min-height: 0; + display: flex; + } } diff --git a/apps/studio/src/assets/styles/app/modals.scss b/apps/studio/src/assets/styles/app/modals.scss index 19bc36efe5e..81f62050ac4 100644 --- a/apps/studio/src/assets/styles/app/modals.scss +++ b/apps/studio/src/assets/styles/app/modals.scss @@ -14,55 +14,47 @@ } &.upgrade-modal { - .dialog-content { - padding: 2rem 2rem 1rem; + .v--modal { + overflow: hidden; + border-radius: 12px; + max-height: calc(100vh - 40px); } - .dialog-c-title { - font-size: 1.2em; - } - .checkbox-wrapper { - margin-left: auto; - margin-right: auto; - ul.check-list { - padding-left: 0; - li { - padding-bottom: 10px; - &:before { - font-family: "Material Icons"; - content: "\e5ca"; - margin-right: 0.5rem; - margin-bottom: -10px; - color: $brand-success; - } - list-style: none; - } - } - > p { - margin-bottom: 0; - } + .v--modal-box { + display: flex; + flex-direction: column; } - .check-list { - margin-bottom: 0; + .dialog-content.upgrade-modal-content { + padding: 0; + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; } - .vue-dialog-buttons { - justify-content: stretch; - padding-left: 0; - padding-right: 0; - padding-top: 1rem; - & > div { - width: 100%; - } - .actions { - justify-content: flex-end; - } + .upgrade-panel { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + .upgrade-panel-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + } + .upgrade-panel-header { + padding-right: 32px; // room for close button } .close-btn { - top: 1.5rem; - right: 1.5rem; + position: absolute; + top: 14px; + right: 14px; + z-index: 2; + color: $text-light; } } - &.confirmation-modal, &.sql-files-import-modal, &.wait-sso-modal { + &.sql-files-import-modal, &.wait-sso-modal { .v--modal { width: auto !important; min-height: 0px; diff --git a/apps/studio/src/assets/styles/app/modals/plugin-manager-modal.scss b/apps/studio/src/assets/styles/app/modals/plugin-manager-modal.scss index 21683dd8ea3..c30e7bf6e88 100644 --- a/apps/studio/src/assets/styles/app/modals/plugin-manager-modal.scss +++ b/apps/studio/src/assets/styles/app/modals/plugin-manager-modal.scss @@ -13,18 +13,19 @@ } .dialog-c-title { - margin-top: $gutter-h; + padding-top: $gutter-h; padding-inline: $gutter-h; } .dialog-content { height: 100%; padding: 0; + display: flex; + flex-direction: column; } .plugin-manager-content { overflow: hidden; - height: calc(100% - 34px); display: flex; } @@ -32,10 +33,13 @@ padding: 0.5rem 0 0; flex-grow: 1; flex-basis: 35%; + display: flex; + flex-direction: column; >.description { padding-inline: $gutter-h; line-height: 1.5rem; + margin-bottom: 0.5rem; } } @@ -45,11 +49,10 @@ .plugin-list { list-style-type: none; - padding: 0; + padding: 0 0 0.5rem; margin: 0; display: flex; flex-direction: column; - margin-top: 1rem; overflow-y: auto; min-width: 400px; @@ -86,6 +89,8 @@ } .author { + display: flex; + align-items: center; font-size: 0.875rem; color: rgba($theme-base, 0.7); } diff --git a/apps/studio/src/assets/styles/app/plugin/plugin-shell.scss b/apps/studio/src/assets/styles/app/plugin/plugin-shell.scss index 50b668a3643..a4063506498 100644 --- a/apps/studio/src/assets/styles/app/plugin/plugin-shell.scss +++ b/apps/studio/src/assets/styles/app/plugin/plugin-shell.scss @@ -4,32 +4,9 @@ overflow: hidden; height: 100%; - .plugin-status { - padding: 1rem; - margin: 1rem; - border-radius: 0.5rem; - background-color: rgba($theme-base, 0.08); - color: $text-dark; - font-size: 0.9rem; - - li { - line-height: 1.5; - } - - a { - text-decoration-line: underline; - } - } - .isolated-plugin-view { width: 100%; height: 100%; - - >iframe { - width: 100%; - height: 100%; - border: none; - } } .gutter:before { diff --git a/apps/studio/src/assets/styles/app/query-editor.scss b/apps/studio/src/assets/styles/app/query-editor.scss index f9df472c1c8..ae17b2452ce 100644 --- a/apps/studio/src/assets/styles/app/query-editor.scss +++ b/apps/studio/src/assets/styles/app/query-editor.scss @@ -174,6 +174,8 @@ > div { display: table-cell; padding: $gutter-h; + padding-right: ($gutter-h - .2); + padding-bottom: ($gutter-h + .2); } .new { margin-right: $gutter-h; diff --git a/apps/studio/src/assets/styles/app/sidebar/connection.scss b/apps/studio/src/assets/styles/app/sidebar/connection.scss index 1b6448bbf92..c2769cadd2b 100644 --- a/apps/studio/src/assets/styles/app/sidebar/connection.scss +++ b/apps/studio/src/assets/styles/app/sidebar/connection.scss @@ -76,8 +76,10 @@ } } .connection-title { - overflow: hidden; padding-right: ($gutter-h * 0.75); + .title > span { + overflow: hidden; + } .subtitle { font-size: 80%; color: $text-lighter; diff --git a/apps/studio/src/assets/styles/app/sidebar/history-list.scss b/apps/studio/src/assets/styles/app/sidebar/history-list.scss index fa75c90800f..2c920860b39 100644 --- a/apps/studio/src/assets/styles/app/sidebar/history-list.scss +++ b/apps/studio/src/assets/styles/app/sidebar/history-list.scss @@ -35,6 +35,7 @@ flex-grow: 1; overflow: hidden; padding-right: ($gutter-h * 0.75); + font-size: 1rem; .subtitle { font-size: 80%; color: $text-lighter; diff --git a/apps/studio/src/assets/styles/app/sidebar/secondary-sidebar.scss b/apps/studio/src/assets/styles/app/sidebar/secondary-sidebar.scss index 18178932925..5afdd846c22 100644 --- a/apps/studio/src/assets/styles/app/sidebar/secondary-sidebar.scss +++ b/apps/studio/src/assets/styles/app/sidebar/secondary-sidebar.scss @@ -17,7 +17,9 @@ x-label { font-size: 0.85rem; - letter-spacing: 0.025em; + // NOTE: This was probably ok when we still use Roboto as a font. + // Now we use system font. So at least in mac, this isn't good. + // letter-spacing: 0.025em; } &[selected] { diff --git a/apps/studio/src/assets/styles/app/sidebar/sidebar.scss b/apps/studio/src/assets/styles/app/sidebar/sidebar.scss index 99d555fa5b2..99141e800e7 100644 --- a/apps/studio/src/assets/styles/app/sidebar/sidebar.scss +++ b/apps/studio/src/assets/styles/app/sidebar/sidebar.scss @@ -39,7 +39,9 @@ flex: 0 1 100%; color: $text-dark; font-weight: 500; - letter-spacing: 0.05rem; + // NOTE: This was probably ok when we still use Roboto as a font. + // Now we use system font. So at least in mac, this isn't good. + // letter-spacing: 0.05rem; padding: 0 $gutter-h; // box-shadow: 0 1px darken($theme-bg, 6%); box-shadow: 0 1px $border-color; @@ -516,6 +518,10 @@ padding: ($gutter-h * 0.5); padding-right: $gutter-h * 0.5; padding-left: $gutter-w * 2; + border-radius: 4px; + &:hover { + background: rgba($theme-base, 0.035); + } } .btn-fab { min-width: 18px; diff --git a/apps/studio/src/assets/styles/app/statusbar.scss b/apps/studio/src/assets/styles/app/statusbar.scss index 988aa9600d1..a86007c5502 100644 --- a/apps/studio/src/assets/styles/app/statusbar.scss +++ b/apps/studio/src/assets/styles/app/statusbar.scss @@ -285,6 +285,7 @@ $button-height: $statusbar-height * 0.72; > span { text-overflow: ellipsis; overflow: hidden; + max-width: 30rem; } } } diff --git a/apps/studio/src/assets/styles/app/tabs/database-backup.scss b/apps/studio/src/assets/styles/app/tabs/database-backup.scss index 0c6c4dd79ef..525d84cd8da 100644 --- a/apps/studio/src/assets/styles/app/tabs/database-backup.scss +++ b/apps/studio/src/assets/styles/app/tabs/database-backup.scss @@ -178,8 +178,14 @@ height: 100%; flex-direction: column; display: flex; - justify-content: space-around; + justify-content: center; align-items: center; + padding: 2rem; + text-align: center; + + .card-flat { + max-width: 500px; + } } // Backup tab layout things: diff --git a/apps/studio/src/assets/styles/app/vendor/noty.scss b/apps/studio/src/assets/styles/app/vendor/noty.scss index ecb63c8164d..3071bab78ac 100644 --- a/apps/studio/src/assets/styles/app/vendor/noty.scss +++ b/apps/studio/src/assets/styles/app/vendor/noty.scss @@ -59,3 +59,28 @@ .noty_theme__mint.noty_type__success { color: color.adjust($brand-success, $lightness: 5%); } + +// Onboarding notification +.noty-onboarding-title { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: $gutter-h * 0.5; + display: flex; + align-items: center; + + .noty-onboarding-logo { + width: auto; + height: 1.5rem; + margin-right: 0.5rem; + } +} + +.noty-onboarding-body .link { + color: var(--theme-dark); + text-decoration-line: underline; + font-weight: 500; + + &:focus-visible { + outline: 1px auto var(--text-dark); + } +} diff --git a/apps/studio/src/assets/styles/app/vendor/tabulator.scss b/apps/studio/src/assets/styles/app/vendor/tabulator.scss index 3d7b8618403..2fe3d28c154 100644 --- a/apps/studio/src/assets/styles/app/vendor/tabulator.scss +++ b/apps/studio/src/assets/styles/app/vendor/tabulator.scss @@ -52,7 +52,7 @@ $columnResizeGuideColor: color.mix($query-editor-bg, $theme-base, 80%); background: $row-add; border: 0; .tabulator-cell { - &.primary-key { + &.primary-key, &.read-only-field { > * { opacity: initial; } @@ -67,6 +67,10 @@ $columnResizeGuideColor: color.mix($query-editor-bg, $theme-base, 80%); .tabulator-editing { box-shadow: inset 0 -1px $theme-base!important; } + &.tabulator-moving { + position: absolute; + background: $row-add !important; + } } &.deleted, &.deleted:hover { @@ -372,7 +376,7 @@ $columnResizeGuideColor: color.mix($query-editor-bg, $theme-base, 80%); &:hover, &.editable:hover { background: rgba($theme-base,0.05); } - &.primary-key { + &.primary-keye, &.read-only-field { cursor: default; & > * { opacity: 0.5; @@ -423,7 +427,7 @@ $columnResizeGuideColor: color.mix($query-editor-bg, $theme-base, 80%); background: transparent; .tabulator-headers { .tabulator-col:first-of-type { - &.foreign-key, &.primary-key { + &.foreign-key, &.primary-key, &.read-only-field { &:before{ left: $gutter-w * 0.8; } @@ -490,6 +494,16 @@ $columnResizeGuideColor: color.mix($query-editor-bg, $theme-base, 80%); color: $text-dark; } + // Column headers are the reorder drag handle (movableColumns is enabled), + // so show a grab cursor on hover and a grabbing cursor while a column is + // being dragged. See issue #3165. + &:hover { + cursor: grab; + } + &.tabulator-moving { + cursor: grabbing; + } + &:first-of-type .tabulator-col-content { padding-left: $gutter-w * 1.5; } @@ -528,13 +542,12 @@ $columnResizeGuideColor: color.mix($query-editor-bg, $theme-base, 80%); } } } - &.foreign-key, &.primary-key { + &.foreign-key, &.primary-key, &.read-only-field { .tabulator-col-content { width: calc(100% - #{$foreign-key-width}); } &:before{ $font-size: 13px; - content: 'vpn_key'; font-family: 'Material Icons'; font-size: $font-size; color: var(--theme-primary); @@ -543,6 +556,15 @@ $columnResizeGuideColor: color.mix($query-editor-bg, $theme-base, 80%); align-items: center; } } + &.foreign-key, &.primary-key { + &:before { + content: 'vpn_key' + } + } + &.read-only-field:before { + color: $text-disabled; + content: 'edit_off'; + } &.primary-key:before { color: $theme-secondary; } @@ -831,7 +853,7 @@ $columnResizeGuideColor: color.mix($query-editor-bg, $theme-base, 80%); } &.tabulator-range-selected { - &.primary-key:before { + &.primary-key:before, &.read-only-field:before { color: black; } &.foreign-key:before { diff --git a/apps/studio/src/assets/styles/components/_all.scss b/apps/studio/src/assets/styles/components/_all.scss index 672143648b4..e2372a2178a 100644 --- a/apps/studio/src/assets/styles/components/_all.scss +++ b/apps/studio/src/assets/styles/components/_all.scss @@ -7,4 +7,5 @@ @import "dropzone.scss"; @import "text-editor.scss"; @import "json-viewer.scss"; -@import './upsell/upsell-content.scss'; +@import './upsell/upgrade-panel.scss'; +@import './upsell/ai-shell-upsell.scss'; diff --git a/apps/studio/src/assets/styles/components/context-menu.scss b/apps/studio/src/assets/styles/components/context-menu.scss index 57465d7e7bf..8464302fc0a 100644 --- a/apps/studio/src/assets/styles/components/context-menu.scss +++ b/apps/studio/src/assets/styles/components/context-menu.scss @@ -1,57 +1,4 @@ -@use "sass:color"; -// Original file souce copyright John Datserakis https://github.com/johndatserakis/vue-simple-context-menu - -.vue-simple-context-menu { - top: 0; - left: 0; - margin: 0; - padding: 0; - display: none; - list-style: none; - position: absolute; - z-index: 1000000; - padding: ($gutter-h * 0.75) 0; - border-radius: 6px; - box-shadow: var(--elevation-5); - font-size: 0.85rem; - text-transform: none; - font-weight: 400; - min-width: 150px; - border: 0; - @include card-shadow-hover; -} -.vue-simple-context-menu--active { - display: block; -} -.vue-simple-context-menu__item { - display: flex; - padding: 0 $gutter-w; - font-size: 0.9rem; - min-height: 28px; - cursor: pointer; - align-items: center; - &.disabled { - pointer-events: none; - color: color.adjust($text-dark, $lightness: -25%); - background: color.adjust(rgba($theme-base, 0.05), $lightness: -25%); - } - i { - height: 24px!important; - line-height: 24px!important; - width: 24px; - opacity: 0.6; - margin-right: $gutter-h!important; - } - .shortcut { - padding-left: 5px; - color: color.adjust($text-dark, $lightness: -30%); - } - i.material-icons.menu-icon { - margin-right:0!important; - margin-left: 0.4rem; - } -} -.vue-simple-context-menu__divider, hr { +hr { min-height: 0; width: 100%; height: 1px; @@ -64,24 +11,19 @@ display: none; } } -.vue-simple-context-menu li:first-of-type { - margin-top: 0; -} -.vue-simple-context-menu li:last-of-type { - margin-bottom: 0; -} // ------ Theme Customization ------ -.BksContextMenu-list, .vue-simple-context-menu { - background: color.adjust($theme-bg, $lightness: 5%); +.BksContextMenu-list { + background: var(--menu-bg); color: $text; } -.BksContextMenu-item, .vue-simple-context-menu__item { +.BksContextMenu-item { color: $text-dark; &.disabled { opacity: 0.5; + pointer-events: none; } &:hover { background: rgba($theme-base, 0.05); @@ -93,6 +35,6 @@ opacity: 0.5; } -.BksContextMenu-item-divider, .vue-simple-context-menu__divider, hr { +.BksContextMenu-item-divider, hr { border-top: 1px solid $border-color; } diff --git a/apps/studio/src/assets/styles/components/upsell/ai-shell-upsell.scss b/apps/studio/src/assets/styles/components/upsell/ai-shell-upsell.scss new file mode 100644 index 00000000000..1cdb55cc4d3 --- /dev/null +++ b/apps/studio/src/assets/styles/components/upsell/ai-shell-upsell.scss @@ -0,0 +1,497 @@ +@use "sass:color"; + +// ============================================================================ +// AI Shell upgrade prompt (compact card + animated mini preview) +// ============================================================================ +.ai-shell-upsell { + position: relative; + height: 100%; + width: 100%; + display: flex; + // "safe center" stops the top from getting clipped when the card is + // taller than the viewport; the scroll container takes over instead. + align-items: safe center; + justify-content: center; + padding: 1.75rem 1.5rem; + overflow-y: auto; + overflow-x: hidden; + min-width: 0; + + .bg-glow { + position: absolute; + top: -10%; + right: -5%; + width: 60%; + height: 80%; + background: + radial-gradient(circle at 60% 40%, rgba($brand-pink, 0.10), transparent 55%), + radial-gradient(circle at 30% 80%, rgba($brand-purple, 0.10), transparent 55%); + pointer-events: none; + } + + .compact-card { + position: relative; + width: 100%; + max-width: 620px; + min-width: 0; + flex-shrink: 0; + background: + linear-gradient( + 180deg, + color.adjust($query-editor-bg, $lightness: 4%) 0%, + color.adjust($query-editor-bg, $lightness: 1%) 100% + ); + border: 1px solid rgba($theme-base, 0.08); + border-radius: 14px; + padding: 1.4rem 1.7rem 1.2rem; + box-shadow: 0 30px 60px rgba(0, 0, 0, 0.45); + } + + .head-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.4rem; + + .title-block { + display: flex; + flex-direction: column; + min-width: 0; + } + + .eyebrow { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + color: $theme-primary; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + line-height: 1; + margin-bottom: 4px; + + .material-icons { + font-size: 13px; + } + } + } + + .mark { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba($brand-pink, 0.15), rgba($brand-purple, 0.15)); + border: 1px solid rgba($brand-pink, 0.25); + border-radius: 10px; + flex-shrink: 0; + + .material-icons { + font-size: 20px; + background: linear-gradient(135deg, $brand-pink, $brand-purple); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + } + + h1 { + font-size: 1.25rem; + font-weight: 500; + color: $text-dark; + margin: 0; + letter-spacing: -0.01em; + text-transform: none; + } + + p.lede { + font-size: 0.85rem; + line-height: 1.5; + color: $text; + margin: 0 0 0.9rem; + text-transform: none; + font-weight: 400; + } + + // -------------------------------------------------------------------------- + // Mini preview + // -------------------------------------------------------------------------- + .cc-preview { + background: color.adjust($query-editor-bg, $lightness: -3%); + border: 1px solid $border-color; + border-radius: 8px; + margin: 0 0 0.9rem; + overflow: hidden; + min-width: 0; + } + + .cc-preview-body { + padding: 0.6rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 280px; + min-width: 0; + transition: opacity 0.35s ease; + + &.is-resetting { + opacity: 0; + + // While the body is faded out for reset, snap children back to their + // hidden state without transitioning — otherwise they crossfade with + // the body fading back in and the final frame "flashes" through. + .cc-step, + .cc-sql-reveal, + .cc-caret { + transition: none !important; + animation: none !important; + } + } + } + + // User bubble — right-aligned, yellow-tinted (matches real AI Shell) + .cc-user { + align-self: flex-end; + max-width: 78%; + background: rgba($theme-primary, 0.10); + color: $text-dark; + border-radius: 8px; + padding: 0.4rem 0.7rem; + font-size: 0.75rem; + line-height: 1.4; + } + + .cc-asst { + display: flex; + flex-direction: column; + gap: 0.4rem; + min-width: 0; + + p { + margin: 0; + color: $text-dark; + font-size: 0.75rem; + line-height: 1.45; + text-transform: none; + font-weight: 400; + } + + code { + background: rgba($theme-base, 0.06); + padding: 0.05rem 0.3rem; + border-radius: 3px; + font-family: monospace; + font-size: 0.7rem; + } + } + + .cc-thinking { + height: 0.9rem; + display: flex; + align-items: center; + } + + .cc-caret { + display: inline-block; + width: 0.5rem; + height: 0.85rem; + background: linear-gradient(135deg, $brand-pink, $brand-purple); + border-radius: 1px; + animation: cc-caret-blink 0.9s steps(2, end) infinite; + } + + @keyframes cc-caret-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0.15; } + } + + // Tool call indicator — minimal "Get Columns — 12 columns" row + .cc-tool { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.1rem 0 0.1rem 0.625rem; + position: relative; + font-size: 0.72rem; + color: $text-light; + align-self: flex-start; + } + + .cc-tool-bar { + position: absolute; + left: 0; + top: 0.2rem; + bottom: 0.2rem; + width: 2px; + background: rgba($theme-base, 0.10); + border-radius: 1px; + } + + .cc-tool-name { color: $text-dark; } + .cc-tool-sep { color: $text-lighter; } + .cc-tool-meta { color: $text-light; } + + // Run Query block + .cc-runquery { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.15rem; + min-width: 0; + } + + .cc-rq-head { + font-size: 0.75rem; + color: $text-dark; + font-weight: 500; + text-transform: none; + } + + .cc-sql { + font-family: monospace; + font-size: 0.7rem; + line-height: 1.5; + color: $text-dark; + background: color.adjust($query-editor-bg, $lightness: -6%); + border: 1px solid $border-color; + border-radius: 5px; + padding: 0.45rem 0.7rem; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + min-width: 0; + + .kw { color: $brand-pink; } + .id { color: $brand-secondary; } + .num { color: $brand-primary; } + .str { color: $brand-primary; } + } + + // Result block (after SQL "runs") + .cc-result { + margin-top: 0.4rem; + border: 1px solid $border-color; + border-radius: 5px; + background: color.adjust($query-editor-bg, $lightness: -6%); + overflow: hidden; + min-width: 0; + } + + .cc-result-table { + width: 100%; + border-collapse: collapse; + font-family: monospace; + font-size: 0.7rem; + + th, td { + padding: 0.35rem 0.7rem; + text-align: left; + border-bottom: 1px solid $border-color; + } + + th { + color: $text-light; + font-weight: 500; + background: rgba($theme-base, 0.02); + text-transform: none; + letter-spacing: 0; + } + + td { + color: $text-dark; + + &.num { color: $brand-primary; } + } + + tr:last-child td { border-bottom: 0; } + } + + // Final assistant answer — plain text. The real AI shell renders raw text + // without per-word color, so bold emphasis only (no accent color). + .cc-final { + margin-top: 0.35rem; + + strong { + color: $text-dark; + font-weight: 600; + } + } + + // -------------------------------------------------------------------------- + // Step transitions (driven by Vue state on AiShellPreview) + // -------------------------------------------------------------------------- + .cc-step { + opacity: 0; + transform: translateY(6px); + transition: opacity 0.35s ease, transform 0.35s ease; + will-change: opacity, transform; + } + + .cc-step-x { + transform: translateX(14px); + } + + .cc-step.cc-step-show { + opacity: 1; + transform: translate(0, 0); + } + + // SQL "curtain" reveal — clip-path bottom-up + .cc-sql-reveal { + clip-path: inset(0 0 100% 0); + transition: clip-path 1.1s cubic-bezier(0.22, 0.61, 0.36, 1); + + &.shown { + clip-path: inset(0 0 0% 0); + } + } + + // Respect reduced motion: render the final state immediately, no transitions. + .cc-reduced-motion { + .cc-step, + .cc-sql-reveal { + opacity: 1 !important; + transform: none !important; + clip-path: none !important; + transition: none !important; + } + .cc-caret { animation: none; opacity: 1; } + .cc-preview-body.is-resetting { opacity: 1; } + } + + // -------------------------------------------------------------------------- + // Pills — single inline row, the three differentiators separated by middots. + // -------------------------------------------------------------------------- + .pills { + list-style: none; + margin: 0 0 0.9rem; + padding: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.25rem 0.65rem; + font-size: 0.78rem; + color: $text-dark; + } + + .pill-row { + display: inline-flex; + align-items: center; + gap: 0.35rem; + text-transform: none; + letter-spacing: 0; + + .material-icons { + font-size: 16px; + color: $brand-success; + } + + span { + font-weight: 500; + color: $text-dark; + white-space: nowrap; + } + + & + .pill-row::before { + content: "\00b7"; + color: $text-lighter; + margin-right: 0.3rem; + } + } + + // -------------------------------------------------------------------------- + // Testimonial (single line above CTAs) — bumped up to let it breathe. + // -------------------------------------------------------------------------- + .testimonial { + display: flex; + align-items: center; + gap: 0.7rem; + font-size: 0.78rem; + color: $text-light; + margin: 0.4rem 0 1rem; + padding: 0.65rem 0.9rem; + background: rgba($theme-base, 0.025); + border: 1px solid $border-color; + border-radius: 8px; + line-height: 1.4; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; + + .stars { + color: $theme-primary; + font-size: 0.78rem; + letter-spacing: 0.5px; + flex-shrink: 0; + } + + .quote { + color: $text-dark; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + + .attr { + color: $text-lighter; + flex-shrink: 0; + } + } + + // -------------------------------------------------------------------------- + // CTA buttons (existing UpsellButtons) + // -------------------------------------------------------------------------- + .ai-shell-upsell-buttons { + .help { + font-size: 0.7rem; + } + } + + // -------------------------------------------------------------------------- + // Lifetime-license footer band — sits flush at the card's bottom edge. + // -------------------------------------------------------------------------- + .lifetime-note { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin: 1rem -1.7rem -1.2rem; + padding: 0.6rem 1.2rem; + background: rgba($theme-primary, 0.10); + border-top: 1px solid rgba($theme-primary, 0.22); + border-radius: 0 0 14px 14px; + color: $text-dark; + font-size: 0.78rem; + line-height: 1.35; + text-transform: none; + letter-spacing: 0; + + .material-icons { + font-size: 16px; + color: $theme-primary; + flex-shrink: 0; + } + + strong { + color: $text-dark; + font-weight: 600; + text-transform: none; + letter-spacing: 0; + } + } +} + +// ============================================================================ +// Hide the global statusbar entirely when the AI Shell upgrade prompt is +// the active tab. The upsell carries its own lifetime-license footer inside +// the card, so the empty connection-color band below is just chrome noise. +// Uses :has() — Electron's bundled Chromium 105+ supports it. +// ============================================================================ +.app:has(.tab-pane.active .ai-shell-upsell) .global-status-bar { + display: none; +} diff --git a/apps/studio/src/assets/styles/components/upsell/upgrade-panel.scss b/apps/studio/src/assets/styles/components/upsell/upgrade-panel.scss new file mode 100644 index 00000000000..a9e854565d1 --- /dev/null +++ b/apps/studio/src/assets/styles/components/upsell/upgrade-panel.scss @@ -0,0 +1,266 @@ +@use "sass:color"; + +// Shared content panel used inside the upgrade modal AND inline in tabs +// (e.g. multi-table export, import-from-file). The modal supplies its own +// chrome; the standalone variant gives the panel a centered card look. + +.upgrade-panel { + width: 100%; + + .upgrade-panel-scroll { + padding: 22px 24px 4px; + } + + // Header + .upgrade-panel-header { + display: flex; + align-items: center; + gap: 14px; + + .bk-badge { + width: 38px; + height: 38px; + display: block; + flex-shrink: 0; + } + .title-block { + flex: 1; + min-width: 0; + &.triggered { + align-self: flex-start; + } + .eyebrow { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + color: $theme-primary; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + margin-bottom: 2px; + .material-icons { + font-size: 13px; + } + } + .title { + margin: 0; + font-size: 18px; + font-weight: 500; + color: $text-dark; + letter-spacing: -0.1px; + line-height: 1.25; + padding-bottom: 0; + } + } + .indie-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + font-size: 11px; + color: $text; + background: rgba($theme-base, 0.04); + border: 1px solid $border-color; + border-radius: 999px; + white-space: nowrap; + flex-shrink: 0; + .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: $brand-success; + } + } + } + .subtitle { + margin: 6px 0 0; + padding-left: 52px; + font-size: 13px; + color: $text-light; + line-height: 1.5; + } + + // What you unlock + .unlock-section { + padding-top: 18px; + .section-label { + font-size: 11px; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 10px; + font-weight: 600; + } + .unlock-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; + } + .unlock-item { + display: flex; + align-items: flex-start; + gap: 11px; + list-style: none; + padding: 0; + &:before { content: none; } + .unlock-icon { + font-size: 18px; + margin-top: 1px; + flex-shrink: 0; + } + .unlock-text { + min-width: 0; + font-size: 13px; + line-height: 1.45; + } + .unlock-title { + color: $text-dark; + font-weight: 600; + margin-right: 6px; + } + .unlock-blurb { + color: $text-light; + } + } + .unlock-more { + display: flex; + align-items: center; + gap: 11px; + padding-left: 29px; + list-style: none; + &:before { content: none; } + a { + font-size: 12.5px; + color: $text-light; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 4px; + .link-emphasis { + color: $brand-secondary; + text-decoration: underline; + text-decoration-color: rgba($brand-secondary, 0.4); + margin-left: 2px; + } + .material-icons { + font-size: 14px; + color: $brand-secondary; + } + } + } + } + + // Testimonial + .testimonial { + margin: 18px 0 0; + padding: 14px 16px; + background: rgba($theme-base, 0.03); + border: 1px solid $border-color; + border-radius: 8px; + display: flex; + gap: 14px; + align-items: flex-start; + + .avatar { + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 50%; + background: linear-gradient(135deg, rgba($theme-primary, 0.22), rgba($brand-secondary, 0.18)); + border: 1px solid $border-color; + display: flex; + align-items: center; + justify-content: center; + color: $text-dark; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.4px; + } + .testimonial-body { + flex: 1; + min-width: 0; + } + blockquote { + margin: 0; + font-size: 13px; + line-height: 1.55; + color: $text-dark; + font-weight: 400; + } + figcaption { + margin-top: 6px; + font-size: 11.5px; + color: $text-light; + display: flex; + align-items: center; + gap: 6px; + .author { + color: $text; + font-weight: 600; + } + .sep { + color: $text-lighter; + } + } + } + + // CTAs — wraps the shared component + .cta-row { + margin-top: 18px; + padding: 16px 0 4px; + border-top: 1px solid $border-color; + } + + // Lifetime license footer band + .lifetime-footer { + padding: 11px 24px; + background: rgba($theme-primary, 0.07); + border-top: 1px solid rgba($theme-primary, 0.18); + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + font-size: 12.5px; + color: $text; + flex-shrink: 0; + + .material-icons { + font-size: 18px; + color: $theme-primary; + } + strong { + color: $text-dark; + font-weight: 600; + } + .asterisk { + color: $text-light; + } + } + + // Standalone (inline in a tab): give the panel its own card chrome + // matching the modal's surface color so the two read the same. + &.upgrade-panel--standalone { + max-width: 620px; + margin: 24px auto; + background: color.adjust($theme-bg, $lightness: 1%); + border: 1px solid $border-color; + border-radius: 12px; + overflow: hidden; + align-self: center; + } +} + +// Wrapper used by tabs to center the standalone panel and provide breathing room. +// Block layout (not flex) so margin: auto on the standalone card centers it +// horizontally and the wrapper itself becomes the vertical scroll container. +.upgrade-panel-tab-wrapper { + width: 100%; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 0 24px 24px; +} diff --git a/apps/studio/src/assets/styles/components/upsell/upsell-content.scss b/apps/studio/src/assets/styles/components/upsell/upsell-content.scss deleted file mode 100644 index b30ed408056..00000000000 --- a/apps/studio/src/assets/styles/components/upsell/upsell-content.scss +++ /dev/null @@ -1,80 +0,0 @@ -.connection-upsell-content { - margin-top: 1.64rem; - font-size: 0.9rem; - .intro { - - } - .content { - display: flex; - flex-direction: column; - gap: 0.85rem; - padding: 1rem; - padding-bottom: 1.25rem; - border-radius: 0.5rem; - background-color: rgba($theme-base, 0.08); - color: $text-dark; - - .main-content-container { - display: flex; - flex-direction: column; - gap: 0.85rem; - - ul { - margin: 0; - padding: 0; - list-style-type: none; - } - } - - p { - margin: 0; - line-height: 2.01; - } - - .intro { - font-weight: 700; - font-size: 1rem; - display: flex; - gap: 0.85rem; - - .material-icons { - padding-top: 0.25rem; - } - } - - ul { - display: flex; - flex-direction: column; - gap: 0.71rem; - margin: 0; - padding: 0; - margin-left: 2rem; - list-style-type: none; - - .bullet { - margin-right: 1rem; - } - } - - .bottom { - margin-left: 2rem; - } - } - - .actions { - display: flex; - justify-content: flex-end; - gap: 0.43rem; - margin-top: 1rem; - - .primary .material-icons { - font-size: 0.85rem; - margin-right: 0.14rem; - } - - .primary:hover { - background-color: $theme-primary; - color: #000000; - } - } -} diff --git a/apps/studio/src/assets/styles/themes/scssvars-to-cssprops.scss b/apps/studio/src/assets/styles/themes/scssvars-to-cssprops.scss index 96937956f0c..2d4c6f57102 100644 --- a/apps/studio/src/assets/styles/themes/scssvars-to-cssprops.scss +++ b/apps/studio/src/assets/styles/themes/scssvars-to-cssprops.scss @@ -29,6 +29,8 @@ --input-highlight: #{$input-highlight}; --query-editor-bg: #{$query-editor-bg}; + --menu-bg: color-mix(in srgb, var(--theme-bg) 95%, #fff); + --menu-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 15px rgba(0, 0, 0, 0.12); --scrollbar-track: #{$scrollbar-track}; --scrollbar-thumb: #{$scrollbar-thumb}; diff --git a/apps/studio/src/backend/lib/FileHelpers.ts b/apps/studio/src/backend/lib/FileHelpers.ts index 10df6805c9f..58f4c04ead7 100644 --- a/apps/studio/src/backend/lib/FileHelpers.ts +++ b/apps/studio/src/backend/lib/FileHelpers.ts @@ -13,7 +13,7 @@ export type SaveFileOptions = { /** Save a file after showing a dialog */ async function save(options: SaveFileOptions) { - const encoding = options.encoding ?? "utf8"; + const encoding: BufferEncoding = options.encoding as BufferEncoding ?? "utf8"; const result = await dialog.showSaveDialog({ defaultPath: options.fileName, @@ -22,7 +22,7 @@ async function save(options: SaveFileOptions) { if (result.canceled) return false; - writeFileSync(result.filePath, options.content, encoding); + writeFileSync(result.filePath, options.content, { encoding }); return true; } @@ -30,9 +30,7 @@ async function save(options: SaveFileOptions) { export function initializeFileHelpers() { if (initialized) return; - ipcMain.handle("fileHelpers:save", (_event, params) => { - save(params); - }); + ipcMain.handle("fileHelpers:save", (_event, params) => save(params)); initialized = true; } diff --git a/apps/studio/src/backend/lib/OfflineLicense.ts b/apps/studio/src/backend/lib/OfflineLicense.ts index 0bb162585c0..bfb32a6d465 100644 --- a/apps/studio/src/backend/lib/OfflineLicense.ts +++ b/apps/studio/src/backend/lib/OfflineLicense.ts @@ -33,6 +33,7 @@ let _cachedLicense = null } */ +const filename = platformInfo.isDevelopment ? 'license-dev.json' : 'license.json' export interface LicenseOptions { licensePath?: string @@ -46,7 +47,7 @@ export class OfflineLicense { return _cachedLicense } - defaultPath = path.join(platformInfo.userDirectory, 'license.json') + defaultPath = path.join(platformInfo.userDirectory, filename) defaultKeyPath = path.join(platformInfo.resourcesPath, 'production_pub.pem') path: string publicKeyPath: string diff --git a/apps/studio/src/backend/lib/security.ts b/apps/studio/src/backend/lib/security.ts index f0a3475fd65..eaf8fdcebec 100644 --- a/apps/studio/src/backend/lib/security.ts +++ b/apps/studio/src/backend/lib/security.ts @@ -2,7 +2,7 @@ import { getActiveWindows } from "@/background/WindowBuilder"; import { AppEvent } from "@/common/AppEvent"; import bksConfig from "@/common/bksConfig"; import rawLog from "@bksLogger"; -import { powerMonitor } from "electron"; +import { ipcMain, powerMonitor } from "electron"; const log = rawLog.scope("security"); @@ -10,6 +10,17 @@ let idleCheckInterval: NodeJS.Timer; let initialized = false; +// Tracks the last time any Beekeeper window reported user input. powerMonitor's +// system idle time is unreliable on Linux (especially Wayland and tiling WMs) +// and can report the user as idle while they're actively using the app, so we +// combine it with renderer-reported activity (see the `userActive` IPC, which +// the renderer fires from real mousedown/keydown events). +let lastAppInputAt = Date.now(); + +function appIdleSeconds(): number { + return Math.floor((Date.now() - lastAppInputAt) / 1000); +} + export function initializeSecurity() { if (initialized) { log.warn("Security already initialized"); @@ -17,14 +28,30 @@ export function initializeSecurity() { } if (bksConfig.security.disconnectOnIdle) { + ipcMain.on("userActive", () => { + lastAppInputAt = Date.now(); + }); + + let hasDisconnectedWhileIdle = false; idleCheckInterval = setInterval(() => { - if ( - powerMonitor.getSystemIdleTime() > - bksConfig.security.idleThresholdSeconds - ) { - log.info("User has been idle, disconnecting."); - disconnect("User has been idle"); + const systemIdle = powerMonitor.getSystemIdleTime(); + const appIdle = appIdleSeconds(); + const effectiveIdle = Math.min(systemIdle, appIdle); + const overThreshold = + effectiveIdle > bksConfig.security.idleThresholdSeconds; + + if (!overThreshold) { + hasDisconnectedWhileIdle = false; + return; } + + if (hasDisconnectedWhileIdle) return; + + log.info( + `User has been idle for ${effectiveIdle}s (system=${systemIdle}, app=${appIdle}), disconnecting.` + ); + disconnect("User has been idle"); + hasDisconnectedWhileIdle = true; }, (bksConfig.security.idleCheckIntervalSeconds || 1) * 1000); log.info("Idle checker started"); } diff --git a/apps/studio/src/background/NativeMenuActionHandlers.ts b/apps/studio/src/background/NativeMenuActionHandlers.ts index e0d09786f06..882ed8968c6 100644 --- a/apps/studio/src/background/NativeMenuActionHandlers.ts +++ b/apps/studio/src/background/NativeMenuActionHandlers.ts @@ -1,7 +1,8 @@ import _ from 'lodash' import {AppEvent} from '../common/AppEvent' import { buildWindow, getActiveWindows, OpenOptions } from './WindowBuilder' -import { app , shell } from 'electron' +import { app } from 'electron' +import { safeOpenExternal } from './lib/electron/safeOpenExternal' import platformInfo from '../common/platform_info' import path from 'path' import { IGroupedUserSettings } from '../common/appdb/models/user_setting' @@ -42,6 +43,9 @@ export default class NativeMenuActionHandlers implements IMenuActionHandler { paste(_1: Electron.MenuItem, win: ElectronWindow): void { if (win) win.webContents.paste() } + pasteAsNewRows(_1: Electron.MenuItem, win: ElectronWindow): void { + if (win) win.webContents.send(AppEvent.pasteAsNewRows) + } selectAll(_1: Electron.MenuItem, win: ElectronWindow): void { if (win) win.webContents.selectAll() } @@ -112,11 +116,15 @@ export default class NativeMenuActionHandlers implements IMenuActionHandler { } opendocs(): void { - shell.openExternal("https://docs.beekeeperstudio.io/") + safeOpenExternal("https://docs.beekeeperstudio.io/") } contactSupport(): void { - shell.openExternal("https://docs.beekeeperstudio.io/support/contact-support/") + safeOpenExternal("https://docs.beekeeperstudio.io/support/contact-support/") + } + + openGettingStarted(): void { + safeOpenExternal("https://docs.beekeeperstudio.io/getting-started-guide/") } checkForUpdates(_menuItem: Electron.MenuItem, _win: Electron.BrowserWindow): void { @@ -213,10 +221,22 @@ export default class NativeMenuActionHandlers implements IMenuActionHandler { }) } + togglePrivacyMode = async (): Promise => { + this.settings.privacyMode.value = !this.settings.privacyMode.value + await this.settings.privacyMode.save() + getActiveWindows().forEach(window => { + window.send(AppEvent.settingsChanged) + }) + } + switchLicenseState = async (state: Electron.MenuItem | DevLicenseState, win: ElectronWindow) => { if (win) win.webContents.send(AppEvent.switchLicenseState, state) } + simulatePlatform = async (platform: Electron.MenuItem | string, win: ElectronWindow) => { + if (win) win.webContents.send(AppEvent.simulatePlatform, platform) + } + toggleBeta = async (menuItem: Electron.MenuItem): Promise => { const label = _.isString(menuItem) ? menuItem : menuItem.label const beta = label.toLowerCase() == 'beta'; diff --git a/apps/studio/src/background/WindowBuilder.ts b/apps/studio/src/background/WindowBuilder.ts index bbad8a03fe3..474d9dd84c1 100644 --- a/apps/studio/src/background/WindowBuilder.ts +++ b/apps/studio/src/background/WindowBuilder.ts @@ -6,6 +6,7 @@ import platformInfo from '../common/platform_info' import { IGroupedUserSettings } from '../common/appdb/models/user_setting' import rawLog from '@bksLogger' import querystring from 'query-string' +import { safeOpenExternal } from './lib/electron/safeOpenExternal' // eslint-disable-next-line @@ -79,9 +80,15 @@ class BeekeeperWindow { if (url === this.appUrl) return // this is good log.info("navigate to", url) e.preventDefault() - const u = new URL(url) + let u: URL + try { + u = new URL(url) + } catch { + log.warn('will-navigate: ignoring invalid URL', url) + return + } u.searchParams.append('ref', 'bks-app') - electron.shell.openExternal(u.toString()); + safeOpenExternal(u.toString()); }) this.win.webContents.setWindowOpenHandler(({ url }) => { diff --git a/apps/studio/src/background/lib/electron/ProtocolBuilder.ts b/apps/studio/src/background/lib/electron/ProtocolBuilder.ts index 6386f5f8751..991cf8fc2f7 100644 --- a/apps/studio/src/background/lib/electron/ProtocolBuilder.ts +++ b/apps/studio/src/background/lib/electron/ProtocolBuilder.ts @@ -46,23 +46,38 @@ export const ProtocolBuilder = { mappings: '' }); - // our app runs from dist/, regardless of whether this is inside of the // app.asar file, but we want to not allow loading of content from outside of // the dist directory - let normalizedPath = path.normalize(path.join(__dirname, 'renderer', pathName)) + const distRoot = path.resolve(path.join(__dirname, 'renderer')) + const normalizedPath = path.resolve(path.join(distRoot, pathName)) log.debug("resolving", pathName, 'to', normalizedPath) const extension = path.extname(pathName).toLowerCase() - if (extension === '.map' && platformInfo.isDevelopment) { - // we want to check the directory and resolve it - if (!fs.existsSync(normalizedPath)) { - // probably some weird path like: - // app://./home/rathboma/Projects/beekeeper-studio/studio/node_modules/@google-cloud/bigquery/build/src/rowQueue.js.map - normalizedPath = pathName + + // Containment check: refuse anything that escapes dist/renderer. + if ( + normalizedPath !== distRoot && + !normalizedPath.startsWith(distRoot + path.sep) + ) { + if (extension === '.map') { + respond({ + mimeType: 'application/json', + data: Buffer.from(emptySourceMap), + }) + return } + respond({ error: -6 }) + return } readFile(normalizedPath, (error, data) => { + if (error && extension === '.map') { + respond({ + mimeType: 'application/json', + data: Buffer.from(emptySourceMap), + }) + return + } respond({ mimeType: mimeTypeOf(pathName), data, @@ -77,9 +92,18 @@ export const ProtocolBuilder = { const url = new URL(request.url); const pluginId = url.host; const pathName = path.join(pluginId, url.pathname); - const normalized = path.normalize(pathName) - const fullPath = path.join(platformInfo.userDirectory, "plugins", normalized) + const pluginsRoot = path.resolve(platformInfo.pluginsDirectory) + const pluginRoot = path.resolve(path.join(pluginsRoot, pluginId)) + const fullPath = path.resolve(path.join(pluginsRoot, pathName)) log.debug("resolving", pathName, 'to', fullPath) + // Containment check: refuse anything that escapes the plugin's own directory. + if ( + fullPath !== pluginRoot && + !fullPath.startsWith(pluginRoot + path.sep) + ) { + respond({ error: -6 }) // file not found + return; + } if (bksConfig.get(`plugins.${pluginId}.disabled`)) { respond({ error: -20 }) // blocked by client return; diff --git a/apps/studio/src/background/lib/electron/safeOpenExternal.ts b/apps/studio/src/background/lib/electron/safeOpenExternal.ts new file mode 100644 index 00000000000..eb52e882c15 --- /dev/null +++ b/apps/studio/src/background/lib/electron/safeOpenExternal.ts @@ -0,0 +1,41 @@ +import { shell } from 'electron' +import rawLog from '@bksLogger' + +const log = rawLog.scope('safeOpenExternal') + +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']) + +/** + * Pure predicate — true iff `url` is a string that parses as an absolute + * URL with an http(s) scheme. No side effects, no Electron deps; safe to + * unit-test exhaustively. + * + * Other protocols (file:, javascript:, vbs:, smb:, data:, ms-…:, etc.) can + * launch local programs and must never reach shell.openExternal. + */ +export function isSafeExternalUrl(url: unknown): url is string { + if (typeof url !== 'string' || !url) return false + try { + const parsed = new URL(url) + return ALLOWED_PROTOCOLS.has(parsed.protocol) + } catch { + return false + } +} + +/** + * Single trusted entry point for shell.openExternal in the main process. + * + * Any URL that arrives from the renderer, a plugin, the utility process, + * or a will-navigate event must pass through here. Direct calls to + * shell.openExternal elsewhere are blocked by the no-restricted-syntax + * ESLint rule (see .eslintrc.js). + */ +export function safeOpenExternal(url: unknown): boolean { + if (!isSafeExternalUrl(url)) { + log.warn('Refusing to open external URL — invalid or disallowed protocol:', url) + return false + } + shell.openExternal(url) + return true +} diff --git a/apps/studio/src/background/update_manager.ts b/apps/studio/src/background/update_manager.ts index ef8d4b109fb..88390dc62ab 100644 --- a/apps/studio/src/background/update_manager.ts +++ b/apps/studio/src/background/update_manager.ts @@ -13,7 +13,7 @@ autoUpdater.logger = log // HACK(mc, 2019-09-10): work around https://github.com/electron-userland/electron-builder/issues/4046 function dealWithAppImage() { - if (platformInfo.isAppImage) { + if (platformInfo.isAppImageLauncher) { // remap temporary running AppImage to actual source // THIS IS PROBABLY SUPER BRITTLE AND MAKES ME WANT TO STOP USING APPIMAGE // eslint-disable-next-line diff --git a/apps/studio/src/common/AppEvent.ts b/apps/studio/src/common/AppEvent.ts index 5480b953e9f..17729c79128 100644 --- a/apps/studio/src/common/AppEvent.ts +++ b/apps/studio/src/common/AppEvent.ts @@ -1,5 +1,6 @@ import Vue from "vue" import rawLog from '@bksLogger' +import { ShareableModule } from "@/store/DataModules"; const log = rawLog.scope('AppEvent') @@ -23,6 +24,7 @@ export enum AppEvent { createTableFromFile = 'new_table_from_file', openTableProperties = 'loadTableProperties', loadTable = 'loadTable', + loadSelectTop = 'loadSelectTop', quickSearch = 'quickSearch', promptLogin = 'cloud_signin', promptCreateWorkspace = 'cloud_create_workspace', @@ -73,11 +75,46 @@ export enum AppEvent { switchedTab = 'switchedTab', /** A tab is about to be closed. First argument is the tab. */ closingTab = 'closingTab', + simulatePlatform = 'simulatePlatform', updatePin = 'updatePin', /** The theme has been changed. */ changedTheme = 'changedTheme', /** A plugin menu item was clicked in the native/client menu under the tools. */ pluginMenuClicked = 'pluginMenuClicked', + /** Open query edit history on a new / existing query tab. + * @example + * this.trigger(AppEvent.openQueryEditHistory, savedQueryId); + **/ + openQueryEditHistory = 'openQueryEditHistory', + /** Open a share modal by passing the subject as the first parameter (See {@link OpenShareModalOptions}). + * The subject should be available in the cloud. + * @example + * this.trigger(AppEvent.openShareModal, { id: 1, module: "data/queries" }); + */ + openShareModal = 'openShareModal', + /** Paste clipboard contents as new rows in the active table's Data tab. */ + pasteAsNewRows = 'pasteAsNewRows', + /** Open a modal to move a connection or a saved query to a folder + * @example + * this.trigger(AppEvent.openMoveFileModal, { + * type: "connection", + * value: this.config, // the connection config + * }); + **/ + openMoveFileModal = 'openMoveFileModal', + /** Open a modal to move a folder to another folder + * @example + * this.trigger(AppEvent.openMoveFolderModal, { + * type: "connectionFolder", + * value: item, // the folder + * }); + **/ + openMoveFolderModal = 'openMoveFolderModal', +} + +export type OpenShareModalOptions = { + id: number; + module: ShareableModule; } export interface RootBinding { diff --git a/apps/studio/src/common/IPlatformInfo.ts b/apps/studio/src/common/IPlatformInfo.ts index 27397368571..3dd80f13687 100644 --- a/apps/studio/src/common/IPlatformInfo.ts +++ b/apps/studio/src/common/IPlatformInfo.ts @@ -11,10 +11,14 @@ export interface IPlatformInfo { sessionType: string, isWayland: boolean, isSnap: string, + isFlatpak: boolean, isPortable: string, isDevelopment: boolean, isAppImage: boolean, + isAppImageLauncher: boolean, sshAuthSock: string, + sshConfigExists: boolean, + defaultSshIdentityFile: string, environment: string, resourcesPath: string, env: { @@ -30,6 +34,7 @@ export interface IPlatformInfo { downloadsDirectory: string, homeDirectory: string, pluginsDirectory: string, + driverDepsDirectory: string, testMode: boolean, appDbPath: string, updatesDisabled: boolean, @@ -37,4 +42,9 @@ export interface IPlatformInfo { parsedAppVersion: BksVersion, cloudUrl: string, locale: string, + // Resolved log level pushed from main when the renderer requests + // platformInfo. Renderer applies this to its console + ipc transports + // so BKS_LOG_LEVEL / DEBUG affect what the renderer actually emits. + // Mirrors the LogLevel union from the shared logger library. + logLevel?: 'error' | 'warn' | 'info' | 'verbose' | 'debug' | 'silly', } diff --git a/apps/studio/src/common/appdb/Connection.ts b/apps/studio/src/common/appdb/Connection.ts index 107bdf49c1d..2332f2f41b6 100644 --- a/apps/studio/src/common/appdb/Connection.ts +++ b/apps/studio/src/common/appdb/Connection.ts @@ -7,6 +7,7 @@ import { UserSetting } from './models/user_setting' import { LoggerOptions } from 'typeorm/logger/LoggerOptions' import { PinnedEntity } from "./models/PinnedEntity" import { CloudCredential } from "./models/CloudCredential" +import { VaultProvider } from "./models/VaultProvider" import { OpenTab } from "./models/OpenTab" import { LicenseKey } from "./models/LicenseKey" import { HiddenEntity } from "./models/HiddenEntity" @@ -20,6 +21,8 @@ import { EncryptedPluginData } from "./models/EncryptedPluginData" import { FormatterPreset } from "./models/FormatterPreset" import { QueryFolder } from "./models/QueryFolder" import { ConnectionFolder } from "./models/ConnectionFolder" +import { TabulatorPersistence } from "./models/TabulatorPersistence" +import { QueryAudit } from "./models/QueryAudit" const models = [ SavedConnection, @@ -29,6 +32,7 @@ const models = [ UserSetting, PinnedEntity, CloudCredential, + VaultProvider, OpenTab, LicenseKey, HiddenEntity, @@ -42,6 +46,8 @@ const models = [ FormatterPreset, QueryFolder, ConnectionFolder, + TabulatorPersistence, + QueryAudit, ] interface IConnectionState { diff --git a/apps/studio/src/common/appdb/models/ConnectionFolder.ts b/apps/studio/src/common/appdb/models/ConnectionFolder.ts index 97b22c20eae..c761d4e2d4e 100644 --- a/apps/studio/src/common/appdb/models/ConnectionFolder.ts +++ b/apps/studio/src/common/appdb/models/ConnectionFolder.ts @@ -1,7 +1,8 @@ -import { Entity, Column, OneToMany, ManyToOne, JoinColumn, BeforeRemove } from 'typeorm' +import { Entity, Column, OneToMany, ManyToOne, JoinColumn, BeforeRemove, BeforeInsert, BeforeUpdate, Not, IsNull } from 'typeorm' import { ApplicationEntity } from './application_entity' import { SavedConnection } from './saved_connection' -import pluralize from 'pluralize' +import { pluralize } from '@/vendor/pluralize' +import { PreventMovingFolderInsideItself } from '../validators/PreventMovingFolderInsideItself' @Entity({ name: 'connection_folder' }) export class ConnectionFolder extends ApplicationEntity { @@ -20,6 +21,7 @@ export class ConnectionFolder extends ApplicationEntity { expanded = true @Column({ type: 'integer', nullable: true, default: null }) + @PreventMovingFolderInsideItself parentId: Nullable = null // Do NOT initialize this to null. A null initializer becomes an own property @@ -39,4 +41,19 @@ export class ConnectionFolder extends ApplicationEntity { throw new Error(`Cannot delete folder "${this.name}" — move or remove its ${pluralize('connection', count, true)} first.`) } } + + @BeforeInsert() + @BeforeUpdate() + async preventDuplicateName(): Promise { + if (!this.name) return + const where: any = { + name: this.name, + parentId: this.parentId ?? IsNull(), + } + if (this.id) where.id = Not(this.id) + const existing = await ConnectionFolder.findOneBy(where) + if (existing) { + throw new Error(`A folder named "${this.name}" already exists in this location.`) + } + } } diff --git a/apps/studio/src/common/appdb/models/LicenseKey.ts b/apps/studio/src/common/appdb/models/LicenseKey.ts index 9045f87a1d4..47244321c75 100644 --- a/apps/studio/src/common/appdb/models/LicenseKey.ts +++ b/apps/studio/src/common/appdb/models/LicenseKey.ts @@ -52,6 +52,9 @@ export class LicenseKey extends ApplicationEntity { @Column({ type: 'json', nullable: true }) maxAllowedAppRelease: { tagName: string } + @Column({ type: 'datetime', nullable: true }) + invalidatedAt: Date | null + /** Get all licenses except trial */ static async all() { return await LicenseKey.findBy({ licenseType: Not("TrialLicense" as const) }); diff --git a/apps/studio/src/common/appdb/models/QueryAudit.ts b/apps/studio/src/common/appdb/models/QueryAudit.ts new file mode 100644 index 00000000000..d94ce73ef73 --- /dev/null +++ b/apps/studio/src/common/appdb/models/QueryAudit.ts @@ -0,0 +1,103 @@ +import { + Column, + Entity, + EntityManager, + Index, + IsNull, + LessThan, + LessThanOrEqual, + ManyToOne, + Not, +} from "typeorm"; +import { ApplicationEntity } from "./application_entity"; +import { FavoriteQuery } from "./favorite_query"; +import { TransportQueryAuditDetail } from "@/common/transport/TransportQueryAudit"; + +@Entity({ name: "query_audit", orderBy: { createdAt: "DESC", id: "DESC" } }) +export class QueryAudit extends ApplicationEntity { + withProps(props?: any): QueryAudit { + if (props) QueryAudit.merge(this, props); + return this; + } + + @Index() + @Column({ type: "integer", nullable: false }) + favoriteQueryId: number; + + // Audits are deleted automatically when their query is removed (DB-level + // ON DELETE CASCADE). Do not initialize to null; see FavoriteQuery.queryFolder. + @ManyToOne(() => FavoriteQuery, { nullable: false, onDelete: "CASCADE" }) + favoriteQuery?: FavoriteQuery; + + @Index() + @Column({ type: "integer", nullable: true }) + previousAuditId: number | null; + + @Column({ type: "varchar", nullable: false }) + action: "create" | "update"; + + /** `title` can be null if it's not changed. Always use `getDetail` to resolve it. + * @see {QueryAudit.fetchDetail} **/ + @Column({ type: "varchar", nullable: true }) + title: string | null; + + /** `text` can be null if it's not changed. Always use `getDetail` to resolve it. + * @see {QueryAudit.fetchDetail} **/ + @Column({ type: "text", nullable: true, select: false }) + text: string | null; + + private async resolveTitle(): Promise { + return await QueryAudit.findOne({ + select: ["title"], + where: { + favoriteQueryId: this.favoriteQueryId, + createdAt: LessThanOrEqual(this.createdAt), + title: Not(IsNull()), + }, + }).then((q) => q.title); + } + + private async resolveSnapshot(): Promise<{ text: string; title: string }> { + const title = await this.resolveTitle(); + const textObj = await QueryAudit.findOne({ + select: ["text"], + where: { + favoriteQueryId: this.favoriteQueryId, + createdAt: LessThanOrEqual(this.createdAt), + text: Not(IsNull()), + }, + }); + return { title, text: textObj?.text ?? "" }; + } + + async fetchDetail(): Promise { + return { ...this, values: await this.resolveSnapshot() }; + } + + /** @param updatedAt - apply query's `updatedAt` (for testing only). */ + async restore(updatedAt?: Date): Promise { + await FavoriteQuery.createQueryBuilder("query") + .update() + .set({ + title: await this.resolveTitle(), + text: () => + QueryAudit.createQueryBuilder() + .subQuery() + .select("text") + .from(QueryAudit, "audit") + .where("audit.favoriteQueryId = :queryId") + .andWhere("audit.createdAt <= :createdAt") + .andWhere("audit.text IS NOT NULL") + .orderBy("audit.createdAt", "DESC") + .limit(1) + .getQuery(), + updatedAt, + }) + .where("id = :queryId") + .setParameters({ + queryId: this.favoriteQueryId, + createdAt: this.createdAt, + }) + .execute(); + } +} diff --git a/apps/studio/src/common/appdb/models/QueryFolder.ts b/apps/studio/src/common/appdb/models/QueryFolder.ts index c59ec95b6d6..e0208c10c71 100644 --- a/apps/studio/src/common/appdb/models/QueryFolder.ts +++ b/apps/studio/src/common/appdb/models/QueryFolder.ts @@ -1,7 +1,8 @@ -import { Entity, Column, OneToMany, ManyToOne, JoinColumn, BeforeRemove } from 'typeorm' +import { Entity, Column, OneToMany, ManyToOne, JoinColumn, BeforeRemove, BeforeInsert, BeforeUpdate, Not, IsNull } from 'typeorm' import { ApplicationEntity } from './application_entity' import { FavoriteQuery } from './favorite_query' -import pluralize from 'pluralize' +import { pluralize } from '@/vendor/pluralize' +import { PreventMovingFolderInsideItself } from '../validators/PreventMovingFolderInsideItself' @Entity({ name: 'query_folder' }) export class QueryFolder extends ApplicationEntity { @@ -20,6 +21,7 @@ export class QueryFolder extends ApplicationEntity { expanded = true @Column({ type: 'integer', nullable: true, default: null }) + @PreventMovingFolderInsideItself parentId: Nullable = null // Do NOT initialize this to null. A null initializer becomes an own property @@ -39,4 +41,19 @@ export class QueryFolder extends ApplicationEntity { throw new Error(`Cannot delete folder "${this.name}" — move or remove its ${pluralize('query', count, true)} first.`) } } + + @BeforeInsert() + @BeforeUpdate() + async preventDuplicateName(): Promise { + if (!this.name) return + const where: any = { + name: this.name, + parentId: this.parentId ?? IsNull(), + } + if (this.id) where.id = Not(this.id) + const existing = await QueryFolder.findOneBy(where) + if (existing) { + throw new Error(`A folder named "${this.name}" already exists in this location.`) + } + } } diff --git a/apps/studio/src/common/appdb/models/TabulatorPersistence.ts b/apps/studio/src/common/appdb/models/TabulatorPersistence.ts new file mode 100644 index 00000000000..bfd5c00d479 --- /dev/null +++ b/apps/studio/src/common/appdb/models/TabulatorPersistence.ts @@ -0,0 +1,25 @@ +import { Column, Entity, Index, Unique } from "typeorm"; +import { ApplicationEntity } from "./application_entity"; +import { TransportTabulatorPersistence } from "@/common/transport/TransportTabulatorPersistence"; + +type InitInput = Partial; + +@Entity({ name: "tabulator_persistence" }) +@Unique(["persistenceID", "type"]) +export class TabulatorPersistence extends ApplicationEntity { + withProps(input: InitInput): TabulatorPersistence { + if (!input) return this; + TabulatorPersistence.merge(this, input as any); + return this; + } + + @Index() + @Column({ type: "varchar", nullable: false }) + persistenceID!: string; + + @Column({ type: "varchar", nullable: false }) + type!: string; + + @Column({ type: "text", nullable: false }) + data!: string; +} diff --git a/apps/studio/src/common/appdb/models/VaultProvider.ts b/apps/studio/src/common/appdb/models/VaultProvider.ts new file mode 100644 index 00000000000..31dee421f20 --- /dev/null +++ b/apps/studio/src/common/appdb/models/VaultProvider.ts @@ -0,0 +1,46 @@ +import { loadEncryptionKey } from '@/common/encryption_key' +import { Column, Entity } from 'typeorm' +import { EncryptTransformer } from '../transformers/Transformers' +import { ApplicationEntity } from './application_entity' +import { VaultProviderType } from '@/lib/vault/types' + +const encrypt = new EncryptTransformer(loadEncryptionKey()) + +/** + * Connection details for one vault. Per-machine and encrypted at rest, so this + * never travels with an exported connection — only the secret refs do. + * + * `name` is the stable identifier a connection's refs pin to, NOT the id. Two + * teammates who each add a vault called "Qa Vault" share the same wiring. + */ +@Entity({ name: 'vault_provider' }) +export class VaultProvider extends ApplicationEntity { + + withProps(props?: any): VaultProvider { + if (props) VaultProvider.merge(this, props) + return this + } + + @Column({ type: 'varchar', nullable: false, unique: true }) + name: string + + @Column({ type: 'varchar', nullable: false, default: 'azure-key-vault' }) + providerType: VaultProviderType = 'azure-key-vault' + + @Column({ type: 'varchar', nullable: true }) + vaultUrl: Nullable = null + + @Column({ type: 'varchar', nullable: true }) + tenantId: Nullable = null + + @Column({ type: 'varchar', nullable: true }) + clientId: Nullable = null + + /** Never leaves the utility process. `list` substitutes a boolean for it. */ + @Column({ type: 'varchar', nullable: true, transformer: [encrypt] }) + clientSecret: Nullable = null + + /** Fallback order for refs that do not pin a vault. Lower runs first. */ + @Column({ type: 'integer', nullable: false, default: 0 }) + position = 0 +} diff --git a/apps/studio/src/common/appdb/models/favorite_query.ts b/apps/studio/src/common/appdb/models/favorite_query.ts index 7eb15c8e878..9e3ab1a1f06 100644 --- a/apps/studio/src/common/appdb/models/favorite_query.ts +++ b/apps/studio/src/common/appdb/models/favorite_query.ts @@ -1,9 +1,10 @@ import ISavedQuery from '@/common/interfaces/ISavedQuery' import { MaxLength } from 'class-validator'; -import { Entity, Column, Index, BeforeInsert, BeforeUpdate, ManyToOne, JoinColumn } from 'typeorm' +import { Entity, Column, Index, BeforeInsert, BeforeUpdate, ManyToOne, JoinColumn, OneToMany } from 'typeorm' import { ApplicationEntity } from './application_entity' import { QueryLike } from './base' import { QueryFolder } from './QueryFolder' +import { QueryAudit } from './QueryAudit'; @Entity({ name: 'favorite_query' }) export class FavoriteQuery extends ApplicationEntity implements QueryLike, ISavedQuery { @@ -42,6 +43,9 @@ export class FavoriteQuery extends ApplicationEntity implements QueryLike, ISave @JoinColumn({ name: 'queryFolderId' }) queryFolder?: QueryFolder + @OneToMany(() => QueryAudit, (audit) => audit.favoriteQuery) + queryAudits: QueryAudit[] + @BeforeInsert() @BeforeUpdate() setDefaultDatabase(): void { @@ -53,6 +57,4 @@ export class FavoriteQuery extends ApplicationEntity implements QueryLike, ISave this.connectionHash = 'DEPRECATED' } } - - } diff --git a/apps/studio/src/common/appdb/models/saved_connection.ts b/apps/studio/src/common/appdb/models/saved_connection.ts index 2f83d7ccbb1..797036d4023 100644 --- a/apps/studio/src/common/appdb/models/saved_connection.ts +++ b/apps/studio/src/common/appdb/models/saved_connection.ts @@ -1,11 +1,12 @@ +import { IsNotEmpty, IsString } from "class-validator" import { Entity, Column, BeforeInsert, BeforeUpdate, ManyToOne, JoinColumn } from "typeorm" import { ApplicationEntity } from './application_entity' import { loadEncryptionKey } from '../../encryption_key' import { ConnectionString } from 'connection-string' import log from '@bksLogger' -import { AzureCredsEncryptTransformer, EncryptTransformer, SurrealDbEncryptTransformer } from '../transformers/Transformers' +import { AzureCredsEncryptTransformer, EncryptTransformer, SnowflakeOptionsTransformer, SurrealDbEncryptTransformer } from '../transformers/Transformers' import { IConnection, SshMode } from '@/common/interfaces/IConnection' -import { AzureAuthOptions, BigQueryOptions, CassandraOptions, ConnectionType, ConnectionTypes, LibSQLOptions, RedshiftOptions, IamAuthOptions, SQLAnywhereOptions, SurrealDBOptions } from "@/lib/db/types" +import { AzureAuthOptions, BigQueryOptions, CassandraOptions, ConnectionType, ConnectionTypes, DynamoDBOptions, LibSQLOptions, RedshiftOptions, IamAuthOptions, SQLAnywhereOptions, SurrealDBOptions, SnowflakeOptions, SqlServerOptions } from "@/lib/db/types" import { resolveHomePathToAbsolute } from "@/handlers/utils" import { ReadOnlyOrDefault } from "../validators/ReadOnlyOrDefault" import { ConnectionFolder } from './ConnectionFolder' @@ -13,9 +14,11 @@ import { ConnectionFolder } from './ConnectionFolder' const encrypt = new EncryptTransformer(loadEncryptionKey()) const azureEncrypt = new AzureCredsEncryptTransformer(loadEncryptionKey()) const surrealEncrypt = new SurrealDbEncryptTransformer(loadEncryptionKey()) +const snowflakeTransformer = new SnowflakeOptionsTransformer() export interface ConnectionOptions { cluster?: string + jwtAuthEnabled?: boolean connectionMethod?: 'manual' | 'connectionString' connectionString?: string } @@ -78,6 +81,9 @@ export class DbConnectionBase extends ApplicationEntity { case 'tidb': port = 4000 break + case 'starrocks': + port = 9030 + break case 'postgresql': case 'greengage': port = 5432 @@ -95,6 +101,7 @@ export class DbConnectionBase extends ApplicationEntity { port = 1521 break case 'cassandra': + case 'scylladb': port = 9042 break case 'bigquery': @@ -180,6 +187,18 @@ export class DbConnectionBase extends ApplicationEntity { @Column({ type: 'varchar', nullable: true }) sshBastionHost: Nullable = null + @Column({ type: 'int', nullable: true }) + sshBastionHostPort: Nullable = null + + @Column({ type: 'varchar', length: 8, nullable: false, default: 'agent' }) + sshBastionMode: SshMode = 'agent' + + @Column({ type: 'varchar', nullable: true }) + sshBastionUsername: Nullable = null + + @Column({ type: 'varchar', nullable: true }) + sshBastionKeyfile: Nullable = null + @Column({ type: 'int', nullable: true }) sshKeepaliveInterval: Nullable = 60 @@ -234,10 +253,25 @@ export class DbConnectionBase extends ApplicationEntity { @Column({ type: 'simple-json', nullable: false, transformer: [surrealEncrypt] }) surrealDbOptions: SurrealDBOptions = {}; + @Column({ type: 'simple-json', nullable: false }) + dynamoDbOptions: DynamoDBOptions = {}; + + @Column({ type: 'simple-json', nullable: false, transformer: [snowflakeTransformer] }) + snowflakeOptions: SnowflakeOptions = {}; + // this is only for SQL Server. @Column({ type: 'boolean', nullable: false }) trustServerCertificate = false + // SQL Server only. Integrated authentication (SSPI/Kerberos/NTLM) via msnodesqlv8. + @Column({ type: 'boolean', nullable: false }) + windowsAuthEnabled = false + + // SQL Server integrated auth only. Encryption mode, optional pinned server certificate, + // and optional SPN override for the Kerberos/Windows (ODBC) connection method. + @Column({ type: 'simple-json', nullable: false }) + sqlServerOptions: SqlServerOptions = {} + // oracle only. @Column({type: 'varchar', nullable: true}) serviceName: Nullable = null @@ -266,6 +300,8 @@ export class SavedConnection extends DbConnectionBase implements IConnection { return this; } + @IsString({ message: 'Name is required' }) + @IsNotEmpty({ message: 'Name is required' }) @Column("varchar") name!: string @@ -298,9 +334,18 @@ export class SavedConnection extends DbConnectionBase implements IConnection { @JoinColumn({ name: 'connectionFolderId' }) connectionFolder?: ConnectionFolder + /** Legacy single-secret field. Read only, so old connections keep working. */ @Column({ type: 'varchar', nullable: true }) vaultSecretName: Nullable = null + /** + * Ordered vault secret refs, most specific first, e.g. + * "Qa Vault:devau--qa-a-sa,Qa Vault:devau,Shared Vault:global". + * Refs only — resolved values are never written here. + */ + @Column({ type: 'varchar', nullable: true }) + vaultSecretRefs: Nullable = null + @Column({type: 'varchar', nullable: true, transformer: [encrypt]}) password: Nullable = null @@ -310,6 +355,12 @@ export class SavedConnection extends DbConnectionBase implements IConnection { @Column({ type: 'varchar', nullable: true, transformer: [encrypt] }) sshPassword: Nullable = null + @Column({ type: 'varchar', nullable: true, transformer: [encrypt] }) + sshBastionPassword: Nullable = null + + @Column({ type: 'varchar', nullable: true, transformer: [encrypt] }) + sshBastionKeyfilePassword: Nullable = null + _sshMode: SshMode = "agent" @Column({ name: "sshMode", type: "varchar", length: "8", nullable: false, default: "agent" }) @@ -388,13 +439,23 @@ export class SavedConnection extends DbConnectionBase implements IConnection { this.connectionType = 'redshift' } - if (parsed.hostname && parsed.hostname.includes('cockroachlabs.cloud')) { + const cockroachOptions = parsedUncoded.params?.options || '' + const hasCockroachJwtOption = + /--crdb:jwt_auth_enabled=true/.test(cockroachOptions) || + /--crdb(?::|%3A)jwt_auth_enabled(?:=|%3D)true/i.test(url) + const hasCockroachClusterOption = + /--cluster=([A-Za-z0-9\-_]+)/.test(cockroachOptions) || + /--cluster(?:=|%3D)[A-Za-z0-9\-_]+/i.test(url) + const hasCockroachProtocol = + ['cockroach', 'cockroachdb'].includes(parsed.protocol as string) + + if ((parsed.hostname && parsed.hostname.includes('cockroachlabs.cloud')) || hasCockroachJwtOption || hasCockroachClusterOption || hasCockroachProtocol) { this.connectionType = 'cockroachdb' - if (parsedUncoded.params?.options) { - // TODO: fix this - const regex = /--cluster=([A-Za-z0-9\-_]+)/ - const clusters = parsedUncoded.params.options.match(regex) - this.options['cluster'] = clusters ? clusters[1] : undefined + const clusterMatch = cockroachOptions.match(/--cluster=([A-Za-z0-9\-_]+)/) + this.options = { + ...this.options, + cluster: clusterMatch ? clusterMatch[1] : undefined, + jwtAuthEnabled: hasCockroachJwtOption, } } @@ -437,6 +498,8 @@ export class SavedConnection extends DbConnectionBase implements IConnection { this.password = null this.sshPassword = null this.sshKeyfilePassword = null + this.sshBastionPassword = null + this.sshBastionKeyfilePassword = null } } diff --git a/apps/studio/src/common/appdb/models/used_connection.ts b/apps/studio/src/common/appdb/models/used_connection.ts index 9d6aab7a0ff..7a12a5b85dd 100644 --- a/apps/studio/src/common/appdb/models/used_connection.ts +++ b/apps/studio/src/common/appdb/models/used_connection.ts @@ -17,6 +17,10 @@ export class UsedConnection extends DbConnectionBase implements ISimpleConnectio this.sshHost = other.sshHost this.sshPort = other.sshPort this.sshBastionHost = other.sshBastionHost + this.sshBastionHostPort = other.sshBastionHostPort + this.sshBastionMode = other.sshBastionMode + this.sshBastionUsername = other.sshBastionUsername + this.sshBastionKeyfile = other.sshBastionKeyfile this.sshKeepaliveInterval = other.sshKeepaliveInterval this.ssl = other.ssl this.sslCaFile = other.sslCaFile @@ -29,6 +33,8 @@ export class UsedConnection extends DbConnectionBase implements ISimpleConnectio } this.options = other.options this.trustServerCertificate = other.trustServerCertificate + this.windowsAuthEnabled = other.windowsAuthEnabled + this.sqlServerOptions = other.sqlServerOptions this.redshiftOptions = other.redshiftOptions this.cassandraOptions = other.cassandraOptions this.socketPath = other.socketPath @@ -41,6 +47,7 @@ export class UsedConnection extends DbConnectionBase implements ISimpleConnectio this.libsqlOptions = other.libsqlOptions this.sqlAnywhereOptions = other.sqlAnywhereOptions this.surrealDbOptions = other.surrealDbOptions + this.dynamoDbOptions = other.dynamoDbOptions } diff --git a/apps/studio/src/common/appdb/models/used_query.ts b/apps/studio/src/common/appdb/models/used_query.ts index b2453f7705e..b495ac09cd9 100644 --- a/apps/studio/src/common/appdb/models/used_query.ts +++ b/apps/studio/src/common/appdb/models/used_query.ts @@ -27,7 +27,7 @@ export class UsedQuery extends ApplicationEntity { status = 'pending' @Column({ type:'bigint', nullable: true}) - numberOfRecords?: BigInt + numberOfRecords?: bigint @Column({ type: 'integer', nullable: false, default: -1 }) workspaceId = -1 diff --git a/apps/studio/src/common/appdb/transformers/Transformers.ts b/apps/studio/src/common/appdb/transformers/Transformers.ts index 764b170c25f..cf353c299e7 100644 --- a/apps/studio/src/common/appdb/transformers/Transformers.ts +++ b/apps/studio/src/common/appdb/transformers/Transformers.ts @@ -1,7 +1,7 @@ import { ValueTransformer } from 'typeorm'; import Encryptor, { SimpleEncryptor } from 'simple-encryptor' import { AzureAuthOptions } from '../models/saved_connection'; -import { SurrealDBOptions } from '@/lib/db/types'; +import { SnowflakeOptions, SurrealDBOptions } from '@/lib/db/types'; import _ from 'lodash' import rawLog from '@bksLogger' @@ -51,6 +51,20 @@ export class SurrealDbEncryptTransformer implements ValueTransformer { } +export class SnowflakeOptionsTransformer implements ValueTransformer { + // add encryption for certain options if needed + to(value: SnowflakeOptions): SnowflakeOptions { + // doesn't make sense to save this as it changes every 30 seconds in authenticator + const newVal = _.cloneDeep(value); + delete newVal.passcode; + return newVal; + } + + from(value: SnowflakeOptions): SnowflakeOptions { + return value; + } +} + export class AzureCredsEncryptTransformer implements ValueTransformer { private encryptor: SimpleEncryptor; diff --git a/apps/studio/src/common/appdb/validators/PreventMovingFolderInsideItself.ts b/apps/studio/src/common/appdb/validators/PreventMovingFolderInsideItself.ts new file mode 100644 index 00000000000..af63fe180c7 --- /dev/null +++ b/apps/studio/src/common/appdb/validators/PreventMovingFolderInsideItself.ts @@ -0,0 +1,56 @@ +import { registerDecorator, ValidationArguments } from "class-validator"; +import { BaseEntity } from "typeorm"; + +/** + * Rejects a parentId that would make a folder its own descendant. + * + * Applied to the parentId column of a self-referencing entity. The table is read + * from the entity's own metadata, so this works for any folder type. + */ +export function PreventMovingFolderInsideItself(object: Object, propertyName: string) { + registerDecorator({ + name: 'preventMovingFolderInsideItself', + async: true, + target: object.constructor, + propertyName: propertyName, + validator: { + async validate(parentId: unknown, args: ValidationArguments) { + const entity = args.object as BaseEntity & { id?: number | null } + + // A row with no id yet can't be anyone's ancestor, and a root has + // nothing above it to collide with. + if (!entity.id || parentId == null) { + return true; + } + + const repository = (entity.constructor as typeof BaseEntity).getRepository(); + // From TypeORM metadata, not user input — safe to interpolate. + const table = repository.metadata.tableName; + + // Walk up from the *pending* parent, not the stored one: validation runs + // before the write, so the row still points at the old parent. + // + // UNION, not UNION ALL: nothing enforced acyclicity before this check + // existed, so an old app.db can still hold a cycle. UNION ALL would + // recurse forever on one and hang the utility process; deduping on id + // terminates instead. + const ancestors = await repository.query( + `WITH RECURSIVE ancestors(id, parentId) AS ( + SELECT id, parentId FROM ${table} WHERE id = ? + UNION + SELECT f.id, f.parentId FROM ${table} f + JOIN ancestors ON f.id = ancestors.parentId + ) + SELECT id FROM ancestors`, + [parentId] + ); + + return !ancestors.some((a: { id: number }) => a.id === entity.id); + }, + defaultMessage(args: ValidationArguments) { + const { name } = args.object as { name?: string }; + return `Cannot move folder "${name}" inside itself.`; + } + } + }) +} diff --git a/apps/studio/src/common/bksConfig/BksConfigProvider.ts b/apps/studio/src/common/bksConfig/BksConfigProvider.ts index 5d3297c7b4b..337a98d5fc7 100644 --- a/apps/studio/src/common/bksConfig/BksConfigProvider.ts +++ b/apps/studio/src/common/bksConfig/BksConfigProvider.ts @@ -17,10 +17,11 @@ export type IniArray = { }; export interface ConfigEntryDetailWarning { - type: "unrecognized-key" | "system-user-conflict"; + type: "unrecognized-key" | "system-user-conflict" | "unknown-allow-plugin" | "deprecated-key"; sourceName: "system" | "user"; section: string; path: string; + value?: string; } type IniValue = string | number | boolean | IniArray | undefined; @@ -29,6 +30,7 @@ export type ConfigValue = IniValue | Record; export type KeybindingPath = DeepKeyOf; +/** A key must be an uppercased string */ type ModifierMap = Record string)>; interface IBksConfigDebugInfo { @@ -136,8 +138,27 @@ const uiModifierMap: ModifierMap = { PAGEDOWN: "PageDown", }; +const contextMenuModifierMap: ModifierMap = { + CTRL: "Control", + CMD: "Control", + CTRLORCMD: "Control", + CMDORCTRL: "Control", + COMMAND: "Control", + CONTROLORCOMMAND: "Control", + COMMANDORCONTROL: "Control", + SHIFT: "Shift", + ALT: "Alt", + OPTION: "Alt", + ALTGR: "AltGraph", + SUPER: "Super", + META: "Meta", + PAGEUP: "PageUp", + PAGEDOWN: "PageDown", + ENTER: "Enter" +} + export function convertKeybinding( - target: KeybindingTarget, + target: Omit, keybinding: string, platform: Platform ): string; @@ -147,7 +168,7 @@ export function convertKeybinding( platform: Platform ): string[]; export function convertKeybinding( - target: "electron" | "v-hotkey" | "codemirror" | "ui", + target: KeybindingTarget, keybinding: string, platform: Platform ): string[] | string { @@ -169,9 +190,14 @@ export function convertKeybinding( case "tabulator": modifierMap = tabulatorModifierMap; joinChar = ' + '; + break; case "ui": modifierMap = uiModifierMap; break; + case "context-menu": + modifierMap = contextMenuModifierMap; + joinChar = '+' + break; default: log.error("Unrecognized target for keybinding conversion: ", target) return; @@ -192,6 +218,10 @@ export function convertKeybinding( if (mod === "ctrlorcmd") { mod = platform === "mac" ? "meta" : "ctrl"; } + + if (mod === "delete" && platform === "mac") { + mod = "backspace"; + } } if (target === "codemirror" && !modifierMap[key]) { @@ -201,8 +231,8 @@ export function convertKeybinding( if (target === "tabulator" && !modifierMap[key]) { mod = mod.toLowerCase(); } - - if (target === "ui" && !modifierMap[key]) { + + if ((target === "ui" || target === "context-menu") && !modifierMap[key]) { mod = _.upperFirst(mod.toLowerCase()); } @@ -217,10 +247,9 @@ export function convertKeybinding( } /** - * Array that is parsed by ini.parse is not exactly an array because - * it doesn't have `length` property. Testing it with `Array.isArray` or - * `_.isArray` will fail. Use this to test it. - */ + * `ini.parse` encodes arrays as objects without a `.length` property. + * This checks whether a value matches that structure. + **/ export function isIniArray(value: any): value is IniArray { return ( _.isObject(value) && @@ -281,12 +310,11 @@ export class BksConfigProvider { } has(path: string): boolean { - return this.userConfig.has(path); + return !_.isNil(_.get(this.mergedConfig, path)); } get(path: string): ConfigValue { - const { value } = this.resolvePath(path); - return value; + return this.resolvePath(path).value; } getAll(): IBksConfig { diff --git a/apps/studio/src/common/bksConfig/ConfigMetadataProvider.ts b/apps/studio/src/common/bksConfig/ConfigMetadataProvider.ts index 6f7614552ee..8f40e0032e8 100644 --- a/apps/studio/src/common/bksConfig/ConfigMetadataProvider.ts +++ b/apps/studio/src/common/bksConfig/ConfigMetadataProvider.ts @@ -1,9 +1,10 @@ import type { IPlatformInfo } from "../IPlatformInfo"; import type { ConfigMetadata, KeybindingSection } from "@/types"; -import type { BksConfig } from "./BksConfigProvider"; +import type { BksConfig, KeybindingPath } from "./BksConfigProvider"; import { convertKeybinding, ConfigValue } from "./BksConfigProvider"; import { InvalidConfigMetadata } from "./errors"; import defaultMetadata from "../../../config-metadata.json"; +import { formatDisplayKeybinding } from "@beekeeperstudio/ui-kit"; /** * Provides UI-specific config functionality that requires metadata. @@ -45,6 +46,19 @@ export class ConfigMetadataProvider { return sections; } + getKeybindingLabel(path: KeybindingPath): string { + const keybindings = this.options.bksConfig.getKeybindings( + "context-menu", + path + ); + + const bindings = Array.isArray(keybindings) + ? keybindings + : [keybindings] + + return bindings.map(formatDisplayKeybinding).join(", "); + } + private parseKeybindingSections( obj: Record, parent: string diff --git a/apps/studio/src/common/bksConfig/mainBksConfig.ts b/apps/studio/src/common/bksConfig/mainBksConfig.ts index 0a34f4c7a03..a66266e9e78 100644 --- a/apps/studio/src/common/bksConfig/mainBksConfig.ts +++ b/apps/studio/src/common/bksConfig/mainBksConfig.ts @@ -10,12 +10,14 @@ import { BksConfigSource, BksConfig, } from "./BksConfigProvider"; +import globals from "@/common/globals"; type ConfigFileName = | "default.config.ini" | "system.config.ini" | "user.config.ini" - | "local.config.ini"; + | "local.config.ini" + | "deprecated.config.ini"; const log = rawLog.scope("BksConfig"); @@ -26,6 +28,7 @@ const log = rawLog.scope("BksConfig"); export function checkUnrecognized( defaultConfig: IBksConfig, newConfig: Partial, + deprecated: Partial, sourceName: "system" | "user" ): ConfigEntryDetailWarning[] { const results: ConfigEntryDetailWarning[] = []; @@ -39,7 +42,7 @@ export function checkUnrecognized( continue; } - const unrecognized = !_.has(defaultConfig, path); + const unrecognized = !_.has(defaultConfig, path) && !_.has(deprecated, path); const value = obj[key]; if (unrecognized) { @@ -50,7 +53,7 @@ export function checkUnrecognized( section, path, }); - } else if (typeof value === "object") { + } else if (typeof value === "object" && !Array.isArray(value)) { traverse(value, path); } } @@ -58,6 +61,23 @@ export function checkUnrecognized( traverse(newConfig); + // Validate that pluginSystem.allow only contains known bundled plugin IDs + const allow = _.get(newConfig, "pluginSystem.allow") as string[] | undefined; + if (Array.isArray(allow)) { + const bundledPluginIds = globals.plugins.ensureInstalled.map((p) => p.id); + for (const id of allow) { + if (!bundledPluginIds.includes(id)) { + results.push({ + type: "unknown-allow-plugin", + sourceName, + section: "pluginSystem", + path: "pluginSystem.allow", + value: id, + }); + } + } + } + return results; } @@ -73,7 +93,7 @@ export function checkConflicts( for (const key of Object.keys(obj)) { const path = parentPath ? `${parentPath}.${key}` : key; const value = obj[key]; - if (typeof value === "object") { + if (typeof value === "object" && !Array.isArray(value)) { traverse(value, path); } else if (_.has(target, path)) { results.push({ @@ -91,6 +111,36 @@ export function checkConflicts( return results; } +export function checkDeprecations( + config: Partial, + deprecations: Partial, + sourceName: "system" | "user" +): ConfigEntryDetailWarning[] { + const results: ConfigEntryDetailWarning[] = []; + + function traverse(obj: Record, parentPath = "") { + for (const key of Object.keys(obj)) { + const path = parentPath ? `${parentPath}.${key}` : key; + const value = obj[key]; + if (typeof value === "object" && !Array.isArray(value)) { + traverse(value, path); + } else if (_.has(config, path)) { + results.push({ + type: "deprecated-key", + sourceName, + section: parentPath, + path, + value + }); + } + } + } + + traverse(deprecations); + + return results; +} + const bundledConfigPath = path.join(process.resourcesPath); function copyBundledConfig(file: ConfigFileName, dest: string) { @@ -137,10 +187,11 @@ export function loadConfig(file: ConfigFileName): IBksConfig | Partial, - userConfig: Partial + userConfig: Partial, + deprecatedConfig: Partial ) { const systemConfigWarnings = checkUnrecognized( defaultConfig, systemConfig, + deprecatedConfig, "system" ); const userConfigWarnings = checkUnrecognized( defaultConfig, userConfig, + deprecatedConfig, "user" ); const systemUserConflicts = checkConflicts(userConfig, systemConfig, "user"); + const userDeprecations = checkDeprecations(userConfig, deprecatedConfig, "user"); + const systemDeprecations = checkDeprecations(systemConfig, deprecatedConfig, "system"); + const warnings = systemConfigWarnings.concat( userConfigWarnings, - systemUserConflicts + systemUserConflicts, + userDeprecations, + systemDeprecations ); return warnings; } @@ -222,6 +285,7 @@ export function mainBksConfig(): BksConfig { const defaultConfig: IBksConfig = loadConfig("default.config.ini"); const systemConfig: Partial = loadConfig("system.config.ini"); + const deprecatedConfig: Partial = loadConfig("deprecated.config.ini"); let userConfig: Partial = {}; try { userConfig = loadConfig( @@ -234,7 +298,8 @@ export function mainBksConfig(): BksConfig { const warnings = collectConfigWarnings( defaultConfig, systemConfig, - userConfig + userConfig, + deprecatedConfig ); const source: BksConfigSource = { defaultConfig, diff --git a/apps/studio/src/common/globals.ts b/apps/studio/src/common/globals.ts index 76e3c94de03..55993e82953 100644 --- a/apps/studio/src/common/globals.ts +++ b/apps/studio/src/common/globals.ts @@ -6,10 +6,6 @@ export default { psqlTimeout: 15000, // 15 seconds psqlIdleTimeout: 20000, defaultChunkSize: 100, - largeFieldWidth: 300, - maxColumnWidth: 1000, - minColumnWidth: 100, - maxInitialWidth: 500, maxDetailViewTextLength: 30, bigTableColumnWidth: 125, maxColumnWidthTableInfo: 300, @@ -37,8 +33,8 @@ export default { * @see `BundledPluginModule` in src-commercial/backend/plugin-system/modules/BundledPluginModule.ts **/ ensureInstalled: [ - "@beekeeperstudio/bks-ai-shell", - "@beekeeperstudio/bks-er-diagram", + { id: "bks-ai-shell", pkg: "@beekeeperstudio/bks-ai-shell" }, + { id: "bks-er-diagram", pkg: "@beekeeperstudio/bks-er-diagram" }, ], } } diff --git a/apps/studio/src/common/interfaces/IAccessGrant.ts b/apps/studio/src/common/interfaces/IAccessGrant.ts new file mode 100644 index 00000000000..dd11f7ff745 --- /dev/null +++ b/apps/studio/src/common/interfaces/IAccessGrant.ts @@ -0,0 +1,9 @@ +import { IMembership } from "./IMembership"; + +export interface IAccessGrant { + id: number | null; + membershipId: number; + membership: IMembership; + canRead: boolean; + canWrite: boolean; +} diff --git a/apps/studio/src/common/interfaces/IConnection.ts b/apps/studio/src/common/interfaces/IConnection.ts index d5ff55b604c..abafd8fbc15 100644 --- a/apps/studio/src/common/interfaces/IConnection.ts +++ b/apps/studio/src/common/interfaces/IConnection.ts @@ -1,5 +1,7 @@ -import { AzureAuthOptions, BigQueryOptions, CassandraOptions, LibSQLOptions, RedshiftOptions, ConnectionType, SQLAnywhereOptions, IamAuthOptions, SurrealDBOptions } from "@/lib/db/types" +import { AzureAuthOptions, BigQueryOptions, CassandraOptions, DynamoDBOptions, LibSQLOptions, RedshiftOptions, ConnectionType, SQLAnywhereOptions, IamAuthOptions, SurrealDBOptions, SnowflakeOptions, SqlServerOptions } from "@/lib/db/types" import { Transport } from "../transport" +import { IShareable } from "./IShareable" +import { IAccessGrant } from "./IAccessGrant" export type SshMode = null | 'agent' | 'userpass' | 'keyfile' @@ -14,7 +16,9 @@ export function isUltimateType(s: ConnectionType) { 'mongodb', 'sqlanywhere', 'trino', - 'surrealdb' + 'surrealdb', + 'dynamodb', + 'snowflake' ] return types.includes(s) } @@ -38,6 +42,10 @@ export interface ISimpleConnection extends Transport { sshKeyfile: Nullable sshUsername: Nullable sshBastionHost: Nullable + sshBastionHostPort: Nullable + sshBastionMode: SshMode + sshBastionUsername: Nullable + sshBastionKeyfile: Nullable sshKeepaliveInterval: Nullable ssl: boolean sslCaFile: Nullable @@ -47,6 +55,8 @@ export interface ISimpleConnection extends Transport { readOnlyMode: boolean labelColor?: Nullable trustServerCertificate?: boolean + windowsAuthEnabled?: boolean + sqlServerOptions?: SqlServerOptions serviceName: Nullable options?: any redshiftOptions?: RedshiftOptions @@ -58,6 +68,8 @@ export interface ISimpleConnection extends Transport { libsqlOptions?: LibSQLOptions sqlAnywhereOptions?: SQLAnywhereOptions surrealDbOptions?: SurrealDBOptions + dynamoDbOptions?: DynamoDBOptions + snowflakeOptions?: SnowflakeOptions connectionFolderId?: Nullable position?: number } @@ -69,10 +81,14 @@ export interface IConnection extends ISimpleConnection { password: Nullable sshPassword: Nullable sshKeyfilePassword: Nullable + sshBastionPassword: Nullable + sshBastionKeyfilePassword: Nullable vaultSecretName?: Nullable + vaultSecretRefs?: Nullable } -export interface ICloudSavedConnection extends IConnection { +export interface ICloudSavedConnection extends IConnection, IShareable { userSpecificCredentials: boolean userSpecificPaths: boolean + accessGrants?: IAccessGrant[] } diff --git a/apps/studio/src/common/interfaces/IDirectoryImportStats.ts b/apps/studio/src/common/interfaces/IDirectoryImportStats.ts new file mode 100644 index 00000000000..f5775b2fc81 --- /dev/null +++ b/apps/studio/src/common/interfaces/IDirectoryImportStats.ts @@ -0,0 +1,6 @@ +export interface IDirectoryImportStats { + warnings: string[]; + directories: number; + queries: number; +} + diff --git a/apps/studio/src/common/interfaces/IMembership.ts b/apps/studio/src/common/interfaces/IMembership.ts new file mode 100644 index 00000000000..020325f4315 --- /dev/null +++ b/apps/studio/src/common/interfaces/IMembership.ts @@ -0,0 +1,8 @@ +export interface IMembership { + id: number; + workspaceId: number; + userId: number; + email: string; + name: string; +} + diff --git a/apps/studio/src/common/interfaces/IMenuActionHandler.ts b/apps/studio/src/common/interfaces/IMenuActionHandler.ts index cb85587aa13..e5d22344cc0 100644 --- a/apps/studio/src/common/interfaces/IMenuActionHandler.ts +++ b/apps/studio/src/common/interfaces/IMenuActionHandler.ts @@ -9,12 +9,14 @@ type ElectronWindow = Electron.BrowserWindow | undefined export interface IMenuActionHandler { togglePrimarySidebar: (menuItem: Electron.MenuItem, browserWindow: ElectronWindow) => void toggleSecondarySidebar: (menuItem: Electron.MenuItem, browserWindow: ElectronWindow) => void + togglePrivacyMode: (menuItem: Electron.MenuItem, browserWindow: ElectronWindow) => void quit: (menuItem: Electron.MenuItem, win: ElectronWindow) => void undo: (menuItem: Electron.MenuItem, win: ElectronWindow) => void redo: (menuItem: Electron.MenuItem, win: ElectronWindow) => void cut: (menuItem: Electron.MenuItem, win: ElectronWindow) => void copy: (menuItem: Electron.MenuItem, win: ElectronWindow) => void paste: (menuItem: Electron.MenuItem, win: ElectronWindow) => void + pasteAsNewRows: (menuItem: Electron.MenuItem, win: ElectronWindow) => void selectAll?: (menuItem: Electron.MenuItem, win: ElectronWindow) => void zoomreset: (menuItem: Electron.MenuItem, win: ElectronWindow) => void zoomin: (menuItem: Electron.MenuItem, win: ElectronWindow) => void @@ -28,6 +30,7 @@ export interface IMenuActionHandler { restart: (menuItem: Electron.MenuItem, win: ElectronWindow) => void opendocs: (menuItem: Electron.MenuItem, win: ElectronWindow) => void contactSupport: (menuItem: Electron.MenuItem, win: ElectronWindow) => void + openGettingStarted: (menuItem: Electron.MenuItem, win: ElectronWindow) => void newWindow: (menuItem: Electron.MenuItem, win: ElectronWindow) => void newQuery: (menuItem: Electron.MenuItem, win: ElectronWindow) => void newTab: (menuItem: Electron.MenuItem, win: ElectronWindow) => void @@ -46,6 +49,7 @@ export interface IMenuActionHandler { importSqlFiles: (menuItem: Electron.MenuItem, win: ElectronWindow) => void toggleMinimalMode: (menuItem: Electron.MenuItem, win: ElectronWindow) => void switchLicenseState: (menuItem: Electron.MenuItem, win: ElectronWindow, state: DevLicenseState) => void + simulatePlatform: (menuItem: Electron.MenuItem, win: ElectronWindow, platform: string) => void toggleBeta: (menuItem: Electron.MenuItem, win: ElectronWindow) => void managePlugins: (menuItem: Electron.MenuItem, win: ElectronWindow) => void updatePin: (menuItem: Electron.MenuItem, win: ElectronWindow) => void diff --git a/apps/studio/src/common/interfaces/IQueryAudit.ts b/apps/studio/src/common/interfaces/IQueryAudit.ts new file mode 100644 index 00000000000..9dd5b5421aa --- /dev/null +++ b/apps/studio/src/common/interfaces/IQueryAudit.ts @@ -0,0 +1,15 @@ +import { + TransportQueryAudit, + TransportQueryAuditDetail, +} from "@/common/transport/TransportQueryAudit"; + +export type IQueryAudit = TransportQueryAudit & { + /** NOTE: `user` can contain nothing! */ + user: { + id: number; + name: string; + email: string; + } | {}; +}; + +export type IQueryAuditDetail = TransportQueryAuditDetail; diff --git a/apps/studio/src/common/interfaces/IQueryFolder.ts b/apps/studio/src/common/interfaces/IQueryFolder.ts index d368d62dcda..8acac23aea0 100644 --- a/apps/studio/src/common/interfaces/IQueryFolder.ts +++ b/apps/studio/src/common/interfaces/IQueryFolder.ts @@ -1,15 +1,19 @@ +import { IShareable } from "./IShareable" +import { IAccessGrant } from "./IAccessGrant" +import { Transport } from "../transport" - -export interface IFolder { +export interface IFolder extends IShareable, Transport { id: number | null name: string - expanded?: boolean - parentId?: number | null + parentId: number | null description?: string | null - createdAt?: Date - updatedAt?: Date + accessGrants?: IAccessGrant[] + /** Is it a personal folder? */ + personal: boolean; + /** A default folder is made by the system */ + default: boolean; } export type IQueryFolder = IFolder -export type IConnectionFolder = IFolder \ No newline at end of file +export type IConnectionFolder = IFolder diff --git a/apps/studio/src/common/interfaces/ISavedQuery.ts b/apps/studio/src/common/interfaces/ISavedQuery.ts index 0ba2ec4662a..03ef19d1e6c 100644 --- a/apps/studio/src/common/interfaces/ISavedQuery.ts +++ b/apps/studio/src/common/interfaces/ISavedQuery.ts @@ -1,6 +1,6 @@ +import { IShareable } from "./IShareable" - -export default interface ISavedQuery { +export default interface ISavedQuery extends IShareable { id: number | null title: string // same as title, damn you title @@ -11,9 +11,4 @@ export default interface ISavedQuery { position?: number createdAt: Date | number | null updatedAt: Date | null - user?: { - id: number - name: string - } - } diff --git a/apps/studio/src/common/interfaces/IShareable.ts b/apps/studio/src/common/interfaces/IShareable.ts new file mode 100644 index 00000000000..15119dd1261 --- /dev/null +++ b/apps/studio/src/common/interfaces/IShareable.ts @@ -0,0 +1,19 @@ +import { IAccessGrant } from "./IAccessGrant"; +import { IMembership } from "./IMembership"; + +export interface IShareable { + id: number | null; + /** Can my team read this? */ + teamRead: boolean; + /** Can my team write this? */ + teamWrite: boolean; + /** Can I read this? */ + canRead: boolean; + /** Can I write this? */ + canWrite: boolean; + /** Can I manage the share settings? */ + canManage: boolean; + /** The user who created this */ + membership: IMembership; + accessGrants?: IAccessGrant[]; +} diff --git a/apps/studio/src/common/interfaces/IWorkspace.ts b/apps/studio/src/common/interfaces/IWorkspace.ts index 5b52f46d93b..f8823cc2030 100644 --- a/apps/studio/src/common/interfaces/IWorkspace.ts +++ b/apps/studio/src/common/interfaces/IWorkspace.ts @@ -1,4 +1,4 @@ - +import { IMembership } from "./IMembership" export interface IWorkspace { id: number @@ -11,6 +11,7 @@ export interface IWorkspace { active: boolean isOwner?: boolean level: string + currentMembership: IMembership; } export const LocalWorkspace: IWorkspace = { @@ -20,6 +21,12 @@ export const LocalWorkspace: IWorkspace = { type: 'local', name: 'Local Workspace', icon: 'laptop', - active: true - -} \ No newline at end of file + active: true, + currentMembership: { + id: -1, + workspaceId: -1, + userId: -1, + name: "", + email: "", + }, +} diff --git a/apps/studio/src/common/menus/MenuBuilder.ts b/apps/studio/src/common/menus/MenuBuilder.ts index 93b49c21bd6..44994773a19 100644 --- a/apps/studio/src/common/menus/MenuBuilder.ts +++ b/apps/studio/src/common/menus/MenuBuilder.ts @@ -11,6 +11,7 @@ export default class extends DefaultMenu { viewMenu(): Electron.MenuItemConstructorOptions { const result: Electron.MenuItemConstructorOptions = { label: 'View', + role: 'viewMenu', submenu: [ this.menuItems.zoomreset, this.menuItems.zoomin, @@ -22,11 +23,14 @@ export default class extends DefaultMenu { this.menuItems.editorFontSizeIncrease, this.menuItems.editorFontSizeDecrease, { type: 'separator' }, - this.menuItems.fullscreen, - this.menuItems.themeToggle, this.menuItems.primarySidebarToggle, this.menuItems.secondarySidebarToggle, + { type: 'separator' }, + this.menuItems.themeToggle, this.menuItems.reload, + // This is added automatically in Mac + ...(!this.platformInfo.isMac ? [this.menuItems.fullscreen] : []), + this.menuItems.privacyModeToggle // Disable this for now in favor of #2380 // this.menuItems.minimalModeToggle, ] @@ -40,25 +44,32 @@ export default class extends DefaultMenu { label: 'Dev', submenu: [ this.menuItems.reload, + this.menuItems.simulatePlatform, this.menuItems.licenseState, ], } } helpMenu() { - const helpMenu = { + const helpMenu: Electron.MenuItemConstructorOptions = { id: "help", label: "Help", + role: "help", submenu: [ this.menuItems.keyboardShortcuts, - this.menuItems.enterLicense, - this.menuItems.checkForUpdate, this.menuItems.opendocs, this.menuItems.support, + this.menuItems.gettingStartedGuide, + { type: 'separator' }, this.menuItems.addBeekeeper, this.menuItems.devtools, - this.menuItems.about, + // Moved to Beekeeper Studio menu for mac + ...(!this.platformInfo.isMac ? [this.menuItems.checkForUpdate] : []), this.menuItems.restart, + { type: 'separator' }, + // Moved to Beekeeper Studio menu for mac + ...(!this.platformInfo.isMac ? [this.menuItems.about] : []), + this.menuItems.enterLicense, ] }; @@ -74,28 +85,36 @@ export default class extends DefaultMenu { if (this.platformInfo.isMac) { appMenu.push({ label: "SqlWolf", + role: "appMenu", submenu: [ this.menuItems.about, + this.menuItems.checkForUpdate, + { type: 'separator' }, { role: 'services' }, + { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, + { type: 'separator' }, { role: 'quit' } ] }) } - const fileMenu = { + const fileMenu: Electron.MenuItemConstructorOptions = { id: 'file', label: 'File', + role: 'fileMenu', submenu: [ this.menuItems.newWindow, this.menuItems.newTab, this.menuItems.closeTab, + { type: 'separator' }, this.menuItems.importSqlFiles, this.menuItems.quickSearch, this.menuItems.disconnect, - this.menuItems.quit + // Moved to Beekeeper Studio menu for mac + ...(!this.platformInfo.isMac ? [this.menuItems.quit] : []), ] } @@ -113,15 +132,18 @@ export default class extends DefaultMenu { { id: 'edit', label: 'Edit', + role: 'editMenu', submenu: [ this.menuItems.undo, this.menuItems.redo, + { type: 'separator' }, this.menuItems.cut, this.menuItems.copy, this.menuItems.paste, + this.menuItems.pasteAsNewRows, this.menuItems.selectAll, ] - }, + } as Electron.MenuItemConstructorOptions, this.viewMenu(), { id: "tools", @@ -130,8 +152,9 @@ export default class extends DefaultMenu { this.menuItems.backupDatabase, this.menuItems.restoreDatabase, this.menuItems.exportTables, + ...(this.bksConfig.security.lockMode === "pin" ? [this.menuItems.updatePin] : []), + { type: 'separator' }, this.menuItems.managePlugins, - ...(this.bksConfig.security.lockMode === "pin" ? [this.menuItems.updatePin] : []) ] }, ...windowMenu, diff --git a/apps/studio/src/common/menus/MenuItems.ts b/apps/studio/src/common/menus/MenuItems.ts index 9bed6bb6c7c..690173ac9f6 100644 --- a/apps/studio/src/common/menus/MenuItems.ts +++ b/apps/studio/src/common/menus/MenuItems.ts @@ -22,43 +22,63 @@ export function menuItems(actionHandler: IMenuActionHandler, settings: IGroupedU undo: { id: 'undo', label: "Undo", + // Displayed only — the focused editor (CodeMirror, text inputs) + // handles the shortcut itself. Registering it would fire an extra + // webContents.undo() per keypress, undoing 2-3 steps at once. accelerator: "CommandOrControl+Z", - click: actionHandler.undo + click: actionHandler.undo, + registerAccelerator: false, + role: 'undo', }, redo: { id: "redo", label: "Redo", accelerator: platformInfo.isWindows ? 'Ctrl+Y' : 'Shift+CommandOrControl+Z', - click: actionHandler.redo + click: actionHandler.redo, + registerAccelerator: false, + role: 'redo', }, cut: { id: 'cut', label: 'Cut', accelerator: 'CommandOrControl+X', click: actionHandler.cut, - registerAccelerator: false - + registerAccelerator: false, + role: 'cut', }, copy: { id: 'copy', label: 'Copy', accelerator: 'CommandOrControl+C', click: actionHandler.copy, - registerAccelerator: false + registerAccelerator: false, + role: 'copy', }, paste: { id: 'paste', label: 'Paste', accelerator: 'CommandOrControl+V', click: actionHandler.paste, - registerAccelerator: false + registerAccelerator: false, + role: 'paste', + }, + pasteAsNewRows: { + id: 'paste-as-new-rows', + label: 'Paste as new rows', + // Displayed only — the shortcut is handled by the table grid's own + // keymap so it stays scoped to the table and doesn't fire elsewhere + // (e.g. plain-text paste in the query editor). + accelerator: 'CommandOrControl+Shift+V', + registerAccelerator: false, + click: actionHandler.pasteAsNewRows, }, selectAll: { id: 'select-all', label: 'Select All', accelerator: 'CommandOrControl+A', - click: actionHandler.selectAll + click: actionHandler.selectAll, + role: 'selectAll', }, // view zoomreset: { @@ -120,7 +140,8 @@ export function menuItems(actionHandler: IMenuActionHandler, settings: IGroupedU about: { id: 'about', label: 'About SqlWolf', - click: actionHandler.about + click: actionHandler.about, + role: 'about', }, devtools: { id: 'dev-tools', @@ -148,6 +169,11 @@ export function menuItems(actionHandler: IMenuActionHandler, settings: IGroupedU label: 'Contact Support', click: actionHandler.contactSupport }, + gettingStartedGuide: { + id: 'gettingStartedGuide', + label: 'Getting Started Guide', + click: actionHandler.openGettingStarted + }, reload: { id: 'reload-window', label: "Reload Window", @@ -217,6 +243,12 @@ export function menuItems(actionHandler: IMenuActionHandler, settings: IGroupedU click: actionHandler.toggleSecondarySidebar, enabled: false, }, + privacyModeToggle: { + id: 'privacy-mode-toggle', + label: 'Toggle Privacy Mode', + click: actionHandler.togglePrivacyMode, + checked: settings?.privacyMode?.value + }, themeToggle: { id: "theme-toggle-menu", label: "Theme", @@ -287,6 +319,28 @@ export function menuItems(actionHandler: IMenuActionHandler, settings: IGroupedU label: "Toggle Minimal Mode", click: actionHandler.toggleMinimalMode, }, + simulatePlatform: { + id: "simulate-platform", + label: "DEV Simulate Platform", + submenu: [ + { + type: 'radio', + label: "None (use real platform)", + checked: true, + click: (item, win) => actionHandler.simulatePlatform(item, win, 'none'), + }, + { + type: 'radio', + label: "Snap", + click: (item, win) => actionHandler.simulatePlatform(item, win, 'snap'), + }, + { + type: 'radio', + label: "Flatpak", + click: (item, win) => actionHandler.simulatePlatform(item, win, 'flatpak'), + }, + ], + }, licenseState: { id: "license-state", label: "DEV Switch License State", diff --git a/apps/studio/src/common/platformWarnings.ts b/apps/studio/src/common/platformWarnings.ts new file mode 100644 index 00000000000..2128a84baf1 --- /dev/null +++ b/apps/studio/src/common/platformWarnings.ts @@ -0,0 +1,48 @@ +export interface PlatformWarning { + /** Key on $config to check (e.g. 'isSnap', 'isFlatpak') */ + configKey: string + /** Optional $config key that must be falsy for the warning to show */ + unless?: string + message: string + link?: string + linkText?: string +} + +/** + * Platform-specific warnings keyed by location in the UI. + * The PlatformWarning component filters these at render time + * based on the current platform config. + */ +export const platformWarnings: Record = { + 'database-file': [ + { + configKey: 'isSnap', + message: 'Snap packages have limited file access. To use a database on an external drive you\'ll need to grant extra permissions.', + link: 'https://docs.beekeeperstudio.io/support/troubleshooting/#i-get-permission-denied-when-trying-to-access-a-database-on-an-external-drive', + linkText: 'Learn more', + }, + { + configKey: 'isFlatpak', + message: 'Flatpak apps can only access files in your home directory by default.', + link: 'https://docs.beekeeperstudio.io/installation/linux/#flatpak', + linkText: 'Learn more', + }, + ], + 'ssh-agent': [ + { + configKey: 'isSnap', + message: 'SSH Agent Forwarding is not available in the Snap version of Beekeeper Studio due to the Snap security model.', + link: 'https://docs.beekeeperstudio.io/installation/linux/#ssh-key-access-for-the-snap', + linkText: 'Learn more', + }, + ], + 'ssh-keyfile': [ + { + configKey: 'isSnap', + unless: 'snapSshPlug', + message: 'Snap packages don\'t have access to your .ssh directory by default. You\'ll need to enable SSH access and restart Beekeeper.', + link: 'https://docs.beekeeperstudio.io/installation/linux/#ssh-key-access-for-the-snap', + linkText: 'Learn more', + }, + ], +} diff --git a/apps/studio/src/common/platform_info/mainPlatformInfo.ts b/apps/studio/src/common/platform_info/mainPlatformInfo.ts index 067703967eb..540cfc912c4 100644 --- a/apps/studio/src/common/platform_info/mainPlatformInfo.ts +++ b/apps/studio/src/common/platform_info/mainPlatformInfo.ts @@ -1,9 +1,11 @@ import yargs from 'yargs-parser' import _ from 'lodash' +import { existsSync } from 'fs' import { resolve, join } from 'path' import { IPlatformInfo } from '../IPlatformInfo' import { BksVersion } from '@/lib/license' + // TODO: Automatically enable wayland without flags once // we're confident it will 'just work' for all Wayland users. const p = process @@ -28,6 +30,15 @@ export function resolveAppVersion(appVersion): BksVersion { } +const VALID = ['error', 'warn', 'info', 'verbose', 'debug', 'silly']; + +export function resolveLevel(env: any, isDev = false) { + const override = env.BKS_LOG_LEVEL?.toLowerCase() || undefined; + if (override && (VALID as string[]).includes(override)) return override; + if (env.NODE_ENV === 'development' || env.DEBUG || isDev) return 'silly'; + return 'warn'; +} + export function mainPlatformInfo(): IPlatformInfo { @@ -60,6 +71,7 @@ export function mainPlatformInfo(): IPlatformInfo { userDirectory = join(p.env.PORTABLE_EXECUTABLE_DIR, 'beekeeper_studio_data') } const pluginsDirectory = join(userDirectory, 'plugins') + const driverDepsDirectory = join(userDirectory, 'driver-deps') const sessionType = p.env.XDG_SESSION_TYPE @@ -79,10 +91,16 @@ export function mainPlatformInfo(): IPlatformInfo { sessionType, isWayland: isWaylandMode(), isSnap: p.env.ELECTRON_SNAP, + isFlatpak: !!p.env.FLATPAK_ID || existsSync('/.flatpak-info'), isPortable: isWindows && p.env.PORTABLE_EXECUTABLE_DIR, isDevelopment: isDevEnv, - isAppImage: p.env.DESKTOPINTEGRATION === 'AppImageLauncher', + isAppImage: !!process.env.APPIMAGE, + isAppImageLauncher: process.env.DESKTOPINTEGRATION === 'AppImageLauncher', sshAuthSock: p.env.SSH_AUTH_SOCK, + sshConfigExists: existsSync(join(homeDirectory, '.ssh', 'config')), + defaultSshIdentityFile: ['id_ed25519', 'id_ecdsa', 'id_rsa', 'id_dsa'] + .map((name) => join(homeDirectory, '.ssh', name)) + .find((path) => existsSync(path)) || '', environment: p.env.NODE_ENV, resourcesPath, env: { @@ -98,6 +116,7 @@ export function mainPlatformInfo(): IPlatformInfo { downloadsDirectory, homeDirectory, pluginsDirectory, + driverDepsDirectory, testMode, appDbPath: join(userDirectory, isDevEnv ? 'app-dev.db' : 'app.db'), updatesDisabled, @@ -106,6 +125,10 @@ export function mainPlatformInfo(): IPlatformInfo { // cloudUrl: isDevEnv ? 'https://staging.beekeeperstudio.io' : 'https://app.beekeeperstudio.io', // cloudUrl: 'https://app.beekeeperstudio.io', locale, + // Resolved here once so main, utility, and renderer all read the same + // value: main consumes platformInfo directly, utility receives it as a + // JSON env var when forked, renderer fetches it over IPC. + logLevel: resolveLevel(p.env, isDevEnv), cloudUrl: isDevEnv ? 'http://localhost:3000' : 'https://app.beekeeperstudio.io' } diff --git a/apps/studio/src/common/platform_info/utilityPlatformInfo.ts b/apps/studio/src/common/platform_info/utilityPlatformInfo.ts index 3b16b10935f..35d164111e7 100644 --- a/apps/studio/src/common/platform_info/utilityPlatformInfo.ts +++ b/apps/studio/src/common/platform_info/utilityPlatformInfo.ts @@ -1,11 +1,7 @@ import { IPlatformInfo } from "../IPlatformInfo"; -import rawLog from '@bksLogger' -const log = rawLog.scope('utilityPlatformInfo') - -// why build it again from stratch? We just get it from the environment, thanks main process! +// The utility process gets the resolved platformInfo (including logLevel) +// from main as a JSON env var when it's forked. No rebuilding needed. export function utilityPlatformInfo(): IPlatformInfo { - const result = JSON.parse(process.env.bksPlatformInfo) - log.info(result) - return result + return JSON.parse(process.env.bksPlatformInfo) } diff --git a/apps/studio/src/common/tabulator.ts b/apps/studio/src/common/tabulator.ts index 113532a90e8..e744bdd619d 100644 --- a/apps/studio/src/common/tabulator.ts +++ b/apps/studio/src/common/tabulator.ts @@ -12,6 +12,9 @@ import { } from "@/lib/menu/tableMenu"; import { rowHeaderField } from "@/common/utils"; import _ from "lodash"; +import rawLog from "@bksLogger"; + +const log = rawLog.scope("common/tabulator"); interface Options extends TabulatorOptions { table?: string; @@ -36,6 +39,13 @@ export function tabulatorForTableData( columns: ["width", "visible"], }, persistenceMode: "local", + persistenceWriterFunc: (id: string, type: string, data: unknown) => { + try { + localStorage.setItem(`${id}-${type}`, JSON.stringify(data)); + } catch (e) { + log.warn(e); + } + }, renderHorizontal: "virtual", autoResize: false, nestedFieldSeparator: false, @@ -47,7 +57,9 @@ export function tabulatorForTableData( resizableColumnGuide: true, movableColumns: true, height: "100%", - editTriggerEvent: "dblclick", + editTriggerEvent: window.bksConfig.ui.tableTable.editTrigger === "click" + ? "click" + : "dblclick", debugInvalidComponentFuncs: false, history: true, keybindings: { @@ -91,10 +103,11 @@ export function tabulatorForTableData( }; const mergedOptions = _.merge(defaultOptions, tabulatorOptions); const tabulator = new TabulatorFull(el, mergedOptions); + if (options.onRangeChange) { - function onRangeChange() { + const onRangeChange = () => { options.onRangeChange(tabulator.getRanges()); - } + }; tabulator.on("cellMouseUp", onRangeChange); tabulator.on("headerMouseUp", onRangeChange); tabulator.on( diff --git a/apps/studio/src/common/transport/TransportOpenTab.ts b/apps/studio/src/common/transport/TransportOpenTab.ts index 984f09d919c..a3fb8624e43 100644 --- a/apps/studio/src/common/transport/TransportOpenTab.ts +++ b/apps/studio/src/common/transport/TransportOpenTab.ts @@ -138,6 +138,30 @@ export function duplicate(obj: TransportOpenTab): TransportOpenTab { return result; } +/** + * Decide what the query editor should show when a tab is (re)opened. + * - originalText: the saved baseline used for dirty-comparison / discard. + * - editorText: what to load into the editor — the auto-saved in-progress + * edits when the tab was left dirty, otherwise the baseline. + * + * `savedText` is the text of the linked saved query (FavoriteQuery / cloud + * query), or null/undefined for a query that has never been saved. + */ +export function resolveEditorText( + obj: Pick, + savedText?: string | null +): { originalText: string | null; editorText: string | null } { + const baselineText = savedText || obj.unsavedQueryText || null + // When the tab was left dirty, the auto-saved edits live in unsavedQueryText. + // Restore those into the editor while keeping the saved text as the baseline, + // so the dirty indicator shows and discarding reverts to the saved version. + const editorText = + obj.unsavedChanges && obj.unsavedQueryText != null + ? obj.unsavedQueryText + : baselineText + return { originalText: baselineText, editorText } +} + export function findTable(obj: TransportOpenTab, tables: TableOrView[]): TableOrView | null { const result = tables.find((t) => { return obj.tableName === t.name && @@ -172,8 +196,8 @@ export function matches(obj: TransportOpenTab, other: TransportOpenTab): boolean // at a time. return obj.tabType === 'import-export-database' case 'query': - return (obj.queryId === other.queryId && obj.queryId !== null && other.queryId !== null) || - (obj.usedQueryId === other.usedQueryId && obj.usedQueryId !== null && other.queryId !== null) + return (obj.queryId === other.queryId && !_.isNil(obj.queryId) && !_.isNil(other.queryId)) || + (obj.usedQueryId === other.usedQueryId && !_.isNil(obj.usedQueryId) && !_.isNil(other.usedQueryId)) case 'backup': return obj.tabType === 'backup'; case 'restore': diff --git a/apps/studio/src/common/transport/TransportQueryAudit.ts b/apps/studio/src/common/transport/TransportQueryAudit.ts new file mode 100644 index 00000000000..cf34d0c3253 --- /dev/null +++ b/apps/studio/src/common/transport/TransportQueryAudit.ts @@ -0,0 +1,16 @@ +import { Transport } from "."; + +export interface TransportQueryAudit extends Transport { + action: "create" | "update"; + createdAt: Date; + /** `null` if title has not changed. */ + title: string | null; +} + +export interface TransportQueryAuditDetail extends TransportQueryAudit { + previousAuditId: number | null; + values: { + title: string; + text: string; + }; +} diff --git a/apps/studio/src/common/transport/TransportTabulatorPersistence.ts b/apps/studio/src/common/transport/TransportTabulatorPersistence.ts new file mode 100644 index 00000000000..7ede4cf76de --- /dev/null +++ b/apps/studio/src/common/transport/TransportTabulatorPersistence.ts @@ -0,0 +1,7 @@ +import { Transport } from "."; + +export interface TransportTabulatorPersistence extends Transport { + persistenceID: string; + type: string; + data: string; +} diff --git a/apps/studio/src/common/transport/index.ts b/apps/studio/src/common/transport/index.ts index 584787fbec6..d5161586442 100644 --- a/apps/studio/src/common/transport/index.ts +++ b/apps/studio/src/common/transport/index.ts @@ -28,6 +28,7 @@ export interface TransportLicenseKey extends Transport { licenseType: 'TrialLicense' | 'PersonalLicense' | 'BusinessLicense', active: boolean maxAllowedAppRelease: { tagName: string } | null + invalidatedAt: Date | null } export interface TransportPinnedConn extends Transport { @@ -67,7 +68,7 @@ export interface TransportUsedQuery extends Transport { database: string; connectionHash: string; status: string; - numberOfRecords?: BigInt; + numberOfRecords?: bigint; workspaceId: number; } diff --git a/apps/studio/src/common/utils.ts b/apps/studio/src/common/utils.ts index c0d9b819bd6..fa54515e276 100644 --- a/apps/studio/src/common/utils.ts +++ b/apps/studio/src/common/utils.ts @@ -2,32 +2,35 @@ import { Error as CustomError } from '../lib/errors' import _ from 'lodash'; -import { format } from 'sql-formatter'; +import { format, formatDialect, FormatOptionsWithDialect, FormatOptionsWithLanguage } from 'sql-formatter'; import { TableFilter, TableOrView, Routine, TableColumn } from '@/lib/db/models'; import { SettingsPlugin } from '@/plugins/SettingsPlugin'; import { IndexColumn } from '@shared/lib/dialects/models'; import type { Stream } from 'stream'; export function camelCaseObjectKeys(data) { + if (_.isArray(data)) return data.map(camelCaseObjectKeys); if (_.isPlainObject(data)) { - const result = _.deepMapKeys(data, (_value, key) => _.camelCase(key)) - return result + return _.deepMapKeys(data, (_value, key) => _.camelCase(key)) } return data } -// I don't know why different, but don't want to edit. export function snakeCaseObjectKeys(data) { - const result = _.mapKeys(data, (_value, key) => { - return _.snakeCase(key) - }) - return result + if (_.isArray(data)) return data.map(snakeCaseObjectKeys); + if (_.isPlainObject(data)) { + return _.mapValues( + _.mapKeys(data, (_v, k) => _.snakeCase(k)), + snakeCaseObjectKeys + ) + } + return data } export function parseIndexColumn(str: string): IndexColumn { str = str.trim() - const order = str.endsWith('DESC') ? 'DESC' : 'ASC' + const order = str.endsWith(' DESC') ? 'DESC' : 'ASC' const nameAndPrefix = str.replaceAll(' DESC', '').trimEnd() let name: string = nameAndPrefix @@ -110,13 +113,22 @@ export function makeString(value: any): string { return _.toString(value); } +// Format SQL / SQL-like text using sql-formatter. Accepts both the classic +// `{ language }` shape (built-in dialects like postgresql, mysql, trino) and +// the v15 `{ dialect }` shape for custom dialect definitions (PartiQL). Falls +// back to the raw input if the formatter can't parse — callers rely on this +// never throwing. export function safeSqlFormat( - ...args: Parameters -): ReturnType { + query: string, + options?: FormatOptionsWithLanguage | FormatOptionsWithDialect +): string { try { - return format(args[0], args[1]); - } catch (ex) { - return args[0]; + if (options && 'dialect' in options && options.dialect) { + return formatDialect(query, options as FormatOptionsWithDialect); + } + return format(query, options as FormatOptionsWithLanguage); + } catch (_ex) { + return query; } } @@ -252,7 +264,7 @@ export function friendlyJsonObject(obj: T): T { }, }); - if(!obj.hasOwnProperty("toString")){ + if(!Object.prototype.hasOwnProperty.call(obj, "toString")){ Object.defineProperties(obj, { toString: { value() { @@ -346,6 +358,7 @@ export function isDateDataType (dataType) { } export function isNumericDataType (dataType) { + if (isDateDataType(dataType)) return false const base = normalizeDataType(dataType) const numericStarts = [ 'smallint', diff --git a/apps/studio/src/common/utils/folderTree.ts b/apps/studio/src/common/utils/folderTree.ts new file mode 100644 index 00000000000..9be70717e95 --- /dev/null +++ b/apps/studio/src/common/utils/folderTree.ts @@ -0,0 +1,123 @@ +import type { + FolderNode, + ItemNode, + TreeNodeMoveEvent, +} from "@beekeeperstudio/ui-kit"; +import { HasId } from "@/common/interfaces/IGeneric"; +import { IFolder } from "@/common/interfaces/IQueryFolder"; + +export type ExtendedNode = ExtendedFolderNode | ExtendedItemNode; + +export type ExtendedFolderNode = FolderNode & { ref: IFolder }; + +export interface ExtendedItemNode extends ItemNode { + ref: T; + /** The key that references the parent folder. Connection and Query use keys + * like `connectionFolderId` or `queryFolderId` to reference the parent folder. */ + parentIdKey: string; +} + +/** + * `children` holds references to the same node objects, so a flat array still + * describes the whole tree. + */ +export function buildFolderNodes(folders: IFolder[]): ExtendedFolderNode[] { + const nodes: ExtendedFolderNode[] = folders.map(buildFolderNode); + + const byId = new Map(); + for (const node of nodes) { + byId.set(node.id, node); + } + + for (const node of nodes) { + if (node.parentId === null) { + continue; + } + const parent = byId.get(node.parentId); + if (parent && parent !== node) { + parent.children.push(node); + } + } + + return nodes; +} + +export function buildFolderNode(folder: IFolder): ExtendedFolderNode { + return { + id: `folder-${folder.id}` as FolderNode["id"], + parentId: folder.parentId ? `folder-${folder.parentId}` : null, + type: "folder", + name: folder.name, + ref: folder, + children: [], + draggable: true, + }; +} + +export function buildItemNodes( + items: T[], + parentIdKey: string, + nameKey: string +): ExtendedItemNode[] { + return items.map((item) => { + const parentId = item[parentIdKey]; + return { + id: `item-${item.id}` as ItemNode["id"], + parentId: parentId ? `folder-${parentId}` : null, + parentIdKey, + type: "item", + name: item[nameKey] ?? "", + ref: item, + draggable: true, + }; + }); +} + +/** Transform {@link TreeNodeMoveEvent} into a consumable payload for the reorder action. */ +export function parseReorderTarget(event: TreeNodeMoveEvent) { + const target = event.target as ExtendedItemNode | ExtendedFolderNode; + + if (target.type === "folder") { + if (event.position !== "inside") { + throw new Error( + "Items can only be reordered within their own list, not moved relative to folders." + ); + } + + return { parentId: target.ref.id, position: { before: null } } as const; + } + + if (target.type === "item") { + const parentId: number = target.ref[target.parentIdKey]; + const targetId = target.ref.id; + + if (event.position === "after") { + return { parentId, position: { after: targetId } } as const; + } + + return { parentId, position: { before: targetId } } as const; + } + + throw new Error(`Unknown target type "${target["type"]}"`); +} + +export function getSelfAndAncestors( + selfId: number, + list: IFolder[], + returnList: IFolder[] = [] +): IFolder[] { + const index = list.findIndex((item) => item.id === selfId); + + if (index === -1) { + return returnList; + } + + const self = list[index]; + + returnList.push(self); + + /** Ancestors are excluded from this list. */ + const filteredList = list.toSpliced(index, 1); + + return getSelfAndAncestors(self.parentId, filteredList, returnList); +} diff --git a/apps/studio/src/components/ConfigurationWarningModal.vue b/apps/studio/src/components/ConfigurationWarningModal.vue index 8d51e1bc567..98bf1ed11ab 100644 --- a/apps/studio/src/components/ConfigurationWarningModal.vue +++ b/apps/studio/src/components/ConfigurationWarningModal.vue @@ -40,6 +40,47 @@ + +
@@ -66,6 +107,7 @@ - + diff --git a/apps/studio/src/components/CoreInterface.vue b/apps/studio/src/components/CoreInterface.vue index 79d23011b45..51bf7d50648 100644 --- a/apps/studio/src/components/CoreInterface.vue +++ b/apps/studio/src/components/CoreInterface.vue @@ -4,13 +4,13 @@ class="interface" v-hotkey="keymap" > +
- - - +
+ + + -
- -
+
+ +
- + +
+ + diff --git a/apps/studio/src/components/CoreTabs.vue b/apps/studio/src/components/CoreTabs.vue index bbb85e924d3..f4fd5d2caae 100644 --- a/apps/studio/src/components/CoreTabs.vue +++ b/apps/studio/src/components/CoreTabs.vue @@ -65,6 +65,7 @@ stars Upgrade
+
@@ -85,7 +86,7 @@ :tab="tab" :tab-id="tab.id" @update-tab="updateTab" - /> + /> -