diff --git a/composer.json b/composer.json index 314b76b0..2f932693 100644 --- a/composer.json +++ b/composer.json @@ -57,6 +57,7 @@ "gplanchat/durable-laravel": "@dev" }, "require-dev": { + "doctrine/orm": "^3.0", "friendsofphp/php-cs-fixer": "^3.0", "gplanchat/durable-phpstan": "@dev", "gplanchat/durable-rector": "@dev", diff --git a/composer.lock b/composer.lock index 7eddd811..bccce676 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4673e00a18c20105a9c3ff4a9640b431", + "content-hash": "d5a30b88c750464826ffb257af16cc68", "packages": [ { "name": "brick/math", @@ -488,7 +488,7 @@ "dist": { "type": "path", "url": "src/Durable", - "reference": "40d593db8ffd3387d387405523a63a57aaf27abe" + "reference": "8be3b6e4e0ef16164efb609d9c0e69aa12968d6c" }, "require": { "php": ">=8.2", @@ -524,6 +524,16 @@ } ], "description": "PHP library for durable execution: workflows, activities, event journal, and transports (HttpKernel integration lives in the bundle).", + "keywords": [ + "activity", + "durable-execution", + "event-sourcing", + "fiber", + "long-running", + "orchestration", + "saga", + "workflow" + ], "transport-options": { "relative": true } @@ -534,7 +544,7 @@ "dist": { "type": "path", "url": "src/Bridge/Dbal", - "reference": "fc0a50ba49b7ef6e27e667e40122ec066af46373" + "reference": "8dbaae5ab6a14fbaef8764f8509467828ee83906" }, "require": { "doctrine/dbal": "^3.7 || ^4.0", @@ -564,6 +574,15 @@ } ], "description": "Doctrine DBAL journal, metadata and parent-link stores for gplanchat/durable — durable execution on a single SQL database, no orchestrator cluster", + "keywords": [ + "doctrine", + "doctrine-dbal", + "durable-execution", + "event-store", + "long-running", + "orchestration", + "workflow" + ], "transport-options": { "relative": true } @@ -574,7 +593,7 @@ "dist": { "type": "path", "url": "src/Bridge/Illuminate", - "reference": "6946428d1fa3a241e1d907d36666a4c35bdec332" + "reference": "0a38d4b04a25bb986f0156c481aeddb373f08c74" }, "require": { "gplanchat/durable": "self.version", @@ -611,6 +630,16 @@ } ], "description": "Illuminate (Laravel) journal, metadata and run stores for gplanchat/durable — durable execution on the connection Laravel already owns", + "keywords": [ + "durable-execution", + "eloquent", + "event-store", + "illuminate", + "laravel", + "long-running", + "orchestration", + "workflow" + ], "transport-options": { "relative": true } @@ -621,7 +650,7 @@ "dist": { "type": "path", "url": "src/Bridge/Temporal", - "reference": "02e08229462d41ad9eab4ccbc5923263ad4fc53a" + "reference": "c9faa1e201c1e56e477c3bfc4fc9e642774ebf09" }, "require": { "ext-grpc": "*", @@ -658,6 +687,16 @@ } ], "description": "Temporal journal EventStore + worker (gRPC only, no Temporal PHP SDK) for gplanchat/durable", + "keywords": [ + "durable-execution", + "grpc", + "long-running", + "nexus", + "orchestration", + "temporal", + "temporalio", + "workflow" + ], "transport-options": { "relative": true } @@ -668,7 +707,7 @@ "dist": { "type": "path", "url": "src/DurableBundle", - "reference": "3c6e1f5bba6af95386df385f68bb4e5f4ab97eec" + "reference": "ca2fbd88a7ad283caaa1454de8c7843f14bcc362" }, "require": { "gplanchat/durable": "self.version", @@ -685,6 +724,7 @@ "symfony/uid": "^6.4 || ^7.0 || ^8.0" }, "suggest": { + "doctrine/orm": "Declares the DBAL journal tables to doctrine:schema:update and doctrine:migrations:diff, which would otherwise generate DROP TABLE for them (SchemaListener\\DurableSchemaListener)", "phpunit/phpunit": "Required by the shipped test helper (Testing\\DurableBundleTestTrait)", "symfony/framework-bundle": "Required by consumers of the shipped test helper (Testing\\DurableBundleTestTrait), whose static::getContainer() resolves against a KernelTestCase", "symfony/web-profiler-bundle": "Web Debug Toolbar and profiler: Durable panel (workflows / activities)" @@ -710,6 +750,16 @@ } ], "description": "Symfony bundle for gplanchat/durable: extension, autoconfiguration of workflows and activities.", + "keywords": [ + "durable-execution", + "long-running", + "messenger", + "orchestration", + "saga", + "symfony", + "symfony-bundle", + "workflow" + ], "transport-options": { "relative": true } @@ -720,7 +770,7 @@ "dist": { "type": "path", "url": "src/DurableLaravel", - "reference": "9302b7f4ee5d308a82a3f5ee094aef5ee3b59941" + "reference": "b92e1877246d6060b5ddd423c8678748a1ed8dbb" }, "require": { "gplanchat/durable": "self.version", @@ -762,6 +812,15 @@ } ], "description": "Laravel integration for gplanchat/durable — binds the four storage ports from one published config file", + "keywords": [ + "durable-execution", + "laravel", + "long-running", + "orchestration", + "queue", + "saga", + "workflow" + ], "transport-options": { "relative": true } @@ -772,7 +831,7 @@ "dist": { "type": "path", "url": "src/DurablePlugin", - "reference": "bd0df46a7647a87434fe7642703c1b42e71827cd" + "reference": "77868a416afd4c6b6ca4952cbbe9e8371e718ed4" }, "require": { "gplanchat/durable": "self.version", @@ -815,6 +874,16 @@ } ], "description": "Sylius-oriented dashboard plugin for durable workflow tracking.", + "keywords": [ + "dashboard", + "durable-execution", + "long-running", + "orchestration", + "sylius", + "sylius-plugin", + "symfony", + "workflow" + ], "transport-options": { "relative": true } @@ -6679,6 +6748,513 @@ }, "time": "2019-12-04T15:06:13+00:00" }, + { + "name": "doctrine/collections", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-json": "*", + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Collections\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/2.6.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcollections", + "type": "tidelift" + } + ], + "time": "2026-01-15T10:01:58+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "doctrine/orm", + "version": "3.6.8", + "source": { + "type": "git", + "url": "https://github.com/doctrine/orm.git", + "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/orm/zipball/a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", + "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/collections": "^2.2", + "doctrine/dbal": "^3.8.2 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2", + "doctrine/inflector": "^1.4 || ^2.0", + "doctrine/instantiator": "^1.3 || ^2", + "doctrine/lexer": "^3", + "doctrine/persistence": "^3.3.1 || ^4", + "ext-ctype": "*", + "php": "^8.1", + "psr/cache": "^1 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.3.9 || ^7.0 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0", + "phpbench/phpbench": "^1.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "2.1.23", + "phpstan/phpstan-deprecation-rules": "^2", + "phpunit/phpunit": "^10.5.0 || ^11.5", + "psr/log": "^1 || ^2 || ^3", + "symfony/cache": "^5.4 || ^6.2 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-deepclone": "Improves performance when not using native lazy objects (Symfony 8.1+)", + "ext-dom": "Provides support for XSD validation for XML mapping files", + "symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\ORM\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "Object-Relational-Mapper for PHP", + "homepage": "https://www.doctrine-project.org/projects/orm.html", + "keywords": [ + "database", + "orm" + ], + "support": { + "issues": "https://github.com/doctrine/orm/issues", + "source": "https://github.com/doctrine/orm/tree/3.6.8" + }, + "time": "2026-08-05T19:05:32+00:00" + }, + { + "name": "doctrine/persistence", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/persistence.git", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "doctrine/event-manager": "^1 || ^2", + "php": "^8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.58 || ^12", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Persistence\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", + "homepage": "https://www.doctrine-project.org/projects/persistence.html", + "keywords": [ + "mapper", + "object", + "odm", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/persistence/issues", + "source": "https://github.com/doctrine/persistence/tree/4.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", + "type": "tidelift" + } + ], + "time": "2026-04-26T12:12:52+00:00" + }, { "name": "ergebnis/agent-detector", "version": "1.2.0", @@ -7022,7 +7598,7 @@ "dist": { "type": "path", "url": "src/DurablePhpstan", - "reference": "606b10d331b4b3db47e8708f442766a328889aec" + "reference": "3cdd062794a47c570ff390aa254f4f449f4f3216" }, "require": { "gplanchat/durable": "self.version", @@ -7058,6 +7634,15 @@ } ], "description": "PHPStan extension for gplanchat/durable — resolves ActivityStub and ChildWorkflowStub calls from their typed contract, so a mistyped activity is an analysis error rather than a runtime failure", + "keywords": [ + "durable-execution", + "long-running", + "orchestration", + "phpstan", + "phpstan-extension", + "static-analysis", + "workflow" + ], "transport-options": { "relative": true } @@ -7068,7 +7653,7 @@ "dist": { "type": "path", "url": "src/DurableRector", - "reference": "6be6142045b2da6d7e11605b79b191e1ecaad003" + "reference": "4b3c7519859795a1af7635474a1ee28960b0a15f" }, "require": { "gplanchat/durable": "self.version", @@ -7102,6 +7687,16 @@ } ], "description": "Rector rules that migrate a project off the official Temporal PHP SDK onto gplanchat/durable — attribute rewrites that keep the workflow and activity type names a running server already knows", + "keywords": [ + "durable-execution", + "long-running", + "migration", + "orchestration", + "rector", + "rector-rules", + "temporal", + "workflow" + ], "transport-options": { "relative": true } diff --git a/src/Bridge/Dbal/README.md b/src/Bridge/Dbal/README.md index 3fa8cf73..698d72fb 100644 --- a/src/Bridge/Dbal/README.md +++ b/src/Bridge/Dbal/README.md @@ -82,7 +82,30 @@ journal cannot have two sources of truth. ## Schema Tables are created on first write; there is no migration to run and no `doctrine/migrations` -dependency. To manage them yourself, call `DurableSchema::addToSchema()` from your own schema +dependency. + +**With Doctrine ORM installed, they are also declared to its tooling.** The bundle registers a +`postGenerateSchema` listener, so `doctrine:schema:update` and `doctrine:migrations:diff` know these +tables belong to the application. Without it they would look like orphans and a generated migration +would **drop them** — with the journal, every in-flight execution. + +The listener declares the tables when the journal writes on the database the ORM inspects — the +same `Connection` object, or a different one proven to reach the same database. Two distinct +`Connection` objects can point at the same database, so the listener runs the probe Symfony's own +`AbstractSchemaListener` uses: it creates a throwaway table on one connection and checks whether the +other can drop it. Declaring on the wrong database would create tables where they do not belong, so +a probe that cannot conclude declares nothing. + +Once migrations own the schema, turn the lazy creation off — otherwise both mechanisms write +behind each other: + +```yaml +durable: + dbal: + auto_setup: false +``` + +To manage the tables entirely yourself, call `DurableSchema::addToSchema()` from your own schema provider and keep the table names in sync with the configuration above. The journal table has no `sequence` column — `readStream()` promises insertion order and the diff --git a/src/Bridge/Dbal/Schema/DurableSchema.php b/src/Bridge/Dbal/Schema/DurableSchema.php index 84953a7b..5fbdf906 100644 --- a/src/Bridge/Dbal/Schema/DurableSchema.php +++ b/src/Bridge/Dbal/Schema/DurableSchema.php @@ -9,10 +9,19 @@ use Doctrine\DBAL\Types\Types; /** - * Tables du backend DBAL : journal, métadonnées d'exécution, lien parent/enfant. + * Tables du backend DBAL : journal, métadonnées d'exécution, lien parent/enfant, catalogue de runs. * - * L'auto-création suit le modèle du transport Doctrine de Messenger : la première écriture - * crée ce qui manque. Pas de doctrine/migrations — la forme est figée par ce fichier. + * Deux façons de les obtenir, et le transport Doctrine de Messenger a les deux : + * + * - **L'auto-création** ({@see ensure()}) : la première écriture crée ce qui manque. Pratique en + * développement, et c'est le défaut. + * - **La déclaration** ({@see configureSchema()}) : les tables rejoignent le schéma que Doctrine + * construit, donc `doctrine:schema:update` et `doctrine:migrations:diff` les connaissent. Sans + * elle, l'outillage les voit comme orphelines et **génère leur suppression** — un journal + * d'exécutions durables effacé par une migration que personne n'a relue de près. + * + * Les deux ensemble se marchent dessus dès que les migrations tiennent le schéma : `auto_setup` + * éteint alors l'auto-création, comme le fait le transport Doctrine. * * @see DUR030 */ @@ -26,6 +35,7 @@ public function __construct( private readonly string $metadataTable = 'durable_workflow_metadata', private readonly string $parentLinkTable = 'durable_child_workflow_parent_link', private readonly string $runsTable = 'durable_workflow_runs', + private readonly bool $autoSetup = true, ) {} /** @@ -33,7 +43,7 @@ public function __construct( */ public function ensure(): void { - if ($this->ensured) { + if (!$this->autoSetup || $this->ensured) { return; } $this->ensured = true; @@ -53,7 +63,34 @@ public function ensure(): void } /** - * Déclare les tables manquantes ; branché aussi sur `configureSchema` côté bundle. + * Ajoute au schéma que Doctrine construit les tables qui manquent, pour que l'outillage les + * connaisse au lieu de les prendre pour des orphelines à supprimer. + * + * Le journal peut vivre sur une autre connexion que celle de l'ORM. Y déclarer ces tables + * ferait créer, dans la base de l'application, des tables qui n'y sont pas — et laisserait + * l'outillage proposer de supprimer, dans la base du journal, celles qui y sont. D'où la + * même garde que les adaptateurs amont : même connexion, ou même base prouvée par la sonde. + * + * @param \Closure(\Closure(string): mixed): bool $isSameDatabase + * + * @return Schema le schéma, complété + */ + public function configureSchema(Schema $schema, Connection $forConnection, \Closure $isSameDatabase): Schema + { + if ($forConnection !== $this->connection && !$isSameDatabase($this->connection->executeStatement(...))) { + return $schema; + } + + $this->addToSchema($schema, array_values(array_filter( + [$this->eventsTable, $this->metadataTable, $this->parentLinkTable, $this->runsTable], + static fn(string $table): bool => $schema->hasTable($table), + ))); + + return $schema; + } + + /** + * Déclare les tables manquantes dans le schéma passé. * * @param list $skip tables déjà présentes */ diff --git a/src/DurableBundle/DependencyInjection/Configuration.php b/src/DurableBundle/DependencyInjection/Configuration.php index 58ed390b..5593d447 100644 --- a/src/DurableBundle/DependencyInjection/Configuration.php +++ b/src/DurableBundle/DependencyInjection/Configuration.php @@ -20,6 +20,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->info("Backend DBAL : exécution durable sur une seule base SQL, sans cluster d'orchestration (DUR030).") ->children() ->scalarNode('connection')->defaultValue('doctrine.dbal.default_connection')->info('Service ID de la Doctrine\\DBAL\\Connection à utiliser')->end() + ->booleanNode('auto_setup')->defaultTrue()->info("Créer les tables manquantes à la première écriture. À passer à false dès que doctrine/migrations tient le schéma : les deux mécanismes écriraient sinon l'un derrière l'autre.")->end() ->scalarNode('lock_factory')->defaultValue('lock.factory')->info("Service ID de la Symfony\\Component\\Lock\\LockFactory qui sérialise les reprises d'une même exécution")->end() ->end() ->end() diff --git a/src/DurableBundle/DependencyInjection/DurableExtension.php b/src/DurableBundle/DependencyInjection/DurableExtension.php index f7aba215..fe7ce46a 100644 --- a/src/DurableBundle/DependencyInjection/DurableExtension.php +++ b/src/DurableBundle/DependencyInjection/DurableExtension.php @@ -4,6 +4,7 @@ namespace Gplanchat\Durable\Bundle\DependencyInjection; +use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; use Gplanchat\Bridge\Dbal\Messenger\SingleResumeLockMiddleware; use Gplanchat\Bridge\Dbal\Schema\DurableSchema; use Gplanchat\Bridge\Dbal\Store\DbalChildWorkflowParentLinkStore; @@ -40,6 +41,7 @@ use Gplanchat\Durable\Bundle\Messenger\MessengerWorkflowResumeDispatcher; use Gplanchat\Durable\Bundle\Messenger\WorkflowRunDispatchProfilerMiddleware; use Gplanchat\Durable\Bundle\Profiler\DurableExecutionTrace; +use Gplanchat\Durable\Bundle\SchemaListener\DurableSchemaListener; use Gplanchat\Durable\Bundle\Transport\MessengerActivityTransport; use Gplanchat\Durable\Bundle\Transport\MessengerWorkflowTimerDispatcher; use Gplanchat\Durable\Debug\WorkflowExecutionObserverInterface; @@ -147,10 +149,22 @@ private function registerDbalStores(ContainerBuilder $container, array $config): $config['workflow_metadata']['table_name'], $config['child_workflow']['parent_link_store']['table_name'], ]) + ->setArgument('$autoSetup', $config['dbal']['auto_setup']) ->setPublic(false) ; $schema = new Reference('durable.dbal.schema'); + // Sans cet écouteur, `doctrine:migrations:diff` ne voit pas les tables du journal et + // génère leur suppression. Enregistré seulement si l'ORM est là : le pont DBAL fonctionne + // sans lui, et une application qui n'a que la DBAL n'a pas de schéma à compléter. + if (class_exists(GenerateSchemaEventArgs::class)) { + $container->register('durable.dbal.schema_listener', DurableSchemaListener::class) + ->setArguments([$schema]) + ->addTag('doctrine.event_listener', ['event' => 'postGenerateSchema']) + ->setPublic(false) + ; + } + if ($eventStoreDbal) { $container->register('durable.event_store.dbal', DbalEventStore::class) ->setArguments([$connection, $schema, $config['event_store']['table_name']]) diff --git a/src/DurableBundle/SchemaListener/DurableSchemaListener.php b/src/DurableBundle/SchemaListener/DurableSchemaListener.php new file mode 100644 index 00000000..0945d67c --- /dev/null +++ b/src/DurableBundle/SchemaListener/DurableSchemaListener.php @@ -0,0 +1,73 @@ +getEntityManager()->getConnection(); + + $this->schema->configureSchema( + $event->getSchema(), + $connection, + self::isSameDatabase($connection), + ); + } + + /** + * Sonde « même base » : deux objets `Connection` distincts peuvent pointer la même base, et + * seule une écriture le prouve. Le principe est celui de + * `Symfony\Bridge\Doctrine\SchemaListener\AbstractSchemaListener::getIsSameDatabaseChecker()`, + * dont la déclaration est identique de Symfony 6.4 à 8.0 — vérifié sur `v6.4.0`, `7.2` et + * `8.0`. Elle est recopiée plutôt qu'héritée pour deux raisons : elle y est `protected`, donc + * inaccessible sans étendre la classe, et l'étendre imposerait `symfony/doctrine-bridge` au + * bundle pour vingt lignes qui ne dépendent que de la DBAL. + * + * @return \Closure(\Closure(string): mixed): bool + */ + private static function isSameDatabase(Connection $connection): \Closure + { + return static function (\Closure $exec) use ($connection): bool { + $checkTable = 'durable_schema_check_' . bin2hex(random_bytes(7)); + $connection->executeStatement(\sprintf('CREATE TABLE %s (id INTEGER NOT NULL)', $checkTable)); + + try { + $exec(\sprintf('DROP TABLE %s', $checkTable)); + } catch (\Exception) { + // La connexion du journal n'a pas pu supprimer la table : soit une autre base, + // soit un droit manquant. Le second contrôle tranche. + } + + try { + $connection->executeStatement(\sprintf('DROP TABLE %s', $checkTable)); + + return false; + } catch (TableNotFoundException) { + return true; + } + }; + } +} diff --git a/src/DurableBundle/composer.json b/src/DurableBundle/composer.json index bfff38ff..ac1149b2 100644 --- a/src/DurableBundle/composer.json +++ b/src/DurableBundle/composer.json @@ -30,7 +30,8 @@ "suggest": { "phpunit/phpunit": "Required by the shipped test helper (Testing\\DurableBundleTestTrait)", "symfony/framework-bundle": "Required by consumers of the shipped test helper (Testing\\DurableBundleTestTrait), whose static::getContainer() resolves against a KernelTestCase", - "symfony/web-profiler-bundle": "Web Debug Toolbar and profiler: Durable panel (workflows / activities)" + "symfony/web-profiler-bundle": "Web Debug Toolbar and profiler: Durable panel (workflows / activities)", + "doctrine/orm": "Declares the DBAL journal tables to doctrine:schema:update and doctrine:migrations:diff, which would otherwise generate DROP TABLE for them (SchemaListener\\DurableSchemaListener)" }, "autoload": { "psr-4": { diff --git a/tests/unit/Bridge/Dbal/DurableSchemaDeclarationTest.php b/tests/unit/Bridge/Dbal/DurableSchemaDeclarationTest.php new file mode 100644 index 00000000..c04c150d --- /dev/null +++ b/tests/unit/Bridge/Dbal/DurableSchemaDeclarationTest.php @@ -0,0 +1,119 @@ +configureSchema($schema, $connection, static fn(): bool => true); + + foreach (self::TABLES as $table) { + self::assertTrue($schema->hasTable($table), \sprintf('%s doit être déclarée', $table)); + } + } + + /** + * Le schéma que Doctrine construit porte déjà les tables des entités, et peut porter les + * nôtres si une migration précédente les a créées. Redéclarer une table présente lèverait. + */ + public function testUneTableDejaPresenteDansLeSchemaNEstPasRedeclaree(): void + { + $connection = self::connection(); + $schema = new Schema(); + $dejaLa = $schema->createTable('durable_events'); + $dejaLa->addColumn('id', 'bigint'); + + (new DurableSchema($connection))->configureSchema($schema, $connection, static fn(): bool => true); + + self::assertTrue($schema->hasTable('durable_events')); + self::assertTrue($schema->hasTable('durable_workflow_runs'), 'les autres sont déclarées quand même'); + } + + /** + * Le journal peut vivre sur une autre connexion que celle de l'ORM. Y déclarer nos tables + * ferait créer, dans la base de l'application, des tables qui n'y sont pas — et supprimer, + * dans la base du journal, celles qui y sont. + */ + public function testRienNEstDeclareQuandCeNEstPasLaMemeBase(): void + { + $schema = new Schema(); + + (new DurableSchema(self::connection()))->configureSchema( + $schema, + self::connection(), + static fn(): bool => false, + ); + + self::assertSame([], $schema->getTables(), 'aucune table ne doit rejoindre le schéma d\'une autre base'); + } + + public function testLaMemeBaseSurUneAutreConnexionEstDeclaree(): void + { + $schema = new Schema(); + + (new DurableSchema(self::connection()))->configureSchema( + $schema, + self::connection(), + static fn(): bool => true, + ); + + self::assertCount(\count(self::TABLES), $schema->getTables()); + } + + /** + * Quand les migrations tiennent le schéma, le DDL paresseux du pont n'a plus lieu d'être : + * il écrirait derrière le dos de l'outil qui en a désormais la charge. + */ + public function testAutoSetupDesactiveNeCreeAucuneTable(): void + { + $connection = self::connection(); + + (new DurableSchema($connection, autoSetup: false))->ensure(); + + self::assertSame([], $connection->createSchemaManager()->listTableNames()); + } + + public function testAutoSetupActifCreeLesTables(): void + { + $connection = self::connection(); + + (new DurableSchema($connection))->ensure(); + + foreach (self::TABLES as $table) { + self::assertContains($table, $connection->createSchemaManager()->listTableNames()); + } + } + + private static function connection(): \Doctrine\DBAL\Connection + { + return DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + } +} diff --git a/tests/unit/DurableBundle/SchemaListener/DurableSchemaListenerTest.php b/tests/unit/DurableBundle/SchemaListener/DurableSchemaListenerTest.php new file mode 100644 index 00000000..b1749c29 --- /dev/null +++ b/tests/unit/DurableBundle/SchemaListener/DurableSchemaListenerTest.php @@ -0,0 +1,112 @@ + */ + private array $fichiers = []; + + protected function tearDown(): void + { + foreach ($this->fichiers as $fichier) { + @unlink($fichier); + } + $this->fichiers = []; + } + + public function testLaMemeConnexionDeclareLesTables(): void + { + $connection = self::enMemoire(); + $schema = new Schema(); + + (new DurableSchemaListener(new DurableSchema($connection))) + ->postGenerateSchema($this->evenement($connection, $schema)); + + foreach (self::TABLES as $table) { + self::assertTrue($schema->hasTable($table), \sprintf('%s doit être déclarée', $table)); + } + } + + /** + * Deux objets `Connection` distincts sur le même fichier : c'est le cas que la sonde existe + * pour trancher, et celui qu'une sonde câblée sur `false` traitait comme une autre base. + */ + public function testDeuxConnexionsSurLaMemeBaseDeclarentLesTables(): void + { + $fichier = $this->fichier(); + $journal = self::surFichier($fichier); + $orm = self::surFichier($fichier); + $schema = new Schema(); + + (new DurableSchemaListener(new DurableSchema($journal))) + ->postGenerateSchema($this->evenement($orm, $schema)); + + self::assertCount(\count(self::TABLES), $schema->getTables(), 'la sonde doit reconnaître la même base'); + } + + public function testDeuxBasesDistinctesNeDeclarentRien(): void + { + $journal = self::surFichier($this->fichier()); + $orm = self::surFichier($this->fichier()); + $schema = new Schema(); + + (new DurableSchemaListener(new DurableSchema($journal))) + ->postGenerateSchema($this->evenement($orm, $schema)); + + self::assertSame([], $schema->getTables(), 'aucune table ne doit rejoindre le schéma d\'une autre base'); + } + + private function evenement(Connection $connection, Schema $schema): GenerateSchemaEventArgs + { + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getConnection')->willReturn($connection); + + return new GenerateSchemaEventArgs($em, $schema); + } + + private static function enMemoire(): Connection + { + return DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + } + + private static function surFichier(string $chemin): Connection + { + return DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $chemin]); + } + + private function fichier(): string + { + $chemin = \sprintf('%s/durable-schema-%s.sqlite', sys_get_temp_dir(), bin2hex(random_bytes(6))); + $this->fichiers[] = $chemin; + + return $chemin; + } +}