From cc3c3a2fcd3955d366b75d537fcff48c06a58757 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Thu, 24 Sep 2026 12:36:35 +0200 Subject: [PATCH 1/2] fix: creating a task works on PostgreSQL The next task number was read with max() under FOR UPDATE, which PostgreSQL refuses, so no task could be created there and the demo's sample data failed. The row with the highest number is read and locked instead. CI now also runs the suite on PostgreSQL. --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++ app/Application/TaskNumberSequence.php | 8 ++++-- tests/TestCase.php | 27 +++++++++++++---- 3 files changed, 66 insertions(+), 9 deletions(-) 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/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, + ]); } /** From 0ec0c999a179efaeed872e45c54024189bcb8e96 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Thu, 24 Sep 2026 12:36:35 +0200 Subject: [PATCH 2/2] release: 0.2.1 --- module.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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",