diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 6874790..6fde9ae 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -75,6 +75,7 @@ jobs: DB_USERNAME: root DB_PASSWORD: password QUEUE_CONNECTION: redis + QUEUE_FAILED_DRIVER: "null" REDIS_HOST: 127.0.0.1 REDIS_PASSWORD: REDIS_PORT: 6379 @@ -90,10 +91,18 @@ jobs: 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: Upload laravel.log if tests fail + if: failure() + uses: actions/upload-artifact@v4 + with: + name: laravel-log + path: vendor/orchestra/testbench-core/laravel/storage/logs/laravel.log + - name: Code Coverage run: | vendor/bin/phpunit --testdox --coverage-clover=coverage.clover --testsuite unit diff --git a/.gitignore b/.gitignore index 271eb9a..4d84bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ build composer.lock vendor coverage +coverage.xml .env .phpunit.cache .phpunit.result.cache diff --git a/composer.json b/composer.json index 084fc66..7cdc83c 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "feature": "phpunit --testdox --testsuite feature", "unit": "phpunit --testdox --testsuite unit", "test": "phpunit --testdox", + "coverage": "XDEBUG_MODE=coverage phpunit --testdox --testsuite unit --coverage-clover coverage.xml", "post-autoload-dump": [ "@clear", "@prepare" diff --git a/src/ContinuedWorkflow.php b/src/ContinuedWorkflow.php new file mode 100644 index 0000000..849d657 --- /dev/null +++ b/src/ContinuedWorkflow.php @@ -0,0 +1,9 @@ +orderBy('id'); } - public function parents(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function parents(): BelongsToMany { return $this->belongsToMany( config('workflows.stored_workflow_model', self::class), @@ -76,7 +88,7 @@ public function parents(): \Illuminate\Database\Eloquent\Relations\BelongsToMany )->withPivot(['parent_index', 'parent_now']); } - public function children(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function children(): BelongsToMany { return $this->belongsToMany( config('workflows.stored_workflow_model', self::class), @@ -86,6 +98,42 @@ public function children(): \Illuminate\Database\Eloquent\Relations\BelongsToMan )->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']) + ->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']) + ->orderBy('child_workflow_id'); + } + + public function active(): self + { + $active = $this->fresh(); + + if ($active->status::class === WorkflowContinuedStatus::class) { + $active = $this->activeWorkflow() + ->first(); + } + + return $active; + } + public function prunable(): Builder { return static::where('status', 'completed') diff --git a/src/States/WorkflowContinuedStatus.php b/src/States/WorkflowContinuedStatus.php new file mode 100644 index 0000000..99f7b8b --- /dev/null +++ b/src/States/WorkflowContinuedStatus.php @@ -0,0 +1,10 @@ +allowTransition(WorkflowPendingStatus::class, WorkflowFailedStatus::class) ->allowTransition(WorkflowPendingStatus::class, WorkflowRunningStatus::class) ->allowTransition(WorkflowRunningStatus::class, WorkflowCompletedStatus::class) + ->allowTransition(WorkflowRunningStatus::class, WorkflowContinuedStatus::class) ->allowTransition(WorkflowRunningStatus::class, WorkflowFailedStatus::class) ->allowTransition(WorkflowRunningStatus::class, WorkflowWaitingStatus::class) ->allowTransition(WorkflowWaitingStatus::class, WorkflowFailedStatus::class) diff --git a/src/Traits/Continues.php b/src/Traits/Continues.php new file mode 100644 index 0000000..2294b63 --- /dev/null +++ b/src/Traits/Continues.php @@ -0,0 +1,73 @@ +replaying) { + $parentWorkflow = $context->storedWorkflow->parents() + ->wherePivot('parent_index', '!=', StoredWorkflow::CONTINUE_PARENT_INDEX) + ->wherePivot('parent_index', '!=', StoredWorkflow::ACTIVE_WORKFLOW_INDEX) + ->withPivot('parent_index') + ->first(); + + $newWorkflow = self::make($context->storedWorkflow->class); + + if ($parentWorkflow) { + $parentWorkflow->children() + ->attach($newWorkflow->storedWorkflow, [ + 'parent_index' => $parentWorkflow->pivot->parent_index, + 'parent_now' => $context->now, + ]); + + $parentWorkflow->children() + ->wherePivot('parent_index', $parentWorkflow->pivot->parent_index) + ->detach($context->storedWorkflow); + } + + $newWorkflow->storedWorkflow->parents() + ->attach($context->storedWorkflow, [ + 'parent_index' => StoredWorkflow::CONTINUE_PARENT_INDEX, + 'parent_now' => $context->now, + ]); + + $rootWorkflow = $context->storedWorkflow->parents() + ->wherePivot('parent_index', StoredWorkflow::ACTIVE_WORKFLOW_INDEX)->first(); + + if ($rootWorkflow) { + $rootWorkflow->children() + ->attach($newWorkflow->storedWorkflow, [ + 'parent_index' => StoredWorkflow::ACTIVE_WORKFLOW_INDEX, + 'parent_now' => $context->now, + ]); + + $rootWorkflow->children() + ->wherePivot('parent_index', StoredWorkflow::ACTIVE_WORKFLOW_INDEX) + ->detach($context->storedWorkflow); + } else { + $context->storedWorkflow->children() + ->attach($newWorkflow->storedWorkflow, [ + 'parent_index' => StoredWorkflow::ACTIVE_WORKFLOW_INDEX, + 'parent_now' => $context->now, + ]); + } + + $newWorkflow->start(...$arguments); + } + + self::$context = $context; + + return resolve(new ContinuedWorkflow()); + } +} diff --git a/src/Workflow.php b/src/Workflow.php index 636ffb7..60fc7b9 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -23,6 +23,7 @@ use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; use Workflow\States\WorkflowCompletedStatus; +use Workflow\States\WorkflowContinuedStatus; use Workflow\States\WorkflowRunningStatus; use Workflow\States\WorkflowWaitingStatus; use Workflow\Traits\Sagas; @@ -79,6 +80,8 @@ public function query($method) public function middleware() { $parentWorkflow = $this->storedWorkflow->parents() + ->wherePivot('parent_index', '!=', StoredWorkflow::CONTINUE_PARENT_INDEX) + ->wherePivot('parent_index', '!=', StoredWorkflow::ACTIVE_WORKFLOW_INDEX) ->first(); if ($parentWorkflow) { @@ -121,6 +124,8 @@ public function handle(): void } $parentWorkflow = $this->storedWorkflow->parents() + ->wherePivot('parent_index', '!=', StoredWorkflow::CONTINUE_PARENT_INDEX) + ->wherePivot('parent_index', '!=', StoredWorkflow::ACTIVE_WORKFLOW_INDEX) ->first(); $log = $this->storedWorkflow->logs() @@ -214,6 +219,11 @@ public function handle(): void throw new Exception('Workflow failed.', 0, $th); } + if ($return instanceof ContinuedWorkflow) { + $this->storedWorkflow->status->transitionTo(WorkflowContinuedStatus::class); + return; + } + $this->storedWorkflow->output = Serializer::serialize($return); $this->storedWorkflow->status->transitionTo(WorkflowCompletedStatus::class); diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 9e61c5e..fd3d8ad 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -20,6 +20,7 @@ use Workflow\States\WorkflowPendingStatus; use Workflow\Traits\Awaits; use Workflow\Traits\AwaitWithTimeouts; +use Workflow\Traits\Continues; use Workflow\Traits\Fakes; use Workflow\Traits\SideEffects; use Workflow\Traits\Timers; @@ -28,6 +29,7 @@ final class WorkflowStub { use Awaits; use AwaitWithTimeouts; + use Continues; use Fakes; use Macroable; use SideEffects; @@ -54,20 +56,22 @@ public function __call($method, $arguments) ->map(static fn ($method) => $method->getName()) ->contains($method) ) { - $this->storedWorkflow->signals() + $activeWorkflow = $this->storedWorkflow->active(); + + $activeWorkflow->signals() ->create([ 'method' => $method, 'arguments' => Serializer::serialize($arguments), ]); - $this->storedWorkflow->toWorkflow(); + $activeWorkflow->toWorkflow(); if (static::faked()) { $this->resume(); return; } - return Signal::dispatch($this->storedWorkflow, self::connection(), self::queue()); + return Signal::dispatch($activeWorkflow, self::connection(), self::queue()); } if (collect((new ReflectionClass($this->storedWorkflow->class))->getMethods()) @@ -76,9 +80,11 @@ public function __call($method, $arguments) ->map(static fn ($method) => $method->getName()) ->contains($method) ) { - return (new $this->storedWorkflow->class( - $this->storedWorkflow, - ...Serializer::unserialize($this->storedWorkflow->arguments), + $activeWorkflow = $this->storedWorkflow->active(); + + return (new $activeWorkflow->class( + $activeWorkflow, + ...Serializer::unserialize($activeWorkflow->arguments), )) ->query($method); } @@ -140,21 +146,25 @@ public function id() public function logs() { - return $this->storedWorkflow->logs; + return $this->storedWorkflow->active() + ->logs; } public function exceptions() { - return $this->storedWorkflow->exceptions; + return $this->storedWorkflow->active() + ->exceptions; } public function output() { - if ($this->storedWorkflow->fresh()->output === null) { + $activeWorkflow = $this->storedWorkflow->active(); + + if ($activeWorkflow->output === null) { return null; } - return Serializer::unserialize($this->storedWorkflow->fresh()->output); + return Serializer::unserialize($activeWorkflow->output); } public function completed(): bool @@ -179,7 +189,7 @@ public function running(): bool public function status(): string|bool { - return $this->storedWorkflow->fresh() + return $this->storedWorkflow->active() ->status::class; } diff --git a/tests/.env.feature b/tests/.env.feature index d47a269..406cbc8 100644 --- a/tests/.env.feature +++ b/tests/.env.feature @@ -8,6 +8,7 @@ DB_USERNAME=laravel DB_PASSWORD=laravel QUEUE_CONNECTION=redis +QUEUE_FAILED_DRIVER=null REDIS_HOST=redis REDIS_PASSWORD= diff --git a/tests/.env.unit b/tests/.env.unit index 4d6aa94..c300690 100644 --- a/tests/.env.unit +++ b/tests/.env.unit @@ -8,6 +8,7 @@ DB_USERNAME=laravel DB_PASSWORD=laravel QUEUE_CONNECTION=sync +QUEUE_FAILED_DRIVER=null REDIS_HOST=redis REDIS_PASSWORD= diff --git a/tests/Feature/ContinueAsNewWorkflowTest.php b/tests/Feature/ContinueAsNewWorkflowTest.php new file mode 100644 index 0000000..e653d12 --- /dev/null +++ b/tests/Feature/ContinueAsNewWorkflowTest.php @@ -0,0 +1,25 @@ +start(); + + while ($workflow->running()); + + $this->assertEquals(WorkflowCompletedStatus::class, $workflow->status()); + $this->assertEquals('workflow_3', $workflow->output()); + } +} diff --git a/tests/Feature/ParentContinueAsNewChildWorkflowTest.php b/tests/Feature/ParentContinueAsNewChildWorkflowTest.php new file mode 100644 index 0000000..2584d10 --- /dev/null +++ b/tests/Feature/ParentContinueAsNewChildWorkflowTest.php @@ -0,0 +1,25 @@ +start(); + + while ($workflow->running()); + + $this->assertEquals(WorkflowCompletedStatus::class, $workflow->status()); + $this->assertEquals('parent_child_workflow_3', $workflow->output()); + } +} diff --git a/tests/Fixtures/TestChildContinueAsNewWorkflow.php b/tests/Fixtures/TestChildContinueAsNewWorkflow.php new file mode 100644 index 0000000..1fd8b60 --- /dev/null +++ b/tests/Fixtures/TestChildContinueAsNewWorkflow.php @@ -0,0 +1,23 @@ += $totalCount) { + return 'child_workflow_' . $activityResult; + } + + return yield WorkflowStub::continueAsNew($count + 1, $totalCount); + } +} diff --git a/tests/Fixtures/TestContinueAsNewWorkflow.php b/tests/Fixtures/TestContinueAsNewWorkflow.php new file mode 100644 index 0000000..b0ffde6 --- /dev/null +++ b/tests/Fixtures/TestContinueAsNewWorkflow.php @@ -0,0 +1,23 @@ += $totalCount) { + return 'workflow_' . $activityResult; + } + + return yield WorkflowStub::continueAsNew($count + 1, $totalCount); + } +} diff --git a/tests/Fixtures/TestCountActivity.php b/tests/Fixtures/TestCountActivity.php new file mode 100644 index 0000000..1c926ea --- /dev/null +++ b/tests/Fixtures/TestCountActivity.php @@ -0,0 +1,15 @@ +canceled); - } - yield WorkflowStub::await(fn (): bool => $this->canceled); $result = yield ActivityStub::make(TestActivity::class); diff --git a/tests/TestCase.php b/tests/TestCase.php index 804f923..d69c0d2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -57,6 +57,8 @@ protected function defineDatabaseMigrations() '--realpath' => true, ]); + $this->artisan('queue:failed-table'); + $this->loadLaravelMigrations(); } diff --git a/tests/Unit/Models/StoredWorkflowTest.php b/tests/Unit/Models/StoredWorkflowTest.php index a256a96..95c2148 100644 --- a/tests/Unit/Models/StoredWorkflowTest.php +++ b/tests/Unit/Models/StoredWorkflowTest.php @@ -5,8 +5,14 @@ namespace Tests\Unit\Models; use Illuminate\Support\Carbon; +use Tests\Fixtures\TestContinueAsNewWorkflow; +use Tests\Fixtures\TestWorkflow; use Tests\TestCase; use Workflow\Models\StoredWorkflow; +use Workflow\Serializers\Serializer; +use Workflow\States\WorkflowContinuedStatus; +use Workflow\States\WorkflowRunningStatus; +use Workflow\WorkflowStub; final class StoredWorkflowTest extends TestCase { @@ -70,4 +76,161 @@ public function testModel(): void $this->assertSame(0, $workflow->timers()->count()); $this->assertSame(0, $workflow->children()->count()); } + + public function testContinuedWorkflows(): void + { + $parentWorkflow = StoredWorkflow::create([ + 'class' => 'ParentWorkflow', + 'status' => 'continued', + ]); + + $continuedWorkflow = StoredWorkflow::create([ + 'class' => 'ContinuedWorkflow', + 'status' => 'completed', + ]); + + $continuedWorkflow->parents() + ->attach($parentWorkflow, [ + 'parent_index' => StoredWorkflow::CONTINUE_PARENT_INDEX, + 'parent_now' => now(), + ]); + + $result = $parentWorkflow->continuedWorkflows(); + + $this->assertSame(1, $parentWorkflow->continuedWorkflows()->count()); + $this->assertSame($continuedWorkflow->id, $parentWorkflow->continuedWorkflows()->first()->id); + } + + public function testActiveWithContinuedWorkflow(): void + { + $parentWorkflow = StoredWorkflow::create([ + 'class' => 'ParentWorkflow', + 'status' => WorkflowContinuedStatus::class, + ]); + + $continuedWorkflow = StoredWorkflow::create([ + 'class' => 'ContinuedWorkflow', + 'status' => 'completed', + ]); + + $continuedWorkflow->parents() + ->attach($parentWorkflow, [ + 'parent_index' => StoredWorkflow::CONTINUE_PARENT_INDEX, + 'parent_now' => now(), + ]); + + $parentWorkflow->children() + ->attach($continuedWorkflow, [ + 'parent_index' => StoredWorkflow::ACTIVE_WORKFLOW_INDEX, + 'parent_now' => now(), + ]); + + $active = $parentWorkflow->active(); + + $this->assertSame($continuedWorkflow->id, $active->id); + } + + public function testActiveWithShortcut(): void + { + $rootWorkflow = StoredWorkflow::create([ + 'class' => 'RootWorkflow', + 'status' => WorkflowContinuedStatus::class, + ]); + + $activeWorkflow = StoredWorkflow::create([ + 'class' => 'ActiveWorkflow', + 'status' => 'completed', + ]); + + $rootWorkflow->children() + ->attach($activeWorkflow, [ + 'parent_index' => StoredWorkflow::ACTIVE_WORKFLOW_INDEX, + 'parent_now' => now(), + ]); + + $active = $rootWorkflow->active(); + + $this->assertSame($activeWorkflow->id, $active->id); + } + + public function testActiveWorkflowShortcutTransferOnContinue(): void + { + $rootWorkflow = StoredWorkflow::create([ + 'class' => TestWorkflow::class, + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowRunningStatus::class, + ]); + + $intermediateWorkflow = StoredWorkflow::create([ + 'class' => TestContinueAsNewWorkflow::class, + 'arguments' => Serializer::serialize([1, 3]), + 'status' => WorkflowRunningStatus::class, + ]); + + $intermediateWorkflow->parents() + ->attach($rootWorkflow->id, [ + 'parent_index' => StoredWorkflow::ACTIVE_WORKFLOW_INDEX, + 'parent_now' => now(), + ]); + + WorkflowStub::setContext([ + 'storedWorkflow' => $intermediateWorkflow, + 'index' => 0, + 'now' => now(), + 'replaying' => false, + ]); + + WorkflowStub::continueAsNew(2, 3); + + $this->assertSame(1, $intermediateWorkflow->continuedWorkflows()->count()); + $newWorkflow = $intermediateWorkflow->continuedWorkflows() + ->first(); + + $activeParent = $newWorkflow->parents() + ->wherePivot('parent_index', StoredWorkflow::ACTIVE_WORKFLOW_INDEX) + ->first(); + + $this->assertNotNull($activeParent); + $this->assertSame($rootWorkflow->id, $activeParent->id); + } + + public function testActiveWorkflowWithMultipleContinuations(): void + { + $rootWorkflow = StoredWorkflow::create([ + 'class' => 'RootWorkflow', + 'status' => WorkflowContinuedStatus::class, + ]); + + $intermediateWorkflow = StoredWorkflow::create([ + 'class' => 'IntermediateWorkflow', + 'status' => WorkflowContinuedStatus::class, + ]); + + $finalWorkflow = StoredWorkflow::create([ + 'class' => 'FinalWorkflow', + 'status' => 'completed', + ]); + + $intermediateWorkflow->parents() + ->attach($rootWorkflow, [ + 'parent_index' => StoredWorkflow::CONTINUE_PARENT_INDEX, + 'parent_now' => now(), + ]); + + $finalWorkflow->parents() + ->attach($intermediateWorkflow, [ + 'parent_index' => StoredWorkflow::CONTINUE_PARENT_INDEX, + 'parent_now' => now(), + ]); + + $rootWorkflow->children() + ->attach($finalWorkflow, [ + 'parent_index' => StoredWorkflow::ACTIVE_WORKFLOW_INDEX, + 'parent_now' => now(), + ]); + + $active = $rootWorkflow->active(); + + $this->assertSame($finalWorkflow->id, $active->id); + } } diff --git a/tests/Unit/States/WorkflowStatusTest.php b/tests/Unit/States/WorkflowStatusTest.php index bc99973..50bafe1 100644 --- a/tests/Unit/States/WorkflowStatusTest.php +++ b/tests/Unit/States/WorkflowStatusTest.php @@ -7,6 +7,7 @@ use Tests\TestCase; use Workflow\Models\StoredWorkflow; use Workflow\States\WorkflowCompletedStatus; +use Workflow\States\WorkflowContinuedStatus; use Workflow\States\WorkflowCreatedStatus; use Workflow\States\WorkflowFailedStatus; use Workflow\States\WorkflowPendingStatus; @@ -20,6 +21,7 @@ public function testConfig(): void $config = StoredWorkflow::make()->getStates()->first()->all(); $this->assertSame([ WorkflowCompletedStatus::$name, + WorkflowContinuedStatus::$name, WorkflowCreatedStatus::$name, WorkflowFailedStatus::$name, WorkflowPendingStatus::$name, diff --git a/tests/Unit/WorkflowTest.php b/tests/Unit/WorkflowTest.php index 3f95402..b816e1b 100644 --- a/tests/Unit/WorkflowTest.php +++ b/tests/Unit/WorkflowTest.php @@ -9,6 +9,8 @@ use Illuminate\Support\Facades\Event; use Tests\Fixtures\TestActivity; use Tests\Fixtures\TestChildWorkflow; +use Tests\Fixtures\TestContinueAsNewWorkflow; +use Tests\Fixtures\TestCountActivity; use Tests\Fixtures\TestOtherActivity; use Tests\Fixtures\TestParentWorkflow; use Tests\Fixtures\TestThrowOnReturnWorkflow; @@ -20,6 +22,7 @@ use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; use Workflow\States\WorkflowCompletedStatus; +use Workflow\States\WorkflowContinuedStatus; use Workflow\States\WorkflowFailedStatus; use Workflow\States\WorkflowPendingStatus; use Workflow\Workflow; @@ -327,4 +330,61 @@ public function testThrowsWrappedException(): void $workflow = new TestThrowOnReturnWorkflow($storedWorkflow); $workflow->handle(); } + + public function testContinueAsNew(): void + { + $storedWorkflow = StoredWorkflow::create([ + 'class' => TestContinueAsNewWorkflow::class, + 'arguments' => Serializer::serialize([0, 3]), + 'status' => WorkflowPendingStatus::class, + ]); + + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => now(), + 'class' => TestCountActivity::class, + 'result' => Serializer::serialize(0), + ]); + + $workflow = new TestContinueAsNewWorkflow($storedWorkflow); + $workflow->handle(); + + $this->assertInstanceOf(WorkflowContinuedStatus::class, $storedWorkflow->fresh()->status); + + $this->assertSame(1, $storedWorkflow->continuedWorkflows()->count()); + } + + public function testContinueAsNewWithParentWorkflow(): void + { + $parentWorkflow = WorkflowStub::load(WorkflowStub::make(TestContinueAsNewWorkflow::class)->id()); + $storedParentWorkflow = StoredWorkflow::findOrFail($parentWorkflow->id()); + $storedParentWorkflow->arguments = Serializer::serialize([]); + $storedParentWorkflow->save(); + + $storedWorkflow = StoredWorkflow::create([ + 'class' => TestContinueAsNewWorkflow::class, + 'arguments' => Serializer::serialize([0, 3]), + 'status' => WorkflowPendingStatus::class, + ]); + + $storedWorkflow->parents() + ->attach($storedParentWorkflow, [ + 'parent_index' => 0, + 'parent_now' => now(), + ]); + + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => now(), + 'class' => TestCountActivity::class, + 'result' => Serializer::serialize(0), + ]); + + $workflow = new TestContinueAsNewWorkflow($storedWorkflow); + $workflow->handle(); + + $this->assertSame(1, $storedWorkflow->continuedWorkflows()->count()); + } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy