diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9ee41156a..05e0e64551 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,196 +10,83 @@ env: CARGO_TERM_COLOR: always jobs: - check: - name: Nix Flake Check + test: + name: Devenv Integration Test runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v4 - - - name: Install Nix - uses: cachix/install-nix-action@v24 + - uses: actions/checkout@v5 + + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - extra_nix_config: | - experimental-features = nix-command flakes - - - name: Setup Cachix - uses: cachix/cachix-action@v14 - with: - name: sinex - skipPush: ${{ github.event_name == 'pull_request' }} - - - name: Nix Flake Check - run: nix flake check --accept-flake-config - test: - name: Cargo Test - runs-on: ubuntu-latest - - services: - postgres: - image: timescale/timescaledb:2.15.2-pg16 # Pin exact Timescale/Postgres versions for deterministic CI - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 + - uses: cachix/cachix-action@v16 + with: + name: sinity + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + skipPush: true - steps: - - uses: actions/checkout@v4 - - - name: Install ripgrep - run: | - sudo apt-get update - sudo apt-get install -y ripgrep - - - name: Enforce forbidden pattern guards - run: bash scripts/check_forbidden_patterns.sh + - name: Install devenv.sh + shell: bash + run: nix profile install nixpkgs#devenv - - name: Regenerate schemas and ensure repository is clean - # Treat schemas/ like a generated artifact (similar to cargo fmt). + - name: Run migrations and tests + shell: 'nix develop --no-pure-eval --command bash --noprofile --norc -euo pipefail {0}' env: - DATABASE_URL: postgres://sinex_test:test_password@localhost/sinex_test + DEVENV_ROOT: ${{ github.workspace }} + DEVENV_DOTFILE: ${{ github.workspace }}/.devenv run: | - ./scripts/schema-dev.sh generate - if ! git diff --quiet -- schemas; then - echo "::error::Schema artifacts are stale. Run './scripts/schema-dev.sh generate' and commit the results." - git status schemas - exit 1 + devenv test + + export PGDATA="$PWD/postgres_data" + rm -rf "$PGDATA" + mkdir -p "$PGDATA" + export PGHOST=127.0.0.1 + export PGPORT=55432 + initdb --auth=trust --no-locale --encoding=UTF8 + echo "unix_socket_directories = '$PWD'" >> "$PGDATA/postgresql.conf" + echo "listen_addresses = '127.0.0.1'" >> "$PGDATA/postgresql.conf" + echo "port = $PGPORT" >> "$PGDATA/postgresql.conf" + echo "shared_preload_libraries = 'timescaledb'" >> "$PGDATA/postgresql.conf" + pg_ctl start -w -l postgres.log -o "-k $PWD -p $PGPORT" + trap 'pg_ctl stop' EXIT + SUPERUSER=$(id -un) + if ! PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'sinity'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE sinity LOGIN CREATEDB;" + fi + if ! PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'sinex_dev'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE sinex_dev OWNER sinity;" fi - - - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - - name: Cache Cargo - uses: actions/cache@v3 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - - name: Install PostgreSQL client - run: | - sudo apt-get update - sudo apt-get install -y postgresql-client - - - name: Setup test database - env: - PGPASSWORD: postgres - run: | - psql -h localhost -U postgres -c "CREATE USER sinex_test WITH PASSWORD 'test_password' CREATEDB" - psql -h localhost -U postgres -c "CREATE DATABASE sinex_test OWNER sinex_test" - psql -h localhost -U postgres -d sinex_test -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"" - psql -h localhost -U postgres -d sinex_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb" - - - name: Run migrations - env: - DATABASE_URL: postgres://sinex_test:test_password@localhost/sinex_test - run: | - cd crate/lib/sinex-schema - cargo run -- up - - - name: Run tests - env: - DATABASE_URL: postgres://sinex_test:test_password@localhost/sinex_test - RUST_LOG: debug - run: cargo nextest run --all-features --workspace --profile reliable - coverage: - name: Code Coverage - runs-on: ubuntu-latest - - services: - postgres: - image: timescale/timescaledb:2.15.2-pg16 # Pin exact Timescale/Postgres versions for deterministic CI - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - uses: actions/checkout@v4 - - - name: Install ripgrep - run: | - sudo apt-get update - sudo apt-get install -y ripgrep - - - name: Enforce forbidden pattern guards - run: bash scripts/check_forbidden_patterns.sh + ensure_extension() { + local db="$1" + shift + for candidate in "$@"; do + if PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -tAc "SELECT 1 FROM pg_available_extensions WHERE name = '${candidate}'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS ${candidate};" + return 0 + fi + done + echo "::error::None of the requested extensions ($*) are available in this PostgreSQL build." + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -tAc "SELECT name FROM pg_available_extensions ORDER BY name;" + exit 1 + } - - name: Regenerate schemas and ensure repository is clean - # Treat schemas/ like a generated artifact (similar to cargo fmt). - env: - DATABASE_URL: postgres://sinex_test:test_password@localhost/sinex_test - run: | + ensure_extension sinex_dev pgx_ulid ulid + ensure_extension sinex_dev pg_jsonschema + ensure_extension sinex_dev timescaledb + ensure_extension sinex_dev vector + psql -h "$PGHOST" -p "$PGPORT" -U sinity -d sinex_dev -c 'SELECT 1' >/dev/null + export DATABASE_URL="postgresql://${SUPERUSER}@${PGHOST}:${PGPORT}/sinex_dev" + pushd crate/lib/sinex-schema >/dev/null + cargo run -- up + popd + bash scripts/check_forbidden_patterns.sh ./scripts/schema-dev.sh generate if ! git diff --quiet -- schemas; then - echo "::error::Schema artifacts are stale. Run './scripts/schema-dev.sh generate' and commit the results." + echo "::error::Schema artifacts are stale. Run './scripts/schema-dev.sh generate' locally." git status schemas exit 1 fi - - - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - components: llvm-tools-preview - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Install PostgreSQL client - run: | - sudo apt-get update - sudo apt-get install -y postgresql-client - - - name: Setup test database - env: - PGPASSWORD: postgres - run: | - psql -h localhost -U postgres -c "CREATE USER sinex_test WITH PASSWORD 'test_password' CREATEDB" - psql -h localhost -U postgres -c "CREATE DATABASE sinex_test OWNER sinex_test" - psql -h localhost -U postgres -d sinex_test -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"" - psql -h localhost -U postgres -d sinex_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb" - - - name: Run migrations - env: - DATABASE_URL: postgres://sinex_test:test_password@localhost/sinex_test - run: | - cd crate/lib/sinex-schema - cargo run -- up - - - name: Generate code coverage - env: - DATABASE_URL: postgres://sinex_test:test_password@localhost/sinex_test - run: cargo llvm-cov --all-features --workspace --profile reliable --lcov --output-path lcov.info - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - files: lcov.info - fail_ci_if_error: false + devenv tasks run dev:test diff --git a/.github/workflows/schema-validation.yml b/.github/workflows/schema-validation.yml index 0085fcc5c1..3beb3848bb 100644 --- a/.github/workflows/schema-validation.yml +++ b/.github/workflows/schema-validation.yml @@ -22,83 +22,82 @@ env: jobs: validate-schemas: runs-on: ubuntu-latest - - services: - postgres: - image: timescale/timescaledb:2.15.2-pg16 # Keep validation jobs in lock-step with the pinned CI version - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: sinex_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v12 - - - name: Setup Nix cache - uses: DeterminateSystems/magic-nix-cache-action@v7 - - - name: Install PostgreSQL extensions - run: | - sudo apt-get update - sudo apt-get install -y postgresql-client - - # Wait for PostgreSQL to be ready - until pg_isready -h localhost -p 5432 -U postgres; do - echo "Waiting for PostgreSQL..." - sleep 2 - done - - # Install required extensions - PGPASSWORD=postgres psql -h localhost -U postgres -d sinex_test -c " - CREATE EXTENSION IF NOT EXISTS pgx_ulid CASCADE; - CREATE EXTENSION IF NOT EXISTS pg_jsonschema CASCADE; - CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; - CREATE EXTENSION IF NOT EXISTS pgvector CASCADE; - " || echo "Extensions installation completed with warnings" - - - name: Run database migrations - run: | - export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sinex_test" - cd crate/lib/sinex-schema - nix develop -c cargo run -- up - - - name: Test schema registry functionality - run: | - export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sinex_test" - - # Test that schema registry table exists and works - PGPASSWORD=postgres psql -h localhost -U postgres -d sinex_test -c " - -- Test schema insertion - INSERT INTO sinex_schemas.event_payload_schemas - (source, event_type, schema_version, schema_content, content_hash) - VALUES - ('test.source', 'test.event', '1.0.0', '{}'::jsonb, md5(random()::text)); - - -- Test schema activation - UPDATE sinex_schemas.event_payload_schemas - SET is_active = true - WHERE source = 'test.source' AND event_type = 'test.event'; - - -- Verify schema exists - SELECT COUNT(*) FROM sinex_schemas.event_payload_schemas - WHERE source = 'test.source'; - " - - - name: Smoke-test schema CLI + - uses: actions/checkout@v5 + + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - uses: cachix/cachix-action@v16 + with: + name: sinity + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + skipPush: true + + - name: Install devenv.sh + shell: bash + run: nix profile install nixpkgs#devenv + + - name: Validate schemas inside Nix + shell: 'nix develop --no-pure-eval --command bash --noprofile --norc -euo pipefail {0}' + env: + DEVENV_ROOT: ${{ github.workspace }} + DEVENV_DOTFILE: ${{ github.workspace }}/.devenv run: | - export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sinex_test" + export PGDATA="$PWD/postgres_data" + rm -rf "$PGDATA" + mkdir -p "$PGDATA" + export PGHOST=127.0.0.1 + export PGPORT=55432 + initdb --auth=trust --no-locale --encoding=UTF8 + echo "unix_socket_directories = '$PWD'" >> "$PGDATA/postgresql.conf" + echo "listen_addresses = '127.0.0.1'" >> "$PGDATA/postgresql.conf" + echo "port = $PGPORT" >> "$PGDATA/postgresql.conf" + echo "shared_preload_libraries = 'timescaledb'" >> "$PGDATA/postgresql.conf" + pg_ctl start -w -l postgres.log -o "-k $PWD -p $PGPORT" + trap 'pg_ctl stop' EXIT + + SUPERUSER=$(id -un) + if ! PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'sinity'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE sinity LOGIN CREATEDB;" + fi + if ! PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'sinex_dev'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE sinex_dev OWNER sinity;" + fi + + ensure_extension() { + local db="$1" + shift + for candidate in "$@"; do + if PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -tAc "SELECT 1 FROM pg_available_extensions WHERE name = '${candidate}'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS ${candidate};" + return 0 + fi + done + echo "::error::None of the requested extensions ($*) are available in this PostgreSQL build." + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -tAc "SELECT name FROM pg_available_extensions ORDER BY name;" + exit 1 + } + + ensure_extension sinex_dev pgx_ulid ulid + ensure_extension sinex_dev pg_jsonschema + ensure_extension sinex_dev timescaledb + ensure_extension sinex_dev vector + + export DATABASE_URL="postgresql://${SUPERUSER}@${PGHOST}:${PGPORT}/sinex_dev" + + pushd crate/lib/sinex-schema >/dev/null + cargo run -- up + popd + + psql -h "$PGHOST" -p "$PGPORT" -U sinity -d sinex_dev -v ON_ERROR_STOP=1 -c "INSERT INTO sinex_schemas.event_payload_schemas (source, event_type, schema_version, schema_content, content_hash) VALUES ('test.source', 'test.event', '1.0.0', '{}'::jsonb, md5(random()::text)) ON CONFLICT (source, event_type, schema_version) DO NOTHING;" + psql -h "$PGHOST" -p "$PGPORT" -U sinity -d sinex_dev -v ON_ERROR_STOP=1 -c "UPDATE sinex_schemas.event_payload_schemas SET is_active = true WHERE source = 'test.source' AND event_type = 'test.event';" + psql -h "$PGHOST" -p "$PGPORT" -U sinity -d sinex_dev -v ON_ERROR_STOP=1 -c "SELECT COUNT(*) FROM sinex_schemas.event_payload_schemas WHERE source = 'test.source';" + TMP_DIR=$(mktemp -d) - nix develop -c cargo run -p sinex-core --bin sinex-schema --features schema-manager -- generate --output "$TMP_DIR" --sync + cargo run -p sinex-core --bin sinex-schema --features schema-manager -- generate --output "$TMP_DIR" --sync deploy-staging: runs-on: ubuntu-latest diff --git a/.github/workflows/sqlx-cache.yml b/.github/workflows/sqlx-cache.yml index c3386f3c8d..c34e06bb62 100644 --- a/.github/workflows/sqlx-cache.yml +++ b/.github/workflows/sqlx-cache.yml @@ -16,56 +16,83 @@ jobs: validate-cache: name: Validate SQLx Query Cache runs-on: ubuntu-latest - - services: - postgres: - image: timescale/timescaledb:2.15.2-pg16 # Keep in sync with main CI runs - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: sinex_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - + steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: actions-rs/toolchain@v1 + - uses: actions/checkout@v5 + + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - uses: cachix/cachix-action@v16 with: - toolchain: stable - override: true - - - name: Install sqlx-cli - run: cargo install sqlx-cli --no-default-features --features postgres - - - name: Setup database + name: sinity + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + skipPush: true + + - name: Install devenv.sh + shell: bash + run: nix profile install nixpkgs#devenv + + - name: Validate SQLx cache inside Nix + shell: 'nix develop --no-pure-eval --command bash --noprofile --norc -euo pipefail {0}' env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/sinex_test - PGPASSWORD: postgres + DEVENV_ROOT: ${{ github.workspace }} + DEVENV_DOTFILE: ${{ github.workspace }}/.devenv run: | - sudo apt-get update && sudo apt-get install -y postgresql-client postgresql-contrib - psql -h localhost -U postgres -c "CREATE USER sinex_test WITH PASSWORD 'test_password' CREATEDB" - psql -h localhost -U postgres -c "CREATE DATABASE sinex_test OWNER sinex_test" - psql -h localhost -U postgres -d sinex_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb" - cd crate/lib/sinex-schema + export PGDATA="$PWD/postgres_data" + rm -rf "$PGDATA" + mkdir -p "$PGDATA" + export PGHOST=127.0.0.1 + export PGPORT=55432 + initdb --auth=trust --no-locale --encoding=UTF8 + echo "unix_socket_directories = '$PWD'" >> "$PGDATA/postgresql.conf" + echo "listen_addresses = '127.0.0.1'" >> "$PGDATA/postgresql.conf" + echo "port = $PGPORT" >> "$PGDATA/postgresql.conf" + echo "shared_preload_libraries = 'timescaledb'" >> "$PGDATA/postgresql.conf" + pg_ctl start -w -l postgres.log -o "-k $PWD -p $PGPORT" + trap 'pg_ctl stop' EXIT + + SUPERUSER=$(id -un) + if ! PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'sinity'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE sinity LOGIN CREATEDB;" + fi + if ! PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'sinex_dev'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE sinex_dev OWNER sinity;" + fi + + ensure_extension() { + local db="$1" + shift + for candidate in "$@"; do + if PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -tAc "SELECT 1 FROM pg_available_extensions WHERE name = '${candidate}'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS ${candidate};" + return 0 + fi + done + echo "::error::None of the requested extensions ($*) are available in this PostgreSQL build." + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -tAc "SELECT name FROM pg_available_extensions ORDER BY name;" + exit 1 + } + + ensure_extension sinex_dev pgx_ulid ulid + ensure_extension sinex_dev pg_jsonschema + ensure_extension sinex_dev timescaledb + ensure_extension sinex_dev vector + + export DATABASE_URL="postgresql://${SUPERUSER}@${PGHOST}:${PGPORT}/sinex_dev" + + pushd crate/lib/sinex-schema >/dev/null cargo run -- up - - - name: Check query cache is current - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/sinex_test - run: | - cargo sqlx prepare --check --workspace - - - name: Update cache if outdated - if: failure() - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/sinex_test - run: | + popd + + if cargo sqlx prepare --workspace --check -- --all-targets --all-features; then + exit 0 + fi + echo "::error::SQLx query cache is outdated. Run 'cargo sqlx prepare --workspace' locally and commit the changes." - cargo sqlx prepare --workspace - git diff --exit-code .sqlx || (echo "::error::Cache would be updated with these changes:" && git diff .sqlx && exit 1) + cargo sqlx prepare --workspace -- --all-targets --all-features + if ! git diff --quiet .sqlx; then + git diff .sqlx + fi + exit 1 diff --git a/.github/workflows/sqlx-check.yml b/.github/workflows/sqlx-check.yml index cf14d5b5e3..56e22ee418 100644 --- a/.github/workflows/sqlx-check.yml +++ b/.github/workflows/sqlx-check.yml @@ -16,75 +16,79 @@ jobs: sqlx-check: name: Verify SQLX offline cache runs-on: ubuntu-latest - - services: - postgres: - image: timescale/timescaledb:2.15.2-pg16 # Align with main CI image - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: sinex_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - + steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v4 + - uses: actions/checkout@v5 + + - uses: cachix/install-nix-action@v31 with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v4 + nix_path: nixpkgs=channel:nixos-unstable + + - uses: cachix/cachix-action@v16 with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Install sqlx-cli - run: | - cargo install sqlx-cli --no-default-features --features native-tls,postgres - - - name: Run migrations + name: sinity + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + skipPush: true + + - name: Install devenv.sh + shell: bash + run: nix profile install nixpkgs#devenv + + - name: Run SQLx checks inside Nix + shell: 'nix develop --no-pure-eval --command bash --noprofile --norc -euo pipefail {0}' env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/sinex_test + DEVENV_ROOT: ${{ github.workspace }} + DEVENV_DOTFILE: ${{ github.workspace }}/.devenv run: | - cd crate/lib/sinex-schema + export PGDATA="$PWD/postgres_data" + rm -rf "$PGDATA" + mkdir -p "$PGDATA" + export PGHOST=127.0.0.1 + export PGPORT=55432 + initdb --auth=trust --no-locale --encoding=UTF8 + echo "unix_socket_directories = '$PWD'" >> "$PGDATA/postgresql.conf" + echo "listen_addresses = '127.0.0.1'" >> "$PGDATA/postgresql.conf" + echo "port = $PGPORT" >> "$PGDATA/postgresql.conf" + echo "shared_preload_libraries = 'timescaledb'" >> "$PGDATA/postgresql.conf" + pg_ctl start -w -l postgres.log -o "-k $PWD -p $PGPORT" + trap 'pg_ctl stop' EXIT + + SUPERUSER=$(id -un) + if ! PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'sinity'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE sinity LOGIN CREATEDB;" + fi + if ! PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'sinex_dev'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE sinex_dev OWNER sinity;" + fi + + ensure_extension() { + local db="$1" + shift + for candidate in "$@"; do + if PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -tAc "SELECT 1 FROM pg_available_extensions WHERE name = '${candidate}'" | grep -q 1; then + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS ${candidate};" + return 0 + fi + done + echo "::error::None of the requested extensions ($*) are available in this PostgreSQL build." + PGUSER="$SUPERUSER" psql -h "$PGHOST" -p "$PGPORT" -d "$db" -tAc "SELECT name FROM pg_available_extensions ORDER BY name;" + exit 1 + } + + ensure_extension sinex_dev pgx_ulid ulid + ensure_extension sinex_dev pg_jsonschema + ensure_extension sinex_dev timescaledb + ensure_extension sinex_dev vector + + export DATABASE_URL="postgresql://${SUPERUSER}@${PGHOST}:${PGPORT}/sinex_dev" + + pushd crate/lib/sinex-schema >/dev/null cargo run -- up - - - name: Check SQLX offline cache is up to date - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/sinex_test - run: | - # Generate fresh cache + popd + cargo sqlx prepare --workspace --check -- --all-targets --all-features - - - name: Verify offline mode compilation - env: - SQLX_OFFLINE: true - run: | - cargo check --all-features - - - name: Run tests with offline mode - env: - SQLX_OFFLINE: true - TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/sinex_test - run: | - cargo nextest run --all-features + + SQLX_OFFLINE=1 cargo check --all-features + + SQLX_OFFLINE=1 TEST_DATABASE_URL="postgresql://${SUPERUSER}@${PGHOST}:${PGPORT}/sinex_dev" \ + cargo nextest run --all-features diff --git a/crate/core/sinex-ingestd/tests/validator_schema_order_tests.rs b/crate/core/sinex-ingestd/tests/validator_schema_order_tests.rs index f3f1d60f7a..5b78e7d211 100644 --- a/crate/core/sinex-ingestd/tests/validator_schema_order_tests.rs +++ b/crate/core/sinex-ingestd/tests/validator_schema_order_tests.rs @@ -8,9 +8,7 @@ use sinex_test_utils::{sinex_test, TestContext}; async fn validator_prefers_latest_semver(ctx: TestContext) -> color_eyre::Result<()> { let repo = ctx.pool.schemas(); - sqlx::query!("CREATE EXTENSION IF NOT EXISTS ulid") - .execute(&ctx.pool) - .await?; + ensure_ulid_extension(&ctx.pool).await?; repo.register_schema(NewEventSchema { source: "semver-source".to_string(), @@ -54,6 +52,25 @@ async fn validator_prefers_latest_semver(ctx: TestContext) -> color_eyre::Result } } +async fn ensure_ulid_extension(pool: &sinex_core::DbPool) -> color_eyre::Result<()> { + let available = sqlx::query_scalar::<_, String>( + "SELECT name FROM pg_available_extensions WHERE name IN ('ulid', 'pgx_ulid') LIMIT 1", + ) + .fetch_optional(pool) + .await?; + + let Some(extension) = available else { + return Err(color_eyre::eyre::eyre!( + "Neither 'ulid' nor 'pgx_ulid' extensions are available in this PostgreSQL instance" + )); + }; + + let stmt = format!(r#"CREATE EXTENSION IF NOT EXISTS "{extension}""#); + sqlx::query(&stmt).execute(pool).await?; + + Ok(()) +} + #[sinex_test] async fn validator_handles_double_digit_versions(ctx: TestContext) -> color_eyre::Result<()> { let repo = ctx.pool.schemas(); diff --git a/crate/lib/sinex-core/tests/property/event_model_fuzzing_test.rs b/crate/lib/sinex-core/tests/property/event_model_fuzzing_test.rs index 9a33ee8274..d44efda3f5 100644 --- a/crate/lib/sinex-core/tests/property/event_model_fuzzing_test.rs +++ b/crate/lib/sinex-core/tests/property/event_model_fuzzing_test.rs @@ -21,6 +21,7 @@ //! - Focus on the modern RawEvent::schemaless() API and database insertion paths use sinex_test_utils::prelude::*; +use color_eyre::eyre::Report; use chrono::{DateTime, TimeZone, Utc}; use proptest::strategy::ValueTree; @@ -411,7 +412,7 @@ sinex_proptest! { let _ = event.ts_orig; let _ = event.ingestor_version; let _ = event.payload_schema_id; - Ok(()) + Ok::<(), Report>(()) } /// Test filesystem events with extreme payloads @@ -438,7 +439,7 @@ sinex_proptest! { let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - Ok(()) + Ok::<(), Report>(()) } /// Test terminal events with extreme payloads @@ -469,7 +470,7 @@ sinex_proptest! { let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - Ok(()) + Ok::<(), Report>(()) } /// Test clipboard events with extreme payloads @@ -491,7 +492,7 @@ sinex_proptest! { let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - Ok(()) + Ok::<(), Report>(()) } } @@ -522,11 +523,12 @@ sinex_proptest! { let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - Ok(()) + Ok::<(), Report>(()) } } -/// Test system events with extreme payloads +sinex_proptest! { + /// Test system events with extreme payloads fn test_system_events_robustness( payload in fuzzed_system_payloads(), source in prop_oneof![ @@ -551,10 +553,10 @@ sinex_proptest! { let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - Ok(()) + Ok::<(), Report>(()) } -/// Test JSON serialization robustness with extreme payloads + /// Test JSON serialization robustness with extreme payloads fn test_json_serialization_robustness( payload in malformed_json_values() ) -> TestResult<()> { @@ -572,8 +574,9 @@ sinex_proptest! { } let _ = serde_json::to_string_pretty(&event); - Ok(()) + Ok::<(), Report>(()) } +} sinex_proptest! { /// Test ULID robustness with extreme timestamps @@ -600,7 +603,7 @@ sinex_proptest! { // Verify the event can be serialized let _json = serde_json::to_string(&event); - Ok(()) + Ok::<(), Report>(()) } /// Test string handling robustness @@ -623,7 +626,7 @@ sinex_proptest! { // Test that we can create the struct without panicking by checking key fields prop_assert_eq!(event.source.as_str(), source.as_str()); prop_assert_eq!(event.host.as_str(), host.as_str()); - Ok(()) + Ok::<(), Report>(()) } } @@ -660,7 +663,7 @@ async fn test_database_insertion_robustness(ctx: TestContext) -> TestResult<()> } } } - Ok(()) + Ok::<(), Report>(()) } /// Test event creation with extreme payloads in database context @@ -731,7 +734,7 @@ async fn test_extreme_payload_database_handling(ctx: TestContext) -> TestResult< } } } - Ok(()) + Ok::<(), Report>(()) } // ============================================================================ @@ -757,7 +760,7 @@ mod additional_tests { // Test serialization instead of database insertion let _result = serde_json::to_string(&event); - Ok(()) + Ok::<(), Report>(()) } #[sinex_test] @@ -782,7 +785,7 @@ mod additional_tests { // Test serialization instead of database insertion let _result = serde_json::to_string(&event); - Ok(()) + Ok::<(), Report>(()) } #[sinex_test] @@ -806,7 +809,7 @@ mod additional_tests { // Test serialization instead of database insertion let _result = serde_json::to_string(&event); - Ok(()) + Ok::<(), Report>(()) } #[sinex_test] @@ -838,6 +841,6 @@ mod additional_tests { // This should not panic assert!(result.is_ok()); - Ok(()) + Ok::<(), Report>(()) } } diff --git a/crate/lib/sinex-core/tests/property/schema_property_test.rs b/crate/lib/sinex-core/tests/property/schema_property_test.rs index d653bce2da..f420300f6d 100644 --- a/crate/lib/sinex-core/tests/property/schema_property_test.rs +++ b/crate/lib/sinex-core/tests/property/schema_property_test.rs @@ -5,6 +5,7 @@ //! - Additional schema-related property tests for validation chains //! - Schema compatibility and evolution properties +use color_eyre::eyre::Report; use proptest::prelude::*; use proptest::strategy::ValueTree; use serde_json::{json, Value}; @@ -158,7 +159,7 @@ fn test_json_validation_normal_payloads( let json_str = payload.to_string(); let result = validate_json(&json_str); drop(result); - Ok(()) + Ok::<(), Report>(()) } #[sinex_prop] @@ -168,7 +169,7 @@ fn test_json_validation_security_payloads( let json_str = payload.to_string(); let result = validate_json(&json_str); drop(result); - Ok(()) + Ok::<(), Report>(()) } #[sinex_prop] @@ -188,7 +189,7 @@ fn test_json_validation_consistency( prop_assert!(false, "Validation was not consistent"); } } - Ok(()) + Ok::<(), Report>(()) } // ============================================================================= @@ -244,7 +245,7 @@ fn test_schema_evolution_properties( // Mixed outcomes are acceptable; evolution may introduce new constraints. } } - Ok(()) + Ok::<(), Report>(()) } // ============================================================================= @@ -282,7 +283,7 @@ fn test_validation_chain_properties( ); } } - Ok(()) + Ok::<(), Report>(()) } /// Test validation chain with numeric values @@ -309,7 +310,7 @@ fn test_validation_chain_numeric_properties( prop_assert!(test_number <= 1000, "Valid range numbers should be <= 1000"); } } - Ok(()) + Ok::<(), Report>(()) } // ============================================================================= @@ -346,7 +347,7 @@ async fn schema_registry_should_drive_json_validation(ctx: TestContext) -> TestR "JSON validation should enforce registered schemas once property tests are restored (TODO #17)" ); - Ok(()) + Ok::<(), Report>(()) } // ============================================================================= @@ -380,7 +381,7 @@ fn test_json_validation_edge_cases() -> TestResult { let json_str = payload.to_string(); let _ = validate_json(&json_str); } - Ok(()) + Ok::<(), Report>(()) } // ============================================================================= @@ -460,7 +461,7 @@ fn test_validation_performance_properties( ); } } - Ok(()) + Ok::<(), Report>(()) } // ============================================================================= @@ -502,7 +503,7 @@ mod unit_tests { match invalid_result { Ok(_) | Err(_) => {} // Any result is acceptable for testing } - Ok(()) + Ok::<(), Report>(()) } #[sinex_test] @@ -525,7 +526,7 @@ mod unit_tests { .unwrap() .current(); assert!(problematic.is_object()); // All problematic payloads are objects - Ok(()) + Ok::<(), Report>(()) } #[sinex_test] @@ -540,7 +541,7 @@ mod unit_tests { assert!(!event_type.is_empty()); assert!(source.len() >= 3); // 1 + 2 minimum assert!(event_type.len() >= 3); // 1 + 2 minimum - Ok(()) + Ok::<(), Report>(()) } #[sinex_test] @@ -561,7 +562,7 @@ mod unit_tests { assert!(invalid_name.is_empty()); // Should fail length check assert!(invalid_age < 18); // Should fail age check - Ok(()) + Ok::<(), Report>(()) } #[sinex_test] @@ -573,6 +574,6 @@ mod unit_tests { // Test that we can detect validation issues assert!(empty_value.is_empty()); // Should fail validation assert!(!valid_value.is_empty()); // Should pass validation - Ok(()) + Ok::<(), Report>(()) } } diff --git a/crate/lib/sinex-core/tests/property/ulid_property_test.rs b/crate/lib/sinex-core/tests/property/ulid_property_test.rs index 552a8e12e7..3a40bbadc5 100644 --- a/crate/lib/sinex-core/tests/property/ulid_property_test.rs +++ b/crate/lib/sinex-core/tests/property/ulid_property_test.rs @@ -196,6 +196,7 @@ sinex_proptest! { } Ok(()) } +} sinex_proptest! { fn test_concurrent_ulid_timestamp_correlation( @@ -355,6 +356,7 @@ fn arb_ulid_from_time_range( }) } +sinex_proptest! { fn test_ulid_ordering_property_in_memory( ulids in arb_ulid_sequence(2, 20) ) -> color_eyre::eyre::Result<()> { @@ -374,6 +376,7 @@ fn arb_ulid_from_time_range( "All ULIDs in sequence should be unique"); Ok(()) } +} // Database test temporarily disabled due to direct sqlx usage // TODO: Reimplement using repository pattern diff --git a/crate/lib/sinex-schema/src/migrations/m20241028_000001_create_canonical_schema.rs b/crate/lib/sinex-schema/src/migrations/m20241028_000001_create_canonical_schema.rs index c2eb32f25f..bff43e6663 100644 --- a/crate/lib/sinex-schema/src/migrations/m20241028_000001_create_canonical_schema.rs +++ b/crate/lib/sinex-schema/src/migrations/m20241028_000001_create_canonical_schema.rs @@ -568,6 +568,7 @@ impl MigrationTrait for Migration { async fn ensure_required_extensions(conn: &SchemaManagerConnection<'_>) -> Result<(), DbErr> { for extension in REQUIRED_EXTENSIONS { + let mut target = *extension; let check_sql = format!( "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_available_extensions WHERE name = '{ext}') AS available", ext = extension.replace('\'', "''"), @@ -578,7 +579,28 @@ async fn ensure_required_extensions(conn: &SchemaManagerConnection<'_>) -> Resul .and_then(|row| row.try_get_by_index::(0).ok()) .unwrap_or(false); - if !available { + let mut resolved_available = available; + + if !resolved_available && *extension == "ulid" { + let fallback = "pgx_ulid"; + let fallback_check = format!( + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_available_extensions WHERE name = '{ext}') AS available", + ext = fallback + ); + resolved_available = conn + .query_one(Statement::from_string( + DatabaseBackend::Postgres, + fallback_check, + )) + .await? + .and_then(|row| row.try_get_by_index::(0).ok()) + .unwrap_or(false); + if resolved_available { + target = fallback; + } + } + + if !resolved_available { warn!( extension = %extension, "Skipping unavailable PostgreSQL extension; install it manually if needed" @@ -586,10 +608,7 @@ async fn ensure_required_extensions(conn: &SchemaManagerConnection<'_>) -> Resul continue; } - let statement = format!( - r#"CREATE EXTENSION IF NOT EXISTS "{ext}";"#, - ext = extension - ); + let statement = format!(r#"CREATE EXTENSION IF NOT EXISTS "{ext}";"#, ext = target); conn.execute_unprepared(&statement).await?; } diff --git a/crate/lib/sinex-test-utils/macros/src/lib.rs b/crate/lib/sinex-test-utils/macros/src/lib.rs index bf6d35df82..717cd5a1b3 100644 --- a/crate/lib/sinex-test-utils/macros/src/lib.rs +++ b/crate/lib/sinex-test-utils/macros/src/lib.rs @@ -286,19 +286,25 @@ pub fn sinex_prop(attr: TokenStream, item: TokenStream) -> TokenStream { quote!() }; - let seed_stmt = opts.seed.map(|seed| { - quote! { - cfg.rng_algorithm = ::proptest::test_runner::RngAlgorithm::ChaCha; - cfg.rng_seed = Some(#seed); - } - }).unwrap_or_else(|| { - quote! { - if let Ok(seed_env) = std::env::var("SINEX_PROPTEST_SEED").ok().and_then(|s| s.parse::().ok()) { + let seed_stmt = opts + .seed + .map(|seed| { + quote! { cfg.rng_algorithm = ::proptest::test_runner::RngAlgorithm::ChaCha; - cfg.rng_seed = Some(seed_env); + cfg.rng_seed = ::proptest::test_runner::RngSeed::Fixed(#seed); } - } - }); + }) + .unwrap_or_else(|| { + quote! { + if let Some(seed_env) = std::env::var("SINEX_PROPTEST_SEED") + .ok() + .and_then(|s| s.parse::().ok()) + { + cfg.rng_algorithm = ::proptest::test_runner::RngAlgorithm::ChaCha; + cfg.rng_seed = ::proptest::test_runner::RngSeed::Fixed(seed_env); + } + } + }); let shrink_stmt = opts .max_shrink_time_ms @@ -356,16 +362,7 @@ pub fn sinex_prop(attr: TokenStream, item: TokenStream) -> TokenStream { let runner_setup = quote! { use ::proptest::prelude::*; - let mut cfg = ::proptest::test_runner::Config::default(); - cfg.cases = #cases; - if let Ok(val) = std::env::var("SINEX_PROPTEST_CASES").ok().and_then(|s| s.parse::().ok()) { - cfg.cases = val; - } - cfg.failure_persistence = ::proptest::test_runner::FailurePersistence::File( - std::env::var("SINEX_PROPTEST_DIR") - .map(std::path::PathBuf::from) - .unwrap_or_else(|_| std::path::PathBuf::from("target/proptest-regressions")) - ); + let mut cfg = sinex_test_utils::sinex_prop_runner_config(#cases, module_path!(), test_name); #seed_stmt #shrink_stmt let mut runner = ::proptest::test_runner::TestRunner::new(cfg); @@ -414,10 +411,13 @@ pub fn sinex_prop(attr: TokenStream, item: TokenStream) -> TokenStream { } Err(err) => { eprintln!("❌ {} ({:.1?})", test_name.replace('_', " "), elapsed); + let failure_ctx = ctx_snapshot_ref + .map(|ctx| sinex_test_utils::snapshot_helper::FailureContext::Borrowed(ctx)) + .unwrap_or(sinex_test_utils::snapshot_helper::FailureContext::None); sinex_test_utils::snapshot_helper::persist_failure( test_name, format!("{err:?}"), - ctx_snapshot_ref, + failure_ctx, ); Err(color_eyre::eyre::eyre!("{err}")) } @@ -438,8 +438,10 @@ pub fn sinex_prop(attr: TokenStream, item: TokenStream) -> TokenStream { #tuple_unpack #( #destructures )* std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { #user_body })) - .map_err(|_| ::proptest::test_runner::TestCaseError::fail("panic in property")) - .and_then(|res| res.map_err(|err| ::proptest::test_runner::TestCaseError::fail(format!("{err:?}")))) + .map_err(|_| ::proptest::test_runner::TestCaseError::fail("panic in property")) + .and_then(|res| { + res.map_err(|err| ::proptest::test_runner::TestCaseError::fail(format!("{err:?}"))) + }) }); let elapsed = start.elapsed(); match result { @@ -452,7 +454,7 @@ pub fn sinex_prop(attr: TokenStream, item: TokenStream) -> TokenStream { sinex_test_utils::snapshot_helper::persist_failure( test_name, format!("{err:?}"), - None, + sinex_test_utils::snapshot_helper::FailureContext::None, ); Err(color_eyre::eyre::eyre!("{err}")) } @@ -663,7 +665,7 @@ pub fn sinex_proptest(input: TokenStream) -> TokenStream { out.push(quote! { #(#passthrough_attrs)* - #[sinex_prop #meta_tokens] + #[sinex_test_utils::sinex_prop #meta_tokens] fn #name( #ctx_tokens #( #param_defs ),* ) -> sinex_test_utils::TestResult<()> { #( #destructures )* #body @@ -864,7 +866,7 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { sinex_test_utils::snapshot_helper::persist_failure( test_name, format!("{err:?}"), - None, + sinex_test_utils::snapshot_helper::FailureContext::None, ); } } @@ -927,7 +929,7 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { sinex_test_utils::snapshot_helper::persist_failure( test_name, format!("{err:?}"), - None, + sinex_test_utils::snapshot_helper::FailureContext::None, ); } } @@ -952,6 +954,7 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { eprintln!("🔄 {} [timeout: {}s]", test_name.replace('_', " "), #timeout_secs); let ctx = TestContext::with_name(test_name).await?; + let ctx_failure_snapshot = ctx.failure_snapshot(); let result: color_eyre::eyre::Result<()> = if #timeout_secs > 10 { let progress_task = tokio::spawn(async { @@ -988,7 +991,9 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { sinex_test_utils::snapshot_helper::persist_failure( test_name, format!("{err:?}"), - Some(&ctx), + sinex_test_utils::snapshot_helper::FailureContext::Snapshot( + ctx_failure_snapshot.clone(), + ), ); } } @@ -1029,7 +1034,7 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { sinex_test_utils::snapshot_helper::persist_failure( test_name, format!("{err:?}"), - None, + sinex_test_utils::snapshot_helper::FailureContext::None, ); } } diff --git a/crate/lib/sinex-test-utils/src/lib.rs b/crate/lib/sinex-test-utils/src/lib.rs index 5f5a80dbd5..c2e27ac4b0 100644 --- a/crate/lib/sinex-test-utils/src/lib.rs +++ b/crate/lib/sinex-test-utils/src/lib.rs @@ -24,10 +24,20 @@ pub use sinex_core::types::error::SinexError; // Library Result type using SinexError pub type Result = std::result::Result; pub type TestResult = color_eyre::eyre::Result; +pub use test_context::TestContextFailureSnapshot; pub struct ProptestCasesGuard { previous: Option, } +/// Internal helper used by the sinex_prop macro to build a configured TestRunner. +pub fn sinex_prop_runner_config( + default_cases: u32, + module_path: &'static str, + test_name: &str, +) -> proptest::test_runner::Config { + property_testing::build_runner_config(default_cases, module_path, test_name) +} + impl ProptestCasesGuard { pub fn new(cases: u32) -> Self { let previous = std::env::var("PROPTEST_CASES").ok(); diff --git a/crate/lib/sinex-test-utils/src/property_testing.rs b/crate/lib/sinex-test-utils/src/property_testing.rs index 0e7beef2e8..84a0c4cc49 100644 --- a/crate/lib/sinex-test-utils/src/property_testing.rs +++ b/crate/lib/sinex-test-utils/src/property_testing.rs @@ -3,9 +3,12 @@ // Provides property-based testing capabilities that integrate seamlessly // with the unified test infrastructure and event builders. +use once_cell::sync::Lazy; use proptest::prelude::*; use proptest::strategy::{BoxedStrategy, Strategy}; +use proptest::test_runner::{Config as ProptestConfig, FileFailurePersistence}; use serde_json::{json, Value}; +use std::{collections::HashMap, env, fs, path::PathBuf, sync::Mutex}; /// Property test strategies for common Sinex types pub struct SinexStrategies; @@ -188,10 +191,92 @@ impl SinexStrategies { } } +static PERSISTENCE_CACHE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +pub(crate) fn build_runner_config( + default_cases: u32, + module_path: &'static str, + test_name: &str, +) -> ProptestConfig { + let mut cfg = ProptestConfig::default(); + cfg.cases = default_cases; + if let Some(override_cases) = env_proptest_case_override() { + cfg.cases = override_cases; + } + if let Some(path) = regression_file_path(module_path, test_name) { + cfg.failure_persistence = Some(Box::new(FileFailurePersistence::Direct(path))); + } + cfg +} + +fn env_proptest_case_override() -> Option { + env::var("SINEX_PROPTEST_CASES") + .ok() + .and_then(|raw| raw.parse::().ok()) +} + +fn regression_file_path(module_path: &str, test_name: &str) -> Option<&'static str> { + let mut path = env::var("SINEX_PROPTEST_DIR") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("target/proptest-regressions")); + + for segment in module_path + .split("::") + .filter(|segment| !segment.is_empty()) + { + path.push(sanitize_component(segment)); + } + + let file_name = format!("{}.proptest-regressions", sanitize_component(test_name)); + path.push(file_name); + + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + eprintln!( + "sinex_test_utils: failed to create proptest directory {}: {err}", + parent.display() + ); + return None; + } + } + + Some(cache_leaked_path(path)) +} + +fn sanitize_component(input: &str) -> String { + input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} + +fn cache_leaked_path(path: PathBuf) -> &'static str { + let path_string = path.to_string_lossy().into_owned(); + let mut cache = PERSISTENCE_CACHE + .lock() + .expect("sinex proptest persistence cache poisoned"); + if let Some(existing) = cache.get(&path_string) { + return existing; + } + let leaked: &'static str = Box::leak(path_string.clone().into_boxed_str()); + cache.insert(path_string, leaked); + leaked +} + #[cfg(test)] mod tests { use super::*; use crate::{sinex_prop, TestContext, TestResult}; + use color_eyre::eyre::Report; #[sinex_prop(cases = 8)] async fn property_creates_filesystem_events( @@ -202,7 +287,7 @@ mod tests { let event = ctx.create_test_event(&source, &event_type, payload).await?; assert_eq!(event.source.as_str(), "filesystem"); assert!(event.id.is_some()); - Ok(()) + Ok::<(), Report>(()) } #[sinex_prop(cases = 8, seed = 42)] @@ -216,7 +301,7 @@ mod tests { let mut expected = payload.clone(); TestContext::sanitize_payload(&mut expected); assert_eq!(inserted.payload, expected); - Ok(()) + Ok::<(), Report>(()) } #[sinex_prop(cases = 16)] @@ -229,6 +314,6 @@ mod tests { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_'), "source not normalized: {source}", ); - Ok(()) + Ok::<(), Report>(()) } } diff --git a/crate/lib/sinex-test-utils/src/snapshot_helper.rs b/crate/lib/sinex-test-utils/src/snapshot_helper.rs index 538e22c0e5..ccbc78c44f 100644 --- a/crate/lib/sinex-test-utils/src/snapshot_helper.rs +++ b/crate/lib/sinex-test-utils/src/snapshot_helper.rs @@ -1,7 +1,7 @@ //! Snapshot testing helpers using insta use crate::database_pool::get_pool_stats; -use crate::TestContext; +use crate::{TestContext, TestContextFailureSnapshot}; use chrono::Utc; use serde::Serialize; use std::env; @@ -51,26 +51,32 @@ impl Default for SnapshotTestHelper { } #[derive(Serialize)] -struct FailureSnapshot<'a> { - test: &'a str, +struct FailureSnapshot { + test: String, error: String, timestamp: String, pool: crate::database_pool::PoolStats, - context: Option>, + context: Option, logs: Option>, } #[derive(Serialize)] -struct TestContextSnapshot<'a> { - name: &'a str, +struct ContextSnapshot { + name: String, baseline_events: i64, elapsed_ms: u128, } +pub enum FailureContext<'a> { + None, + Borrowed(&'a TestContext), + Snapshot(TestContextFailureSnapshot), +} + /// Persist contextual information about a failing test. Artifacts are written to /// `target/test-artifacts/` by default and can be overridden via the /// `SINEX_TEST_FAIL_DIR` environment variable. -pub fn persist_failure(test_name: &str, error: impl Into, ctx: Option<&TestContext>) { +pub fn persist_failure(test_name: &str, error: impl Into, ctx: FailureContext<'_>) { let snapshot_dir = env::var("SINEX_TEST_FAIL_DIR") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("target/test-artifacts")); @@ -83,15 +89,28 @@ pub fn persist_failure(test_name: &str, error: impl Into, ctx: Option<&T return; } - let ctx_snapshot = ctx.map(|c| TestContextSnapshot { - name: c.test_name(), - baseline_events: c.baseline_event_count(), - elapsed_ms: c.elapsed().as_millis(), - }); - let logs = ctx.map(|c| c.captured_logs()); + let (ctx_snapshot, logs) = match ctx { + FailureContext::None => (None, None), + FailureContext::Borrowed(ctx) => ( + Some(ContextSnapshot { + name: ctx.test_name().to_string(), + baseline_events: ctx.baseline_event_count(), + elapsed_ms: ctx.elapsed().as_millis(), + }), + Some(ctx.captured_logs()), + ), + FailureContext::Snapshot(snapshot) => ( + Some(ContextSnapshot { + name: snapshot.test_name().to_string(), + baseline_events: snapshot.baseline_events(), + elapsed_ms: snapshot.elapsed_ms(), + }), + Some(snapshot.captured_logs()), + ), + }; let snapshot = FailureSnapshot { - test: test_name, + test: test_name.to_string(), error: error.into(), timestamp: Utc::now().to_rfc3339(), pool: get_pool_stats(), diff --git a/crate/lib/sinex-test-utils/src/test_context.rs b/crate/lib/sinex-test-utils/src/test_context.rs index 05609ec4f7..e610ead526 100644 --- a/crate/lib/sinex-test-utils/src/test_context.rs +++ b/crate/lib/sinex-test-utils/src/test_context.rs @@ -108,6 +108,32 @@ pub struct TestContext { env: SinexEnvironment, } +#[derive(Clone)] +pub struct TestContextFailureSnapshot { + test_name: String, + baseline_events: i64, + start_time: Instant, + captured_logs: Arc>>, +} + +impl TestContextFailureSnapshot { + pub fn test_name(&self) -> &str { + &self.test_name + } + + pub fn baseline_events(&self) -> i64 { + self.baseline_events + } + + pub fn elapsed_ms(&self) -> u128 { + self.start_time.elapsed().as_millis() + } + + pub fn captured_logs(&self) -> Vec { + self.captured_logs.lock().clone() + } +} + impl TestContext { /// Backwards-compatible accessor for the shared database pool. pub fn pool(&self) -> &DbPool { @@ -311,6 +337,16 @@ impl TestContext { self.baseline_events } + /// Capture snapshot metadata that survives if the context is moved. + pub fn failure_snapshot(&self) -> TestContextFailureSnapshot { + TestContextFailureSnapshot { + test_name: self.test_name.clone(), + baseline_events: self.baseline_events, + start_time: self.start_time, + captured_logs: Arc::clone(&self.captured_logs), + } + } + /// Current total number of events pub async fn current_event_count(&self) -> TestResult { Ok(self.pool.events().count_all().await?) diff --git a/devenv.nix b/devenv.nix index e0f94f22ca..c873386c07 100644 --- a/devenv.nix +++ b/devenv.nix @@ -1,7 +1,10 @@ -{ inputs, pkgs, lib, config, ... }: +{ inputs ? {}, pkgs, lib, config, ... }: let system = pkgs.stdenv.hostPlatform.system; - fenixPkgs = inputs.fenix.packages.${system}.complete; + fenixInput = + if inputs ? fenix then inputs.fenix + else builtins.getFlake "github:nix-community/fenix?rev=d4e14d370b4763c67ea02a39f01f5366297d61cb&narHash=sha256-nx0zy/+yR57FwloXmatf3CaXgzA4zJqIFbplnpaKn/Y="; + fenixPkgs = fenixInput.packages.${system}.complete; pythonDeps = with pkgs.python3Packages; [ click psycopg2 @@ -18,6 +21,41 @@ let }; propagatedBuildInputs = with pkgs.python3Packages; [ tiktoken ]; }; + pgJsonschemaPkg = pkgs.stdenv.mkDerivation rec { + pname = "pg_jsonschema"; + version = "0.3.3"; + + src = pkgs.fetchurl { + url = "https://github.com/supabase/pg_jsonschema/releases/download/v${version}/pg_jsonschema-v${version}-pg16-amd64-linux-gnu.deb"; + hash = "sha256-6VSbAZrrItYgnpKMhVqffC4fGp9zzPYaMB6/Bf+Ha/g="; + }; + + nativeBuildInputs = [ pkgs.dpkg ]; + + dontBuild = true; + dontStrip = true; + dontFixup = true; + + unpackPhase = '' + dpkg-deb -x $src . + ''; + + installPhase = '' + mkdir -p $out/lib $out/share/postgresql/extension + find . -name "*.so" -type f -exec cp {} $out/lib/ \; + find . -name "*.sql" -type f -exec cp {} $out/share/postgresql/extension/ \; + find . -name "*.control" -type f -exec cp {} $out/share/postgresql/extension/ \; + ''; + }; + postgresqlWithExtensions = + pkgs.postgresql_16.withPackages (ps: + lib.filter (pkg: pkg != null) [ + (if ps ? timescaledb then ps.timescaledb else null) + (if ps ? pgvector then ps.pgvector else null) + pgJsonschemaPkg + (if ps ? pgx_ulid then ps.pgx_ulid else null) + ] + ); basePackages = with pkgs; [ fenixPkgs.toolchain fenixPkgs.rust-analyzer @@ -39,7 +77,7 @@ let mold python3 nats-server - postgresql_16 + postgresqlWithExtensions mprocs btop jq @@ -58,6 +96,14 @@ let ] ++ pythonDeps ++ [ ttok ]; dbusLibPath = pkgs.lib.makeLibraryPath [ pkgs.dbus ]; in { + devenv = { + root = + let + rootEnv = builtins.getEnv "DEVENV_ROOT"; + in + if rootEnv != "" then rootEnv else toString ./.; + }; + packages = basePackages; env = { diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000000..dd9df62433 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +ignore = [ + "crate/lib/sinex-core/tests/property/event_model_fuzzing_test.rs", + "crate/lib/sinex-core/tests/property/ulid_property_test.rs", +]