From d3cf00e523905a1b3667a8db89b0a797393a475b Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:50:06 +0200 Subject: [PATCH 01/21] Add isolated database compose stack --- docker/docker-compose.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docker/docker-compose.yml diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..8cf5d1b --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,16 @@ +name: neventstore + +services: + sqlexpress: + image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 + environment: + ACCEPT_EULA: "Y" + MSSQL_PID: Express + SA_PASSWORD: Password1 + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Password1 -Q 'SELECT 1' >/dev/null 2>&1"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 20s + volumes: \ No newline at end of file From 78a3a0ebaa43f8024a487c0d0422f9b5820e5da2 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:50:37 +0200 Subject: [PATCH 02/21] Complete isolated database compose stack --- docker/docker-compose.yml | 57 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8cf5d1b..0ff3588 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,5 +1,3 @@ -name: neventstore - services: sqlexpress: image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 @@ -13,4 +11,57 @@ services: timeout: 5s retries: 30 start_period: 20s - volumes: \ No newline at end of file + volumes: + - sqldata:/var/opt/mssql + + mysql: + image: mysql:8.0 + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_DATABASE: NEventStore + MYSQL_ROOT_PASSWORD: Password1 + MYSQL_USER: sa + MYSQL_PASSWORD: Password1 + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -pPassword1 --silent"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 20s + volumes: + - mysqldata:/var/lib/mysql + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: NEventStore + POSTGRES_USER: sa + POSTGRES_PASSWORD: Password1 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U sa -d NEventStore"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 10s + volumes: + - postgresdata:/var/lib/postgresql/data + + oracle: + image: gvenzl/oracle-xe + environment: + ORACLE_ALLOW_REMOTE: "true" + ORACLE_PASSWORD: Password1 + healthcheck: + test: ["CMD-SHELL", "healthcheck.sh"] + interval: 15s + timeout: 10s + retries: 40 + start_period: 30s + volumes: + - oracledata:/opt/oracle/oradata + +volumes: + sqldata: + mysqldata: + postgresdata: + oracledata: From 67a4f4f9d0ae616cbd07664f3c1495fc30a2d2cc Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:50:45 +0200 Subject: [PATCH 03/21] Add dynamic database port mappings --- docker/docker-compose.dynamic.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docker/docker-compose.dynamic.yml diff --git a/docker/docker-compose.dynamic.yml b/docker/docker-compose.dynamic.yml new file mode 100644 index 0000000..444e117 --- /dev/null +++ b/docker/docker-compose.dynamic.yml @@ -0,0 +1,13 @@ +services: + sqlexpress: + ports: + - "127.0.0.1::1433" + mysql: + ports: + - "127.0.0.1::3306" + postgres: + ports: + - "127.0.0.1::5432" + oracle: + ports: + - "127.0.0.1::1521" From cedd26e484dd9d0023a7ce79aa2bb4a4fe6c5b6d Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:50:51 +0200 Subject: [PATCH 04/21] Add debug database port mappings --- docker/docker-compose.debug.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docker/docker-compose.debug.yml diff --git a/docker/docker-compose.debug.yml b/docker/docker-compose.debug.yml new file mode 100644 index 0000000..de30004 --- /dev/null +++ b/docker/docker-compose.debug.yml @@ -0,0 +1,13 @@ +services: + sqlexpress: + ports: + - "127.0.0.1:50001:1433" + mysql: + ports: + - "127.0.0.1:50003:3306" + postgres: + ports: + - "127.0.0.1:50004:5432" + oracle: + ports: + - "127.0.0.1:50005:1521" From e54781798111ad5f6512fd6e6401d8977753b72a Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:50:57 +0200 Subject: [PATCH 05/21] Add test database port mappings --- docker/docker-compose.test.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docker/docker-compose.test.yml diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml new file mode 100644 index 0000000..247d3a1 --- /dev/null +++ b/docker/docker-compose.test.yml @@ -0,0 +1,13 @@ +services: + sqlexpress: + ports: + - "127.0.0.1:51001:1433" + mysql: + ports: + - "127.0.0.1:51003:3306" + postgres: + ports: + - "127.0.0.1:51004:5432" + oracle: + ports: + - "127.0.0.1:51005:1521" From 74aae640d568ed52cc20a016e369b0b33d263dd7 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:51:03 +0200 Subject: [PATCH 06/21] Add ci database port mappings --- docker/docker-compose.ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docker/docker-compose.ci.yml diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml new file mode 100644 index 0000000..6807926 --- /dev/null +++ b/docker/docker-compose.ci.yml @@ -0,0 +1,13 @@ +services: + sqlexpress: + ports: + - "127.0.0.1:52001:1433" + mysql: + ports: + - "127.0.0.1:52003:3306" + postgres: + ports: + - "127.0.0.1:52004:5432" + oracle: + ports: + - "127.0.0.1:52005:1521" From 4ffccf61eb88cd71aa3db130cb030068bb6e82d7 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:51:36 +0200 Subject: [PATCH 07/21] Add Bash environment start script --- start-environment.sh | 140 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 start-environment.sh diff --git a/start-environment.sh b/start-environment.sh new file mode 100644 index 0000000..1a8ae5a --- /dev/null +++ b/start-environment.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Purpose: Start the complete isolated database stack for one worktree/environment. +# Usage: ./start-environment.sh [debug|test|ci|] +# Reserved environments use predictable ports; omitted names derive from the worktree directory +# and use Docker-assigned ports. Successful starts write .env.debug, .env.test, .env.ci, or +# .env.dynamic only after every service is healthy. Volumes are preserved by stop by default; +# use stop-environment.sh --remove-data to remove only that environment's data. +# The script is non-interactive and exits non-zero on invalid input, Docker/Compose failure, +# unhealthy services, failed database initialization, unresolved ports, or file-write failure. +# Safety: no container_name values or global Docker cleanup commands are used. Fixed ports support +# predictable human/CI workflows; dynamic ports prevent worktree collisions. Compose owns names +# for containers, networks, and volumes. Component values, not complete connection strings, are +# written so provider-specific construction remains in test code. + +set -euo pipefail + +readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly COMPOSE_DIR="$ROOT_DIR/docker" +readonly REPOSITORY_PREFIX="neventstore" +readonly DATABASE_NAME="NEventStore" +readonly DATABASE_USERNAME="sa" +readonly DATABASE_PASSWORD="Password1" + +fail() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +normalize_environment_name() { + printf '%s' "$1" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/-+/-/g; s/^-//; s/-$//' +} + +default_environment_name() { + local worktree_root + worktree_root="$(git -C "$ROOT_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$ROOT_DIR")" + basename "$worktree_root" +} + +compose_override_for() { + case "$1" in + debug|test|ci) printf '%s/docker-compose.%s.yml' "$COMPOSE_DIR" "$1" ;; + *) printf '%s/docker-compose.dynamic.yml' "$COMPOSE_DIR" ;; + esac +} + +env_file_for() { + case "$1" in + debug|test|ci) printf '%s/.env.%s' "$ROOT_DIR" "$1" ;; + *) printf '%s/.env.dynamic' "$ROOT_DIR" ;; + esac +} + +wait_for_service() { + local service="$1" + local container_id status attempt + container_id="$(${COMPOSE[@]} ps -q "$service")" + [[ -n "$container_id" ]] || fail "Compose did not create service '$service'." + + for attempt in $(seq 1 120); do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || true)" + [[ "$status" == "healthy" ]] && return 0 + [[ "$status" == "exited" || "$status" == "dead" ]] && break + sleep 2 + done + + docker logs --tail 100 "$container_id" >&2 || true + fail "Service '$service' did not become healthy (last status: ${status:-unknown})." +} + +published_port() { + local service="$1" + local container_port="$2" + local binding port + binding="$(${COMPOSE[@]} port "$service" "$container_port" | tail -n 1)" + port="${binding##*:}" + [[ "$port" =~ ^[0-9]+$ ]] || fail "Could not resolve the published port for '$service:$container_port'." + printf '%s' "$port" +} + +[[ $# -le 1 ]] || fail "Usage: ./start-environment.sh [debug|test|ci|]" +command -v docker >/dev/null 2>&1 || fail "Docker is not installed or not on PATH." +docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is unavailable." + +raw_environment="${1:-$(default_environment_name)}" +environment_name="$(normalize_environment_name "$raw_environment")" +[[ -n "$environment_name" ]] || fail "The environment name becomes empty after normalization." + +project_name="$REPOSITORY_PREFIX-$environment_name" +override_file="$(compose_override_for "$environment_name")" +env_file="$(env_file_for "$environment_name")" +readonly -a COMPOSE=(docker compose --project-name "$project_name" -f "$COMPOSE_DIR/docker-compose.yml" -f "$override_file") +readonly -a SERVICES=(sqlexpress mysql postgres oracle) + +"${COMPOSE[@]}" up --detach +for service in "${SERVICES[@]}"; do + wait_for_service "$service" +done + +sql_container="$(${COMPOSE[@]} ps -q sqlexpress)" +docker exec "$sql_container" /opt/mssql-tools/bin/sqlcmd \ + -S localhost -U sa -P "$DATABASE_PASSWORD" \ + -Q "IF DB_ID(N'$DATABASE_NAME') IS NULL CREATE DATABASE [$DATABASE_NAME];" >/dev/null + +sqlserver_port="$(published_port sqlexpress 1433)" +mysql_port="$(published_port mysql 3306)" +postgres_port="$(published_port postgres 5432)" +oracle_port="$(published_port oracle 1521)" + +temporary_file="$(mktemp "$ROOT_DIR/.env.tmp.XXXXXX")" +trap 'rm -f "$temporary_file"' EXIT +cat > "$temporary_file" < Date: Sat, 11 Jul 2026 13:51:54 +0200 Subject: [PATCH 08/21] Add Bash environment stop script --- stop-environment.sh | 76 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 stop-environment.sh diff --git a/stop-environment.sh b/stop-environment.sh new file mode 100644 index 0000000..612947e --- /dev/null +++ b/stop-environment.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Purpose: Stop only the Compose project belonging to one worktree/environment. +# Usage: ./stop-environment.sh [debug|test|ci|] [--remove-data] +# Reserved names match the fixed environments. With no name, the worktree directory is normalized +# and used as a dynamic environment. Generated .env files and named volumes are preserved by +# default, making repeated starts predictable. --remove-data adds Compose --volumes and affects +# only the selected project. The script is non-interactive and exits non-zero on invalid input or +# Compose failure. It never uses fixed container names or global Docker cleanup commands because +# one developer or agent must not be allowed to demolish everybody else's databases by enthusiasm. + +set -euo pipefail + +readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly COMPOSE_DIR="$ROOT_DIR/docker" +readonly REPOSITORY_PREFIX="neventstore" + +fail() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +normalize_environment_name() { + printf '%s' "$1" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/-+/-/g; s/^-//; s/-$//' +} + +default_environment_name() { + local worktree_root + worktree_root="$(git -C "$ROOT_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$ROOT_DIR")" + basename "$worktree_root" +} + +compose_override_for() { + case "$1" in + debug|test|ci) printf '%s/docker-compose.%s.yml' "$COMPOSE_DIR" "$1" ;; + *) printf '%s/docker-compose.dynamic.yml' "$COMPOSE_DIR" ;; + esac +} + +environment_argument="" +remove_data=false +for argument in "$@"; do + case "$argument" in + --remove-data) remove_data=true ;; + --*) fail "Unknown option '$argument'." ;; + *) + [[ -z "$environment_argument" ]] || fail "Only one environment name may be supplied." + environment_argument="$argument" + ;; + esac +done + +command -v docker >/dev/null 2>&1 || fail "Docker is not installed or not on PATH." +docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is unavailable." + +raw_environment="${environment_argument:-$(default_environment_name)}" +environment_name="$(normalize_environment_name "$raw_environment")" +[[ -n "$environment_name" ]] || fail "The environment name becomes empty after normalization." + +project_name="$REPOSITORY_PREFIX-$environment_name" +override_file="$(compose_override_for "$environment_name")" +compose=(docker compose --project-name "$project_name" -f "$COMPOSE_DIR/docker-compose.yml" -f "$override_file") + +if $remove_data; then + "${compose[@]}" down --volumes +else + "${compose[@]}" down +fi + +printf 'Stopped environment %s (Compose project %s).\n' "$environment_name" "$project_name" +if $remove_data; then + printf 'Removed only this environment\047s named volumes; generated .env files were preserved.\n' +else + printf 'Named volumes and generated .env files were preserved.\n' +fi From b88955cb04a68991004bb3570222221e370f7abd Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:52:18 +0200 Subject: [PATCH 09/21] Add PowerShell environment start script --- start-environment.ps1 | 199 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 start-environment.ps1 diff --git a/start-environment.ps1 b/start-environment.ps1 new file mode 100644 index 0000000..e957896 --- /dev/null +++ b/start-environment.ps1 @@ -0,0 +1,199 @@ +<# +.SYNOPSIS +Starts the complete isolated database stack for one worktree/environment. + +.DESCRIPTION +Usage: ./start-environment.ps1 [debug|test|ci|] +Reserved environments use predictable ports. With no name, the current Git worktree directory is +normalized and Docker assigns collision-free ports. A successful start atomically writes exactly +one of .env.debug, .env.test, .env.ci, or .env.dynamic after every service is healthy. +Volumes are preserved by stop by default; use stop-environment.ps1 --remove-data to remove +only that environment's data. The script is non-interactive and throws on every failure. + +Fixed ports exist for predictable human and CI workflows; dynamic ports exist for parallel +worktrees. Compose, not the scripts, owns container/network/volume names, so container_name is +forbidden. Generated files contain provider components rather than complete connection strings so +construction remains test-code responsibility. Global Docker cleanup commands are prohibited +because they can destroy unrelated environments. +#> + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string] $EnvironmentName +) + +$ErrorActionPreference = 'Stop' +$RootDirectory = $PSScriptRoot +$ComposeDirectory = Join-Path $RootDirectory 'docker' +$RepositoryPrefix = 'neventstore' +$DatabaseName = 'NEventStore' +$DatabaseUsername = 'sa' +$DatabasePassword = 'Password1' + +function Normalize-EnvironmentName { + param([Parameter(Mandatory)][string] $Name) + + $normalized = $Name.ToLowerInvariant() -replace '[^a-z0-9]+', '-' + $normalized = $normalized -replace '-+', '-' + return $normalized.Trim('-') +} + +function Get-DefaultEnvironmentName { + $worktreeRoot = (& git -C $RootDirectory rev-parse --show-toplevel 2>$null) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($worktreeRoot)) { + $worktreeRoot = $RootDirectory + } + + return Split-Path $worktreeRoot.Trim() -Leaf +} + +function Get-ComposeOverride { + param([Parameter(Mandatory)][string] $Name) + + if ($Name -in @('debug', 'test', 'ci')) { + return Join-Path $ComposeDirectory "docker-compose.$Name.yml" + } + + return Join-Path $ComposeDirectory 'docker-compose.dynamic.yml' +} + +function Get-EnvironmentFile { + param([Parameter(Mandatory)][string] $Name) + + if ($Name -in @('debug', 'test', 'ci')) { + return Join-Path $RootDirectory ".env.$Name" + } + + return Join-Path $RootDirectory '.env.dynamic' +} + +function Invoke-Compose { + param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) + + & docker compose --project-name $script:ProjectName ` + -f (Join-Path $ComposeDirectory 'docker-compose.yml') ` + -f $script:OverrideFile @Arguments + + if ($LASTEXITCODE -ne 0) { + throw "Docker Compose failed: $($Arguments -join ' ')" + } +} + +function Wait-ServiceHealthy { + param([Parameter(Mandatory)][string] $Service) + + $containerId = (Invoke-Compose ps -q $Service | Select-Object -Last 1).Trim() + if ([string]::IsNullOrWhiteSpace($containerId)) { + throw "Compose did not create service '$Service'." + } + + $status = 'unknown' + for ($attempt = 1; $attempt -le 120; $attempt++) { + $status = (& docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $containerId 2>$null) + if ($status -eq 'healthy') { + return + } + + if ($status -in @('exited', 'dead')) { + break + } + + Start-Sleep -Seconds 2 + } + + & docker logs --tail 100 $containerId 2>$null | Write-Error + throw "Service '$Service' did not become healthy (last status: $status)." +} + +function Get-PublishedPort { + param( + [Parameter(Mandatory)][string] $Service, + [Parameter(Mandatory)][int] $ContainerPort + ) + + $binding = (Invoke-Compose port $Service $ContainerPort | Select-Object -Last 1).Trim() + $port = $binding.Substring($binding.LastIndexOf(':') + 1) + if ($port -notmatch '^\d+$') { + throw "Could not resolve the published port for '$Service`:$ContainerPort'." + } + + return $port +} + +& docker compose version *> $null +if ($LASTEXITCODE -ne 0) { + throw 'Docker Compose v2 is unavailable.' +} + +$rawEnvironment = if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { + Get-DefaultEnvironmentName +} +else { + $EnvironmentName +} + +$normalizedEnvironment = Normalize-EnvironmentName $rawEnvironment +if ([string]::IsNullOrWhiteSpace($normalizedEnvironment)) { + throw 'The environment name becomes empty after normalization.' +} + +$script:ProjectName = "$RepositoryPrefix-$normalizedEnvironment" +$script:OverrideFile = Get-ComposeOverride $normalizedEnvironment +$environmentFile = Get-EnvironmentFile $normalizedEnvironment + +Invoke-Compose up --detach +foreach ($service in @('sqlexpress', 'mysql', 'postgres', 'oracle')) { + Wait-ServiceHealthy $service +} + +$sqlContainer = (Invoke-Compose ps -q sqlexpress | Select-Object -Last 1).Trim() +& docker exec $sqlContainer /opt/mssql-tools/bin/sqlcmd ` + -S localhost -U sa -P $DatabasePassword ` + -Q "IF DB_ID(N'$DatabaseName') IS NULL CREATE DATABASE [$DatabaseName];" *> $null +if ($LASTEXITCODE -ne 0) { + throw 'Failed to create or verify the SQL Server NEventStore database.' +} + +$sqlServerPort = Get-PublishedPort sqlexpress 1433 +$mySqlPort = Get-PublishedPort mysql 3306 +$postgresPort = Get-PublishedPort postgres 5432 +$oraclePort = Get-PublishedPort oracle 1521 + +$temporaryFile = Join-Path $RootDirectory ".env.tmp.$([Guid]::NewGuid().ToString('N'))" +try { + @( + 'SQLSERVER_HOST=127.0.0.1' + "SQLSERVER_PORT=$sqlServerPort" + "SQLSERVER_DATABASE=$DatabaseName" + "SQLSERVER_USERNAME=$DatabaseUsername" + "SQLSERVER_PASSWORD=$DatabasePassword" + 'MYSQL_HOST=127.0.0.1' + "MYSQL_PORT=$mySqlPort" + "MYSQL_DATABASE=$DatabaseName" + "MYSQL_USERNAME=$DatabaseUsername" + "MYSQL_PASSWORD=$DatabasePassword" + 'POSTGRES_HOST=127.0.0.1' + "POSTGRES_PORT=$postgresPort" + "POSTGRES_DATABASE=$DatabaseName" + "POSTGRES_USERNAME=$DatabaseUsername" + "POSTGRES_PASSWORD=$DatabasePassword" + 'ORACLE_HOST=127.0.0.1' + "ORACLE_PORT=$oraclePort" + 'ORACLE_SERVICE=XE' + 'ORACLE_USERNAME=system' + "ORACLE_PASSWORD=$DatabasePassword" + ) | Set-Content -Path $temporaryFile -Encoding utf8NoBOM + + Move-Item -Path $temporaryFile -Destination $environmentFile -Force +} +finally { + Remove-Item $temporaryFile -Force -ErrorAction SilentlyContinue +} + +$testEnvironment = if ($normalizedEnvironment -in @('debug', 'test', 'ci')) { $normalizedEnvironment } else { 'dynamic' } +Write-Host "Environment: $normalizedEnvironment" +Write-Host "Compose project: $script:ProjectName" +Write-Host "Configuration: $environmentFile" +Write-Host "Ports: SQL Server=$sqlServerPort, MySQL=$mySqlPort, PostgreSQL=$postgresPort, Oracle=$oraclePort" +Write-Host "Tests: `$env:NEVENTSTORE_ENVIRONMENT='$testEnvironment'; dotnet test ./src/NEventStore.Persistence.Sql.Core.sln" From 03bd38d58cd79b61e7f057632dbbbe21c84fa014 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:52:30 +0200 Subject: [PATCH 10/21] Add PowerShell environment stop script --- stop-environment.ps1 | 96 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 stop-environment.ps1 diff --git a/stop-environment.ps1 b/stop-environment.ps1 new file mode 100644 index 0000000..ecb852a --- /dev/null +++ b/stop-environment.ps1 @@ -0,0 +1,96 @@ +<# +.SYNOPSIS +Stops only the Compose project belonging to one worktree/environment. + +.DESCRIPTION +Usage: ./stop-environment.ps1 [debug|test|ci|] [--remove-data] +Reserved names select fixed environments. With no name, the current Git worktree directory is +normalized and used as a dynamic environment. Generated .env files and named volumes are preserved +by default. --remove-data removes only the selected Compose project's volumes. The script is +non-interactive and throws on invalid input or Compose failure. It never uses fixed container names +or global Docker cleanup commands, because unrelated worktrees are not collateral damage. +#> + +[CmdletBinding(PositionalBinding = $false)] +param( + [Parameter(Position = 0)] + [string] $EnvironmentName, + + [Alias('remove-data')] + [switch] $RemoveData +) + +$ErrorActionPreference = 'Stop' +$RootDirectory = $PSScriptRoot +$ComposeDirectory = Join-Path $RootDirectory 'docker' +$RepositoryPrefix = 'neventstore' + +function Normalize-EnvironmentName { + param([Parameter(Mandatory)][string] $Name) + + $normalized = $Name.ToLowerInvariant() -replace '[^a-z0-9]+', '-' + $normalized = $normalized -replace '-+', '-' + return $normalized.Trim('-') +} + +function Get-DefaultEnvironmentName { + $worktreeRoot = (& git -C $RootDirectory rev-parse --show-toplevel 2>$null) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($worktreeRoot)) { + $worktreeRoot = $RootDirectory + } + + return Split-Path $worktreeRoot.Trim() -Leaf +} + +function Get-ComposeOverride { + param([Parameter(Mandatory)][string] $Name) + + if ($Name -in @('debug', 'test', 'ci')) { + return Join-Path $ComposeDirectory "docker-compose.$Name.yml" + } + + return Join-Path $ComposeDirectory 'docker-compose.dynamic.yml' +} + +& docker compose version *> $null +if ($LASTEXITCODE -ne 0) { + throw 'Docker Compose v2 is unavailable.' +} + +$rawEnvironment = if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { + Get-DefaultEnvironmentName +} +else { + $EnvironmentName +} + +$normalizedEnvironment = Normalize-EnvironmentName $rawEnvironment +if ([string]::IsNullOrWhiteSpace($normalizedEnvironment)) { + throw 'The environment name becomes empty after normalization.' +} + +$projectName = "$RepositoryPrefix-$normalizedEnvironment" +$overrideFile = Get-ComposeOverride $normalizedEnvironment +$arguments = @( + 'compose' + '--project-name', $projectName + '-f', (Join-Path $ComposeDirectory 'docker-compose.yml') + '-f', $overrideFile + 'down' +) +if ($RemoveData) { + $arguments += '--volumes' +} + +& docker @arguments +if ($LASTEXITCODE -ne 0) { + throw "Docker Compose failed while stopping environment '$normalizedEnvironment'." +} + +Write-Host "Stopped environment $normalizedEnvironment (Compose project $projectName)." +if ($RemoveData) { + Write-Host "Removed only this environment's named volumes; generated .env files were preserved." +} +else { + Write-Host 'Named volumes and generated .env files were preserved.' +} From 3a8f7d20ff7f44f3224cf1b3ac9f26c2768d8d1c Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:53:34 +0200 Subject: [PATCH 11/21] Add selected environment file loader --- .../EnvironmentFileConnectionString.cs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/NEventStore.Persistence.Sql.Tests/EnvironmentFileConnectionString.cs diff --git a/src/NEventStore.Persistence.Sql.Tests/EnvironmentFileConnectionString.cs b/src/NEventStore.Persistence.Sql.Tests/EnvironmentFileConnectionString.cs new file mode 100644 index 0000000..f3c41c5 --- /dev/null +++ b/src/NEventStore.Persistence.Sql.Tests/EnvironmentFileConnectionString.cs @@ -0,0 +1,113 @@ +namespace NEventStore.Persistence.Sql.Tests +{ + internal static class EnvironmentFileConnectionString + { + private const string SelectorName = "NEVENTSTORE_ENVIRONMENT"; + + public static string? TryBuild(string providerName) + { + var selectedEnvironment = Environment.GetEnvironmentVariable(SelectorName, EnvironmentVariableTarget.Process) ?? "dynamic"; + selectedEnvironment = selectedEnvironment.Trim().ToLowerInvariant(); + if (selectedEnvironment != "dynamic" && selectedEnvironment != "debug" && selectedEnvironment != "test" && selectedEnvironment != "ci") + { + throw new InvalidOperationException( + string.Format("Unsupported {0} value '{1}'. Expected dynamic, debug, test, or ci.", SelectorName, selectedEnvironment)); + } + + var fileName = string.Format(".env.{0}", selectedEnvironment); + var filePath = FindFile(fileName); + if (filePath == null) + { + return null; + } + + var values = ReadFile(filePath); + return Build(providerName, values, filePath); + } + + private static string? FindFile(string fileName) + { + var searched = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var startDirectory in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory }) + { + var directory = new DirectoryInfo(startDirectory); + while (directory != null && searched.Add(directory.FullName)) + { + var candidate = Path.Combine(directory.FullName, fileName); + if (File.Exists(candidate)) + { + return candidate; + } + directory = directory.Parent; + } + } + + return null; + } + + private static Dictionary ReadFile(string filePath) + { + var values = new Dictionary(StringComparer.Ordinal); + foreach (var rawLine in File.ReadLines(filePath)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) + { + continue; + } + + var separator = line.IndexOf('='); + if (separator <= 0) + { + throw new InvalidOperationException(string.Format("Invalid line in '{0}': {1}", filePath, rawLine)); + } + + values[line.Substring(0, separator).Trim()] = line.Substring(separator + 1).Trim().Trim('"'); + } + return values; + } + + private static string Build(string providerName, IReadOnlyDictionary values, string filePath) + { + switch (providerName) + { + case "MsSql": + return string.Format( + "Server={0},{1};Database={2};User Id={3};Password={4};TrustServerCertificate=True;", + Get(values, "SQLSERVER_HOST", filePath), Get(values, "SQLSERVER_PORT", filePath), + Get(values, "SQLSERVER_DATABASE", filePath), Get(values, "SQLSERVER_USERNAME", filePath), + Get(values, "SQLSERVER_PASSWORD", filePath)); + case "MySql": + return string.Format( + "Server={0};Port={1};Database={2};Uid={3};Pwd={4};AutoEnlist=false;", + Get(values, "MYSQL_HOST", filePath), Get(values, "MYSQL_PORT", filePath), + Get(values, "MYSQL_DATABASE", filePath), Get(values, "MYSQL_USERNAME", filePath), + Get(values, "MYSQL_PASSWORD", filePath)); + case "PostgreSql": + return string.Format( + "Server={0};Port={1};Database={2};Uid={3};Pwd={4};Enlist=false;", + Get(values, "POSTGRES_HOST", filePath), Get(values, "POSTGRES_PORT", filePath), + Get(values, "POSTGRES_DATABASE", filePath), Get(values, "POSTGRES_USERNAME", filePath), + Get(values, "POSTGRES_PASSWORD", filePath)); + case "Oracle": + return string.Format( + "Data Source={0}:{1}/{2};User Id={3};Password={4};Persist Security Info=True;", + Get(values, "ORACLE_HOST", filePath), Get(values, "ORACLE_PORT", filePath), + Get(values, "ORACLE_SERVICE", filePath), Get(values, "ORACLE_USERNAME", filePath), + Get(values, "ORACLE_PASSWORD", filePath)); + default: + throw new InvalidOperationException(string.Format("No .env mapping exists for provider '{0}'.", providerName)); + } + } + + private static string Get(IReadOnlyDictionary values, string key, string filePath) + { + if (values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + + throw new InvalidOperationException(string.Format("Required value '{0}' is missing from '{1}'.", key, filePath)); + } + } +} From 67b95df32ff53053176c212f9709decec0af75fc Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:53:49 +0200 Subject: [PATCH 12/21] Use selected environment file fallback --- .../EnvironmentConnectionFactory.cs | 59 +++++++++---------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs b/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs index 5deeb4e..0de0c3b 100644 --- a/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs +++ b/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs @@ -5,18 +5,21 @@ namespace NEventStore.Persistence.Sql.Tests { public class EnvironmentConnectionFactory : IConnectionFactory { + private readonly string _envDatabaseName; private readonly string _envVarKey; private readonly DbProviderFactory _dbProviderFactory; #if NET462_OR_GREATER public EnvironmentConnectionFactory(string envDatabaseName, string providerInvariantName) { + _envDatabaseName = envDatabaseName; _envVarKey = string.Format("NEventStore.{0}", envDatabaseName); _dbProviderFactory = DbProviderFactories.GetFactory(providerInvariantName); } #endif public EnvironmentConnectionFactory(string envDatabaseName, DbProviderFactory dbProviderFactory) { + _envDatabaseName = envDatabaseName; _envVarKey = string.Format("NEventStore.{0}", envDatabaseName); _dbProviderFactory = dbProviderFactory; } @@ -40,21 +43,7 @@ public Type GetDbProviderFactoryType() private DbConnection OpenInternal() { - var connectionString = Environment.GetEnvironmentVariable(_envVarKey, EnvironmentVariableTarget.Process); - if (connectionString == null) - { - string message = - string.Format( - "Failed to get '{0}' environment variable. Please ensure " + - "you have correctly setup the connection string environment variables. Refer to the " + - "NEventStore wiki for details.", - _envVarKey); - throw new InvalidOperationException(message); - } - connectionString = connectionString.TrimStart('"').TrimEnd('"'); - var connection = _dbProviderFactory.CreateConnection(); - Debug.Assert(connection != null, "connection == null"); - connection!.ConnectionString = connectionString; + var connection = CreateConnection(); try { connection.Open(); @@ -68,21 +57,7 @@ private DbConnection OpenInternal() private async Task OpenInternalAsync(CancellationToken cancellationToken) { - var connectionString = Environment.GetEnvironmentVariable(_envVarKey, EnvironmentVariableTarget.Process); - if (connectionString == null) - { - string message = - string.Format( - "Failed to get '{0}' environment variable. Please ensure " + - "you have correctly setup the connection string environment variables. Refer to the " + - "NEventStore wiki for details.", - _envVarKey); - throw new InvalidOperationException(message); - } - connectionString = connectionString.TrimStart('"').TrimEnd('"'); - var connection = _dbProviderFactory.CreateConnection(); - Debug.Assert(connection != null, "connection == null"); - connection!.ConnectionString = connectionString; + var connection = CreateConnection(); try { await connection.OpenAsync(cancellationToken).ConfigureAwait(false); @@ -93,5 +68,27 @@ private async Task OpenInternalAsync(CancellationToken cancellatio } return connection; } + + private DbConnection CreateConnection() + { + var connectionString = Environment.GetEnvironmentVariable(_envVarKey, EnvironmentVariableTarget.Process); + if (string.IsNullOrWhiteSpace(connectionString)) + { + connectionString = EnvironmentFileConnectionString.TryBuild(_envDatabaseName); + } + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + string.Format( + "Failed to get '{0}' and the selected .env file was not found. Start the database environment or define the existing connection-string environment variable.", + _envVarKey)); + } + + var connection = _dbProviderFactory.CreateConnection(); + Debug.Assert(connection != null, "connection == null"); + connection!.ConnectionString = connectionString.TrimStart('"').TrimEnd('"'); + return connection; + } } -} \ No newline at end of file +} From 37c005392ba45a3c5046f3a8c0fff287c9da8f50 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:54:09 +0200 Subject: [PATCH 13/21] Ignore generated database environment files --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b48498b..1ea9f85 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,10 @@ UpgradeLog*.XML # Custom artifacts/ -.tokensave/ \ No newline at end of file +.tokensave/ + +# Generated local database environment configuration +.env.dynamic +.env.debug +.env.test +.env.ci From df31aa3eb919b28bf303d7a972eab8ddca0a595e Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:54:30 +0200 Subject: [PATCH 14/21] Document isolated agent database usage --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c5af3d9..023c9c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,15 @@ See [build.ps1](build.ps1), [appveyor.yml](appveyor.yml), and [README.md](README Use [docker/Readme.md](docker/Readme.md) and [README.md](README.md) as the source of truth for environment setup. +## Isolated Database Environments + +- Work only inside the assigned Git worktree. +- Use the repository `start-environment` and `stop-environment` scripts; use a dynamic environment by default. +- Do not use the reserved `debug`, `test`, or `ci` environments unless the task explicitly requires one. +- Never commit `.env.dynamic`, `.env.debug`, `.env.test`, or `.env.ci`. +- Never use global Docker cleanup commands. +- Do not stop, remove, inspect for mutation, or otherwise modify another worktree's Compose environment. + ## Repository Map - Core persistence library: `src/NEventStore.Persistence.Sql/` From eafad1f589ef47d9fe0073582800686bb1478237 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:54:55 +0200 Subject: [PATCH 15/21] Document parallel database environments --- docker/Readme.md | 87 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/docker/Readme.md b/docker/Readme.md index 4e5a5c9..b425741 100644 --- a/docker/Readme.md +++ b/docker/Readme.md @@ -1,18 +1,83 @@ -# Docker +# Docker database environments -Docker must be configured to use LCOW in Windows OS. +The repository starts SQL Server, MySQL, PostgreSQL, and Oracle as one Linux-container Docker Compose project. Each environment has isolated containers, networks, named volumes, and database data. -Run the following scripts to start and stop all the Database engines: +Run scripts from the repository root: +```bash +./start-environment.sh +./start-environment.sh debug +./start-environment.sh test +./start-environment.sh ci +./start-environment.sh issue-142 + +./stop-environment.sh +./stop-environment.sh debug +./stop-environment.sh issue-142 +./stop-environment.sh issue-142 --remove-data +``` + +PowerShell uses the equivalent `start-environment.ps1` and `stop-environment.ps1` commands. + +## Environment names + +`debug`, `test`, and `ci` are reserved fixed environments. Any other name is dynamic. When no name is supplied, the script derives it from the Git worktree directory. + +Names are converted to lowercase, unsupported characters become `-`, repeated separators collapse, and leading/trailing separators are removed. The Compose project name is: + +```text +neventstore- +``` + +Compose generates all container, network, and volume names. No service uses `container_name`, because fixed names would prevent parallel worktrees. + +## Ports + +| Database | Debug | Test | CI | +| --- | ---: | ---: | ---: | +| SQL Server | 50001 | 51001 | 52001 | +| MySQL | 50003 | 51003 | 52003 | +| PostgreSQL | 50004 | 51004 | 52004 | +| Oracle | 50005 | 51005 | 52005 | + +Fixed ports make human debugging and CI configuration predictable. Dynamic environments bind to `127.0.0.1` with Docker-assigned host ports so parallel worktrees do not need a shared allocator and cannot collide under normal Docker behavior. + +## Generated configuration + +A successful start overwrites exactly one local file after every database is healthy and SQL Server's `NEventStore` database exists: + +- `debug` writes `.env.debug` +- `test` writes `.env.test` +- `ci` writes `.env.ci` +- any dynamic name writes `.env.dynamic` + +The files contain provider components such as host, port, database, username, and password. Connection strings are assembled by the test code so shell scripts do not own provider syntax. + +The existing complete connection-string environment variables remain supported and take precedence: + +- `NEventStore.MsSql` +- `NEventStore.MySql` +- `NEventStore.PostgreSql` +- `NEventStore.Oracle` + +When those variables are absent, tests load one selected file. Set `NEVENTSTORE_ENVIRONMENT` to `dynamic`, `debug`, `test`, or `ci`; it defaults to `dynamic`. + +```bash +NEVENTSTORE_ENVIRONMENT=dynamic dotnet test ./src/NEventStore.Persistence.Sql.Core.sln +NEVENTSTORE_ENVIRONMENT=debug dotnet test ./src/NEventStore.Persistence.Sql.Core.sln ``` -DockerComposeUp.ps1 -DockerComposeDown.ps1 + +```powershell +$env:NEVENTSTORE_ENVIRONMENT = 'test' +dotnet test ./src/NEventStore.Persistence.Sql.Core.sln ``` -## Docker Environment Variables +The generated files are ignored by Git and must not be committed. + +## Data lifecycle and safety + +Normal stop preserves named volumes and generated `.env` files. This makes repeated starts fast and keeps database state available for investigation. + +`--remove-data` passes `--volumes` only to the selected Compose project. It does not remove another worktree's data. Global commands such as `docker system prune`, `docker volume prune`, and `docker network prune` are prohibited because they are not scoped to one environment. -NEventStore.MsSql="Server=localhost,50001;Database=NEventStore;User Id=sa;Password=Password1;TrustServerCertificate=True;" -NEventStore.MongoDB="mongodb://localhost:50002/NEventStore" -NEventStore.MySql="Server=localhost;Port=50003;Database=NEventStore;Uid=sa;Pwd=Password1;AutoEnlist=false;" -NEventStore.PostgreSql="Server=localhost;Port=50004;Database=NEventStore;Uid=sa;Pwd=Password1;Enlist=false;" -NEventStore.Oracle="Data Source=localhost:50005/XE;User Id=system;Password=Password1;Persist Security Info=True;" +The scripts are non-interactive and return a non-zero exit code when Docker, Compose, service health, SQL Server initialization, port discovery, or configuration-file generation fails. From 91cafe0858fba87c0b3ec0334dda8fa18c0bc990 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:55:15 +0200 Subject: [PATCH 16/21] Document isolated local database setup --- README.md | 56 +++++++++++++++++++++++++++---------------------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 99a00d9..c16f738 100644 --- a/README.md +++ b/README.md @@ -57,17 +57,30 @@ To build the project locally on a Windows Machine: - Optional: update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating relase packages). - Open a Powershell console in Administrative mode and run the build script `build.ps1` in the root of the repository. -## How to Run Unit Tests (locally) +## How to Run Tests (locally) -- Install Database engines or use Docker to run them in a container (you can use the scripts in `./docker` folder). -- Define the following environment variables: +Start the complete Linux-container database environment from the repository root. With no explicit name, the script derives a dynamic environment from the current Git worktree directory and Docker assigns collision-free host ports. - ``` - NEventStore.MsSql="Server=localhost,50001;Database=NEventStore;User Id=sa;Password=Password1;TrustServerCertificate=True;" - NEventStore.MySql="Server=localhost;Port=50003;Database=NEventStore;Uid=sa;Pwd=Password1;AutoEnlist=false;" - NEventStore.PostgreSql="Server=localhost;Port=50004;Database=NEventStore;Uid=sa;Pwd=Password1;Enlist=false;" - NEventStore.Oracle="Data Source=localhost:50005/XE;User Id=system;Password=Password1;Persist Security Info=True;" - ``` +```bash +./start-environment.sh +NEVENTSTORE_ENVIRONMENT=dynamic dotnet test ./src/NEventStore.Persistence.Sql.Core.sln +./stop-environment.sh +``` + +PowerShell equivalents are also provided: + +```powershell +./start-environment.ps1 +$env:NEVENTSTORE_ENVIRONMENT = 'dynamic' +dotnet test ./src/NEventStore.Persistence.Sql.Core.sln +./stop-environment.ps1 +``` + +Reserved `debug`, `test`, and `ci` environments use predictable ports and may run concurrently. Explicit dynamic names such as `issue-142` are also supported. Normal stop preserves database volumes; pass `--remove-data` to the stop script to remove only the selected environment's data. + +Successful starts generate one ignored local configuration file (`.env.dynamic`, `.env.debug`, `.env.test`, or `.env.ci`). Tests compose provider connection strings from that selected file. Existing complete connection-string environment variables remain supported and take precedence. + +See [docker/Readme.md](docker/Readme.md) for commands, fixed ports, naming, generated values, and safety behavior. ## How to contribute @@ -81,19 +94,15 @@ This repository uses GitFlow to develop, if you are not familiar with GitFlow yo ### Installing and configuring Git Flow -Probably the most straightforward way to install GitFlow on your machine is installing [Git Command Line](https://git-for-windows.github.io/), then install the [Visual Studio Plugin for Git-Flow](https://visualstudiogallery.msdn.microsoft.com/27f6d087-9b6f-46b0-b236-d72907b54683). This plugin is accessible from the **Team Explorer** menu and allows you to install GitFlow extension directly from Visual Studio with a simple click. The installer installs standard GitFlow extension both for command line and for Visual Studio Plugin. - -Once installed you can use GitFlow right from Visual Studio or from Command line, which one you prefer. +Probably the most straightforward way to install GitFlow on your machine is installing [Git Command Line](https://git-for-windows.github.io/), then install the [Visual Studio Plugin for Git-Flow](https://visualstudiogallery.msdn.microsoft.com/vsgallery/27f6f087-9b6f-46b0-b236-d72907b54683). This plugin is accessible from the **Team Explorer** menu and allows you to install GitFlow extension directly for both command line and Visual Studio. ### Build machine and GitVersion -Build machine uses [GitVersion](https://github.com/GitTools/GitVersion) to manage automatic versioning of assemblies and Nuget Packages. You need to be aware that there are a rule that does not allow you to directly commit on master, or the build will fail. - -A commit on master can be done only following the [Git-Flow](http://nvie.com/posts/a-successful-git-branching-model/) model, as a result of a new release coming from develop, or with an hotfix. +Build machine uses [GitVersion](https://github.com/GitTools/GitVersion) to manage automatic versioning of assemblies and Nuget Packages. Direct commits on master are not allowed by the build. A commit on master should only result from the repository's GitFlow release or hotfix process. ### Quick Info for NEventstore projects -Just clone the repository and from command line checkout develop branch with +Just clone the repository and from command line checkout develop branch with ``` git checkout develop @@ -109,17 +118,6 @@ You can leave all values as default. Now your repository is GitFlow enabled. ### Note on Nuget version on Nuspec -Remember to update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating relase packages). - -The .nuspec file is needed because the new `dotnet pack` command has problems dealing with ProjectReferences, submodules get the wrong version number. - -While we are on develop branch, (suppose we just bumped major number so the driver version number is 6.0.0-unstablexxxx), we need to declare that this persistence driver depends from a version greater than the latest published. If the latest version of NEventStore 5.x.x wave iw 5.4.0 we need to declare this package dependency as - -(5.4, 7) - -This means, that we need a NEventStore greater than the latest published, but lesser than the next main version. This allows version 6.0.0-unstable of NEventStore to satisfy the dependency. We remember that prerelease package are considered minor than the stable package. Es. +Remember to update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` if needed before creating release packages. -5.4.0 -5.4.1 -6.0.0-unstable00001 -6.0.0 +The `.nuspec` file is needed because `dotnet pack` has problems dealing with ProjectReferences where submodules get the wrong version number. From 6e9afeb2c9f2224ffbfa3adcfb3cfebd37831ebe Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:55:45 +0200 Subject: [PATCH 17/21] Use reserved ci database environment --- .github/workflows/ci.yml | 107 ++++----------------------------------- 1 file changed, 9 insertions(+), 98 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30d4290..73a0504 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,9 +68,6 @@ jobs: - name: Build solution run: dotnet build ./src/NEventStore.Persistence.Sql.Core.sln -c Release --no-restore /p:ContinuousIntegrationBuild=True - # Tests run on Linux with database services (PostgreSQL, MySQL, SQLite). - # Each database provider runs independently to isolate failures and validate - # database-specific persistence logic. test-sql-databases: needs: build name: Test (Linux, ${{ matrix.tfm }}) @@ -78,77 +75,14 @@ jobs: strategy: fail-fast: false matrix: - # Run each modern TFM independently so failures are isolated and easy to read. tfm: - net8.0 - net9.0 - net10.0 - services: - sqlexpress: - image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 - env: - SA_PASSWORD: Password12! - ACCEPT_EULA: Y - MSSQL_PID: Express - options: >- - --health-cmd "/opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P 'Password12!' -Q 'SELECT 1'" - --health-interval 20s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 1433:1433 - - postgresql: - image: postgres:15-alpine - env: - POSTGRES_DB: NEventStore - POSTGRES_USER: postgres - POSTGRES_PASSWORD: Password12! - options: >- - --health-cmd pg_isready - --health-interval 20s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 5432:5432 - - mysql: - image: mysql:8 - env: - MYSQL_DATABASE: NEventStore - MYSQL_ROOT_PASSWORD: Password12! - options: >- - --health-cmd "mysqladmin ping -h localhost" - --health-interval 20s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 3306:3306 - - oracle: - image: gvenzl/oracle-xe - env: - ORACLE_ALLOW_REMOTE: true - ORACLE_PASSWORD: Password12! - options: >- - --health-cmd "sqlplus -L -S / as sysdba @/dev/null" - --health-interval 25s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 1521:1521 - env: - NEventStore.MsSql: Server=localhost;Database=NEventStore;User Id=SA;Password=Password12!;TrustServerCertificate=True; - NEventStore.PostgreSql: Server=localhost;Database=NEventStore;Uid=postgres;Pwd=Password12!;Enlist=false; - NEventStore.MySql: Server=localhost;Database=NEventStore;Uid=root;Pwd=Password12!;AutoEnlist=false; + NEVENTSTORE_ENVIRONMENT: ci NEventStore.Sqlite: Data Source=:memory:;Cache=Shared; - NEventStore.Oracle: Data Source=localhost:1521/XE;User Id=system;Password=Password12!;Persist Security Info=True; steps: - name: Checkout @@ -174,43 +108,20 @@ jobs: restore-keys: | nuget-${{ runner.os }}- - - name: Create NEventStore database once SQL Server is healthy + - name: Start isolated CI database environment shell: bash - run: | - set -euo pipefail - - # Find the SQL Server service container by ancestor image (more reliable in GH Actions) - container_id="$(/usr/bin/docker ps --filter "ancestor=mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04" --format "{{.ID}}" | head -n 1)" - if [ -z "$container_id" ]; then - echo "SQL Server service container not found." - /usr/bin/docker ps --format "{{.ID}} {{.Image}} {{.Names}}" || true - exit 1 - fi - - # Wait until container reports healthy - for i in $(seq 1 60); do - health_status="$(/usr/bin/docker inspect --format='{{.State.Health.Status}}' "$container_id" 2>/dev/null || echo starting)" - if [ "$health_status" = "healthy" ]; then - echo "SQL Server container healthy" - break - fi - echo "Waiting for SQL Server to be healthy: $health_status ($i/60)" - sleep 2 - done - - if [ "$health_status" != "healthy" ]; then - echo "SQL Server container is not healthy. Logs follow (last 200 lines):" - /usr/bin/docker logs "$container_id" --tail 200 || true - exit 1 - fi - - # Create database using sqlcmd inside the container - /usr/bin/docker exec "$container_id" /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P "Password12!" -Q "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];" + run: bash ./start-environment.sh ci - name: Run tests for ${{ matrix.tfm }} run: dotnet test ./src/NEventStore.Persistence.Sql.Core.sln -c Release -f ${{ matrix.tfm }} --logger "trx;LogFileName=test-results-${{ matrix.tfm }}.trx" + - name: Stop CI database environment + if: always() + shell: bash + run: bash ./stop-environment.sh ci --remove-data + - name: Upload test results + if: always() uses: actions/upload-artifact@v7 with: name: test-results-${{ matrix.tfm }} From f413e80c4f150351c6d94fbe2f38df05b2c4d508 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:55:52 +0200 Subject: [PATCH 18/21] Remove obsolete platform-specific start script --- docker/DockerComposeUp.ps1 | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 docker/DockerComposeUp.ps1 diff --git a/docker/DockerComposeUp.ps1 b/docker/DockerComposeUp.ps1 deleted file mode 100644 index d10ddc3..0000000 --- a/docker/DockerComposeUp.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -$platform = $args[0] -if (!$platform) { - Write-Host "Missing Platform: windows | linux" - exit(1) -} - -Write-Host "Run the CI environment" - -docker-compose -f docker-compose.ci.$platform.db.yml -p nesci up --detach - -Write-Host "Creating Databases" - -Start-Sleep -Seconds 20 - -if ($platform -eq "linux") { -docker exec nesci-sqlexpress-1 /opt/mssql-tools/bin/sqlcmd -l 60 -S localhost -U sa -P Password1 -Q "CREATE DATABASE NEventStore" -} - -if ($platform -eq "windows") { -docker exec nesci-sqlexpress-1 sqlcmd -l 60 -S localhost -U sa -P Password1 -Q "CREATE DATABASE NEventStore" -} From eb52fcfd7b735012ee18aa1752a3b1fca1d4b4eb Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:55:56 +0200 Subject: [PATCH 19/21] Remove obsolete platform-specific stop script --- docker/DockerComposeDown.ps1 | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 docker/DockerComposeDown.ps1 diff --git a/docker/DockerComposeDown.ps1 b/docker/DockerComposeDown.ps1 deleted file mode 100644 index 37c8f3f..0000000 --- a/docker/DockerComposeDown.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -$platform = $args[0] -if (!$platform) { - Write-Host "Missing Platform: windows | linux" - exit(1) -} - -if ($platform -eq "linux") { - # there's a bug on stopping/terminating LCOW - # https://github.com/moby/moby/issues/37919 - # workaround is to kill the containers manually - - #Write-Host "LCOW stopping bug, hack: killing the containers (https://github.com/moby/moby/issues/37919)." - #docker kill -s 9 nesci-sqlexpress-1 - #docker kill -s 9 nesci-mongo-1 - #docker kill -s 9 nesci-mysql-1 - #docker kill -s 9 nesci-postgres-1 -} -# -v "removes all the volumes, there's no need to do it manually" -docker-compose -f docker-compose.ci.$platform.db.yml -p nesci down -v - -# remove unneeded volumes, so we start clear every time -#Write-Host "Removing volumes:" -#docker volume rm nesci-h8-sqldata-${platform}_ci -#docker volume rm nesci-h8_mongodata-${platform}_ci \ No newline at end of file From 790ef1177195dadb1cfc64de80d58929c0204dc4 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:56:01 +0200 Subject: [PATCH 20/21] Remove obsolete Linux-only compose file --- docker/docker-compose.ci.linux.db.yml | 70 --------------------------- 1 file changed, 70 deletions(-) delete mode 100644 docker/docker-compose.ci.linux.db.yml diff --git a/docker/docker-compose.ci.linux.db.yml b/docker/docker-compose.ci.linux.db.yml deleted file mode 100644 index 873c1f8..0000000 --- a/docker/docker-compose.ci.linux.db.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Root Docker Compose file to run the tests on the development machine using some pre-initialized databases -# It must be used with powershell actions that mount the databases files. - -services: - sqlexpress: - container_name: nesci-sqlexpress-1 - platform: linux - image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 - mem_limit: 2000m - environment: - - SA_PASSWORD=Password1 - - ACCEPT_EULA=Y - - MSSQL_PID=Express - ports: - - "50001:1433" -# volumes: -# - C:\Temp/NEventStore:/data -# - nes_sqldata_linux_ci:/var/opt/mssql - -# mongo: -# container_name: nesci-mongo-1 -# platform: linux -# image: mongo #@sha256:52c3314bee611f91d37b9b1bc0cc2755b1388f2de5b396b441f3fe94bef6c56c -# ports: -# - "50002:27017" -## volumes: -## - nes_mongodata_linux_ci:/data/db -## - nes_mongoconfig_linux_ci:/data/configdb - - mysql: - container_name: nesci-mysql-1 - platform: linux - image: mysql:8.0 #5.7 - command: --default-authentication-plugin=mysql_native_password - restart: always - environment: - MYSQL_ROOT_PASSWORD: Password1 - MYSQL_DATABASE: NEventStore - MYSQL_USER: sa - MYSQL_PASSWORD: Password1 - ports: - - "50003:3306" - - postgres: - container_name: nesci-postgres-1 - platform: linux - image: postgres - restart: always - environment: - POSTGRES_PASSWORD: Password1 - POSTGRES_USER: sa - POSTGRES_DB: NEventStore - ports: - - "50004:5432" - - oracle: - container_name: nesci-oracle-1 - platform: linux - image: gvenzl/oracle-xe - restart: always - environment: - - ORACLE_ALLOW_REMOTE=true - - ORACLE_PASSWORD=Password1 - ports: - - "50005:1521" - -#volumes: -# nes_sqldata_linux_ci: -# nes_mongodata_linux_ci: -# nes_mongoconfig_linux_ci: \ No newline at end of file From e6ecac6e7615e2f178b56f7a85722184835232b8 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 13:56:11 +0200 Subject: [PATCH 21/21] Remove obsolete Windows-container compose file --- docker/docker-compose.ci.windows.db.yml | 72 ------------------------- 1 file changed, 72 deletions(-) delete mode 100644 docker/docker-compose.ci.windows.db.yml diff --git a/docker/docker-compose.ci.windows.db.yml b/docker/docker-compose.ci.windows.db.yml deleted file mode 100644 index ead22a6..0000000 --- a/docker/docker-compose.ci.windows.db.yml +++ /dev/null @@ -1,72 +0,0 @@ -# Root Docker Compose file to run the tests on the development machine using some pre-initialized databases -# It must be used with powershell actions that mount the databases files. - -services: - sqlexpress: - container_name: nesci-sqlexpress-1 - platform: windows - image: christianacca/mssql-server-windows-express - environment: - - SA_PASSWORD=Password1 - - ACCEPT_EULA=Y - ports: - - "50001:1433" -# volumes: -# - "C:\\Temp\\NEventStore:c:\\data" -# - "nes_sqldata_windows_ci:c:\\sql\\userdbs\\data" -# - "nes_sqllog_windows_ci:c:\\sql\\userdbs\\log" -# - "nes_sqlbackup_windows_ci:c:\\sql\\backup" - -# mongo: -# container_name: nesci-mongo-1 -# platform: windows -# image: mongo -# ports: -# - "50002:27017" -## volumes: -## - nes_mongodata_windows_ci:/data/db -## - nes_mongoconfig_windows_ci:/data/configdb - - mysql: - container_name: nesci-mysql-1 - platform: windows - image: mysql:8.0 #5.7 - command: --default-authentication-plugin=mysql_native_password - restart: always - environment: - MYSQL_ROOT_PASSWORD: Password1 - MYSQL_DATABASE: NEventStore - MYSQL_USER: sa - MYSQL_PASSWORD: Password1 - ports: - - "50003:3306" - - postgres: - container_name: nesci-postgres-1 - platform: windows - image: postgres - restart: always - environment: - POSTGRES_PASSWORD: Password1 - POSTGRES_USER: sa - POSTGRES_DB: NEventStore - ports: - - "50004:5432" - - oracle: - container_name: nesci-oracle-1 - platform: linux - image: gvenzl/oracle-xe - restart: always - environment: - - ORACLE_ALLOW_REMOTE=true - - ORACLE_PASSWORD=Password1 - ports: - - "50005:1521" - -#volumes: -# nes_sqldata_windows_ci: -# nes_sqllog_windows_ci: -# nes_sqlbackup_windows_ci: -# nes_mongodata_windows_ci: -# nes_mongoconfig_windows_ci: \ No newline at end of file