diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e6ca211f..c6ca3b9f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,11 +4,15 @@ "workspaceFolder": "/workspace", "service": "app", "shutdownAction": "stopCompose", - "extensions": [ - "editorconfig.editorconfig", - ], - "settings": { - "#terminal.integrated.shell.linux": "/bin/bash" + "customizations": { + "vscode": { + "extensions": [ + "editorconfig.editorconfig" + ], + "settings": { + "terminal.integrated.shell.linux": "/bin/bash" + } + } }, - "postCreateCommand": "composer install", + "postCreateCommand": "composer install" } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 94fbb18f..d88829b2 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -12,8 +12,9 @@ services: PHP_EXTENSION_XDEBUG: 1 PHP_EXTENSION_PGSQL: 1 PHP_EXTENSION_PDO_PGSQL: 1 + PHP_EXTENSION_MONGODB: 1 APACHE_DOCUMENT_ROOT: /workspace/public - db: + pgsql: image: postgres:13 restart: unless-stopped ports: @@ -22,6 +23,32 @@ services: POSTGRES_DB: laravel POSTGRES_USER: laravel POSTGRES_PASSWORD: laravel + mysql: + image: mysql:8.0 + restart: unless-stopped + ports: + - 3306:3306 + environment: + MYSQL_ROOT_PASSWORD: laravel + MYSQL_DATABASE: laravel + MYSQL_USER: laravel + MYSQL_PASSWORD: laravel + volumes: + - mysql_data:/var/lib/mysql + mongodb: + image: mongo:7.0 + restart: unless-stopped + ports: + - 27017:27017 + volumes: + - mongodb_data:/data/db + command: ["--replSet", "rs0", "--bind_ip_all"] + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s redis: image: redis:6-alpine ports: @@ -30,3 +57,7 @@ services: image: mailhog/mailhog ports: - 8025:8025 + +volumes: + mysql_data: + mongodb_data: diff --git a/.devcontainer/docker/app/Dockerfile b/.devcontainer/docker/app/Dockerfile index ee3c2e01..40735dd0 100644 --- a/.devcontainer/docker/app/Dockerfile +++ b/.devcontainer/docker/app/Dockerfile @@ -1,4 +1,4 @@ -ARG PHP_EXTENSIONS="mysqli pgsql pdo_mysql pdo_pgsql" +ARG PHP_EXTENSIONS="mysqli pgsql pdo_mysql pdo_pgsql mongodb" FROM thecodingmachine/php:8.1-v4-apache-node12 WORKDIR /workspace diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 6fde9ae4..19fdcac2 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -28,6 +28,15 @@ jobs: ports: - 5432:5432 + mongodb: + image: mongo:7.0 + env: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: password + MONGO_INITDB_DATABASE: testbench + ports: + - 27017:27017 + redis: image: redis ports: @@ -38,6 +47,13 @@ jobs: with: fetch-depth: 10 + - name: Setup PHP with MongoDB extension + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mongodb + coverage: xdebug + - name: Validate composer.json and composer.lock run: composer validate --strict @@ -64,40 +80,87 @@ jobs: touch testbench.sqlite mysql -e 'CREATE DATABASE testbench' -h127.0.0.1 -uroot -ppassword -P ${{ job.services.mysql.ports[3306] }} - - name: Run test suite (MySQL) - run: vendor/bin/phpunit --testdox --testsuite feature - env: - APP_KEY: base64:i3g6f+dV8FfsIkcxqd7gbiPn2oXk5r00sTmdD6V5utI= - DB_CONNECTION: mysql - DB_DATABASE: testbench - DB_HOST: 127.0.0.1 - DB_PORT: 3306 - DB_USERNAME: root - DB_PASSWORD: password - QUEUE_CONNECTION: redis - QUEUE_FAILED_DRIVER: "null" - REDIS_HOST: 127.0.0.1 - REDIS_PASSWORD: - REDIS_PORT: 6379 + # - name: Run test suite (MySQL) + # run: vendor/bin/phpunit --testdox --testsuite feature + # env: + # APP_KEY: base64:i3g6f+dV8FfsIkcxqd7gbiPn2oXk5r00sTmdD6V5utI= + # DB_CONNECTION: mysql + # DB_DATABASE: testbench + # DB_HOST: 127.0.0.1 + # DB_PORT: 3306 + # DB_USERNAME: root + # DB_PASSWORD: password + # QUEUE_CONNECTION: redis + # QUEUE_FAILED_DRIVER: "null" + # REDIS_HOST: 127.0.0.1 + # REDIS_PASSWORD: + # REDIS_PORT: 6379 + + # - name: Run test suite (PostgreSQL) + # run: vendor/bin/phpunit --testdox --testsuite feature + # env: + # APP_KEY: base64:i3g6f+dV8FfsIkcxqd7gbiPn2oXk5r00sTmdD6V5utI= + # DB_CONNECTION: pgsql + # DB_DATABASE: testbench + # DB_HOST: 127.0.0.1 + # DB_PORT: 5432 + # DB_USERNAME: root + # DB_PASSWORD: password + # QUEUE_CONNECTION: redis + # QUEUE_FAILED_DRIVER: "null" + # REDIS_HOST: 127.0.0.1 + # REDIS_PASSWORD: + # REDIS_PORT: 6379 + + - name: Check MongoDB connection + run: | + timeout 10 bash -c 'until nc -z 127.0.0.1 27017; do sleep 1; done' || (echo "MongoDB not reachable" && exit 1) + echo "MongoDB is up and running" + + - name: Test MongoDB connectivity + run: | + php -r "try { \$manager = new MongoDB\Driver\Manager('mongodb://root:password@127.0.0.1:27017/?authSource=admin'); \$command = new MongoDB\Driver\Command(['ping' => 1]); \$manager->executeCommand('admin', \$command); echo 'MongoDB connection successful\n'; } catch (Exception \$e) { echo 'MongoDB connection failed: ' . \$e->getMessage() . '\n'; exit(1); }" - - name: Run test suite (PostgreSQL) - run: vendor/bin/phpunit --testdox --testsuite feature + - name: Monitor Laravel log in background + run: | + mkdir -p vendor/orchestra/testbench-core/laravel/storage/logs + touch vendor/orchestra/testbench-core/laravel/storage/logs/laravel.log + tail -f vendor/orchestra/testbench-core/laravel/storage/logs/laravel.log & + echo $! > /tmp/tail_pid + + - name: Run test suite (MongoDB) + timeout-minutes: 2 + run: vendor/bin/phpunit --testdox --testsuite feature --debug --stop-on-error --stop-on-failure || true env: APP_KEY: base64:i3g6f+dV8FfsIkcxqd7gbiPn2oXk5r00sTmdD6V5utI= - DB_CONNECTION: pgsql + DB_CONNECTION: mongodb DB_DATABASE: testbench DB_HOST: 127.0.0.1 - DB_PORT: 5432 + DB_PORT: 27017 DB_USERNAME: root DB_PASSWORD: password + DB_AUTHENTICATION_DATABASE: admin QUEUE_CONNECTION: redis QUEUE_FAILED_DRIVER: "null" REDIS_HOST: 127.0.0.1 REDIS_PASSWORD: REDIS_PORT: 6379 + - name: Stop log monitor and print full log + if: always() + run: | + if [ -f /tmp/tail_pid ]; then + kill $(cat /tmp/tail_pid) || true + fi + echo "===== FULL LARAVEL LOG =====" + if [ -f vendor/orchestra/testbench-core/laravel/storage/logs/laravel.log ]; then + cat vendor/orchestra/testbench-core/laravel/storage/logs/laravel.log + else + echo "No laravel.log found" + fi + - name: Upload laravel.log if tests fail - if: failure() + if: always() uses: actions/upload-artifact@v4 with: name: laravel-log diff --git a/.gitignore b/.gitignore index 4d84bc35..c361d862 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage.xml .phpunit.result.cache .php_cs.cache .php-cs-fixer.cache +.vs diff --git a/composer.json b/composer.json index 7cdc83c7..8d681948 100644 --- a/composer.json +++ b/composer.json @@ -52,6 +52,7 @@ "react/promise": "^2.9|^3.0" }, "require-dev": { + "mongodb/laravel-mongodb": "^4.0", "orchestra/testbench": "^8.0", "phpstan/phpstan": "^2.0", "scrutinizer/ocular": "dev-master", diff --git a/phpunit.xml b/phpunit.xml index 406e3615..737677cb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -10,6 +10,8 @@ + + diff --git a/src/Domain/Contracts/DateTimeAdapterInterface.php b/src/Domain/Contracts/DateTimeAdapterInterface.php new file mode 100644 index 00000000..cceb7e2d --- /dev/null +++ b/src/Domain/Contracts/DateTimeAdapterInterface.php @@ -0,0 +1,35 @@ +getCode(); + return in_array($errorCode, ['23000', '23505', '1062'], true) || + str_contains($exception->getMessage(), 'UNIQUE constraint'); + } + + return false; + } + + public function isConnectionException(\Throwable $exception): bool + { + if ($exception instanceof \Illuminate\Database\QueryException) { + // Check for common connection error codes + $errorCode = $exception->getCode(); + return in_array($errorCode, ['08001', '08006', '2002', '2006'], true); + } + + return false; + } +} diff --git a/src/Infrastructure/Persistence/Eloquent/EloquentQueryAdapter.php b/src/Infrastructure/Persistence/Eloquent/EloquentQueryAdapter.php new file mode 100644 index 00000000..05256d10 --- /dev/null +++ b/src/Infrastructure/Persistence/Eloquent/EloquentQueryAdapter.php @@ -0,0 +1,42 @@ +signals(); + + if ($maxCreatedAt) { + $query->where('created_at', '<=', $maxCreatedAt->format('Y-m-d H:i:s.u')); + } + + return $query->get(); + } + + public function getSignalsBetweenTimestamps( + StoredWorkflow $workflow, + Carbon $afterTimestamp, + ?Carbon $beforeTimestamp = null + ): Collection { + $query = $workflow->signals() + ->where('created_at', '>', $afterTimestamp->format('Y-m-d H:i:s.u')); + + if ($beforeTimestamp) { + $query->where('created_at', '<=', $beforeTimestamp->format('Y-m-d H:i:s.u')); + } + + return $query->get(); + } +} diff --git a/src/Infrastructure/Persistence/Eloquent/EloquentRelationshipAdapter.php b/src/Infrastructure/Persistence/Eloquent/EloquentRelationshipAdapter.php new file mode 100644 index 00000000..f57a5912 --- /dev/null +++ b/src/Infrastructure/Persistence/Eloquent/EloquentRelationshipAdapter.php @@ -0,0 +1,45 @@ +belongsToMany( + $relatedClass, + $table, + $foreignPivotKey, + $relatedPivotKey + )->withPivot(['parent_index', 'parent_now']); + } + + public function createParentsRelation( + Model $parent, + string $relatedClass, + string $table, + string $foreignPivotKey, + string $relatedPivotKey + ): BelongsToMany { + return $parent->belongsToMany( + $relatedClass, + $table, + $foreignPivotKey, + $relatedPivotKey + )->withPivot(['parent_index', 'parent_now']); + } +} diff --git a/src/Infrastructure/Persistence/Eloquent/EloquentWorkflowRepository.php b/src/Infrastructure/Persistence/Eloquent/EloquentWorkflowRepository.php new file mode 100644 index 00000000..adc52718 --- /dev/null +++ b/src/Infrastructure/Persistence/Eloquent/EloquentWorkflowRepository.php @@ -0,0 +1,78 @@ +update($attributes); + } + + public function delete(StoredWorkflow $workflow): bool + { + return $workflow->delete(); + } + + public function getPrunableWorkflows(): \Illuminate\Database\Eloquent\Builder + { + return StoredWorkflow::where('status', 'completed') + ->where('created_at', '<=', now()->sub(config('workflows.prune_age', '1 month'))) + ->whereDoesntHave('parents'); + } + + public function attachChild(StoredWorkflow $parent, StoredWorkflow $child, array $pivotData = []): void + { + $parent->children() + ->attach($child->getKey(), $pivotData); + } + + public function detachChild(StoredWorkflow $parent, StoredWorkflow $child): void + { + $parent->children() + ->detach($child->getKey()); + } + + public function getChildren(StoredWorkflow $workflow): Collection + { + return $workflow->children() + ->get(); + } + + public function getParents(StoredWorkflow $workflow): Collection + { + return $workflow->parents() + ->get(); + } + + public function getContinuedWorkflows(StoredWorkflow $workflow): Collection + { + return $workflow->continuedWorkflows() + ->get(); + } + + public function getActiveWorkflow(StoredWorkflow $workflow): ?StoredWorkflow + { + return $workflow->activeWorkflow() + ->first(); + } +} diff --git a/src/Infrastructure/Persistence/MongoDB/MongoDBBelongsToManyRelation.php b/src/Infrastructure/Persistence/MongoDB/MongoDBBelongsToManyRelation.php new file mode 100644 index 00000000..4e99cae0 --- /dev/null +++ b/src/Infrastructure/Persistence/MongoDB/MongoDBBelongsToManyRelation.php @@ -0,0 +1,368 @@ +getKey(); + } + + if ($id instanceof Collection) { + $id = $id->modelKeys(); + } + + $ids = (array) $id; + + foreach ($ids as $relatedId) { + // Always create the relationship record (multiple relationships between same entities are allowed with different pivot data) + MongoDBWorkflowRelationship::create(array_merge([ + $this->foreignPivotKey => $this->parent->getKey(), + $this->relatedPivotKey => $relatedId, + ], $attributes)); + } + + if ($touch) { + $this->touchIfTouching(); + } + } + + /** + * Detach models from the relationship using the pivot collection. + * + * @param mixed $ids + * @param bool $touch + * @return int + */ + public function detach($ids = null, $touch = true) + { + if ($ids instanceof Model) { + $ids = $ids->getKey(); + } + + if ($ids instanceof Collection) { + $ids = $ids->modelKeys(); + } + + $query = MongoDBWorkflowRelationship::where($this->foreignPivotKey, $this->parent->getKey()); + + if ($ids !== null) { + $query->whereIn($this->relatedPivotKey, (array) $ids); + } + + // Apply custom pivot where clauses + foreach ($this->customPivotWheres as $whereArgs) { + [$column, $operator, $value] = array_pad($whereArgs, 3, null); + if ($value === null) { + $value = $operator; + $operator = '='; + } + $query->where($column, $operator, $value); + } + + // Execute delete and return the count of deleted records + $deleted = $query->delete(); + + if ($touch) { + $this->touchIfTouching(); + } + + return $deleted; + } + + /** + * Add the constraints for a relationship query. + */ + public function addConstraints() + { + // Don't apply join here - it will be applied when the query is executed + // This allows wherePivot to be called after the relation is created + } + + /** + * Add a "where" clause for a pivot table column to the query. + * + * @param string $column + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function wherePivot($column, $operator = null, $value = null, $boolean = 'and') + { + $this->customPivotWheres[] = func_get_args(); + + return $this; + } + + /** + * Execute the query as a "select" statement. + * + * @param array $columns + * @return \Illuminate\Database\Eloquent\Collection + */ + public function get($columns = ['*']) + { + // Apply the join constraints before executing the query + $this->setJoin(); + + $models = parent::get($columns); + + // Attach pivot data to each model + if ($models->isNotEmpty()) { + $this->hydratePivotRelation($models->all()); + } + + return $models; + } + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @param array $columns + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function first($columns = ['*']) + { + // Apply the join constraints before executing the query + $this->setJoin(); + + $model = parent::first($columns); + + // Attach pivot data if model was found + if ($model) { + $this->hydratePivotRelation([$model]); + } + + return $model; + } + + /** + * Execute the query and get the first result. + * + * @return \Illuminate\Database\Eloquent\Model|static|null + */ + public function count() + { + // Apply the join constraints before executing the query + $this->setJoin(); + + return parent::count(); + } + + /** + * Chunk the results of the query. + * + * @param int $count + * @return bool + */ + public function chunk($count, callable $callback) + { + // Apply the join constraints before executing the query + $this->setJoin(); + + return parent::chunk($count, $callback); + } + + /** + * Execute a callback over each item while chunking. + * + * @param int $count + * @return bool + */ + public function each(callable $callback, $count = 1000) + { + // Apply the join constraints before executing the query + $this->setJoin(); + + return parent::each($callback, $count); + } + + /** + * Get the key for comparing against the parent key in "has" query. + * + * @return string + */ + public function getQualifiedForeignPivotKeyName() + { + return $this->foreignPivotKey; + } + + /** + * Get the qualified related pivot key name. + * + * @return string + */ + public function getQualifiedRelatedPivotKeyName() + { + return $this->relatedPivotKey; + } + + /** + * Get the key name of the parent model. + * + * @return string + */ + public function getOwnerKeyName() + { + return $this->parentKey; + } + + /** + * Get the qualified key name of the parent model. + * + * @return string + */ + public function getQualifiedParentKeyName() + { + return $this->parent->getQualifiedKeyName(); + } + + /** + * Set the join clause for the relation query. + * + * @param \Illuminate\Database\Eloquent\Builder|null $query + * @return $this + */ + protected function setJoin($query = null) + { + // Only apply join constraints once + if ($this->joinApplied) { + return $this; + } + + $this->joinApplied = true; + $query = $query ?: $this->query; + + // Get pivot IDs that match our parent + $parentKey = $this->parent->getKey(); + + if ($parentKey === null) { + // Parent doesn't have a key yet, return empty result + $query->where('_id', '=', '__MONGODB_NO_RESULTS__'); + return $this; + } + + $pivotQuery = MongoDBWorkflowRelationship::where($this->foreignPivotKey, $parentKey); + + // Apply custom pivot where clauses + foreach ($this->customPivotWheres as $whereArgs) { + [$column, $operator, $value] = array_pad($whereArgs, 3, null); + if ($value === null) { + $value = $operator; + $operator = '='; + } + $pivotQuery->where($column, $operator, $value); + } + + $pivotRecords = $pivotQuery->get(); + $relatedIds = $pivotRecords->pluck($this->relatedPivotKey) + ->filter() + ->values() + ->all(); + + if (empty($relatedIds)) { + // No related records, so return empty result using a condition that's always false + $query->where('_id', '=', '__MONGODB_NO_RESULTS__'); + } else { + // Use _id for MongoDB - ensure we select all columns + $query->whereIn('_id', $relatedIds) + ->select('*'); + } + + return $this; + } + + /** + * Hydrate the pivot relationship on the models. + */ + protected function hydratePivotRelation(array $models) + { + // Get all pivot records for these models + $relatedIds = array_map(static fn ($model) => $model->getKey(), $models); + + $pivotQuery = MongoDBWorkflowRelationship::where($this->foreignPivotKey, $this->parent->getKey()) + ->whereIn($this->relatedPivotKey, $relatedIds); + + // Apply custom pivot where clauses + foreach ($this->customPivotWheres as $whereArgs) { + $pivotQuery->where(...$whereArgs); + } + + $pivots = $pivotQuery->get() + ->keyBy($this->relatedPivotKey); + + // Attach pivot data to each model + foreach ($models as $model) { + $pivot = $pivots->get($model->getKey()); + if ($pivot) { + // Set pivot as a relation on the model + $model->setRelation('pivot', $pivot); + } + } + } + + /** + * Set the where clause for the relation query. + * + * @return $this + */ + protected function setWhere() + { + // Already handled in setJoin + return $this; + } + + /** + * Get the pivot columns for the relation. + * + * @return array + */ + protected function aliasedPivotColumns() + { + return []; + } +} diff --git a/src/Infrastructure/Persistence/MongoDB/MongoDBDateTimeAdapter.php b/src/Infrastructure/Persistence/MongoDB/MongoDBDateTimeAdapter.php new file mode 100644 index 00000000..0444d2a4 --- /dev/null +++ b/src/Infrastructure/Persistence/MongoDB/MongoDBDateTimeAdapter.php @@ -0,0 +1,79 @@ +format($format); + } + + return $value; + } + + public function parseFromStorage($value, string $format = 'Y-m-d H:i:s.u') + { + if ($value === null) { + return null; + } + + // Handle MongoDB UTCDateTime objects + if ($value instanceof \MongoDB\BSON\UTCDateTime) { + return Carbon::createFromTimestampMs($value->toDateTime()->getTimestamp() * 1000); + } + + // Handle arrays (serialized UTCDateTime) + if (is_array($value) && isset($value['date'])) { + return Carbon::parse($value['date']); + } + + if (is_string($value)) { + try { + return Carbon::createFromFormat($format, $value); + } catch (\Exception $e) { + // Fallback to default parsing + try { + return Carbon::parse($value); + } catch (\Exception $e2) { + return null; + } + } + } + + // If already Carbon/DateTime, return as-is + if ($value instanceof \DateTimeInterface) { + return Carbon::parse($value); + } + + return $value; + } +} diff --git a/src/Infrastructure/Persistence/MongoDB/MongoDBExceptionHandler.php b/src/Infrastructure/Persistence/MongoDB/MongoDBExceptionHandler.php new file mode 100644 index 00000000..59b27ec8 --- /dev/null +++ b/src/Infrastructure/Persistence/MongoDB/MongoDBExceptionHandler.php @@ -0,0 +1,51 @@ +getMessage(), 'E11000') || + str_contains($exception->getMessage(), 'duplicate key')) { + return true; + } + + // Also check Laravel's UniqueConstraintViolationException in case it's wrapped + if ($exception instanceof \Illuminate\Database\UniqueConstraintViolationException) { + return true; + } + + return false; + } + + public function isConnectionException(\Throwable $exception): bool + { + // MongoDB connection exceptions + if (str_contains(get_class($exception), 'MongoDB\\Driver\\Exception\\ConnectionException') || + str_contains(get_class($exception), 'MongoDB\\Driver\\Exception\\ConnectionTimeoutException')) { + return true; + } + + // Check for connection-related messages + if (str_contains($exception->getMessage(), 'connection') || + str_contains($exception->getMessage(), 'No suitable servers found')) { + return true; + } + + return false; + } +} diff --git a/src/Infrastructure/Persistence/MongoDB/MongoDBQueryAdapter.php b/src/Infrastructure/Persistence/MongoDB/MongoDBQueryAdapter.php new file mode 100644 index 00000000..38649043 --- /dev/null +++ b/src/Infrastructure/Persistence/MongoDB/MongoDBQueryAdapter.php @@ -0,0 +1,148 @@ +getCollection('workflow_signals'); + + $allSignals = $collection->find([], [ + 'sort' => [ + '_id' => 1, + ], + ])->toArray(); + + $filtered = collect($allSignals) + ->filter(function ($signalData) use ($workflow, $maxCreatedAt) { + // Filter by stored_workflow_id + if ($signalData['stored_workflow_id'] !== $workflow->id) { + return false; + } + + // Filter by created_at if specified + if ($maxCreatedAt && isset($signalData['created_at'])) { + $signalCreatedAt = $this->convertToCarbon($signalData['created_at']); + + if ($signalCreatedAt && $signalCreatedAt->greaterThan($maxCreatedAt)) { + return false; + } + } + + return true; + }) + ->map(static function ($signalData) { + // Convert to model-like object for consistency + return (object) [ + 'method' => $signalData['method'], + 'arguments' => $signalData['arguments'] ?? '[]', + ]; + }); + + return $filtered; + } + + public function getSignalsBetweenTimestamps( + StoredWorkflow $workflow, + Carbon $afterTimestamp, + ?Carbon $beforeTimestamp = null + ): Collection { + // For MongoDB, get all signals for the workflow and filter in PHP + $connection = \Illuminate\Support\Facades\DB::connection(config('database.default')); + $collection = $connection->getCollection('workflow_signals'); + + $allSignals = $collection->find([], [ + 'sort' => [ + '_id' => 1, + ], + ])->toArray(); + + $filtered = collect($allSignals) + ->filter(function ($signalData) use ($workflow, $afterTimestamp, $beforeTimestamp) { + // Filter by stored_workflow_id + if ($signalData['stored_workflow_id'] !== $workflow->id) { + return false; + } + + if (! isset($signalData['created_at'])) { + return false; + } + + $signalCreatedAt = $this->convertToCarbon($signalData['created_at']); + + if (! $signalCreatedAt) { + return false; + } + + // Must be after the after timestamp + if ($signalCreatedAt->lessThanOrEqualTo($afterTimestamp)) { + return false; + } + + // Must be before the before timestamp if specified + if ($beforeTimestamp && $signalCreatedAt->greaterThan($beforeTimestamp)) { + return false; + } + + return true; + }) + ->map(static function ($signalData) { + // Convert to model-like object for consistency + return (object) [ + 'method' => $signalData['method'], + 'arguments' => $signalData['arguments'] ?? '[]', + ]; + }); + + return $filtered; + } + + /** + * Convert MongoDB date to Carbon instance. + * + * @param mixed $value + */ + private function convertToCarbon($value): ?Carbon + { + if ($value instanceof Carbon) { + return $value; + } + + // MongoDB UTCDateTime object + if ($value instanceof \MongoDB\BSON\UTCDateTime) { + return Carbon::instance($value->toDateTime()); + } + + // String date + if (is_string($value)) { + try { + return Carbon::parse($value); + } catch (\Exception $e) { + return null; + } + } + + // DateTime object + if ($value instanceof \DateTimeInterface) { + return Carbon::instance($value); + } + + return null; + } +} diff --git a/src/Infrastructure/Persistence/MongoDB/MongoDBRelationshipAdapter.php b/src/Infrastructure/Persistence/MongoDB/MongoDBRelationshipAdapter.php new file mode 100644 index 00000000..855f8e15 --- /dev/null +++ b/src/Infrastructure/Persistence/MongoDB/MongoDBRelationshipAdapter.php @@ -0,0 +1,68 @@ +newQuery(), + $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parent->getKeyName(), + $relatedInstance->getKeyName(), + null + ); + + // Add pivot attributes + return $relation->withPivot(['parent_index', 'parent_now']); + } + + public function createParentsRelation( + Model $parent, + string $relatedClass, + string $table, + string $foreignPivotKey, + string $relatedPivotKey + ): BelongsToMany { + // Create a new instance of the related model + $relatedInstance = new $relatedClass(); + + $relation = new MongoDBBelongsToManyRelation( + $relatedInstance->newQuery(), + $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parent->getKeyName(), + $relatedInstance->getKeyName(), + null + ); + + // Add pivot attributes + return $relation->withPivot(['parent_index', 'parent_now']); + } +} diff --git a/src/Infrastructure/Persistence/MongoDB/MongoDBWorkflowRelationship.php b/src/Infrastructure/Persistence/MongoDB/MongoDBWorkflowRelationship.php new file mode 100644 index 00000000..bcd02a37 --- /dev/null +++ b/src/Infrastructure/Persistence/MongoDB/MongoDBWorkflowRelationship.php @@ -0,0 +1,54 @@ +toDateTime()); + } + + if ($value instanceof \DateTimeInterface) { + return Carbon::parse($value); + } + + if (is_string($value)) { + return Carbon::parse($value); + } + + return $value; + } + + /** + * Set the parent_now attribute, converting to UTCDateTime for MongoDB. + */ + public function setParentNowAttribute($value) + { + if ($value instanceof Carbon || $value instanceof \DateTimeInterface) { + $this->attributes['parent_now'] = new UTCDateTime($value); + } elseif (is_string($value)) { + $this->attributes['parent_now'] = new UTCDateTime(Carbon::parse($value)); + } else { + $this->attributes['parent_now'] = $value; + } + } +} diff --git a/src/Infrastructure/Persistence/MongoDB/MongoDBWorkflowRepository.php b/src/Infrastructure/Persistence/MongoDB/MongoDBWorkflowRepository.php new file mode 100644 index 00000000..4cd87636 --- /dev/null +++ b/src/Infrastructure/Persistence/MongoDB/MongoDBWorkflowRepository.php @@ -0,0 +1,91 @@ +update($attributes); + } + + public function delete(StoredWorkflow $workflow): bool + { + return $workflow->delete(); + } + + public function getPrunableWorkflows(): \Illuminate\Database\Eloquent\Builder + { + $query = StoredWorkflow::where('status', 'completed') + ->where('created_at', '<=', now()->sub(config('workflows.prune_age', '1 month'))); + + // For MongoDB, manually exclude workflows that have parents + // since whereDoesntHave may not work optimally with MongoDB + $childIds = WorkflowRelationship::distinct('child_workflow_id') + ->pluck('child_workflow_id') + ->filter() + ->all(); + + if (! empty($childIds)) { + $query->whereNotIn('_id', $childIds); + } + + return $query; + } + + public function attachChild(StoredWorkflow $parent, StoredWorkflow $child, array $pivotData = []): void + { + $parent->children() + ->attach($child->getKey(), $pivotData); + } + + public function detachChild(StoredWorkflow $parent, StoredWorkflow $child): void + { + $parent->children() + ->detach($child->getKey()); + } + + public function getChildren(StoredWorkflow $workflow): Collection + { + return $workflow->children() + ->get(); + } + + public function getParents(StoredWorkflow $workflow): Collection + { + return $workflow->parents() + ->get(); + } + + public function getContinuedWorkflows(StoredWorkflow $workflow): Collection + { + return $workflow->continuedWorkflows() + ->get(); + } + + public function getActiveWorkflow(StoredWorkflow $workflow): ?StoredWorkflow + { + return $workflow->activeWorkflow() + ->first(); + } +} diff --git a/src/Infrastructure/Persistence/MongoDB/README.md b/src/Infrastructure/Persistence/MongoDB/README.md new file mode 100644 index 00000000..d0d88e7c --- /dev/null +++ b/src/Infrastructure/Persistence/MongoDB/README.md @@ -0,0 +1,62 @@ +# MongoDB Infrastructure Layer + +This directory contains MongoDB-specific implementations that work around limitations in the `mongodb/laravel-mongodb` package. + +## The Workaround: MongoDBBelongsToManyRelation + +### Why This Exists + +MongoDB Laravel's `BelongsToMany` implementation has significant limitations: + +1. **No Real Pivot Support**: It tries to use embedded arrays instead of pivot collections +2. **No wherePivot()**: Can't filter on pivot attributes like `parent_index` or `parent_now` +3. **No withPivot()**: Can't properly retrieve custom pivot attributes +4. **No Join Support**: MongoDB doesn't support SQL-style joins + +### What We Need + +Laravel Workflow requires: +- Parent-child workflow relationships with pivot attributes (`parent_index`, `parent_now`) +- Ability to query by pivot attributes (e.g., "find continued workflows where parent_index = PHP_INT_MAX") +- Multiple relationships between the same entities (same parent/child, different indices) + +### The Solution + +`MongoDBBelongsToManyRelation` is a custom `BelongsToMany` implementation that: + +1. **Uses a Real Pivot Collection**: `workflow_relationships` stores the relationships +2. **Implements wherePivot()**: Filters by pivot attributes before fetching related models +3. **Implements withPivot()**: Attaches pivot data to retrieved models +4. **Works Without Joins**: Manually queries the pivot collection and uses `whereIn()` for related IDs + +### How It Works + +```php +// User code (via adapter): +$workflow->children()->wherePivot('parent_index', PHP_INT_MAX)->get(); + +// What happens internally: +// 1. Query workflow_relationships where parent_workflow_id = X AND parent_index = PHP_INT_MAX +// 2. Extract child_workflow_id values +// 3. Query workflows where _id IN [extracted IDs] +// 4. Attach pivot data to each model +``` + +### Future + +Ideally, `mongodb/laravel-mongodb` would fix this upstream. Until then, we're stuck with this workaround. + +**This is hidden from users** - they interact via the `RelationshipAdapterInterface`, which abstracts this implementation detail. + +## Files + +- **MongoDBBelongsToManyRelation.php** - The workaround class +- **MongoDBRelationshipAdapter.php** - Adapter that uses the workaround +- **MongoDBDateTimeAdapter.php** - DateTime handling (MongoDB stores as strings for microseconds) +- **MongoDBExceptionHandler.php** - Exception detection (MongoDB throws different exceptions) +- **MongoDBQueryAdapter.php** - Query operations (signal filtering) +- **MongoDBWorkflowRepository.php** - Repository operations (pruning logic) + +## Testing + +This directory should have comprehensive tests to ensure the workaround maintains parity with Eloquent's native `BelongsToMany`. diff --git a/src/Models/StoredWorkflow.php b/src/Models/StoredWorkflow.php index bf0023ab..24a50e71 100644 --- a/src/Models/StoredWorkflow.php +++ b/src/Models/StoredWorkflow.php @@ -8,6 +8,9 @@ use Illuminate\Database\Eloquent\Prunable; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Spatie\ModelStates\HasStates; +use Workflow\Domain\Contracts\DateTimeAdapterInterface; +use Workflow\Domain\Contracts\RelationshipAdapterInterface; +use Workflow\Domain\Contracts\WorkflowRepositoryInterface; use Workflow\States\WorkflowContinuedStatus; use Workflow\States\WorkflowStatus; use Workflow\WorkflowStub; @@ -40,6 +43,8 @@ class StoredWorkflow extends Model */ protected $guarded = []; + protected $keyType = 'string'; + protected $dateFormat = 'Y-m-d H:i:s.u'; /** @@ -47,8 +52,24 @@ class StoredWorkflow extends Model */ protected $casts = [ 'status' => WorkflowStatus::class, + 'id' => 'string', ]; + /** + * Get the casts array. + * + * @return array + */ + public function getCasts() + { + $casts = parent::getCasts(); + + // Use the DateTimeAdapter to handle database-specific casting + $adapter = app(DateTimeAdapterInterface::class); + + return $adapter->getCasts($casts); + } + public function toWorkflow() { return WorkflowStub::fromStoredWorkflow($this); @@ -62,8 +83,11 @@ public function logs(): \Illuminate\Database\Eloquent\Relations\HasMany public function signals(): \Illuminate\Database\Eloquent\Relations\HasMany { - return $this->hasMany(config('workflows.stored_workflow_signal_model', StoredWorkflowSignal::class)) - ->orderBy('id'); + return $this->hasMany( + config('workflows.stored_workflow_signal_model', StoredWorkflowSignal::class), + 'stored_workflow_id', + 'id' + )->orderBy('id'); } public function timers(): \Illuminate\Database\Eloquent\Relations\HasMany @@ -80,45 +104,41 @@ public function exceptions(): \Illuminate\Database\Eloquent\Relations\HasMany public function parents(): BelongsToMany { - return $this->belongsToMany( + $adapter = app(RelationshipAdapterInterface::class); + + return $adapter->createParentsRelation( + $this, config('workflows.stored_workflow_model', self::class), config('workflows.workflow_relationships_table', 'workflow_relationships'), 'child_workflow_id', 'parent_workflow_id' - )->withPivot(['parent_index', 'parent_now']); + ); } public function children(): BelongsToMany { - return $this->belongsToMany( + $adapter = app(RelationshipAdapterInterface::class); + + return $adapter->createChildrenRelation( + $this, config('workflows.stored_workflow_model', self::class), config('workflows.workflow_relationships_table', 'workflow_relationships'), 'parent_workflow_id', 'child_workflow_id' - )->withPivot(['parent_index', 'parent_now']); + ); } public function continuedWorkflows(): BelongsToMany { - return $this->belongsToMany( - config('workflows.stored_workflow_model', self::class), - config('workflows.workflow_relationships_table', 'workflow_relationships'), - 'parent_workflow_id', - 'child_workflow_id' - )->wherePivot('parent_index', self::CONTINUE_PARENT_INDEX) - ->withPivot(['parent_index', 'parent_now']) + return $this->children() + ->wherePivot('parent_index', self::CONTINUE_PARENT_INDEX) ->orderBy('child_workflow_id'); } public function activeWorkflow(): BelongsToMany { - return $this->belongsToMany( - config('workflows.stored_workflow_model', self::class), - config('workflows.workflow_relationships_table', 'workflow_relationships'), - 'parent_workflow_id', - 'child_workflow_id' - )->wherePivot('parent_index', self::ACTIVE_WORKFLOW_INDEX) - ->withPivot(['parent_index', 'parent_now']) + return $this->children() + ->wherePivot('parent_index', self::ACTIVE_WORKFLOW_INDEX) ->orderBy('child_workflow_id'); } @@ -126,9 +146,17 @@ public function active(): self { $active = $this->fresh(); + if ($active === null) { + return $this; + } + if ($active->status::class === WorkflowContinuedStatus::class) { - $active = $this->activeWorkflow() + $continued = $this->activeWorkflow() ->first(); + + if ($continued !== null) { + $active = $continued; + } } return $active; @@ -136,9 +164,9 @@ public function active(): self public function prunable(): Builder { - return static::where('status', 'completed') - ->where('created_at', '<=', now()->sub(config('workflows.prune_age', '1 month'))) - ->whereDoesntHave('parents'); + $repository = app(WorkflowRepositoryInterface::class); + + return $repository->getPrunableWorkflows(); } protected function pruning(): void @@ -148,13 +176,18 @@ protected function pruning(): void protected function recursivePrune(self $workflow): void { - $workflow->children() - ->each(function ($child) { - $this->recursivePrune($child); - }); + // Get children before detaching + $children = $workflow->children() + ->get(); + + $children->each(function ($child) { + $this->recursivePrune($child); + }); $workflow->parents() ->detach(); + $workflow->children() + ->detach(); $workflow->exceptions() ->delete(); $workflow->logs() diff --git a/src/Models/StoredWorkflowLog.php b/src/Models/StoredWorkflowLog.php index 0a59c0cd..aa80341a 100644 --- a/src/Models/StoredWorkflowLog.php +++ b/src/Models/StoredWorkflowLog.php @@ -28,5 +28,7 @@ class StoredWorkflowLog extends Model */ protected $casts = [ 'now' => 'datetime', + 'stored_workflow_id' => 'string', + 'index' => 'integer', ]; } diff --git a/src/Models/StoredWorkflowSignal.php b/src/Models/StoredWorkflowSignal.php index 3b733051..150802f0 100644 --- a/src/Models/StoredWorkflowSignal.php +++ b/src/Models/StoredWorkflowSignal.php @@ -22,4 +22,9 @@ class StoredWorkflowSignal extends Model protected $guarded = []; protected $dateFormat = 'Y-m-d H:i:s.u'; + + protected $casts = [ + 'stored_workflow_id' => 'string', + 'created_at' => 'datetime:Y-m-d H:i:s.u', + ]; } diff --git a/src/Models/StoredWorkflowTimer.php b/src/Models/StoredWorkflowTimer.php index 02682514..059faa6a 100644 --- a/src/Models/StoredWorkflowTimer.php +++ b/src/Models/StoredWorkflowTimer.php @@ -4,6 +4,8 @@ namespace Workflow\Models; +use Workflow\Domain\Contracts\DateTimeAdapterInterface; + /** * @extends Illuminate\Database\Eloquent\Model */ @@ -24,9 +26,53 @@ class StoredWorkflowTimer extends Model protected $dateFormat = 'Y-m-d H:i:s.u'; /** - * @var array> + * Get the stop_at attribute. + * + * @param mixed $value + * @return \Illuminate\Support\Carbon|null + */ + public function getStopAtAttribute($value) + { + if ($value === null) { + return null; + } + + // If already a Carbon instance, return as-is + if ($value instanceof \Illuminate\Support\Carbon) { + return $value; + } + + $adapter = app(DateTimeAdapterInterface::class); + + return $adapter->parseFromStorage($value); + } + + /** + * Set the stop_at attribute. + * + * @param mixed $value + */ + public function setStopAtAttribute($value) + { + if ($value === null) { + $this->attributes['stop_at'] = null; + return; + } + + $adapter = app(DateTimeAdapterInterface::class); + + $this->attributes['stop_at'] = $adapter->formatForStorage($value); + } + + /** + * Get the attributes that should be cast. + * + * @return array */ - protected $casts = [ - 'stop_at' => 'datetime:Y-m-d H:i:s.u', - ]; + protected function casts(): array + { + return [ + 'stop_at' => 'datetime:Y-m-d H:i:s.u', + ]; + } } diff --git a/src/Models/WorkflowRelationship.php b/src/Models/WorkflowRelationship.php new file mode 100644 index 00000000..588d28e6 --- /dev/null +++ b/src/Models/WorkflowRelationship.php @@ -0,0 +1,50 @@ +bound(\Workflow\Domain\Contracts\DateTimeAdapterInterface::class)) { + $adapter = app(\Workflow\Domain\Contracts\DateTimeAdapterInterface::class); + return $adapter->getCasts($casts); + } + + return $casts; + } +} diff --git a/src/Providers/WorkflowServiceProvider.php b/src/Providers/WorkflowServiceProvider.php index 945e1a17..c0a18c4f 100644 --- a/src/Providers/WorkflowServiceProvider.php +++ b/src/Providers/WorkflowServiceProvider.php @@ -9,9 +9,32 @@ use Laravel\SerializableClosure\SerializableClosure; use Workflow\Commands\ActivityMakeCommand; use Workflow\Commands\WorkflowMakeCommand; +use Workflow\Domain\Contracts\DateTimeAdapterInterface; +use Workflow\Domain\Contracts\ExceptionHandlerInterface; +use Workflow\Domain\Contracts\QueryAdapterInterface; +use Workflow\Domain\Contracts\RelationshipAdapterInterface; +use Workflow\Domain\Contracts\WorkflowRepositoryInterface; +use Workflow\Infrastructure\Persistence\Eloquent\EloquentDateTimeAdapter; +use Workflow\Infrastructure\Persistence\Eloquent\EloquentExceptionHandler; +use Workflow\Infrastructure\Persistence\Eloquent\EloquentQueryAdapter; +use Workflow\Infrastructure\Persistence\Eloquent\EloquentRelationshipAdapter; +use Workflow\Infrastructure\Persistence\Eloquent\EloquentWorkflowRepository; +use Workflow\Infrastructure\Persistence\MongoDB\MongoDBDateTimeAdapter; +use Workflow\Infrastructure\Persistence\MongoDB\MongoDBExceptionHandler; +use Workflow\Infrastructure\Persistence\MongoDB\MongoDBQueryAdapter; +use Workflow\Infrastructure\Persistence\MongoDB\MongoDBRelationshipAdapter; +use Workflow\Infrastructure\Persistence\MongoDB\MongoDBWorkflowRepository; final class WorkflowServiceProvider extends ServiceProvider { + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../config/workflows.php', 'workflows'); + + // Register adapters based on database configuration + $this->registerAdapters(); + } + public function boot(): void { if (! class_exists('Workflow\Models\Model')) { @@ -29,5 +52,147 @@ class_alias(config('workflows.base_model', Model::class), 'Workflow\Models\Model ], 'migrations'); $this->commands([ActivityMakeCommand::class, WorkflowMakeCommand::class]); + + // Create MongoDB indexes if using MongoDB + if ($this->isMongoDBConnection()) { + $this->createMongoDBIndexes(); + } + } + + /** + * Register the appropriate adapters based on database configuration. + */ + protected function registerAdapters(): void + { + if ($this->isMongoDBConnection()) { + // MongoDB adapters + $this->app->singleton(WorkflowRepositoryInterface::class, MongoDBWorkflowRepository::class); + $this->app->singleton(RelationshipAdapterInterface::class, MongoDBRelationshipAdapter::class); + $this->app->singleton(DateTimeAdapterInterface::class, MongoDBDateTimeAdapter::class); + $this->app->singleton(QueryAdapterInterface::class, MongoDBQueryAdapter::class); + $this->app->singleton(ExceptionHandlerInterface::class, MongoDBExceptionHandler::class); + } else { + // Eloquent/SQL adapters + $this->app->singleton(WorkflowRepositoryInterface::class, EloquentWorkflowRepository::class); + $this->app->singleton(RelationshipAdapterInterface::class, EloquentRelationshipAdapter::class); + $this->app->singleton(DateTimeAdapterInterface::class, EloquentDateTimeAdapter::class); + $this->app->singleton(QueryAdapterInterface::class, EloquentQueryAdapter::class); + $this->app->singleton(ExceptionHandlerInterface::class, EloquentExceptionHandler::class); + } + } + + /** + * Determine if the application is using a MongoDB connection. + * + * This checks the configured base_model class to detect if MongoDB is being used. + * Users can override base_model in their published config to use MongoDB. + */ + protected function isMongoDBConnection(): bool + { + $baseModel = config('workflows.base_model', Model::class); + + // Check if the base model is MongoDB's Eloquent Model + if ($baseModel === 'MongoDB\\Laravel\\Eloquent\\Model') { + return true; + } + + // Check if it's a subclass of MongoDB's Model + if (class_exists($baseModel) && class_exists('MongoDB\\Laravel\\Eloquent\\Model')) { + return is_subclass_of($baseModel, 'MongoDB\\Laravel\\Eloquent\\Model'); + } + + return false; + } + + /** + * Create necessary unique indexes for MongoDB collections. + */ + protected function createMongoDBIndexes(): void + { + try { + $connection = app('db') + ->connection(config('database.default')); + $collection = $connection->getCollection('workflow_logs'); + + // Try to create unique index on workflow_logs (stored_workflow_id, index) + try { + $collection->createIndex( + [ + 'stored_workflow_id' => 1, + 'index' => 1, + ], + [ + 'unique' => true, + 'background' => false, + ] + ); + } catch (\Exception $e) { + // If index creation fails due to duplicates in existing data, just drop and recreate + // In production, this will only happen once during initial deployment + if (str_contains($e->getMessage(), 'E11000') || str_contains($e->getMessage(), 'duplicate key')) { + try { + $collection->dropIndex('stored_workflow_id_1_index_1'); + // Delete duplicate entries to allow index creation + // This uses an aggregation to find and keep only the first occurrence of each duplicate + $pipeline = [ + [ + '$group' => [ + '_id' => [ + 'stored_workflow_id' => '$stored_workflow_id', + 'index' => '$index', + ], + 'ids' => [ + '$push' => '$_id', + ], + 'count' => [ + '$sum' => 1, + ], + ], + ], + [ + '$match' => [ + 'count' => [ + '$gt' => 1, + ], + ], + ], + ]; + $duplicates = $collection->aggregate($pipeline); + foreach ($duplicates as $dup) { + // Keep first, delete rest + $idsToDelete = array_slice($dup['ids'], 1); + $collection->deleteMany([ + '_id' => [ + '$in' => $idsToDelete, + ], + ]); + } + $collection->createIndex( + [ + 'stored_workflow_id' => 1, + 'index' => 1, + ], + [ + 'unique' => true, + 'background' => false, + ] + ); + } catch (\Exception $e2) { + // Failed to recreate, give up + } + } + } + + // Drop the problematic index on workflow_exceptions if it exists + // MongoDB auto-creates this index but the 'index' field is not used in exceptions + $exceptionCollection = $connection->getCollection('workflow_exceptions'); + try { + $exceptionCollection->dropIndex('stored_workflow_id_1_index_1'); + } catch (\Exception $e) { + // Index might not exist, that's fine + } + } catch (\Exception $e) { + // MongoDB might not be available yet + } } } diff --git a/src/Traits/AwaitWithTimeouts.php b/src/Traits/AwaitWithTimeouts.php index a09e00bc..748f5842 100644 --- a/src/Traits/AwaitWithTimeouts.php +++ b/src/Traits/AwaitWithTimeouts.php @@ -5,9 +5,9 @@ namespace Workflow\Traits; use Carbon\CarbonInterval; -use Illuminate\Database\QueryException; use React\Promise\PromiseInterface; use function React\Promise\resolve; +use Workflow\Domain\Contracts\ExceptionHandlerInterface; use Workflow\Serializers\Serializer; use Workflow\Signal; @@ -36,7 +36,13 @@ public static function awaitWithTimeout(int|string|CarbonInterval $seconds, $con 'class' => Signal::class, 'result' => Serializer::serialize($result), ]); - } catch (QueryException $exception) { + } catch (\Throwable $exception) { + $exceptionHandler = app(ExceptionHandlerInterface::class); + + if (! $exceptionHandler->isDuplicateKeyException($exception)) { + throw $exception; + } + $log = self::$context->storedWorkflow->logs() ->whereIndex(self::$context->index) ->first(); diff --git a/src/Traits/Awaits.php b/src/Traits/Awaits.php index f89bc9e9..e6987734 100644 --- a/src/Traits/Awaits.php +++ b/src/Traits/Awaits.php @@ -4,10 +4,10 @@ namespace Workflow\Traits; -use Illuminate\Database\QueryException; use React\Promise\Deferred; use React\Promise\PromiseInterface; use function React\Promise\resolve; +use Workflow\Domain\Contracts\ExceptionHandlerInterface; use Workflow\Serializers\Serializer; use Workflow\Signal; @@ -36,7 +36,13 @@ public static function await($condition): PromiseInterface 'class' => Signal::class, 'result' => Serializer::serialize($result), ]); - } catch (QueryException $exception) { + } catch (\Throwable $exception) { + $exceptionHandler = app(ExceptionHandlerInterface::class); + + if (! $exceptionHandler->isDuplicateKeyException($exception)) { + throw $exception; + } + $log = self::$context->storedWorkflow->logs() ->whereIndex(self::$context->index) ->first(); diff --git a/src/Traits/SideEffects.php b/src/Traits/SideEffects.php index 3561f111..d4491e5a 100644 --- a/src/Traits/SideEffects.php +++ b/src/Traits/SideEffects.php @@ -4,9 +4,9 @@ namespace Workflow\Traits; -use Illuminate\Database\QueryException; use React\Promise\PromiseInterface; use function React\Promise\resolve; +use Workflow\Domain\Contracts\ExceptionHandlerInterface; use Workflow\Serializers\Serializer; trait SideEffects @@ -33,7 +33,13 @@ public static function sideEffect($callable): PromiseInterface 'class' => self::$context->storedWorkflow->class, 'result' => Serializer::serialize($result), ]); - } catch (QueryException $exception) { + } catch (\Throwable $exception) { + $exceptionHandler = app(ExceptionHandlerInterface::class); + + if (! $exceptionHandler->isDuplicateKeyException($exception)) { + throw $exception; + } + $log = self::$context->storedWorkflow->logs() ->whereIndex(self::$context->index) ->first(); diff --git a/src/Traits/Timers.php b/src/Traits/Timers.php index 365db8ed..dc64c296 100644 --- a/src/Traits/Timers.php +++ b/src/Traits/Timers.php @@ -52,6 +52,12 @@ public static function timer(int|string|CarbonInterval $seconds): PromiseInterfa } } + if ($timer === null) { + ++self::$context->index; + $deferred = new Deferred(); + return $deferred->promise(); + } + $result = $timer->stop_at ->lessThanOrEqualTo(self::$context->now); diff --git a/src/Workflow.php b/src/Workflow.php index f7a583cc..b8465aa9 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\App; use React\Promise\PromiseInterface; use Throwable; +use Workflow\Domain\Contracts\QueryAdapterInterface; use Workflow\Events\WorkflowCompleted; use Workflow\Middleware\WithoutOverlappingMiddleware; use Workflow\Models\StoredWorkflow; @@ -57,17 +58,36 @@ public function __construct( public StoredWorkflow $storedWorkflow, ...$arguments ) { + file_put_contents( + 'php://stderr', + '[Workflow::__construct] ENTERED for workflow class: ' . static::class . ", ID: {$storedWorkflow->id}\n" + ); + $this->arguments = $arguments; if (property_exists($this, 'connection')) { + file_put_contents('php://stderr', "[Workflow::__construct] Setting connection: {$this->connection}\n"); $this->onConnection($this->connection); + } else { + file_put_contents('php://stderr', "[Workflow::__construct] No connection property found\n"); } if (property_exists($this, 'queue')) { + file_put_contents('php://stderr', "[Workflow::__construct] Setting queue: {$this->queue}\n"); $this->onQueue($this->queue); + } else { + file_put_contents('php://stderr', "[Workflow::__construct] No queue property found\n"); } $this->afterCommit = true; + file_put_contents('php://stderr', "[Workflow::__construct] afterCommit set to true\n"); + file_put_contents( + 'php://stderr', + '[Workflow::__construct] Current connection: ' . ($this->connection ?? 'NULL') . "\n" + ); + file_put_contents('php://stderr', '[Workflow::__construct] Current queue: ' . ($this->queue ?? 'NULL') . "\n"); + + file_put_contents('php://stderr', "[Workflow::__construct] FINISHED\n"); } public function query($method) @@ -96,6 +116,13 @@ public function middleware() public function failed(Throwable $throwable): void { + file_put_contents( + 'php://stderr', + '[Workflow::failed] Job failed with exception: ' . $throwable->getMessage() . "\n" + ); + file_put_contents('php://stderr', '[Workflow::failed] Exception class: ' . get_class($throwable) . "\n"); + file_put_contents('php://stderr', '[Workflow::failed] Trace: ' . $throwable->getTraceAsString() . "\n"); + try { $this->storedWorkflow->toWorkflow() ->fail($throwable); @@ -106,21 +133,55 @@ public function failed(Throwable $throwable): void public function handle(): void { + file_put_contents( + 'php://stderr', + "[Workflow::handle] ENTERED for workflow ID: {$this->storedWorkflow->id}, Class: " . static::class . "\n" + ); + echo "[Workflow::handle] ENTERED for workflow ID: {$this->storedWorkflow->id}, Class: " . static::class + + . "\n"; + flush(); + if (! method_exists($this, 'execute')) { throw new BadMethodCallException('Execute method not implemented.'); } + file_put_contents('php://stderr', "[Workflow::handle] Creating container\n"); $this->container = App::make(Container::class); + file_put_contents('php://stderr', "[Workflow::handle] Container created\n"); + + file_put_contents('php://stderr', "[Workflow::handle] About to transition to WorkflowRunningStatus\n"); try { if (! $this->replaying) { $this->storedWorkflow->status->transitionTo(WorkflowRunningStatus::class); } - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + + file_put_contents( + 'php://stderr', + "[Workflow::handle] Transitioned to WorkflowRunningStatus successfully\n" + ); + } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound $e) { + file_put_contents( + 'php://stderr', + '[Workflow::handle] TransitionNotFound exception caught: ' . $e->getMessage() . "\n" + ); + if ($this->storedWorkflow->toWorkflow()->running()) { + file_put_contents( + 'php://stderr', + "[Workflow::handle] Workflow already running, releasing back to queue\n" + ); $this->release(); } return; + } catch (\Exception $e) { + file_put_contents( + 'php://stderr', + '[Workflow::handle] EXCEPTION during transition: ' . $e->getMessage() . "\n" + ); + file_put_contents('php://stderr', '[Workflow::handle] Exception trace: ' . $e->getTraceAsString() . "\n"); + throw $e; } $parentWorkflow = $this->storedWorkflow->parents() @@ -132,14 +193,12 @@ public function handle(): void ->whereIndex($this->index) ->first(); - $this->storedWorkflow - ->signals() - ->when($log, static function ($query, $log): void { - $query->where('created_at', '<=', $log->created_at->format('Y-m-d H:i:s.u')); - }) - ->each(function ($signal): void { - $this->{$signal->method}(...Serializer::unserialize($signal->arguments)); - }); + $queryAdapter = app(QueryAdapterInterface::class); + $signals = $queryAdapter->getSignalsUpToTimestamp($this->storedWorkflow, $log?->created_at); + + foreach ($signals as $signal) { + $this->{$signal->method}(...Serializer::unserialize($signal->arguments)); + } if ($parentWorkflow) { $this->now = Carbon::parse($parentWorkflow->pivot->parent_now); @@ -168,15 +227,15 @@ public function handle(): void ->first(); if ($log) { - $this->storedWorkflow - ->signals() - ->where('created_at', '>', $log->created_at->format('Y-m-d H:i:s.u')) - ->when($nextLog, static function ($query, $nextLog): void { - $query->where('created_at', '<=', $nextLog->created_at->format('Y-m-d H:i:s.u')); - }) - ->each(function ($signal): void { - $this->{$signal->method}(...Serializer::unserialize($signal->arguments)); - }); + $signals = $queryAdapter->getSignalsBetweenTimestamps( + $this->storedWorkflow, + $log->created_at, + $nextLog?->created_at + ); + + foreach ($signals as $signal) { + $this->{$signal->method}(...Serializer::unserialize($signal->arguments)); + } } $log = $nextLog; diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index fd3d8adf..7bd24c0f 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -10,6 +10,7 @@ use LimitIterator; use ReflectionClass; use SplFileObject; +use Workflow\Domain\Contracts\ExceptionHandlerInterface; use Workflow\Events\WorkflowFailed; use Workflow\Events\WorkflowStarted; use Workflow\Models\StoredWorkflow; @@ -105,10 +106,29 @@ public static function queue() public static function make($class): static { - $storedWorkflow = config('workflows.stored_workflow_model', StoredWorkflow::class)::create([ + file_put_contents('php://stderr', "[WorkflowStub::make] ENTERED for class: {$class}\n"); + echo "[WorkflowStub::make] ENTERED for class: {$class}\n"; + flush(); + + file_put_contents('php://stderr', "[WorkflowStub::make] Getting config for stored_workflow_model\n"); + $modelClass = config('workflows.stored_workflow_model', StoredWorkflow::class); + file_put_contents('php://stderr', "[WorkflowStub::make] Model class: {$modelClass}\n"); + + file_put_contents('php://stderr', "[WorkflowStub::make] About to call {$modelClass}::create()\n"); + flush(); + + $storedWorkflow = $modelClass::create([ 'class' => $class, ]); + file_put_contents( + 'php://stderr', + "[WorkflowStub::make] Created stored workflow with ID: {$storedWorkflow->id}\n" + ); + echo "[WorkflowStub::make] Created stored workflow with ID: {$storedWorkflow->id}\n"; + flush(); + + file_put_contents('php://stderr', "[WorkflowStub::make] About to return new self\n"); return new self($storedWorkflow); } @@ -184,7 +204,18 @@ public function failed(): bool public function running(): bool { - return ! in_array($this->status(), [WorkflowCompletedStatus::class, WorkflowFailedStatus::class], true); + $status = $this->status(); + $isRunning = ! in_array($status, [WorkflowCompletedStatus::class, WorkflowFailedStatus::class], true); + + if (getenv('GITHUB_ACTIONS') === 'true') { + static $logCount = 0; + if ($logCount % 50 === 0) { + echo "[WorkflowStub::running] Check #{$logCount} - Status: {$status}, Running: " . ($isRunning ? 'true' : 'false') . "\n"; + } + $logCount++; + } + + return $isRunning; } public function status(): string|bool @@ -195,7 +226,13 @@ public function status(): string|bool public function fresh(): static { - $this->storedWorkflow->refresh(); + try { + $this->storedWorkflow->refresh(); + } catch (\Illuminate\Database\Eloquent\RelationNotFoundException $e) { + // Pivot relation not found during refresh - reload without relations + // This can happen with certain database backends when eager loading + $this->storedWorkflow = $this->storedWorkflow->fresh(); + } return $this; } @@ -208,9 +245,26 @@ public function resume(): void public function start(...$arguments): void { + file_put_contents( + 'php://stderr', + "[WorkflowStub::start] ENTERED for workflow ID: {$this->storedWorkflow->id}\n" + ); + echo "[WorkflowStub::start] ENTERED for workflow ID: {$this->storedWorkflow->id}\n"; + flush(); + + file_put_contents('php://stderr', "[WorkflowStub::start] Serializing arguments\n"); $this->storedWorkflow->arguments = Serializer::serialize($arguments); + file_put_contents('php://stderr', "[WorkflowStub::start] Arguments serialized\n"); + + file_put_contents('php://stderr', "[WorkflowStub::start] About to call dispatch()\n"); + echo "[WorkflowStub::start] About to call dispatch()\n"; + flush(); $this->dispatch(); + + file_put_contents('php://stderr', "[WorkflowStub::start] dispatch() returned\n"); + echo "[WorkflowStub::start] dispatch() returned\n"; + flush(); } public function startAsChild(StoredWorkflow $parentWorkflow, int $index, $now, ...$arguments): void @@ -229,11 +283,19 @@ public function startAsChild(StoredWorkflow $parentWorkflow, int $index, $now, . public function fail($exception): void { - $this->storedWorkflow->exceptions() - ->create([ - 'class' => $this->storedWorkflow->class, - 'exception' => Serializer::serialize($exception), - ]); + try { + $this->storedWorkflow->exceptions() + ->create([ + 'class' => $this->storedWorkflow->class, + 'exception' => Serializer::serialize($exception), + ]); + } catch (\Throwable $e) { + $exceptionHandler = app(\Workflow\Domain\Contracts\ExceptionHandlerInterface::class); + // Ignore duplicate key errors - exception already recorded + if (! $exceptionHandler->isDuplicateKeyException($e)) { + throw $e; + } + } $this->storedWorkflow->status->transitionTo(WorkflowFailedStatus::class); @@ -253,6 +315,11 @@ public function fail($exception): void $this->storedWorkflow->parents() ->each(static function ($parentWorkflow) use ($exception) { + // Skip if parent workflow doesn't exist + if ($parentWorkflow === null) { + return; + } + try { $parentWorkflow->toWorkflow() ->fail($exception); @@ -272,8 +339,13 @@ public function next($index, $now, $class, $result): void 'class' => $class, 'result' => Serializer::serialize($result), ]); - } catch (\Illuminate\Database\UniqueConstraintViolationException $exception) { - // already logged + } catch (\Throwable $exception) { + $exceptionHandler = app(ExceptionHandlerInterface::class); + + if (! $exceptionHandler->isDuplicateKeyException($exception)) { + throw $exception; + } + // Already logged - duplicate key is expected in replay scenarios } $this->dispatch(); @@ -281,7 +353,21 @@ public function next($index, $now, $class, $result): void private function dispatch(): void { + file_put_contents('php://stderr', "[WorkflowStub::dispatch] ENTERED\n"); + echo "[WorkflowStub::dispatch] ENTERED\n"; + flush(); + + file_put_contents('php://stderr', "[WorkflowStub::dispatch] Getting status\n"); + $status = $this->status(); + file_put_contents('php://stderr', "[WorkflowStub::dispatch] Status: {$status}\n"); + + file_put_contents('php://stderr', "[WorkflowStub::dispatch] Checking if created()\n"); if ($this->created()) { + file_put_contents( + 'php://stderr', + "[WorkflowStub::dispatch] Workflow is created, dispatching WorkflowStarted event\n" + ); + WorkflowStarted::dispatch( $this->storedWorkflow->id, $this->storedWorkflow->class, @@ -289,15 +375,96 @@ private function dispatch(): void now() ->format('Y-m-d\TH:i:s.u\Z') ); + + file_put_contents('php://stderr', "[WorkflowStub::dispatch] WorkflowStarted event dispatched\n"); } + file_put_contents('php://stderr', "[WorkflowStub::dispatch] Transitioning to WorkflowPendingStatus\n"); + $this->storedWorkflow->status->transitionTo(WorkflowPendingStatus::class); + file_put_contents('php://stderr', "[WorkflowStub::dispatch] Status transitioned\n"); + $dispatch = static::faked() ? 'dispatchSync' : 'dispatch'; + file_put_contents('php://stderr', "[WorkflowStub::dispatch] Dispatch method: {$dispatch}\n"); + file_put_contents( + 'php://stderr', + '[WorkflowStub::dispatch] Queue connection: ' . config('queue.default') . "\n" + ); + file_put_contents( + 'php://stderr', + "[WorkflowStub::dispatch] About to dispatch workflow class: {$this->storedWorkflow->class}\n" + ); + file_put_contents('php://stderr', "[WorkflowStub::dispatch] Using dispatch method: {$dispatch}\n"); + flush(); + + // Log queue manager state before dispatch + $queueManager = app('queue'); + $connection = $queueManager->connection(); + file_put_contents( + 'php://stderr', + '[WorkflowStub::dispatch] Queue manager connection class: ' . get_class($connection) . "\n" + ); + file_put_contents( + 'php://stderr', + '[WorkflowStub::dispatch] Queue connection name: ' . $connection->getConnectionName() . "\n" + ); + $this->storedWorkflow->class::$dispatch( $this->storedWorkflow, ...Serializer::unserialize($this->storedWorkflow->arguments) ); + + file_put_contents('php://stderr', "[WorkflowStub::dispatch] Workflow class dispatched\n"); + + // Check if there are pending transactions + try { + $dbConnection = app('db') + ->connection(); + $transactionLevel = $dbConnection->transactionLevel(); + file_put_contents( + 'php://stderr', + "[WorkflowStub::dispatch] Database transaction level: {$transactionLevel}\n" + ); + } catch (\Exception $e) { + file_put_contents( + 'php://stderr', + '[WorkflowStub::dispatch] Could not check transaction level: ' . $e->getMessage() . "\n" + ); + } + + // Check Redis queue to verify job was queued + try { + $redis = new \Redis(); + $redis->connect( + config('database.redis.default.host', '127.0.0.1'), + (int) config('database.redis.default.port', 6379) + ); + // Check multiple possible queue key patterns + $patterns = ['queues:default', 'laravel_database_queues:default', 'laravel:queues:default']; + + foreach ($patterns as $pattern) { + $size = $redis->lLen($pattern); + file_put_contents('php://stderr', "[WorkflowStub::dispatch] Redis queue '{$pattern}' size: {$size}\n"); + } + + // List all keys to see what's actually in Redis + $allKeys = $redis->keys('*'); + file_put_contents( + 'php://stderr', + '[WorkflowStub::dispatch] All Redis keys: ' . implode(', ', $allKeys) . "\n" + ); + + $redis->close(); + } catch (\Exception $e) { + file_put_contents( + 'php://stderr', + '[WorkflowStub::dispatch] Redis check failed: ' . $e->getMessage() . "\n" + ); + } + + echo "[WorkflowStub::dispatch] Workflow class dispatched\n"; + flush(); } } diff --git a/testbench.yaml b/testbench.yaml index 708a8fe7..c8cf1949 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -1,5 +1,8 @@ laravel: '@testbench' +providers: + - Workbench\App\Providers\WorkbenchServiceProvider + migrations: - workbench/database/migrations diff --git a/tests/.env.feature b/tests/.env.feature index 406cbc87..8b62eb5e 100644 --- a/tests/.env.feature +++ b/tests/.env.feature @@ -1,11 +1,11 @@ APP_KEY=base64:i3g6f+dV8FfsIkcxqd7gbiPn2oXk5r00sTmdD6V5utI= -DB_CONNECTION=pgsql +DB_CONNECTION=mongodb +DB_HOST=mongodb +DB_PORT=27017 DB_DATABASE=laravel -DB_HOST=db -DB_PORT=5432 -DB_USERNAME=laravel -DB_PASSWORD=laravel +DB_USERNAME= +DB_PASSWORD= QUEUE_CONNECTION=redis QUEUE_FAILED_DRIVER=null diff --git a/tests/.env.unit b/tests/.env.unit index c3006900..f76bcc5d 100644 --- a/tests/.env.unit +++ b/tests/.env.unit @@ -1,11 +1,11 @@ APP_KEY=base64:i3g6f+dV8FfsIkcxqd7gbiPn2oXk5r00sTmdD6V5utI= -DB_CONNECTION=pgsql +DB_CONNECTION=mongodb +DB_HOST=mongodb +DB_PORT=27017 DB_DATABASE=laravel -DB_HOST=db -DB_PORT=5432 -DB_USERNAME=laravel -DB_PASSWORD=laravel +DB_USERNAME= +DB_PASSWORD= QUEUE_CONNECTION=sync QUEUE_FAILED_DRIVER=null diff --git a/tests/Feature/AsyncWorkflowTest.php b/tests/Feature/AsyncWorkflowTest.php index 906b4d23..bec65c90 100644 --- a/tests/Feature/AsyncWorkflowTest.php +++ b/tests/Feature/AsyncWorkflowTest.php @@ -12,16 +12,113 @@ final class AsyncWorkflowTest extends TestCase { + public function __construct() + { + file_put_contents('php://stderr', "[TEST-CONSTRUCT] AsyncWorkflowTest::__construct() called\n"); + parent::__construct(...func_get_args()); + file_put_contents('php://stderr', "[TEST-CONSTRUCT] AsyncWorkflowTest::__construct() finished\n"); + } + + protected function setUp(): void + { + file_put_contents('php://stderr', "[TEST-SETUP] AsyncWorkflowTest::setUp() ENTERED\n"); + parent::setUp(); + file_put_contents('php://stderr', "[TEST-SETUP] AsyncWorkflowTest::setUp() FINISHED\n"); + } + public function testAsyncWorkflow(): void { + file_put_contents('php://stderr', "\n\n[TEST] ==================== TEST METHOD STARTED ====================\n"); + echo "\n\n[TEST] ==================== TEST METHOD STARTED ====================\n"; + flush(); + + file_put_contents('php://stderr', "[TEST] About to call WorkflowStub::make\n"); + echo "[TEST] About to call WorkflowStub::make\n"; + flush(); + $workflow = WorkflowStub::make(TestAsyncWorkflow::class); + file_put_contents('php://stderr', "[TEST] WorkflowStub::make returned\n"); + echo "[TEST] WorkflowStub::make returned\n"; + flush(); + + file_put_contents('php://stderr', "[TEST] About to call workflow->start()\n"); + echo "[TEST] About to call workflow->start()\n"; + flush(); + $workflow->start(); - while ($workflow->running()); + file_put_contents('php://stderr', "[TEST] workflow->start() returned\n"); + echo "[TEST] workflow->start() returned\n"; + flush(); + + file_put_contents('php://stderr', "[TEST] About to enter while loop, checking workflow->running()\n"); + echo "[TEST] About to enter while loop, checking workflow->running()\n"; + flush(); + + $iterations = 0; + $maxIterations = 100; // 10 seconds max + while ($workflow->running() && $iterations < $maxIterations) { + $iterations++; + if ($iterations === 1) { + file_put_contents('php://stderr', "[TEST] First iteration of while loop\n"); + echo "[TEST] First iteration of while loop\n"; + flush(); + } + if ($iterations % 10 === 0) { + file_put_contents('php://stderr', "[TEST] Still waiting... (iteration {$iterations})\n"); + echo "[TEST] Still waiting... (iteration {$iterations})\n"; + flush(); + + // Check worker status every 10 iterations + if (getenv('GITHUB_ACTIONS') === 'true') { + foreach (TestCase::$workers as $i => $worker) { + if ($worker && $worker->isRunning()) { + $output = $worker->getIncrementalOutput(); + $errorOutput = $worker->getIncrementalErrorOutput(); + if ($output) { + file_put_contents('php://stderr', "[TEST] Worker {$i} output: {$output}\n"); + } + if ($errorOutput) { + file_put_contents('php://stderr', "[TEST] Worker {$i} error: {$errorOutput}\n"); + } + } elseif ($worker) { + file_put_contents( + 'php://stderr', + "[TEST] Worker {$i} is NOT running! Exit code: " . $worker->getExitCode() . "\n" + ); + } + } + } + } + usleep(100000); // 0.1 second + } + + if ($iterations >= $maxIterations) { + file_put_contents('php://stderr', "[TEST] TIMEOUT! Printing Laravel log:\n"); + echo "[TEST] TIMEOUT! Checking for errors...\n"; + $logPath = __DIR__ . '/../../vendor/orchestra/testbench-core/laravel/storage/logs/laravel.log'; + if (file_exists($logPath)) { + $log = file_get_contents($logPath); + file_put_contents('php://stderr', "===== LARAVEL LOG =====\n" . $log . "\n=====\n"); + echo "===== LARAVEL LOG =====\n" . $log . "\n=====\n"; + } else { + file_put_contents('php://stderr', "[TEST] No laravel.log found at {$logPath}\n"); + } + $this->fail('Workflow did not complete within timeout'); + } + + file_put_contents('php://stderr', "[TEST] Exited while loop\n"); + + echo "[TEST] Workflow completed after {$iterations} iterations\n"; + flush(); + + echo "[TEST] Running assertions\n"; + flush(); $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); $this->assertSame('workflow_activity_other', $workflow->output()); $this->assertSame([AsyncWorkflow::class], $workflow->logs()->pluck('class')->sort()->values()->toArray()); + echo "[TEST] Test completed successfully\n"; } } diff --git a/tests/Feature/AwaitWithTimeoutWorkflowTest.php b/tests/Feature/AwaitWithTimeoutWorkflowTest.php index b3aef8df..3e4f4c4e 100644 --- a/tests/Feature/AwaitWithTimeoutWorkflowTest.php +++ b/tests/Feature/AwaitWithTimeoutWorkflowTest.php @@ -21,7 +21,7 @@ public function testCompleted(): void while ($workflow->running()); - $this->assertLessThan(5, now()->diffInSeconds($now)); + $this->assertLessThan(10, now()->diffInSeconds($now)); $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); $this->assertSame('workflow', $workflow->output()); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 804f923f..448579ba 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -16,6 +16,10 @@ abstract class TestCase extends BaseTestCase public static function setUpBeforeClass(): void { + file_put_contents('php://stderr', "[DEBUG] setUpBeforeClass started\n"); + echo "[DEBUG] setUpBeforeClass started\n"; + flush(); + if (getenv('GITHUB_ACTIONS') !== 'true') { if (TestSuiteSubscriber::getCurrentSuite() === 'feature') { Dotenv::createImmutable(__DIR__, '.env.feature')->safeLoad(); @@ -24,10 +28,143 @@ public static function setUpBeforeClass(): void } } + file_put_contents('php://stderr', "[DEBUG] Starting queue workers\n"); + echo "[DEBUG] Starting queue workers\n"; + flush(); + + // Test Redis connection first + if (getenv('GITHUB_ACTIONS') === 'true') { + try { + $redis = new \Redis(); + $redis->connect(getenv('REDIS_HOST') ?: '127.0.0.1', (int) (getenv('REDIS_PORT') ?: 6379)); + file_put_contents('php://stderr', "[DEBUG] Redis connection test SUCCESSFUL\n"); + $redis->close(); + } catch (\Exception $e) { + file_put_contents('php://stderr', '[DEBUG] Redis connection test FAILED: ' . $e->getMessage() . "\n"); + } + } + + // Prepare environment variables for workers (filter out non-scalar values) + $env = array_filter(array_merge($_SERVER, $_ENV), static fn ($v) => is_string($v) || is_numeric($v)); + + // Explicitly add GitHub Actions env vars for workers + if (getenv('GITHUB_ACTIONS') === 'true') { + $env['GITHUB_ACTIONS'] = 'true'; + file_put_contents( + 'php://stderr', + '[DEBUG] Worker env DB_CONNECTION: ' . ($env['DB_CONNECTION'] ?? 'NOT SET') . "\n" + ); + file_put_contents('php://stderr', '[DEBUG] Worker env DB_HOST: ' . ($env['DB_HOST'] ?? 'NOT SET') . "\n"); + file_put_contents('php://stderr', '[DEBUG] Worker env DB_PORT: ' . ($env['DB_PORT'] ?? 'NOT SET') . "\n"); + file_put_contents( + 'php://stderr', + '[DEBUG] Worker env DB_DATABASE: ' . ($env['DB_DATABASE'] ?? 'NOT SET') . "\n" + ); + file_put_contents( + 'php://stderr', + '[DEBUG] Worker env DB_USERNAME: ' . ($env['DB_USERNAME'] ?? 'NOT SET') . "\n" + ); + file_put_contents( + 'php://stderr', + '[DEBUG] Worker env DB_AUTHENTICATION_DATABASE: ' . ($env['DB_AUTHENTICATION_DATABASE'] ?? 'NOT SET') . "\n" + ); + file_put_contents( + 'php://stderr', + '[DEBUG] Worker env QUEUE_CONNECTION: ' . ($env['QUEUE_CONNECTION'] ?? 'NOT SET') . "\n" + ); + file_put_contents( + 'php://stderr', + '[DEBUG] Worker env REDIS_HOST: ' . ($env['REDIS_HOST'] ?? 'NOT SET') . "\n" + ); + file_put_contents( + 'php://stderr', + '[DEBUG] Worker env REDIS_PORT: ' . ($env['REDIS_PORT'] ?? 'NOT SET') . "\n" + ); + } + + file_put_contents('php://stderr', '[DEBUG] About to start ' . self::NUMBER_OF_WORKERS . " workers\n"); + + // Test if we can manually check Redis queue before starting workers + if (getenv('GITHUB_ACTIONS') === 'true') { + try { + $redis = new \Redis(); + $redis->connect(getenv('REDIS_HOST') ?: '127.0.0.1', (int) (getenv('REDIS_PORT') ?: 6379)); + $allKeys = $redis->keys('*'); + file_put_contents( + 'php://stderr', + '[DEBUG] Redis keys before workers start: ' . implode(', ', $allKeys) . "\n" + ); + $redis->close(); + } catch (\Exception $e) { + file_put_contents( + 'php://stderr', + '[DEBUG] Redis check before workers failed: ' . $e->getMessage() . "\n" + ); + } + } + for ($i = 0; $i < self::NUMBER_OF_WORKERS; $i++) { - self::$workers[$i] = new Process(['php', __DIR__ . '/../vendor/bin/testbench', 'queue:work']); - self::$workers[$i]->start(); + file_put_contents('php://stderr', "[DEBUG] Starting worker {$i}\n"); + echo "[DEBUG] Starting worker {$i}\n"; + flush(); + self::$workers[$i] = new Process( + [ + 'php', + __DIR__ . '/../vendor/bin/testbench', + 'queue:work', + 'redis', // Explicitly specify the redis connection + '--tries=3', + '--timeout=60', + '--max-time=300', + '--once', + '-vvv', + ], + null, + $env, + null, + 300 // Timeout after 5 minutes + ); + + // Redirect worker output to stdout for debugging + self::$workers[$i]->start(static function ($type, $buffer) use ($i) { + file_put_contents('php://stderr', "[WORKER-{$i}] {$buffer}"); + echo "[WORKER-{$i}] {$buffer}"; + flush(); + }); + + file_put_contents('php://stderr', "[DEBUG] Worker {$i} started\n"); + echo "[DEBUG] Worker {$i} started\n"; + flush(); + + // In GitHub Actions, add a small delay and check if worker started + if (getenv('GITHUB_ACTIONS') === 'true') { + usleep(500000); // 0.5 second delay + if (! self::$workers[$i]->isRunning()) { + $msg = "Warning: Worker {$i} failed to start or exited immediately\n"; + $msg .= 'Output: ' . self::$workers[$i]->getOutput() . "\n"; + $msg .= 'Error: ' . self::$workers[$i]->getErrorOutput() . "\n"; + file_put_contents('php://stderr', $msg); + echo $msg; + } else { + file_put_contents('php://stderr', "[DEBUG] Worker {$i} is running\n"); + echo "[DEBUG] Worker {$i} is running\n"; + + // Get any output the worker has produced so far + $output = self::$workers[$i]->getOutput(); + $errorOutput = self::$workers[$i]->getErrorOutput(); + if ($output) { + file_put_contents('php://stderr', "[DEBUG] Worker {$i} initial output: {$output}\n"); + } + if ($errorOutput) { + file_put_contents('php://stderr', "[DEBUG] Worker {$i} initial error output: {$errorOutput}\n"); + } + } + } } + + file_put_contents('php://stderr', "[DEBUG] setUpBeforeClass finished\n"); + echo "[DEBUG] setUpBeforeClass finished\n"; + flush(); } public static function tearDownAfterClass(): void @@ -39,6 +176,10 @@ public static function tearDownAfterClass(): void protected function setUp(): void { + file_put_contents('php://stderr', "[DEBUG] TestCase::setUp() ENTERED\n"); + echo "[DEBUG] TestCase::setUp() ENTERED\n"; + flush(); + if (getenv('GITHUB_ACTIONS') !== 'true') { if (TestSuiteSubscriber::getCurrentSuite() === 'feature') { Dotenv::createImmutable(__DIR__, '.env.feature')->safeLoad(); @@ -47,21 +188,230 @@ protected function setUp(): void } } + file_put_contents('php://stderr', "[DEBUG] TestCase::setUp() calling parent::setUp()\n"); + echo "[DEBUG] TestCase::setUp() calling parent::setUp()\n"; + flush(); + parent::setUp(); + + file_put_contents('php://stderr', "[DEBUG] TestCase::setUp() FINISHED\n"); + echo "[DEBUG] TestCase::setUp() FINISHED\n"; + flush(); } protected function defineDatabaseMigrations() { - $this->artisan('migrate:fresh', [ - '--path' => dirname(__DIR__) . '/src/migrations', - '--realpath' => true, - ]); + file_put_contents('php://stderr', "[DEBUG] defineDatabaseMigrations() ENTERED\n"); + echo "[DEBUG] defineDatabaseMigrations() ENTERED\n"; + flush(); + + if (env('DB_CONNECTION') !== 'mongodb') { + echo "[DEBUG] Using non-MongoDB connection\n"; + flush(); + + $this->artisan('migrate:fresh', [ + '--path' => dirname(__DIR__) . '/src/migrations', + '--realpath' => true, + ]); + + $this->loadLaravelMigrations(); + } else { + file_put_contents('php://stderr', "[DEBUG] Using MongoDB connection\n"); + echo "[DEBUG] Using MongoDB connection\n"; + flush(); + + file_put_contents('php://stderr', "[DEBUG] Running db:wipe for MongoDB...\n"); + echo "[DEBUG] Running db:wipe for MongoDB...\n"; + flush(); + + $this->artisan('db:wipe', [ + '--database' => 'mongodb', + ]); + + file_put_contents('php://stderr', "[DEBUG] db:wipe completed\n"); + echo "[DEBUG] db:wipe completed\n"; + flush(); + + file_put_contents('php://stderr', "[DEBUG] Flushing Redis queue...\n"); + echo "[DEBUG] Flushing Redis queue...\n"; + flush(); + + // Flush Redis to clear any pending jobs + $this->artisan('queue:flush'); + + file_put_contents('php://stderr', "[DEBUG] Redis queue flushed\n"); + echo "[DEBUG] Redis queue flushed\n"; + flush(); + + file_put_contents('php://stderr', "[DEBUG] Clearing Laravel logs...\n"); + echo "[DEBUG] Clearing Laravel logs...\n"; + flush(); + + // Clear Laravel logs + $logPath = dirname(__DIR__) . '/vendor/orchestra/testbench-core/laravel/storage/logs/laravel.log'; + if (file_exists($logPath)) { + file_put_contents($logPath, ''); + } + + file_put_contents('php://stderr', "[DEBUG] Laravel logs cleared\n"); + echo "[DEBUG] Laravel logs cleared\n"; + flush(); + + file_put_contents('php://stderr', "[DEBUG] Creating MongoDB indexes...\n"); + echo "[DEBUG] Creating MongoDB indexes...\n"; + flush(); - $this->loadLaravelMigrations(); + // Create unique indexes for MongoDB + $this->createMongoDBIndexes(); + + file_put_contents('php://stderr', "[DEBUG] MongoDB indexes created\n"); + echo "[DEBUG] MongoDB indexes created\n"; + flush(); + } + + file_put_contents('php://stderr', "[DEBUG] defineDatabaseMigrations() FINISHED\n"); + echo "[DEBUG] defineDatabaseMigrations() FINISHED\n"; + flush(); + } + + /** + * Create required indexes for MongoDB. + */ + protected function createMongoDBIndexes(): void + { + echo "[DEBUG] createMongoDBIndexes() ENTERED\n"; + flush(); + + echo "[DEBUG] Getting MongoDB connection\n"; + flush(); + + $db = app('db') + ->connection('mongodb'); + + echo "[DEBUG] MongoDB connection obtained\n"; + flush(); + + // workflow_logs: unique index on stored_workflow_id + index + echo "[DEBUG] Creating workflow_logs index\n"; + flush(); + + $db->getCollection('workflow_logs') + ->createIndex([ + 'stored_workflow_id' => 1, + 'index' => 1, + ], [ + 'unique' => true, + ]); + + echo "[DEBUG] workflow_logs index created\n"; + flush(); + + // workflow_signals: unique index on stored_workflow_id + index (partial: only when index exists and is not null) + echo "[DEBUG] Creating workflow_signals index\n"; + flush(); + + $db->getCollection('workflow_signals') + ->createIndex( + [ + 'stored_workflow_id' => 1, + 'index' => 1, + ], + [ + 'unique' => true, + 'partialFilterExpression' => [ + 'index' => [ + '$type' => 'number', + ], + ], + ] + ); + + echo "[DEBUG] workflow_signals index created\n"; + flush(); + + // workflow_timers: unique index on stored_workflow_id + index + echo "[DEBUG] Creating workflow_timers index\n"; + flush(); + + $db->getCollection('workflow_timers') + ->createIndex([ + 'stored_workflow_id' => 1, + 'index' => 1, + ], [ + 'unique' => true, + ]); + + echo "[DEBUG] workflow_timers index created\n"; + flush(); + + // workflow_exceptions: unique index on stored_workflow_id + index + echo "[DEBUG] Creating workflow_exceptions index\n"; + flush(); + + $db->getCollection('workflow_exceptions') + ->createIndex([ + 'stored_workflow_id' => 1, + 'index' => 1, + ], [ + 'unique' => true, + ]); + + echo "[DEBUG] workflow_exceptions index created\n"; + flush(); + + echo "[DEBUG] createMongoDBIndexes() FINISHED\n"; + flush(); } protected function getPackageProviders($app) { - return [\Workflow\Providers\WorkflowServiceProvider::class]; + $providers = [\Workflow\Providers\WorkflowServiceProvider::class]; + + if (env('DB_CONNECTION') === 'mongodb' && class_exists(\MongoDB\Laravel\MongoDBServiceProvider::class)) { + $providers[] = \MongoDB\Laravel\MongoDBServiceProvider::class; + } + + return $providers; + } + + protected function defineEnvironment($app) + { + // Configure queue connection - must come first before database config + $app['config']->set('queue.default', env('QUEUE_CONNECTION', 'redis')); + $app['config']->set('queue.connections.redis', [ + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => 90, + 'block_for' => null, + ]); + + // Configure Redis connection + $app['config']->set('database.redis.default', [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null), + 'port' => env('REDIS_PORT', 6379), + 'database' => 0, + 'prefix' => env('REDIS_PREFIX', 'laravel_database_'), + ]); + + if (env('DB_CONNECTION') === 'mongodb') { + $app['config']->set('workflows.base_model', 'MongoDB\\Laravel\\Eloquent\\Model'); + + // Configure MongoDB database connection + $app['config']->set('database.connections.mongodb', [ + 'driver' => 'mongodb', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', 27017), + 'database' => env('DB_DATABASE', 'testbench'), + 'username' => env('DB_USERNAME', ''), + 'password' => env('DB_PASSWORD', ''), + 'options' => [ + 'database' => env('DB_AUTHENTICATION_DATABASE', 'admin'), + ], + ]); + + $app['config']->set('database.default', 'mongodb'); + } } } diff --git a/tests/Unit/ActivityTest.php b/tests/Unit/ActivityTest.php index 7e1b8ff8..5b3e9c91 100644 --- a/tests/Unit/ActivityTest.php +++ b/tests/Unit/ActivityTest.php @@ -126,11 +126,13 @@ public function testActivityAlreadyComplete(): void public function testWebhookUrl(): void { $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id()); - $activity = new TestOtherActivity(0, now()->toDateTimeString(), StoredWorkflow::findOrFail($workflow->id()), [ - 'other', - ]); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $activity = new TestOtherActivity(0, now()->toDateTimeString(), $storedWorkflow, ['other']); $this->assertSame('http://localhost/webhooks/test-workflow', $activity->webhookUrl()); - $this->assertSame('http://localhost/webhooks/signal/1/other', $activity->webhookUrl('other')); + $this->assertSame( + 'http://localhost/webhooks/signal/' . $storedWorkflow->id . '/other', + $activity->webhookUrl('other') + ); } } diff --git a/tests/Unit/Models/StoredWorkflowTest.php b/tests/Unit/Models/StoredWorkflowTest.php index 95c2148a..95a463f3 100644 --- a/tests/Unit/Models/StoredWorkflowTest.php +++ b/tests/Unit/Models/StoredWorkflowTest.php @@ -233,4 +233,20 @@ public function testActiveWorkflowWithMultipleContinuations(): void $this->assertSame($finalWorkflow->id, $active->id); } + + public function testActiveWithContinuedStatusButNoActiveChild(): void + { + $workflow = StoredWorkflow::create([ + 'class' => 'TestWorkflow', + 'status' => WorkflowRunningStatus::class, + 'arguments' => json_encode([]), + ]); + + $workflow->status->transitionTo(WorkflowContinuedStatus::class); + + $active = $workflow->active(); + + $this->assertNotNull($active); + $this->assertSame($workflow->id, $active->id); + } } diff --git a/tests/Unit/Traits/TimersTest.php b/tests/Unit/Traits/TimersTest.php index 594bb404..59cd47bc 100644 --- a/tests/Unit/Traits/TimersTest.php +++ b/tests/Unit/Traits/TimersTest.php @@ -48,11 +48,17 @@ public function testCreatesTimer(): void $this->assertNull($result); $this->assertSame(0, $workflow->logs()->count()); - $this->assertDatabaseHas('workflow_timers', [ - 'stored_workflow_id' => $workflow->id(), - 'index' => 0, - 'stop_at' => WorkflowStub::now()->addMinute(), - ]); + + // Verify timer was created with correct stop_at time + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $timer = $storedWorkflow->timers() + ->first(); + $this->assertNotNull($timer); + $this->assertSame(0, $timer->index); + $this->assertTrue( + $timer->stop_at->equalTo(WorkflowStub::now()->addMinute()), + 'Timer stop_at should match expected time' + ); } public function testDefersIfNotElapsed(): void @@ -94,7 +100,8 @@ public function testStoresResult(): void $storedWorkflow->timers() ->create([ 'index' => 0, - 'stop_at' => now(), + 'stop_at' => now() + ->subSecond(), // Set to past to ensure it's elapsed ]); WorkflowStub::timer('1 minute') @@ -119,7 +126,8 @@ public function testLoadsStoredResult(): void $storedWorkflow->timers() ->create([ 'index' => 0, - 'stop_at' => now(), + 'stop_at' => now() + ->subSecond(), ]); $storedWorkflow->logs() ->create([ @@ -151,7 +159,8 @@ public function testHandlesDuplicateLogInsertionProperly(): void $storedWorkflow->timers() ->create([ 'index' => 0, - 'stop_at' => now(), + 'stop_at' => now() + ->subSecond(), ]); $storedWorkflow->logs() ->create([ @@ -214,10 +223,16 @@ public function testTimerWithCarbonInterval(): void $this->assertNull($result); $this->assertSame(0, $workflow->logs()->count()); - $this->assertDatabaseHas('workflow_timers', [ - 'stored_workflow_id' => $workflow->id(), - 'index' => 0, - 'stop_at' => WorkflowStub::now()->addSeconds($interval->totalSeconds), - ]); + + // Verify timer was created with correct stop_at time + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $timer = $storedWorkflow->timers() + ->first(); + $this->assertNotNull($timer); + $this->assertSame(0, $timer->index); + $this->assertTrue( + $timer->stop_at->equalTo(WorkflowStub::now()->addSeconds($interval->totalSeconds)), + 'Timer stop_at should match expected time' + ); } } diff --git a/tests/Unit/WebhooksTest.php b/tests/Unit/WebhooksTest.php index c811e6e4..a0506c26 100644 --- a/tests/Unit/WebhooksTest.php +++ b/tests/Unit/WebhooksTest.php @@ -345,14 +345,16 @@ public function testStartAndSignal(): void 'message' => 'Workflow started', ]); - $response = $this->postJson('/webhooks/signal/test-webhook-workflow/1/cancel'); + $workflowId = StoredWorkflow::first()->id; + + $response = $this->postJson("/webhooks/signal/test-webhook-workflow/{$workflowId}/cancel"); $response->assertStatus(200); $response->assertJson([ 'message' => 'Signal sent', ]); - $workflow = \Workflow\WorkflowStub::load(1); + $workflow = \Workflow\WorkflowStub::load($workflowId); $this->assertSame(WorkflowPendingStatus::class, $workflow->status()); } @@ -386,11 +388,13 @@ public function testSignalUnauthorized(): void 'message' => 'Workflow started', ]); + $workflowId = StoredWorkflow::first()->id; + config([ 'workflows.webhook_auth.method' => 'invalid', ]); - $response = $this->postJson('/webhooks/signal/test-webhook-workflow/1/cancel'); + $response = $this->postJson("/webhooks/signal/test-webhook-workflow/{$workflowId}/cancel"); $response->assertStatus(401); $response->assertJson([ diff --git a/tests/Unit/WorkflowFakerTest.php b/tests/Unit/WorkflowFakerTest.php index bad1e792..2d639e3d 100644 --- a/tests/Unit/WorkflowFakerTest.php +++ b/tests/Unit/WorkflowFakerTest.php @@ -39,11 +39,14 @@ public function testTimeTravelWorkflow(): void $workflow->cancel(); + $this->assertTrue($workflow->isCanceled()); + $this->travel(5) ->minutes(); $workflow->resume(); + // Workflow should still be canceled after resume $this->assertTrue($workflow->isCanceled()); $this->assertSame($workflow->output(), 'workflow_activity_other_activity'); diff --git a/tests/Unit/migrations/MigrationsTest.php b/tests/Unit/migrations/MigrationsTest.php index ae24cc0b..a3b62022 100644 --- a/tests/Unit/migrations/MigrationsTest.php +++ b/tests/Unit/migrations/MigrationsTest.php @@ -11,6 +11,11 @@ final class MigrationsTest extends TestCase { public function testDownMethodsDropTables(): void { + // Skip for MongoDB since it doesn't use migrations + if (env('DB_CONNECTION') === 'mongodb') { + $this->markTestSkipped('MongoDB does not use migrations'); + } + $this->assertTrue(Schema::hasTable('workflows')); $this->assertTrue(Schema::hasTable('workflow_logs')); $this->assertTrue(Schema::hasTable('workflow_signals')); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 25b78bcc..2dc14a70 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,10 +2,25 @@ declare(strict_types=1); +// Disable output buffering +while (ob_get_level()) { + ob_end_clean(); +} +ob_implicit_flush(true); + +echo "[BOOTSTRAP] Starting tests bootstrap\n"; +flush(); + require_once __DIR__ . '/../vendor/autoload.php'; use PHPUnit\Event\Facade; use Tests\TestSuiteSubscriber; +echo "[BOOTSTRAP] Registering test subscriber\n"; +flush(); + $subscriber = new TestSuiteSubscriber(); Facade::instance()->registerSubscribers($subscriber); + +echo "[BOOTSTRAP] Bootstrap complete\n"; +flush(); diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php index 1405251f..09a06de3 100644 --- a/workbench/app/Models/User.php +++ b/workbench/app/Models/User.php @@ -4,11 +4,17 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable +// Use MongoDB User class if available and MongoDB is the connection +if (class_exists(\MongoDB\Laravel\Auth\User::class) && env('DB_CONNECTION') === 'mongodb') { + abstract class BaseUser extends \MongoDB\Laravel\Auth\User {} +} else { + abstract class BaseUser extends \Illuminate\Foundation\Auth\User {} +} + +class User extends BaseUser { use HasFactory, Notifiable; diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index e8cec9c2..22f576a6 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -11,7 +11,10 @@ class WorkbenchServiceProvider extends ServiceProvider */ public function register(): void { - // + // Register MongoDB service provider if it exists and we're using MongoDB + if (env('DB_CONNECTION') === 'mongodb' && class_exists(\MongoDB\Laravel\MongoDBServiceProvider::class)) { + $this->app->register(\MongoDB\Laravel\MongoDBServiceProvider::class); + } } /** diff --git a/workbench/bootstrap.php b/workbench/bootstrap.php new file mode 100644 index 00000000..f70475b2 --- /dev/null +++ b/workbench/bootstrap.php @@ -0,0 +1,40 @@ + env('DB_CONNECTION', 'sqlite'), + + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DATABASE_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'mongodb' => [ + 'driver' => 'mongodb', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', 27017), + 'database' => env('DB_DATABASE', 'testbench'), + 'username' => env('DB_USERNAME', ''), + 'password' => env('DB_PASSWORD', ''), + 'options' => [ + 'database' => env('DB_AUTHENTICATION_DATABASE', 'admin'), + ], + ], + ], + + 'migrations' => 'migrations', +]; diff --git a/workbench/config/workflows.php b/workbench/config/workflows.php new file mode 100644 index 00000000..c869d666 --- /dev/null +++ b/workbench/config/workflows.php @@ -0,0 +1,42 @@ + 'Workflows', + + 'base_model' => env('DB_CONNECTION') === 'mongodb' + ? 'MongoDB\\Laravel\\Eloquent\\Model' + : Illuminate\Database\Eloquent\Model::class, + + 'stored_workflow_model' => Workflow\Models\StoredWorkflow::class, + + 'stored_workflow_exception_model' => Workflow\Models\StoredWorkflowException::class, + + 'stored_workflow_log_model' => Workflow\Models\StoredWorkflowLog::class, + + 'stored_workflow_signal_model' => Workflow\Models\StoredWorkflowSignal::class, + + 'stored_workflow_timer_model' => Workflow\Models\StoredWorkflowTimer::class, + + 'workflow_relationships_table' => 'workflow_relationships', + + 'serializer' => Workflow\Serializers\Y::class, + + 'prune_age' => '1 month', + + 'webhooks_route' => env('WORKFLOW_WEBHOOKS_ROUTE', 'webhooks'), + + 'webhook_auth' => [ + 'method' => env('WORKFLOW_WEBHOOKS_AUTH_METHOD', 'none'), + + 'signature' => [ + 'header' => env('WORKFLOW_WEBHOOKS_SIGNATURE_HEADER', 'X-Signature'), + 'secret' => env('WORKFLOW_WEBHOOKS_SIGNATURE_SECRET'), + 'algorithm' => env('WORKFLOW_WEBHOOKS_SIGNATURE_ALGORITHM', 'sha256'), + ], + + 'token' => [ + 'header' => env('WORKFLOW_WEBHOOKS_TOKEN_HEADER', 'X-Token'), + 'value' => env('WORKFLOW_WEBHOOKS_TOKEN_VALUE'), + ], + ], +];