diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44f7ed3..13010fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,3 +45,43 @@ jobs: pnpm exec tsc --noEmit pnpm run build git diff --exit-code -- dist + + # The suite again on PostgreSQL, which rejects SQL that SQLite accepts (0.2.0 + # shipped a FOR UPDATE on an aggregate that no PostgreSQL install could run). + test-postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: testing + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DB_CONNECTION: pgsql + DB_HOST: 127.0.0.1 + DB_PORT: 5432 + DB_DATABASE: testing + DB_USERNAME: postgres + DB_PASSWORD: postgres + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: sodium, pdo_pgsql + coverage: none + + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist + + - name: Test on PostgreSQL + run: composer run test + diff --git a/app/Application/TaskNumberSequence.php b/app/Application/TaskNumberSequence.php index d9315ac..5656d05 100644 --- a/app/Application/TaskNumberSequence.php +++ b/app/Application/TaskNumberSequence.php @@ -10,8 +10,9 @@ /** * Per-company task numbering. * - * The number is `max(number) + 1` inside a transaction, which is portable - * across MySQL, PostgreSQL and SQLite. Two concurrent writers can still agree + * The number is the highest one plus one, read inside a transaction. The row + * holding it is read and locked rather than `max()` taken, because PostgreSQL + * refuses FOR UPDATE on an aggregate. Two concurrent writers can still agree * on the same number; the unique index on `(company_id, number)` catches that * and TaskService retries once. */ @@ -22,8 +23,9 @@ public function next(int $companyId): int return DB::transaction(static function () use ($companyId): int { $highest = Task::query() ->forCompany($companyId) + ->orderByDesc('number') ->lockForUpdate() - ->max('number'); + ->value('number'); return (int) $highest + 1; }); diff --git a/module.json b/module.json index 9ed16b2..2cd2469 100644 --- a/module.json +++ b/module.json @@ -18,7 +18,7 @@ "requires": {}, "schema_version": 2, "slug": "tasks-projects", - "version": "0.2.0", + "version": "0.2.1", "license": "AGPL-3.0-only", "compatibility": { "invoiceshelf": ">=3.0.0-alpha.2 <4.0.0", diff --git a/package.json b/package.json index ecca944..6b66dc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@invoiceshelf/module-tasks-projects", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "packageManager": "pnpm@11.6.0", diff --git a/tests/TestCase.php b/tests/TestCase.php index f432ea9..98d8d04 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -87,12 +87,27 @@ protected function getEnvironmentSetUp($app): void { $app['config']->set('app.key', 'base64:'.base64_encode(str_repeat('a', 32))); $app['config']->set('database.default', 'testing'); - $app['config']->set('database.connections.testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - 'foreign_key_constraints' => true, - ]); + // SQLite in memory by default; CI also runs the suite on PostgreSQL, + // which refuses SQL that SQLite lets through (DB_CONNECTION=pgsql). + $app['config']->set('database.connections.testing', getenv('DB_CONNECTION') === 'pgsql' + ? [ + 'driver' => 'pgsql', + 'host' => getenv('DB_HOST') ?: '127.0.0.1', + 'port' => (int) (getenv('DB_PORT') ?: 5432), + 'database' => getenv('DB_DATABASE') ?: 'testing', + 'username' => getenv('DB_USERNAME') ?: 'postgres', + 'password' => getenv('DB_PASSWORD') ?: '', + 'charset' => 'utf8', + 'prefix' => '', + 'schema' => 'public', + 'sslmode' => 'prefer', + ] + : [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); } /**