From 3a816d799a131805eff345726214463006a03e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 20 Aug 2026 13:35:33 +0200 Subject: [PATCH 1/3] fix(installer): repair five Windows installer defects (#233-#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five were filed reviewing PR #232 and are Windows-only: install.sh bootstraps no JDK, so it shares none of these paths. #233 Resolve-Java never probed \jdk, so every re-run downloaded the ~180 MB Temurin zip again and unpacked it over a perfectly good JDK. It is now step 1 — the same order both generated launchers implement — and a bundled JDK below the floor is REPLACED rather than worked around with JAVA_HOME, because the launcher prefers \jdk unconditionally. Reuse and bootstrap set identical state through one Use-EmbeddedJdk helper. #234 Install-EmbeddedJdk removed the existing JDK before its replacement existed, so a failed unpack (running out of disk lands squarely in that window) left a previously working install with no JVM at all. Now: stage, assert bin\java.exe in the staged tree, and only then delete and move. #235 the generated .bat computed JAVA_MAJOR and spent it only on the --add-opens decision, so a stale JAVA_HOME - which outranks the PATH here, deliberately, because it is the JVM the installer probed - silently won and the REPL died with UnsupportedClassVersionError. It now refuses a JVM below %REQUIRED_JAVA%, guarded by `not "0"` for parity with the .ps1 launcher: refuse only a version positively read as too low, never one it failed to parse. Both launchers now name JAVA_HOME as the likely cause. #236 a bad -Version was only discovered by Download-Jar, after ~300 MB of JDK had landed in $Target, and nothing in the failure output mentioned it. Test-RequestedVersion now settles the inputs before Resolve-Java; listings are memoised (successes only) so it costs no extra HTTP, and $script:LastListingError preserves the cause -Quiet swallows. For the residual paths the main flow is wrapped in try/finally - PowerShell runs finally on `exit`, preserving the exit code - which names the JDK left behind. The JDK is KEPT on purpose: with #233 the next attempt reuses it. #237 install.cmd fetched install.ps1 from refs/heads/main, unverified, into a fixed %TEMP% path it never cleaned up. It now fetches a pinned release tag (PS1_REF), verifies it against a pinned SHA-256 with certutil before powershell.exe ever sees the file, and downloads into a fresh %RANDOM% directory removed at a single exit point. Overridable with SOFTCLIENT4ES_INSTALL_REF / _SHA256. Verified without a Windows host: 37 checks driving the real function bodies out of install.ps1's AST (JDK reuse/replace/fallthrough/bootstrap ordering, stage-then-swap under a throwing and a malformed unpack, the generated .bat and .ps1 text, and the top-level try/finally executed from its own extent), 15 static checks on install.cmd including refetching the pinned tag to confirm the pinned SHA-256, and real runs against JFrog (-Help, -ListVersions, a bogus -Version, a missing Scala variant, and an end-to-end install with a JDK pre-placed at \jdk). Not covered: cmd.exe executing install.cmd or the generated .bat, certutil output parsing on real Windows, and Expand-Archive on a real Temurin zip. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- documentation/client/repl.md | 41 +++++-- install.cmd | 117 ++++++++++++++++-- install.ps1 | 230 ++++++++++++++++++++++++++++------- 4 files changed, 323 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 9c551001..4868ef0c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/SOFTNETWORK-APP/SoftClient4ES/main/ irm https://raw.githubusercontent.com/SOFTNETWORK-APP/SoftClient4ES/main/install.ps1 | iex ``` -**Windows (cmd.exe, when `.ps1` files are blocked):** one file — it fetches `install.ps1` if it is not beside it, takes the same flags, and only launches it with `-ExecutionPolicy Bypass`, for that one process. +**Windows (cmd.exe, when `.ps1` files are blocked):** one file — it fetches `install.ps1` if it is not beside it (from a pinned release tag, SHA-256 verified), takes the same flags, and only launches it with `-ExecutionPolicy Bypass`, for that one process. ```bat curl -O https://raw.githubusercontent.com/SOFTNETWORK-APP/SoftClient4ES/main/install.cmd && install.cmd ``` diff --git a/documentation/client/repl.md b/documentation/client/repl.md index ec4c1c47..a3cbca3f 100644 --- a/documentation/client/repl.md +++ b/documentation/client/repl.md @@ -54,20 +54,33 @@ It provides: **On Windows you do not have to install Java yourself.** Since `0.20.4`, `install.ps1` (and therefore `install.cmd`) resolves Java in this order: -1. `%JAVA_HOME%\bin\java.exe` — when `JAVA_HOME` is set, that is the JVM tested, +1. `\jdk\bin\java.exe` — a JDK a previous run bootstrapped into the + same install directory. It is reused as it stands, so re-running the + installer (to upgrade, to add `-NoExtensions`, to retry a failed download) + never downloads the ~180 MB Temurin zip a second time. +2. `%JAVA_HOME%\bin\java.exe` — when `JAVA_HOME` is set, that is the JVM tested, not whatever `java` happens to be first on `PATH`; the two frequently differ. -2. the `java` on `PATH`, when `JAVA_HOME` is not set. -3. Neither is present, or the one found is **below the floor** for your ES +3. the `java` on `PATH`, when `JAVA_HOME` is not set. +4. None of those is present, or the one found is **below the floor** for your ES version ⇒ the installer downloads a portable **Temurin 17** JDK (a zip from Adoptium, never an MSI, so it needs **no administrator rights**) and unpacks - it to `\jdk`. + it to `\jdk`. The new JDK is unpacked and checked before any + existing one is removed, so a failed download never leaves you without a JVM. Java 17 satisfies both floors, so there is only ever one JDK to think about. The bootstrapped JDK lives **inside the install directory**: `uninstall.ps1` removes it along with everything else, and nothing machine-wide is modified — the installer sets `JAVA_HOME` and `PATH` **for its own session only**. Later sessions do not need them, because the generated launcher applies the same order -and finds `\jdk` by relative path. +and finds `\jdk` by relative path — and refuses to start, naming the +version it found, if that JVM turns out to be below the floor (a stale +`JAVA_HOME` is the usual cause) rather than letting the JVM fail with +`UnsupportedClassVersionError`. + +If an install fails part way through — a version that does not exist, an +unreachable repository — a bootstrapped JDK is left in place on purpose and the +installer says where it is: the next attempt reuses it. Delete the install +directory if you are not going to retry. On Linux and macOS `install.sh` still expects a suitable Java to be present. @@ -126,8 +139,22 @@ install.cmd ``` A local `install.ps1` always wins, so a downloaded pair stays self-consistent — -put both files in the same directory when you want a pinned copy rather than -whatever is on `main`. +put both files in the same directory when you want a specific `install.ps1`. + +What the fallback fetches is **pinned to a release tag, not to `main`**, and is +checked against a SHA-256 recorded in `install.cmd` before it is run; a mismatch +is a hard failure, and the download goes to a fresh temporary directory that is +removed when the run ends. So `install.cmd` on its own installs with the +installer of the release it belongs to, never with whatever is currently on +`main`. To use `main`'s installer instead: + +```bat +set SOFTCLIENT4ES_INSTALL_REF=main +install.cmd +``` + +That drops the integrity check — the pinned hash belongs to the pinned tag — +and `install.cmd` says so unless you also set `SOFTCLIENT4ES_INSTALL_SHA256`. `install.cmd` accepts exactly the flags `install.ps1` does and forwards them verbatim, so every option, default and fallback documented below applies diff --git a/install.cmd b/install.cmd index 3754490e..46dab175 100644 --- a/install.cmd +++ b/install.cmd @@ -11,14 +11,15 @@ rem THIS process only, changes nothing on the machine, and needs no elevation. rem rem It is a wrapper and nothing else - every option, default, fallback and rem message lives in install.ps1, so the two entry points can never drift. -rem When install.ps1 is not sitting next to it, it downloads one, so -rem install.cmd on its own is a complete install. Pass the same flags you -rem would pass to install.ps1: +rem When install.ps1 is not sitting next to it, it downloads one - from a +rem pinned release tag, checked against a pinned SHA-256 before it is run - so +rem install.cmd on its own is a complete install. Pass the same flags you would +rem pass to install.ps1: rem rem install.cmd rem install.cmd -ListVersions -EsVersion 8 rem install.cmd -Target "C:\tools\softclient4es" -EsVersion 8 -Version 1.0.0 -rem install.cmd -EsVersion 7 -Version 0.20.4 -NoExtensions +rem install.cmd -EsVersion 7 -Version 0.21.0 -NoExtensions rem install.cmd -Help rem rem Caveat worth knowing: -ExecutionPolicy Bypass is overridden when the policy @@ -30,8 +31,40 @@ rem =========================================================================== setlocal -set "PS1_URL=https://raw.githubusercontent.com/SOFTNETWORK-APP/SoftClient4ES/refs/heads/main/install.ps1" +rem --------------------------------------------------------------------------- +rem Where a missing install.ps1 is fetched from. +rem +rem PINNED TO A RELEASE TAG, not to main. This file also travels inside release +rem bundles, and one that quietly pulled whatever happens to be on main would +rem produce exactly the mix that the "a local install.ps1 always wins" rule +rem below exists to prevent - silently, since a user who ran a released .cmd has +rem no reason to think they are running main. A tag is also immutable, which is +rem what makes the SHA-256 worth checking at all: the script is about to be run +rem with -ExecutionPolicy Bypass, and "it arrived over TLS" only says the +rem transport was sound, not that the file is the one the release intended. +rem +rem RELEASE RITUAL: bump BOTH lines together when cutting a release. +rem curl -fsSL https://raw.githubusercontent.com/SOFTNETWORK-APP/SoftClient4ES//install.ps1 | shasum -a 256 +rem A mismatch is a hard failure, so a bumped tag with a stale hash breaks the +rem fallback loudly instead of running something unverified. +rem +rem To track development instead: set SOFTCLIENT4ES_INSTALL_REF=main +rem The pinned hash belongs to the pinned tag, so overriding the ref drops the +rem integrity check unless SOFTCLIENT4ES_INSTALL_SHA256 supplies another one - +rem and says so when it does not. +rem --------------------------------------------------------------------------- +set "PS1_REF=v0.21.0" +set "PS1_SHA256=e1275bd269c8bb922d28827b6c74e965bbe7b5139b4e16138bacaac7c03912ae" + +rem One `if` per line, never a parenthesised block: cmd expands every %VAR% in a +rem block in ONE parse pass, before running any line in it. +if defined SOFTCLIENT4ES_INSTALL_REF set "PS1_REF=%SOFTCLIENT4ES_INSTALL_REF%" +if defined SOFTCLIENT4ES_INSTALL_REF set "PS1_SHA256=%SOFTCLIENT4ES_INSTALL_SHA256%" + +set "PS1_URL=https://raw.githubusercontent.com/SOFTNETWORK-APP/SoftClient4ES/%PS1_REF%/install.ps1" set "PS1=%~dp0install.ps1" +set "PS1_TMPDIR=" +set "RC=1" rem A local install.ps1 always wins: a downloaded pair must stay self-consistent, rem and a released bundle must never be silently mixed with main. @@ -40,17 +73,23 @@ rem how cmd binds `||` inside an `if` body is ambiguous, and this is not a rem platform where a subtlety can be settled by running it. if exist "%PS1%" goto run call :fetch_ps1 -if errorlevel 1 exit /b 1 +if errorlevel 1 goto cleanup :run powershell -NoProfile -ExecutionPolicy Bypass -File "%PS1%" %* -exit /b %ERRORLEVEL% +set "RC=%ERRORLEVEL%" + +rem Single exit point, so a downloaded install.ps1 is removed however this ends - +rem including the paths that never got as far as running it. +:cleanup +if defined PS1_TMPDIR if exist "%PS1_TMPDIR%" rd /s /q "%PS1_TMPDIR%" +exit /b %RC% rem --------------------------------------------------------------------------- -rem Each line of a subroutine is parsed when it is reached, so %PS1% below sees -rem the value assigned on the previous line. The same code inside the `if not -rem exist (...)` block above would NOT: cmd expands every %VAR% in a -rem parenthesised block in ONE parse pass, before running any line in it. +rem Each line of a subroutine is parsed when it is reached, so %PS1_TMPDIR% below +rem sees the value assigned on the previous line. The same code inside an `if not +rem exist (...)` block would NOT: cmd expands every %VAR% in a parenthesised block +rem in ONE parse pass, before running any line in it. rem --------------------------------------------------------------------------- :fetch_ps1 where curl.exe >nul 2>&1 @@ -60,7 +99,20 @@ if errorlevel 1 ( echo [ERROR] Download install.ps1 manually into the same directory as install.cmd. 1>&2 exit /b 1 ) -set "PS1=%TEMP%\softclient4es-install.ps1" + +rem A fresh directory of our own, rather than the fixed %TEMP%\softclient4es-install.ps1 +rem this used to write: that was a predictable path in a shared directory, executed +rem with -ExecutionPolicy Bypass, and left behind after every run. mkdir fails when +rem the name already exists, so nothing pre-created can be substituted for it. +set "PS1_TMPDIR=%TEMP%\softclient4es-install-%RANDOM%%RANDOM%" +mkdir "%PS1_TMPDIR%" 2>nul +if errorlevel 1 ( + echo [ERROR] Could not create a temporary directory: %PS1_TMPDIR% 1>&2 + set "PS1_TMPDIR=" + exit /b 1 +) +set "PS1=%PS1_TMPDIR%\install.ps1" + echo [INFO] install.ps1 not found next to install.cmd - downloading it... echo [INFO] URL: %PS1_URL% curl.exe -fsSL -o "%PS1%" "%PS1_URL%" @@ -68,5 +120,46 @@ if errorlevel 1 ( echo [ERROR] Could not download install.ps1 from %PS1_URL% 1>&2 exit /b 1 ) + +call :verify_ps1 +if errorlevel 1 exit /b 1 + echo [INFO] Using %PS1% exit /b 0 + +rem --------------------------------------------------------------------------- +rem Integrity check, before the file is handed to powershell.exe. +rem --------------------------------------------------------------------------- +:verify_ps1 +if not defined PS1_SHA256 ( + echo [WARN] SOFTCLIENT4ES_INSTALL_REF is set without SOFTCLIENT4ES_INSTALL_SHA256, 1>&2 + echo [WARN] so the downloaded install.ps1 was NOT checked against a known hash. 1>&2 + exit /b 0 +) + +where certutil.exe >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] certutil.exe is unavailable, so install.ps1 cannot be verified - it was NOT run. 1>&2 + echo [ERROR] Download install.ps1 manually into the same directory as install.cmd. 1>&2 + exit /b 1 +) + +rem certutil prints the digest on the second line. The FOR must set PS1_HASH on a +rem line of its own, and the space-stripping must live OUTSIDE any block, or the +rem one-parse-pass rule above would have it read the previous value. +set "PS1_HASH=" +for /f "skip=1 tokens=* delims=" %%h in ('certutil -hashfile "%PS1%" SHA256') do if not defined PS1_HASH set "PS1_HASH=%%h" +rem Older certutil builds print the digest as space-separated byte pairs. +set "PS1_HASH=%PS1_HASH: =%" + +if /i not "%PS1_HASH%"=="%PS1_SHA256%" ( + echo [ERROR] install.ps1 failed its integrity check - it was NOT run. 1>&2 + echo [ERROR] expected: %PS1_SHA256% 1>&2 + echo [ERROR] actual: %PS1_HASH% 1>&2 + echo [ERROR] url: %PS1_URL% 1>&2 + echo [ERROR] Download install.ps1 manually into the same directory as install.cmd. 1>&2 + exit /b 1 +) + +echo [INFO] install.ps1 SHA-256 verified against %PS1_REF% +exit /b 0 diff --git a/install.ps1 b/install.ps1 index 7a43fcdd..ab044cac 100644 --- a/install.ps1 +++ b/install.ps1 @@ -174,11 +174,28 @@ $EMBEDDED_JDK_DIR = Join-Path $Target "jdk" # `java`, which is frequently a different and older JVM. # Set by Resolve-Java; read by Check-Prerequisites, the launcher writer and the -# summary. +# summary. $script:EmbeddedJdkHome is also what tells the failure path that this +# install depends on a JDK under $Target. $script:JavaMajor = 0 $script:JavaSource = "not found" $script:EmbeddedJdkHome = $null +# Point this run at a JDK inside the install tree - the one the launcher will use. +# SESSION scope only, deliberately not [Environment]::SetEnvironmentVariable(...,"User"): +# a machine-wide JAVA_HOME would silently repoint every other tool on the box, and +# would dangle after uninstall. Later sessions need nothing - the launcher finds +# \jdk by relative path. +function Use-EmbeddedJdk { + param([string]$JdkHome, [int]$Major) + + $script:EmbeddedJdkHome = $JdkHome + $script:JavaMajor = $Major + $script:JavaSource = "bundled JDK ($JdkHome)" + + $env:JAVA_HOME = $JdkHome + $env:PATH = (Join-Path $JdkHome "bin") + ";" + $env:PATH +} + function Install-EmbeddedJdk { Write-Info "Installing a portable Temurin $BOOTSTRAP_JAVA_VERSION JDK (zip, no administrator rights)..." @@ -198,9 +215,16 @@ function Install-EmbeddedJdk { # to a staging dir and MOVE that one level up, so the final JAVA_HOME is the # fixed path \jdk. The launcher hard-codes `%BASE_DIR%\jdk\bin`, and # it must not have to glob for a name that changes with every Temurin build. + # + # Stage, verify, THEN swap: an existing \jdk is removed only once a + # usable replacement is on disk. The unpack needs the 180 MB zip plus ~300 MB + # expanded plus the ~309 MB jar in the same tree, so running out of disk lands + # squarely in this window, and deleting first left a previously working + # install with no JVM at all — broken by the very installer the user would + # then re-run to fix it (issue #234). $staging is scratch by construction, so + # clearing that one up front is fine. $staging = "$EMBEDDED_JDK_DIR.unpack" - if (Test-Path $staging) { Remove-Item -Recurse -Force $staging } - if (Test-Path $EMBEDDED_JDK_DIR) { Remove-Item -Recurse -Force $EMBEDDED_JDK_DIR } + if (Test-Path $staging) { Remove-Item -Recurse -Force $staging } New-Item -ItemType Directory -Force -Path $staging | Out-Null try { @@ -211,6 +235,13 @@ function Install-EmbeddedJdk { Write-Err "The Temurin archive did not unpack as expected (no directory inside $staging)" return $null } + if (-not (Test-Path (Join-Path (Join-Path $inner.FullName "bin") "java.exe"))) { + Write-Err "The Temurin archive did not unpack as expected (no bin\java.exe under $($inner.FullName))" + return $null + } + + # Known-good from here: the previous JDK, if any, can go. + if (Test-Path $EMBEDDED_JDK_DIR) { Remove-Item -Recurse -Force $EMBEDDED_JDK_DIR } Move-Item -Path $inner.FullName -Destination $EMBEDDED_JDK_DIR } finally { @@ -237,35 +268,58 @@ function Install-EmbeddedJdk { function Resolve-Java { Write-Info "Resolving Java (ES$EsVersion requires ${REQUIRED_JAVA_VERSION}+)..." - # JAVA_HOME first, exactly as the launcher will. Probing the PATH `java` when - # JAVA_HOME is set would validate a JVM the REPL is never going to run. - $javaHomeExe = if ($env:JAVA_HOME) { Join-Path (Join-Path $env:JAVA_HOME "bin") "java.exe" } else { "" } - $jhMajor = if ($javaHomeExe -and (Test-Path $javaHomeExe)) { Get-JavaMajorFromExe -Exe $javaHomeExe } else { 0 } + # \jdk first — the JDK a previous run of this installer bootstrapped, + # and the first thing both generated launchers look at. Without this probe every + # re-run downloads the ~180 MB Temurin zip again and unpacks it over a perfectly + # good JDK (JAVA_HOME is set for the session only, so a later shell has nothing + # pointing at it either), and the installer contradicts the resolution order its + # own launchers document (issue #233). + $bundledExe = Join-Path (Join-Path $EMBEDDED_JDK_DIR "bin") "java.exe" + $bundledMajor = if (Test-Path $bundledExe) { Get-JavaMajorFromExe -Exe $bundledExe } else { 0 } + + if ($bundledMajor -ge $REQUIRED_JAVA_VERSION) { + Use-EmbeddedJdk -JdkHome $EMBEDDED_JDK_DIR -Major $bundledMajor + Write-Success "Java $bundledMajor found via $($script:JavaSource) (required: ${REQUIRED_JAVA_VERSION}+) — nothing to download" + return $true + } - if ($jhMajor -gt 0) { - $script:JavaMajor = $jhMajor - $script:JavaSource = "JAVA_HOME ($env:JAVA_HOME)" + if ($bundledMajor -gt 0) { + # The launcher prefers \jdk whenever java.exe is there, so a bundled + # JDK below the floor cannot be left in place and worked around with JAVA_HOME + # — it has to be replaced. Straight to the bootstrap, which swaps it out. + Write-Warn "The JDK bundled at $EMBEDDED_JDK_DIR is Java $bundledMajor — below the required ${REQUIRED_JAVA_VERSION}+; replacing it" } else { - if ($env:JAVA_HOME) { - Write-Warn "JAVA_HOME is set to '$env:JAVA_HOME' but no usable java.exe was found under it" + # Then JAVA_HOME, exactly as the launcher will. Probing the PATH `java` when + # JAVA_HOME is set would validate a JVM the REPL is never going to run. + $javaHomeExe = if ($env:JAVA_HOME) { Join-Path (Join-Path $env:JAVA_HOME "bin") "java.exe" } else { "" } + $jhMajor = if ($javaHomeExe -and (Test-Path $javaHomeExe)) { Get-JavaMajorFromExe -Exe $javaHomeExe } else { 0 } + + if ($jhMajor -gt 0) { + $script:JavaMajor = $jhMajor + $script:JavaSource = "JAVA_HOME ($env:JAVA_HOME)" } - $pathMajor = Get-JavaMajorVersion - if ($pathMajor -gt 0) { - $script:JavaMajor = $pathMajor - $script:JavaSource = "PATH" + else { + if ($env:JAVA_HOME) { + Write-Warn "JAVA_HOME is set to '$env:JAVA_HOME' but no usable java.exe was found under it" + } + $pathMajor = Get-JavaMajorVersion + if ($pathMajor -gt 0) { + $script:JavaMajor = $pathMajor + $script:JavaSource = "PATH" + } } - } - if ($script:JavaMajor -ge $REQUIRED_JAVA_VERSION) { - Write-Success "Java $($script:JavaMajor) found via $($script:JavaSource) (required: ${REQUIRED_JAVA_VERSION}+)" - return $true - } + if ($script:JavaMajor -ge $REQUIRED_JAVA_VERSION) { + Write-Success "Java $($script:JavaMajor) found via $($script:JavaSource) (required: ${REQUIRED_JAVA_VERSION}+)" + return $true + } - if ($script:JavaMajor -eq 0) { - Write-Warn "No usable Java found" - } else { - Write-Warn "Java $($script:JavaMajor) found via $($script:JavaSource) — below the required ${REQUIRED_JAVA_VERSION}+" + if ($script:JavaMajor -eq 0) { + Write-Warn "No usable Java found" + } else { + Write-Warn "Java $($script:JavaMajor) found via $($script:JavaSource) — below the required ${REQUIRED_JAVA_VERSION}+" + } } $jdkHome = Install-EmbeddedJdk @@ -275,15 +329,7 @@ function Resolve-Java { return $false } - $script:EmbeddedJdkHome = $jdkHome - $script:JavaMajor = Get-JavaMajorFromExe -Exe (Join-Path (Join-Path $jdkHome "bin") "java.exe") - $script:JavaSource = "bundled JDK ($jdkHome)" - - # SESSION scope only — deliberately not [Environment]::SetEnvironmentVariable(...,"User"). - # A machine-wide JAVA_HOME would silently repoint every other tool on the box. - # Future sessions do not need it: the launcher prefers \jdk directly. - $env:JAVA_HOME = $jdkHome - $env:PATH = (Join-Path $jdkHome "bin") + ";" + $env:PATH + Use-EmbeddedJdk -JdkHome $jdkHome -Major (Get-JavaMajorFromExe -Exe (Join-Path (Join-Path $jdkHome "bin") "java.exe")) Write-Success "Java $($script:JavaMajor) ready — JAVA_HOME and PATH updated for THIS session" return $true @@ -293,6 +339,16 @@ function Resolve-Java { # List Available Versions # ============================================================================= +# One listing per artifact per run. The pre-flight below consults the same lists +# the bundle-selection block does, and an HTTP call that has already been answered +# must not be paid for - or answered differently - twice in one run. +$script:VersionListings = @{} + +# Why a listing failed, kept for the pre-flight: -Quiet has to stay silent (a +# missing -all bundle is a normal, expected 404) but a genuine outage must not +# reach the user as a bare "no versions found". +$script:LastListingError = $null + function Get-AvailableVersions { param( # Default to the PLAIN artifact, never $ARTIFACT_NAME: that one is @@ -303,6 +359,10 @@ function Get-AvailableVersions { [switch]$Quiet ) + if ($script:VersionListings.ContainsKey($Artifact)) { + return $script:VersionListings[$Artifact] + } + $apiUrl = "${JFROG_API_URL}/${Artifact}" try { @@ -315,9 +375,15 @@ function Get-AvailableVersions { Where-Object { $_ -notmatch '^\.' } | Sort-Object { [Version]($_ -replace '-SNAPSHOT', '.0' -replace '[^0-9.]', '') } + # Successes only: a listing that failed keeps its own semantics (fail hard, + # or empty under -Quiet) if it is asked for again. + $versions = @($versions) + if ($versions.Count -gt 0) { $script:VersionListings[$Artifact] = $versions } + return $versions } catch { + $script:LastListingError = $_.Exception.Message if ($Quiet) { return @() } Write-Err "Failed to fetch versions from repository" Write-Err "Artifact: $Artifact" @@ -418,11 +484,48 @@ function Resolve-LatestVersion { return @($versions)[-1] } +# ============================================================================= +# Pre-flight: settle the inputs before anything large is downloaded +# ============================================================================= +# Resolving Java can write ~300 MB into $Target, and it used to be the FIRST thing +# to touch the disk - so a typo in -Version, an artifact that does not exist for +# the chosen -EsVersion / -ScalaVersion, or an unreachable repository was only +# discovered by Download-Jar, leaving an orphaned jdk\ behind that nothing in the +# failure output even mentioned (issue #236). The listings are the same two cheap +# calls bundle selection makes below, memoised, so this costs nothing. +function Test-RequestedVersion { + $artifacts = @() + if ($WITH_EXTENSIONS) { $artifacts += $BUNDLE_ARTIFACT_NAME } + $artifacts += $PLAIN_ARTIFACT_NAME + + $known = @() + foreach ($artifact in $artifacts) { + $known += @(Get-AvailableVersions -Artifact $artifact -Quiet) + } + + if ($known.Count -eq 0) { + Write-Err "No versions found for $($artifacts -join ' or ')" + Write-Err "Check -EsVersion $EsVersion and -ScalaVersion $ScalaVersion, and that the repository is reachable:" + Write-Err $JFROG_API_URL + if ($script:LastListingError) { Write-Err $script:LastListingError } + exit 1 + } + + if ($Version -ne "latest" -and $known -notcontains $Version) { + Write-Err "Version '$Version' is not published for $($artifacts -join ' or ')" + Write-Err "Run with -ListVersions to see available versions." + exit 1 + } +} + +Test-RequestedVersion + # ============================================================================= # Resolve Java before anything else that depends on it # ============================================================================= # Runs AFTER the -ListVersions early exit (listing versions must not download a -# JDK) and BEFORE bundle selection, which reads the resolved major. +# JDK), AFTER the pre-flight above (a bad -Version must not cost a JDK download) +# and BEFORE bundle selection, which reads the resolved major. if (-not (Resolve-Java)) { exit 1 } # ============================================================================= @@ -875,6 +978,20 @@ if not defined JVER ( ) for /f "tokens=1,2 delims=." %%a in ("%JVER%") do if "%%a"=="1" (set JAVA_MAJOR=%%b) else (set JAVA_MAJOR=%%a) +REM Refuse a JVM below the floor instead of letting it fail with +REM UnsupportedClassVersionError, which names a class-file version rather than a +REM Java one and reads as a broken install. The .ps1 launcher has always had this +REM check; the .bat computed JAVA_MAJOR and spent it only on --add-opens, so a +REM stale %JAVA_HOME% - which outranks the PATH here, deliberately, because that +REM is the JVM the installer probed - silently won (issue #235). +REM The "not 0" guard keeps parity with the .ps1 launcher: refuse only a version +REM we positively read as too low, never one we failed to parse. +if not "%JAVA_MAJOR%"=="0" if %JAVA_MAJOR% LSS %REQUIRED_JAVA% ( + echo Error: Java %REQUIRED_JAVA%+ is required. Found: Java %JAVA_MAJOR% >&2 + if defined JAVA_HOME echo JAVA_HOME takes precedence over the PATH here - point it at a Java %REQUIRED_JAVA%+ JDK, or clear it. >&2 + exit /b 1 +) + REM The extensions (Apache Arrow / DuckDB) need reflective access on Java 9+. REM JAVA_MAJOR is initialised to 0 above so this comparison always has a left REM operand (an empty one is a cmd syntax error that aborts the whole script). @@ -954,6 +1071,9 @@ try { if (`$javaVersion -gt 0 -and `$javaVersion -lt `$RequiredJava) { Write-Error "Java `$RequiredJava+ is required. Found: Java `$javaVersion" + if (`$env:JAVA_HOME) { + Write-Error "JAVA_HOME takes precedence over the PATH here - point it at a Java `$RequiredJava+ JDK, or clear it." + } exit 1 } } @@ -1144,15 +1264,31 @@ Write-Host " SoftClient4ES Installer" -ForegroundColor Cyan Write-Host "==================================================================" -ForegroundColor Cyan Write-Host "" -Check-Prerequisites -Create-Directories -Download-Jar -Extract-BundleLicenses -Show-LicenseNotice -Download-Docs -Create-Config -Create-LogbackConfig # <-- Création du fichier logback.xml -Create-Launcher -Create-Uninstaller -Create-VersionInfo -Print-Summary +# The finally runs on `exit` too, so it covers every failure path below - which is +# the point: whatever went wrong, the user is told about the JDK sitting in $Target +# rather than discovering ~300 MB of it later (issue #236). +$script:InstallCompleted = $false +try { + Check-Prerequisites + Create-Directories + Download-Jar + Extract-BundleLicenses + Show-LicenseNotice + Download-Docs + Create-Config + Create-LogbackConfig # <-- Création du fichier logback.xml + Create-Launcher + Create-Uninstaller + Create-VersionInfo + Print-Summary + $script:InstallCompleted = $true +} +finally { + if (-not $script:InstallCompleted -and $script:EmbeddedJdkHome) { + Write-Host "" + Write-Warn "The install did not complete. A portable Temurin $BOOTSTRAP_JAVA_VERSION JDK (~300 MB) is at:" + Write-Warn " $($script:EmbeddedJdkHome)" + Write-Warn "It is kept on purpose - a re-run reuses it instead of downloading it again." + Write-Warn "Delete that directory if you are not going to retry." + } +} From ecb97308b76a245cd45825846b5a5245058d1dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 20 Aug 2026 19:38:06 +0200 Subject: [PATCH 2/3] docs: 0.21.0 train -- engine 0.21.0, extensions 0.3.0, drivers 0.3.0 Post-release documentation sweep, core half. The web half is the companion PR; the two are swept together because they drift in different ways. Coordinates bumped (39 replacements across 8 files): java-client 0.20.4 -> 0.21.0 community-extensions 0.2.4 -> 0.3.0 arrow-extensions 0.2.5 -> 0.3.0 jdbc / adbc / flight 0.2.5 -> 0.3.0 REPL install examples 0.20.4 -> 0.21.0 Every version was verified PUBLISHED on JFrog before being written, not after: jdbc, adbc and arrow-flight-sql at 0.3.0 for ES 6/7/8/9, and java-client at 0.21.0. The failure this prevents is silent -- a documented version with no -all bundle falls back to the plain artifact, which carries no arrow extensions and therefore no cross-index JOIN, so the docs would demonstrate the path that loses the headline feature. FOUR HISTORICAL STATEMENTS DELIBERATELY NOT BUMPED. A line saying when something ARRIVED is a fact about the past; bumping it inverts its meaning: * documentation/client/repl.md:54 "Since 0.20.4" (Windows Java bootstrap) * documentation/sql/joins.md:114 "since arrow-extensions 0.2.5 ... Before 0.2.5 all of these were rejected" A blind sweep would have turned the second into "aliases work since 0.3.0", telling every 0.2.5/0.20.4 user they lack a feature they have. The sweep script skips any line carrying "since"/"before", and reports which lines it skipped so the decision is reviewable rather than implicit. TWO CORRUPTIONS THE DIFF CAUGHT, both quoted-output drift: * repl.md:217 -- the sweep RENAMED "0.20.4" to "0.21.0" inside a captured --list-versions listing. 0.20.4 is still published, so the listing must GAIN a line, not rename one: 0.20.4 restored, 0.21.0 appended, total 4 -> 5. * install.ps1:547 -- a comment illustrating an ascending series "(0.20.2, 0.20.3, 0.20.4, ...)" had its third term rewritten, making the series skip a release. Reverted. Reading the diff caught both; trusting the regex would not have. No SQL-documentation change was needed for #238's behaviour change. The row order of an un-ordered extraction now interleaves across slices, and a quota-capped result is an arbitrary subset -- both are already documented in documentation/client/scroll.md, which landed with the feature. The SQL docs were checked and make no claim about row ordering, so there is no false statement to correct. Also verified, no change required: install.cmd pins PS1_REF=v0.21.0 and PS1_SHA256, and the pinned digest matches the actual install.ps1 at tag v0.21.0 byte for byte -- a mismatch there makes the Windows installer refuse to run. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 22 +++++++++++----------- documentation/client/adbc_driver.md | 14 +++++++------- documentation/client/arrow_flight_sql.md | 10 +++++----- documentation/client/download_analytics.md | 2 +- documentation/client/jdbc.md | 14 +++++++------- documentation/client/repl.md | 13 +++++++------ install.ps1 | 2 +- 7 files changed, 39 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 4868ef0c..65645ab2 100644 --- a/README.md +++ b/README.md @@ -216,10 +216,10 @@ Download the self-contained fat JAR for your Elasticsearch version: | Elasticsearch Version | Artifact | |-----------------------|----------------------------------------| -| ES 6.x | `softclient4es6-jdbc-driver-0.2.5.jar` | -| ES 7.x | `softclient4es7-jdbc-driver-0.2.5.jar` | -| ES 8.x | `softclient4es8-jdbc-driver-0.2.5.jar` | -| ES 9.x | `softclient4es9-jdbc-driver-0.2.5.jar` | +| ES 6.x | `softclient4es6-jdbc-driver-0.3.0.jar` | +| ES 7.x | `softclient4es7-jdbc-driver-0.3.0.jar` | +| ES 8.x | `softclient4es8-jdbc-driver-0.3.0.jar` | +| ES 9.x | `softclient4es9-jdbc-driver-0.3.0.jar` | > **Java 11+ recommended** (17+ for ES 9.x): **cross-index JOINs require Java 11+** — the embedded JOIN engine is built on Apache Arrow 18.x, which ships Java-11 bytecode. @@ -236,20 +236,20 @@ Driver class: app.softnetwork.elastic.jdbc.ElasticDriver app.softnetwork.elastic softclient4es8-jdbc-driver - 0.2.5 + 0.3.0 ``` **Gradle:** ```groovy -implementation 'app.softnetwork.elastic:softclient4es8-jdbc-driver:0.2.5' +implementation 'app.softnetwork.elastic:softclient4es8-jdbc-driver:0.3.0' ``` **sbt:** ```scala -libraryDependencies += "app.softnetwork.elastic" % "softclient4es8-jdbc-driver" % "0.2.5" +libraryDependencies += "app.softnetwork.elastic" % "softclient4es8-jdbc-driver" % "0.3.0" ``` The JDBC driver JARs are Scala-version-independent (no `_2.12` or `_2.13` suffix) and include all required dependencies. @@ -337,13 +337,13 @@ For programmatic access, add SoftClient4ES to your project. resolvers += "Softnetwork" at "https://softnetwork.jfrog.io/artifactory/releases/" // Choose your Elasticsearch version -libraryDependencies += "app.softnetwork.elastic" %% "softclient4es8-java-client" % "0.20.4" +libraryDependencies += "app.softnetwork.elastic" %% "softclient4es8-java-client" % "0.21.0" // Add the community extensions for materialized views (optional) -libraryDependencies += "app.softnetwork.elastic" %% "softclient4es-community-extensions" % "0.2.4" +libraryDependencies += "app.softnetwork.elastic" %% "softclient4es-community-extensions" % "0.3.0" // Add the arrow extensions for cross-index JOIN (required for JOINs; Java 11+) -libraryDependencies += "app.softnetwork.elastic" %% "softclient4es-arrow-extensions" % "0.2.5" +libraryDependencies += "app.softnetwork.elastic" %% "softclient4es-arrow-extensions" % "0.3.0" // Add the JDBC driver if you want to use it from Scala (optional) -libraryDependencies += "app.softnetwork.elastic" %% "softclient4es-jdbc-driver" % "0.2.5" +libraryDependencies += "app.softnetwork.elastic" %% "softclient4es-jdbc-driver" % "0.3.0" ``` ```scala diff --git a/documentation/client/adbc_driver.md b/documentation/client/adbc_driver.md index f37d2f76..532a0117 100644 --- a/documentation/client/adbc_driver.md +++ b/documentation/client/adbc_driver.md @@ -23,10 +23,10 @@ Download the self-contained fat JAR for your Elasticsearch version: | Elasticsearch | Artifact | |----------------|-----------------------------------------------------| -| ES 6.x | `softclient4es6-adbc-driver-0.2.5.jar` | -| ES 7.x | `softclient4es7-adbc-driver-0.2.5.jar` | -| ES 8.x | `softclient4es8-adbc-driver-0.2.5.jar` | -| ES 9.x | `softclient4es9-adbc-driver-0.2.5.jar` | +| ES 6.x | `softclient4es6-adbc-driver-0.3.0.jar` | +| ES 7.x | `softclient4es7-adbc-driver-0.3.0.jar` | +| ES 8.x | `softclient4es8-adbc-driver-0.3.0.jar` | +| ES 9.x | `softclient4es9-adbc-driver-0.3.0.jar` | ### Maven / Gradle / sbt @@ -36,20 +36,20 @@ Download the self-contained fat JAR for your Elasticsearch version: app.softnetwork.elastic softclient4es8-adbc-driver - 0.2.5 + 0.3.0 ``` **Gradle:** ```groovy -implementation 'app.softnetwork.elastic:softclient4es8-adbc-driver:0.2.5' +implementation 'app.softnetwork.elastic:softclient4es8-adbc-driver:0.3.0' ``` **sbt:** ```scala -libraryDependencies += "app.softnetwork.elastic" % "softclient4es8-adbc-driver" % "0.2.5" +libraryDependencies += "app.softnetwork.elastic" % "softclient4es8-adbc-driver" % "0.3.0" ``` --- diff --git a/documentation/client/arrow_flight_sql.md b/documentation/client/arrow_flight_sql.md index f2cc2720..f05dae29 100644 --- a/documentation/client/arrow_flight_sql.md +++ b/documentation/client/arrow_flight_sql.md @@ -49,15 +49,15 @@ Available images per ES version: ### Fat JAR ```bash -java -jar softclient4es8-arrow-flight-sql-0.2.5.jar +java -jar softclient4es8-arrow-flight-sql-0.3.0.jar ``` | Elasticsearch | Artifact | |---------------|----------| -| ES 6.x | `softclient4es6-arrow-flight-sql-0.2.5.jar` | -| ES 7.x | `softclient4es7-arrow-flight-sql-0.2.5.jar` | -| ES 8.x | `softclient4es8-arrow-flight-sql-0.2.5.jar` | -| ES 9.x | `softclient4es9-arrow-flight-sql-0.2.5.jar` | +| ES 6.x | `softclient4es6-arrow-flight-sql-0.3.0.jar` | +| ES 7.x | `softclient4es7-arrow-flight-sql-0.3.0.jar` | +| ES 8.x | `softclient4es8-arrow-flight-sql-0.3.0.jar` | +| ES 9.x | `softclient4es9-arrow-flight-sql-0.3.0.jar` | --- diff --git a/documentation/client/download_analytics.md b/documentation/client/download_analytics.md index 1852aaca..096f8bb9 100644 --- a/documentation/client/download_analytics.md +++ b/documentation/client/download_analytics.md @@ -15,7 +15,7 @@ beacon to a public endpoint with exactly these fields: |---------------|----------|-----------------------------------------------| | `source` | `portal` | Where the count came from (the docs button) | | `driver` | `jdbc` | Which driver family (`jdbc` or `adbc`) | -| `version` | `0.2.5` | The published artifact version | +| `version` | `0.3.0` | The published artifact version | | `count_delta` | `1` | One download | A timestamp is added on the server. That is the **entire** record. diff --git a/documentation/client/jdbc.md b/documentation/client/jdbc.md index a9b1f33f..73ac44b2 100644 --- a/documentation/client/jdbc.md +++ b/documentation/client/jdbc.md @@ -20,10 +20,10 @@ Download the self-contained fat JAR for your Elasticsearch version. The JARs are | Elasticsearch | Artifact | |---------------|----------| -| ES 6.x | `softclient4es6-jdbc-driver-0.2.5.jar` | -| ES 7.x | `softclient4es7-jdbc-driver-0.2.5.jar` | -| ES 8.x | `softclient4es8-jdbc-driver-0.2.5.jar` | -| ES 9.x | `softclient4es9-jdbc-driver-0.2.5.jar` | +| ES 6.x | `softclient4es6-jdbc-driver-0.3.0.jar` | +| ES 7.x | `softclient4es7-jdbc-driver-0.3.0.jar` | +| ES 8.x | `softclient4es8-jdbc-driver-0.3.0.jar` | +| ES 9.x | `softclient4es9-jdbc-driver-0.3.0.jar` | ### Build Tool Integration @@ -33,20 +33,20 @@ Download the self-contained fat JAR for your Elasticsearch version. The JARs are app.softnetwork.elastic softclient4es8-jdbc-driver - 0.2.5 + 0.3.0 ``` **Gradle:** ```groovy -implementation 'app.softnetwork.elastic:softclient4es8-jdbc-driver:0.2.5' +implementation 'app.softnetwork.elastic:softclient4es8-jdbc-driver:0.3.0' ``` **sbt:** ```scala -libraryDependencies += "app.softnetwork.elastic" % "softclient4es8-jdbc-driver" % "0.2.5" +libraryDependencies += "app.softnetwork.elastic" % "softclient4es8-jdbc-driver" % "0.3.0" ``` --- diff --git a/documentation/client/repl.md b/documentation/client/repl.md index a3cbca3f..4b73d7ff 100644 --- a/documentation/client/repl.md +++ b/documentation/client/repl.md @@ -162,7 +162,7 @@ unchanged — there is no second implementation to drift: ```bat install.cmd -ListVersions -EsVersion 8 -install.cmd -Target "C:\tools\softclient4es" -EsVersion 8 -Version 0.20.4 +install.cmd -Target "C:\tools\softclient4es" -EsVersion 8 -Version 0.21.0 install.cmd -EsVersion 9 -NoExtensions install.cmd -Help ``` @@ -215,8 +215,9 @@ install.cmd -ListVersions -EsVersion 8 • 0.20.2 • 0.20.3 • 0.20.4 + • 0.21.0 - Total: 4 version(s) + Total: 5 version(s) To install a specific version: ./install.sh --es-version 8 --version @@ -244,7 +245,7 @@ install.cmd -ListVersions -EsVersion 8 ./install.sh --list-versions --es-version 8 # Install specific version -./install.sh --es-version 8 --version 0.20.4 +./install.sh --es-version 8 --version 0.21.0 # Install for Elasticsearch 9 (requires Java 17+) ./install.sh --es-version 9 @@ -253,7 +254,7 @@ install.cmd -ListVersions -EsVersion 8 ./install.sh --target /opt/softclient4es # Full custom installation -./install.sh --target ~/tools/softclient4es --es-version 7 --version 0.20.4 +./install.sh --target ~/tools/softclient4es --es-version 7 --version 0.21.0 ``` #### Windows @@ -266,7 +267,7 @@ install.cmd -ListVersions -EsVersion 8 .\install.ps1 -ListVersions -EsVersion 8 # Install specific version -.\install.ps1 -EsVersion 8 -Version 0.20.4 +.\install.ps1 -EsVersion 8 -Version 0.21.0 # Install for Elasticsearch 9 (requires Java 17+) .\install.ps1 -EsVersion 9 @@ -275,7 +276,7 @@ install.cmd -ListVersions -EsVersion 8 .\install.ps1 -Target "C:\tools\softclient4es" # Full custom installation -.\install.ps1 -Target "C:\tools\softclient4es" -EsVersion 7 -Version 0.20.4 +.\install.ps1 -Target "C:\tools\softclient4es" -EsVersion 7 -Version 0.21.0 ``` --- diff --git a/install.ps1 b/install.ps1 index ab044cac..8a403f0f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -63,7 +63,7 @@ Examples: .\install.ps1 .\install.ps1 -ListVersions -EsVersion 8 .\install.ps1 -Target "C:\tools\softclient4es" -EsVersion 8 -Version 1.0.0 - .\install.ps1 -EsVersion 7 -Version 0.20.4 -NoExtensions + .\install.ps1 -EsVersion 7 -Version 0.21.0 -NoExtensions "@ exit 0 From 99e67d1d40eaa18351713a526d889f723343aade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 20 Aug 2026 20:16:44 +0200 Subject: [PATCH 3/3] docs(sql): PARTITION BY, not PARTITIONED BY The partitioning clause has never been spelled PARTITIONED BY, and the argument shape was wrong too. The grammar is keyword("PARTITION") ~ keyword("BY") ~ ident ~ opt(granularity) so the column comes first and the granularity follows it in parentheses: ) PARTITION BY birthdate (MONTH) -- parses ) PARTITIONED BY (birthdate MONTH) -- "end of input expected" Verified against the real parser, all four forms: PARTITIONED BY (birthdate MONTH) FAIL (as documented until now) PARTITION BY birthdate (MONTH) OK PARTITION BY birthdate MONTH FAIL (parentheses are required) PARTITION BY birthdate OK (granularity defaults to DAY) Both worked examples and the two rows of the index-vs-template table are corrected, and the clause's shape is now stated in prose next to the first example so the parentheses rule is not left to be inferred from a sample. Why this mattered more than a typo: before #214 made trailing input a hard error, the parser SILENTLY DROPPED the unrecognised clause. Following these docs produced a plain concrete index instead of an index template, with no error -- a wrong result rather than a failure. Since #214 it fails loudly, which is how it surfaced. Found by parse-probing every SQL example in the documentation through Parser.apply rather than reading them. The same wrong form is in the REPL help JSON, which ships inside the artefact -- filed separately rather than fixed here. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/sql/ddl_statements.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/documentation/sql/ddl_statements.md b/documentation/sql/ddl_statements.md index 865f727f..1df95702 100644 --- a/documentation/sql/ddl_statements.md +++ b/documentation/sql/ddl_statements.md @@ -58,8 +58,8 @@ A SQL table corresponds to: | SQL Definition | Elasticsearch Structure | |-----------------------------------------|----------------------------------------------------| -| `CREATE TABLE` without `PARTITIONED BY` | **Concrete index** | -| `CREATE TABLE` with `PARTITIONED BY` | **Index template** (legacy ES6 or composable ES7+) | +| `CREATE TABLE` without `PARTITION BY` | **Concrete index** | +| `CREATE TABLE` with `PARTITION BY` | **Index template** (legacy ES6 or composable ES7+) | ### Index-backed table (no partitioning) @@ -79,13 +79,17 @@ Creates: ### Template-backed table (with partitioning) +The clause is `PARTITION BY ()` — the column first, the granularity in +parentheses after it. The parentheses are required when a granularity is given; omit the +granularity entirely and it defaults to `DAY`. + ```sql CREATE TABLE users ( id INT, birthdate DATE, PRIMARY KEY (id) ) -PARTITIONED BY (birthdate MONTH); +PARTITION BY birthdate (MONTH); ``` Creates: @@ -304,7 +308,7 @@ CREATE TABLE users ( birthdate DATE, PRIMARY KEY (id) ) -PARTITIONED BY (birthdate MONTH); +PARTITION BY birthdate (MONTH); ``` ### Table Options (index settings)