diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
new file mode 100644
index 0000000..e6a3540
--- /dev/null
+++ b/.github/workflows/integration.yml
@@ -0,0 +1,216 @@
+# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
+
+name: "Integration tests"
+
+on:
+ pull_request:
+ push:
+ branches:
+ - "[0-9]+.[0-9]+.x"
+ - "renovate/*"
+
+jobs:
+ postgres:
+ name: "Postgres"
+
+ runs-on: ${{ matrix.operating-system }}
+
+ services:
+ postgres:
+ # Docker Hub image
+ image: "postgres:${{ matrix.postgres-version }}"
+ # Provide the password for postgres
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: event_store
+ options: >-
+ --health-cmd "pg_isready"
+ ports:
+ - "5432:5432"
+
+ strategy:
+ matrix:
+ dependencies:
+ - "locked"
+ php-version:
+ - "8.5"
+ operating-system:
+ - "ubuntu-latest"
+ postgres-version:
+ - "14.20"
+ - "15.15"
+ - "16.11"
+ - "17.7"
+ - "18.1"
+
+ env:
+ DB_URL: 'pgsql://postgres:postgres@localhost:5432/event_store?charset=utf8'
+ DB_CONNECTION: 'pgsql'
+ DB_DATABASE: 'event_store'
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@2.37.0"
+ with:
+ coverage: "pcov"
+ php-version: "${{ matrix.php-version }}"
+ ini-values: memory_limit=-1
+ extensions: pdo_pgsql
+
+ - uses: ramsey/composer-install@4.0.0
+ with:
+ dependency-versions: ${{ matrix.dependencies }}
+ composer-options: ${{ matrix.composer-options }}
+
+ - name: "Tests"
+ run: "vendor/bin/phpunit --testsuite=integration --no-coverage"
+
+ mariadb:
+ name: "mariadb"
+
+ runs-on: ${{ matrix.operating-system }}
+
+ services:
+ mariadb:
+ image: "mariadb:${{ matrix.mariadb-version }}"
+ env:
+ MYSQL_ALLOW_EMPTY_PASSWORD: yes
+ MYSQL_DATABASE: event_store
+
+ options: >-
+ --health-cmd "mariadb-admin ping --silent"
+ ports:
+ - "3306:3306"
+
+ strategy:
+ matrix:
+ dependencies:
+ - "locked"
+ php-version:
+ - "8.5"
+ operating-system:
+ - "ubuntu-latest"
+ mariadb-version:
+ - "10.6"
+ - "10.11"
+ - "11.4"
+ - "11.8"
+ - "12.1"
+
+ env:
+ DB_URL: 'mysql://root@127.0.0.1:3306/event_store?charset=utf8mb4'
+ DB_CONNECTION: 'mysql'
+ DB_DATABASE: 'event_store'
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@2.37.0"
+ with:
+ coverage: "pcov"
+ php-version: "${{ matrix.php-version }}"
+ ini-values: memory_limit=-1
+ extensions: pdo_mysql
+
+ - uses: ramsey/composer-install@4.0.0
+ with:
+ dependency-versions: ${{ matrix.dependencies }}
+ composer-options: ${{ matrix.composer-options }}
+
+ - name: "Tests"
+ run: "vendor/bin/phpunit --testsuite=integration --no-coverage"
+
+ mysql:
+ name: "mysql"
+
+ runs-on: ${{ matrix.operating-system }}
+
+ services:
+ mysql:
+ image: "mysql:${{ matrix.mysql-version }}"
+
+ env:
+ MYSQL_ALLOW_EMPTY_PASSWORD: yes
+ MYSQL_DATABASE: "event_store"
+
+ options: >-
+ --health-cmd "mysqladmin ping --silent"
+ ports:
+ - "3306:3306"
+
+ strategy:
+ matrix:
+ dependencies:
+ - "locked"
+ php-version:
+ - "8.5"
+ operating-system:
+ - "ubuntu-latest"
+ mysql-version:
+ - "8.0"
+ - "8.4"
+ - "9.5"
+
+ env:
+ DB_URL: 'mysql://root@127.0.0.1:3306/event_store?charset=utf8mb4'
+ DB_CONNECTION: 'mysql'
+ DB_DATABASE: 'event_store'
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@2.37.0"
+ with:
+ coverage: "pcov"
+ php-version: "${{ matrix.php-version }}"
+ ini-values: memory_limit=-1
+ extensions: pdo_mysql
+
+ - uses: ramsey/composer-install@4.0.0
+ with:
+ dependency-versions: ${{ matrix.dependencies }}
+ composer-options: ${{ matrix.composer-options }}
+
+ - name: "Tests"
+ run: "vendor/bin/phpunit --testsuite=integration --no-coverage"
+
+ sqlite:
+ name: "Sqlite"
+
+ runs-on: ${{ matrix.operating-system }}
+
+ strategy:
+ matrix:
+ dependencies:
+ - "locked"
+ php-version:
+ - "8.5"
+ operating-system:
+ - "ubuntu-latest"
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@2.37.0"
+ with:
+ coverage: "pcov"
+ php-version: "${{ matrix.php-version }}"
+ ini-values: memory_limit=-1
+ extensions: pdo_sqlite
+
+ - uses: ramsey/composer-install@4.0.0
+ with:
+ dependency-versions: ${{ matrix.dependencies }}
+ composer-options: ${{ matrix.composer-options }}
+
+ - name: "Tests"
+ run: "vendor/bin/phpunit --testsuite=integration --no-coverage"
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/unit.yml
similarity index 92%
rename from .github/workflows/phpunit.yml
rename to .github/workflows/unit.yml
index 0f9dbce..c32fb23 100644
--- a/.github/workflows/phpunit.yml
+++ b/.github/workflows/unit.yml
@@ -51,4 +51,4 @@ jobs:
dependency-versions: ${{ matrix.dependencies }}
- name: "Tests"
- run: "vendor/bin/phpunit --coverage-clover=clover.xml --coverage-text"
+ run: "vendor/bin/phpunit --testsuite=unit --coverage-clover=clover.xml --coverage-text"
diff --git a/Makefile b/Makefile
index 8106244..2d69f3b 100644
--- a/Makefile
+++ b/Makefile
@@ -22,7 +22,8 @@ phpstan-baseline: vendor
.PHONY: phpunit
phpunit: vendor ## run phpunit tests
- XDEBUG_MODE=coverage vendor/bin/phpunit
+ XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit
+ XDEBUG_MODE=off vendor/bin/phpunit --testsuite=integration --no-coverage
.PHONY: infection
infection: vendor ## run infection
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..62372a0
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,40 @@
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: event_store
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d event_store"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ mariadb:
+ image: mariadb:11.8
+ environment:
+ MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
+ MYSQL_DATABASE: event_store
+ ports:
+ - "3307:3306"
+ healthcheck:
+ test: ["CMD-SHELL", "mariadb-admin ping --silent"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ mysql:
+ image: mysql:8.4
+ environment:
+ MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
+ MYSQL_DATABASE: event_store
+ ports:
+ - "3306:3306"
+ healthcheck:
+ test: ["CMD-SHELL", "mysqladmin ping --silent"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
diff --git a/composer.json b/composer.json
index 8a4cf13..9fcbd0c 100644
--- a/composer.json
+++ b/composer.json
@@ -20,7 +20,7 @@
"require": {
"php": "~8.3.0 || ~8.4.0 || ~8.5.0",
"illuminate/contracts": "^11.0 || ^12.0 || ^13.0",
- "patchlevel/event-sourcing": "^3.15.0"
+ "patchlevel/event-sourcing": "^3.16.0"
},
"require-dev": {
"ext-pdo_sqlite": "*",
diff --git a/composer.lock b/composer.lock
index c1c3ecb..a601abd 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "80c477130da9f2c3338e62513cced8b6",
+ "content-hash": "383cd22b736dfb0a482342bbd5108b8e",
"packages": [
{
"name": "brick/math",
diff --git a/config/event-sourcing.php b/config/event-sourcing.php
index c84a663..5750a01 100644
--- a/config/event-sourcing.php
+++ b/config/event-sourcing.php
@@ -14,6 +14,7 @@
|
*/
'connection' => [
+ 'type' => 'illuminate',
'url' => env('EVENT_SOURCING_DB_URL'),
'connection' => env(
'EVENT_SOURCING_DB_CONNECTION',
@@ -29,17 +30,18 @@
|
| Here you can configure the event store.
| You can choose between different types of stores.
- | dbal_aggregate (default): Store events in a single table with the aggregate and aggregate id.
- | dbal_stream (new default in 4.x): Store events in a single table with a stream id.
+ | dbal_aggregate (legacy): Store events in a single table with the aggregate and aggregate id.
+ | dbal_stream: Store events in a single table with a stream id using dbal.
+ | illuminate_stream (default, based on dbal_stream): Store events in a single table with a stream id using illuminate.
| in_memory: Store events in memory.
| custom: Use a custom store, you need to provide a service.
|
*/
'store' => [
- 'type' => 'dbal_aggregate',
+ 'type' => 'illuminate_stream',
'service' => null,
'options' => [
- 'table_name' => 'eventstore',
+ 'table_name' => 'event_store',
],
'readonly' => false,
'migrate_to_new_store' => [
@@ -120,7 +122,7 @@
],
'default_retry_strategy' => 'default',
'store' => [
- 'type' => 'dbal',
+ 'type' => 'illuminate',
'service' => null,
'options' => [
'table_name' => 'subscriptions',
@@ -160,6 +162,7 @@
*/
'cryptography' => [
'enabled' => false,
+ 'store' => 'illuminate',
'algorithm' => 'aes256',
'use_encrypted_field_name' => true,
'fallback_to_field_name' => false,
diff --git a/database/migrations/create_eventsourcing_tables.php b/database/migrations/create_eventsourcing_tables.php
index 29ad7c8..9a93bfa 100644
--- a/database/migrations/create_eventsourcing_tables.php
+++ b/database/migrations/create_eventsourcing_tables.php
@@ -8,38 +8,40 @@
{
public function up(): void
{
- Schema::create('eventstore', function (Blueprint $table) {
- $table->bigIncrements('id');
- $table->string('aggregate', 255);
- $table->char('aggregate_id', 36);
- $table->integer('playhead');
- $table->string('event', 255);
- $table->json('payload');
- $table->dateTime('recorded_on');
- $table->tinyInteger('new_stream_start')->default(0);
- $table->tinyInteger('archived')->default(0);
+ Schema::create('event_store', function (Blueprint $table) {
+ $table->bigInteger('id', true);
+ $table->string('stream', 255);
+ $table->integer('playhead')->nullable();
+ $table->string('event_id', 255);
+ $table->string('event_name', 255);
+ $table->json('event_payload');
+ $table->dateTimeTz('recorded_on');
+ $table->boolean('archived')->default(false);
$table->json('custom_headers');
- $table->unique(['aggregate', 'aggregate_id', 'playhead']);
- $table->index(['aggregate', 'aggregate_id', 'playhead', 'archived']);
+
+ $table->unique('event_id');
+ $table->unique(['stream', 'playhead']);
+ $table->unique(['stream', 'playhead', 'archived']);
});
Schema::create('subscriptions', function (Blueprint $table) {
$table->string('id', 255);
$table->string('group_name', 32);
$table->string('run_mode', 16);
- $table->integer('position');
$table->string('status', 32);
+ $table->integer('position');
$table->longText('error_message')->nullable();
$table->string('error_previous_status', 32)->nullable();
$table->json('error_context')->nullable();
$table->integer('retry_attempt');
$table->dateTime('last_saved_at');
+ $table->text('cleanup_tasks')->nullable();
$table->index('group_name');
$table->index('status');
$table->primary('id');
});
- Schema::create('eventstore_cipher_keys', function (Blueprint $table) {
+ Schema::create('crypto_keys', function (Blueprint $table) {
$table->string('subject_id', 255);
$table->string('crypto_key', 255);
$table->string('crypto_method', 255);
@@ -50,8 +52,8 @@ public function up(): void
public function down(): void
{
- Schema::dropIfExists('eventstore');
+ Schema::dropIfExists('event_store');
Schema::dropIfExists('subscriptions');
- Schema::dropIfExists('eventstore_cipher_keys');
+ Schema::dropIfExists('event_store_cipher_keys');
}
};
diff --git a/infection.json.dist b/infection.json.dist
index 3ea5103..ec6efbd 100644
--- a/infection.json.dist
+++ b/infection.json.dist
@@ -16,7 +16,7 @@
"mutators": {
"@default": true
},
- "minMsi": 75,
- "minCoveredMsi": 75,
+ "minMsi": 71,
+ "minCoveredMsi": 71,
"testFrameworkOptions": "--testsuite=unit"
}
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index b2ebef9..5208afd 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -10,5 +10,6 @@
+
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 9631d44..204992b 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -1 +1,25 @@
parameters:
+ ignoreErrors:
+ -
+ message: '#^Parameter \#1 \$key of class Patchlevel\\Hydrator\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Cryptography/IlluminateCipherKeyStore.php
+
+ -
+ message: '#^Parameter \#3 \$iv of class Patchlevel\\Hydrator\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Cryptography/IlluminateCipherKeyStore.php
+
+ -
+ message: '#^Parameter \#3 \$errorContext of class Patchlevel\\EventSourcing\\Subscription\\SubscriptionError constructor expects list\\}\>\}\>\|null, array\\|null given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Subscription/Store/IlluminateSubscriptionStore.php
+
+ -
+ message: '#^Strict comparison using \!\=\= between string and null will always evaluate to true\.$#'
+ identifier: notIdentical.alwaysTrue
+ count: 1
+ path: src/Subscription/Store/IlluminateSubscriptionStore.php
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 110f209..22329a1 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -11,6 +11,9 @@
tests/Unit
+
+ tests/Integration
+
@@ -22,6 +25,7 @@
+
diff --git a/src/Attribute/ProjectionConnection.php b/src/Attribute/ProjectionConnection.php
index aa13339..97bbbbb 100644
--- a/src/Attribute/ProjectionConnection.php
+++ b/src/Attribute/ProjectionConnection.php
@@ -5,15 +5,16 @@
namespace Patchlevel\LaravelEventSourcing\Attribute;
use Attribute;
-use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Connection as DBALConnection;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Container\ContextualAttribute;
+use Illuminate\Database\Connection as IlluminateConnection;
#[Attribute(Attribute::TARGET_PARAMETER)]
final readonly class ProjectionConnection implements ContextualAttribute
{
- public static function resolve(self $attribute, Container $container): Connection
+ public static function resolve(self $attribute, Container $container): DBALConnection|IlluminateConnection
{
- return $container->get('event_sourcing.dbal_public_connection');
+ return $container->get('event_sourcing.public_connection');
}
}
diff --git a/src/Cryptography/IlluminateCipherKeyStore.php b/src/Cryptography/IlluminateCipherKeyStore.php
new file mode 100644
index 0000000..c4771c1
--- /dev/null
+++ b/src/Cryptography/IlluminateCipherKeyStore.php
@@ -0,0 +1,97 @@
+ */
+ private array $keyCache = [];
+
+ public function __construct(
+ private readonly Connection $connection,
+ private readonly string $tableName = 'crypto_keys',
+ ) {
+ }
+
+ public function get(string $id): CipherKey
+ {
+ if (array_key_exists($id, $this->keyCache)) {
+ return $this->keyCache[$id];
+ }
+
+ /** @var Row|null $result */
+ $result = $this->connection->selectOne(
+ sprintf('SELECT * FROM %s WHERE subject_id = :subject_id', $this->tableName),
+ ['subject_id' => $id],
+ );
+
+ if ($result === null) {
+ throw new CipherKeyNotExists($id);
+ }
+
+ $this->keyCache[$id] = new CipherKey(
+ base64_decode($result->crypto_key),
+ $result->crypto_method,
+ base64_decode($result->crypto_iv),
+ );
+
+ return $this->keyCache[$id];
+ }
+
+ public function store(string $id, CipherKey $key): void
+ {
+ $this->connection->statement(
+ <<tableName}
+ (subject_id, crypto_key, crypto_method, crypto_iv)
+ VALUES
+ (:subject_id, :crypto_key, :crypto_method, :crypto_iv)
+SQL,
+ [
+ 'subject_id' => $id,
+ 'crypto_key' => base64_encode($key->key),
+ 'crypto_method' => $key->method,
+ 'crypto_iv' => base64_encode($key->iv),
+ ],
+ );
+
+ $this->keyCache[$id] = $key;
+ }
+
+ public function remove(string $id): void
+ {
+ $this->connection->statement(
+ <<tableName} WHERE subject_id = :subject_id
+SQL,
+ ['subject_id' => $id],
+ );
+
+ unset($this->keyCache[$id]);
+ }
+
+ public function clear(): void
+ {
+ $this->keyCache = [];
+ }
+}
diff --git a/src/EventSourcingServiceProvider.php b/src/EventSourcingServiceProvider.php
index 0d55235..68f04c7 100644
--- a/src/EventSourcingServiceProvider.php
+++ b/src/EventSourcingServiceProvider.php
@@ -8,6 +8,8 @@
use DateTimeImmutable;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Tools\DsnParser;
+use Illuminate\Support\Facades\Config;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;
use Patchlevel\EventSourcing\Clock\FrozenClock;
@@ -114,10 +116,13 @@
use Patchlevel\Hydrator\Hydrator;
use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory;
use Patchlevel\Hydrator\MetadataHydrator;
+use Patchlevel\LaravelEventSourcing\Cryptography\IlluminateCipherKeyStore;
use Patchlevel\LaravelEventSourcing\Middleware\AutoSetupMiddleware;
use Patchlevel\LaravelEventSourcing\Middleware\EventSourcingMiddleware;
use Patchlevel\LaravelEventSourcing\Middleware\SubscriptionRebuildAfterFileChangeMiddleware;
+use Patchlevel\LaravelEventSourcing\Store\StreamIlluminateStore;
use Patchlevel\LaravelEventSourcing\Subscription\StaticInMemorySubscriptionStoreFactory;
+use Patchlevel\LaravelEventSourcing\Subscription\Store\IlluminateSubscriptionStore;
use function app;
use function array_filter;
@@ -157,9 +162,6 @@ public function boot(): void
$this->commands([
DatabaseCreateCommand::class,
DatabaseDropCommand::class,
- SchemaCreateCommand::class,
- SchemaUpdateCommand::class,
- SchemaDropCommand::class,
ShowCommand::class,
ShowAggregateCommand::class,
WatchCommand::class,
@@ -174,6 +176,16 @@ public function boot(): void
SubscriptionReactivateCommand::class,
StoreMigrateCommand::class,
]);
+
+ if (config('event-sourcing.connection.type') !== 'dbal') {
+ return;
+ }
+
+ $this->commands([
+ SchemaCreateCommand::class,
+ SchemaUpdateCommand::class,
+ SchemaDropCommand::class,
+ ]);
}
public function register(): void
@@ -236,64 +248,84 @@ private function registerQueryBus(): void
private function registerConnection(): void
{
- $connectionCreationCallback = static function () {
- $url = config('event-sourcing.connection.url');
+ if (config('event-sourcing.connection.type') === 'dbal') {
+ $connectionCreationCallback = static function () {
+ $url = config('event-sourcing.connection.url');
+
+ if (is_string($url)) {
+ return DriverManager::getConnection(
+ (new DsnParser())->parse($url),
+ );
+ }
- if (is_string($url)) {
- return DriverManager::getConnection(
- (new DsnParser())->parse($url),
- );
- }
+ /** @var array $connections */
+ $connections = config('database.connections');
- /** @var array $connections */
- $connections = config('database.connections');
+ /** @var string $connectionKey */
+ $connectionKey = config('event-sourcing.connection.connection');
- /** @var string $connectionKey */
- $connectionKey = config('event-sourcing.connection.connection');
+ if (!array_key_exists($connectionKey, $connections)) {
+ throw new InvalidArgumentException(sprintf('Connection "%s" not found', $connectionKey));
+ }
- if (!array_key_exists($connectionKey, $connections)) {
- throw new InvalidArgumentException(sprintf('Connection "%s" not found', $connectionKey));
- }
+ $connectionParams = $connections[$connectionKey];
+
+ if ($connectionParams['url'] ?? false) {
+ return DriverManager::getConnection(
+ (new DsnParser())->parse($connectionParams['url']),
+ );
+ }
- $connectionParams = $connections[$connectionKey];
+ /** @var 'pdo_mysql'|'pdo_pgsql'|'pdo_sqlite' $driver */
+ $driver = match ($connectionParams['driver']) {
+ 'mysql', 'mariadb' => 'pdo_mysql',
+ 'pgsql' => 'pdo_pgsql',
+ 'sqlite' => 'pdo_sqlite',
+ default => $connectionParams['driver'],
+ };
- if ($connectionParams['url'] ?? false) {
return DriverManager::getConnection(
- (new DsnParser())->parse($connectionParams['url']),
+ array_filter(
+ [
+ 'driver' => $driver,
+ 'dbname' => $connectionParams['database'] ?? null,
+ 'path' => $connectionParams['database'] ?? null,
+ 'user' => $connectionParams['username'] ?? null,
+ 'password' => $connectionParams['password'] ?? null,
+ 'host' => $connectionParams['host'] ?? null,
+ 'port' => $connectionParams['port'] ?? null,
+ ],
+ static fn (mixed $value) => $value !== null,
+ ),
);
+ };
+
+ $publicConnectionCreationCallback = $connectionCreationCallback;
+ } elseif (config('event-sourcing.connection.type') === 'illuminate') {
+ $connectionCreationCallback = static fn () => DB::connection();
+
+ if (!config('event-sourcing.connection.provide_dedicated_connection')) {
+ return;
}
- /** @var 'pdo_mysql'|'pdo_pgsql'|'pdo_sqlite' $driver */
- $driver = match ($connectionParams['driver']) {
- 'mysql', 'mariadb' => 'pdo_mysql',
- 'pgsql' => 'pdo_pgsql',
- 'sqlite' => 'pdo_sqlite',
- default => $connectionParams['driver'],
- };
+ $base = config('database.connections.' . config('database.default'));
+ Config::set('database.connections.event_sourcing_public', $base);
+ DB::purge('event_sourcing_public');
- return DriverManager::getConnection(
- array_filter(
- [
- 'driver' => $driver,
- 'dbname' => $connectionParams['database'] ?? null,
- 'path' => $connectionParams['database'] ?? null,
- 'user' => $connectionParams['username'] ?? null,
- 'password' => $connectionParams['password'] ?? null,
- 'host' => $connectionParams['host'] ?? null,
- 'port' => $connectionParams['port'] ?? null,
- ],
- static fn (mixed $value) => $value !== null,
- ),
- );
- };
+ //Config::set('database.connections.event_sourcing_public', ['url' => config('event-sourcing.connection.url')]);
+
+ $publicConnectionCreationCallback = static fn () => DB::connection('event_sourcing_public');
+ } else {
+ throw new InvalidArgumentException(sprintf('Unknown connection type "%s"', config('event-sourcing.connection.type')));
+ }
- $this->app->singleton('event_sourcing.dbal_connection', $connectionCreationCallback);
+ $this->app->singleton('event_sourcing.connection', $connectionCreationCallback);
if (!config('event-sourcing.connection.provide_dedicated_connection')) {
return;
}
- $this->app->singleton('event_sourcing.dbal_public_connection', $connectionCreationCallback);
+ $this->app->singleton('event_sourcing.public_connection', $publicConnectionCreationCallback);
}
private function registerStore(): void
@@ -330,7 +362,7 @@ private function registerStore(): void
if ($type === 'dbal_aggregate') {
$store = new DoctrineDbalStore(
- app('event_sourcing.dbal_connection'),
+ app('event_sourcing.connection'),
app(EventSerializer::class),
app(HeadersSerializer::class),
$options,
@@ -345,7 +377,23 @@ private function registerStore(): void
if ($type === 'dbal_stream') {
$store = new StreamDoctrineDbalStore(
- app('event_sourcing.dbal_connection'),
+ app('event_sourcing.connection'),
+ app(EventSerializer::class),
+ app(HeadersSerializer::class),
+ app('event_sourcing.clock'),
+ $options,
+ );
+
+ if (config('event-sourcing.store.read_only')) {
+ $store = new StreamReadOnlyStore($store, app('log'));
+ }
+
+ return $store;
+ }
+
+ if ($type === 'illuminate_stream') {
+ $store = new StreamIlluminateStore(
+ app('event_sourcing.connection'),
app(EventSerializer::class),
app(HeadersSerializer::class),
app('event_sourcing.clock'),
@@ -454,6 +502,10 @@ private function registerAggregates(): void
private function registerSchema(): void
{
+ if (config('event-sourcing.connection.type') !== 'dbal') {
+ return;
+ }
+
$this->app->singleton(DoctrineSchemaConfigurator::class, function () {
return new ChainDoctrineSchemaConfigurator(
$this->app->tagged('event_sourcing.doctrine_schema_configurator'),
@@ -462,21 +514,21 @@ private function registerSchema(): void
$this->app->singleton(SchemaDirector::class, static function () {
return new DoctrineSchemaDirector(
- app('event_sourcing.dbal_connection'),
+ app('event_sourcing.connection'),
app(DoctrineSchemaConfigurator::class),
);
});
$this->app->singleton(DatabaseCreateCommand::class, static function () {
return new DatabaseCreateCommand(
- app('event_sourcing.dbal_connection'),
+ app('event_sourcing.connection'),
new DoctrineHelper(),
);
});
$this->app->singleton(DatabaseDropCommand::class, static function () {
return new DatabaseDropCommand(
- app('event_sourcing.dbal_connection'),
+ app('event_sourcing.connection'),
new DoctrineHelper(),
);
});
@@ -710,7 +762,13 @@ private function registerSubscription(): void
$storeCallback = static fn () => StaticInMemorySubscriptionStoreFactory::create();
} elseif (config('event-sourcing.subscription.store.type') === 'dbal') {
$storeCallback = static fn () => new DoctrineSubscriptionStore(
- app('event_sourcing.dbal_connection'),
+ app('event_sourcing.connection'),
+ app('event_sourcing.clock'),
+ config('event-sourcing.subscription.store.options.table_name'),
+ );
+ } elseif (config('event-sourcing.subscription.store.type') === 'illuminate') {
+ $storeCallback = static fn () => new IlluminateSubscriptionStore(
+ app('event_sourcing.connection'),
app('event_sourcing.clock'),
config('event-sourcing.subscription.store.options.table_name'),
);
@@ -871,13 +929,21 @@ private function registerCryptography(): void
static fn () => new OpensslCipherKeyFactory(config('event-sourcing.cryptography.algorithm')),
);
- $this->app->singleton(
- CipherKeyStore::class,
- static fn () => new DoctrineCipherKeyStore(
- app('event_sourcing.dbal_connection'),
- 'eventstore_cipher_keys',
- ),
- );
+ if (config('event-sourcing.cryptography.store') === 'dbal') {
+ $storeCallback = static fn () => new DoctrineCipherKeyStore(
+ app('event_sourcing.connection'),
+ 'event_store_cipher_keys',
+ );
+ } elseif (config('event-sourcing.cryptography.store') === 'illuminate') {
+ $storeCallback = static fn () => new IlluminateCipherKeyStore(
+ app('event_sourcing.connection'),
+ 'event_store_cipher_keys',
+ );
+ } else {
+ throw new InvalidArgumentException('Cryptography store type is unknown.');
+ }
+
+ $this->app->singleton(CipherKeyStore::class, $storeCallback);
$this->app->tag(CipherKeyStore::class, ['event_sourcing.doctrine_schema_configurator']);
@@ -927,7 +993,7 @@ private function registerStoreMigration(): void
$this->app->singleton(
$id,
static fn () => new DoctrineDbalStore(
- app('event_sourcing.dbal_connection'),
+ app('event_sourcing.connection'),
app(EventSerializer::class),
app(HeadersSerializer::class),
config('event-sourcing.migrate_to_new_store.options'),
@@ -937,7 +1003,18 @@ private function registerStoreMigration(): void
$this->app->singleton(
$id,
static fn () => new StreamDoctrineDbalStore(
- app('event_sourcing.dbal_connection'),
+ app('event_sourcing.connection'),
+ app(EventSerializer::class),
+ app(HeadersSerializer::class),
+ app('event_sourcing.clock'),
+ config('event-sourcing.migrate_to_new_store.options'),
+ ),
+ );
+ } elseif ($storeType === 'illuminate_stream') {
+ $this->app->singleton(
+ $id,
+ static fn () => new StreamIlluminateStore(
+ app('event_sourcing.connection'),
app(EventSerializer::class),
app(HeadersSerializer::class),
app('event_sourcing.clock'),
diff --git a/src/Facade/ProjectionConnection.php b/src/Facade/ProjectionConnection.php
index 2fd1a36..b96a35e 100644
--- a/src/Facade/ProjectionConnection.php
+++ b/src/Facade/ProjectionConnection.php
@@ -10,6 +10,6 @@ class ProjectionConnection extends Facade
{
protected static function getFacadeAccessor(): string
{
- return 'event_sourcing.dbal_public_connection';
+ return 'event_sourcing.public_connection';
}
}
diff --git a/src/Store/StreamIlluminateStore.php b/src/Store/StreamIlluminateStore.php
new file mode 100644
index 0000000..87c9489
--- /dev/null
+++ b/src/Store/StreamIlluminateStore.php
@@ -0,0 +1,489 @@
+headersSerializer = $headersSerializer ?? DefaultHeadersSerializer::createDefault();
+ $this->clock = $clock ?? new SystemClock();
+
+ $this->config = [
+ 'table_name' => 'event_store',
+ 'locking' => true,
+ 'lock_id' => self::DEFAULT_LOCK_ID,
+ 'lock_timeout' => -1,
+ 'keep_index' => false,
+ ...$config,
+ ];
+ }
+
+ public function load(
+ Criteria|null $criteria = null,
+ int|null $limit = null,
+ int|null $offset = null,
+ bool $backwards = false,
+ ): Stream {
+ $builder = $this->connection
+ ->table($this->config['table_name'])
+ ->select('*')
+ ->orderBy('id', $backwards ? 'desc' : 'asc');
+
+ $this->applyCriteria($builder, $criteria ?? new Criteria());
+
+ if ($limit !== null) {
+ $builder->limit($limit);
+ }
+
+ if ($offset !== null) {
+ $builder->offset($offset);
+ }
+
+ return new StreamIlluminateStoreStream(
+ $builder->cursor(),
+ $this->eventSerializer,
+ $this->headersSerializer,
+ );
+ }
+
+ public function count(Criteria|null $criteria = null): int
+ {
+ $builder = $this->connection
+ ->table($this->config['table_name']);
+
+ $this->applyCriteria($builder, $criteria ?? new Criteria());
+
+ return $builder->count();
+ }
+
+ public function save(Message ...$messages): void
+ {
+ if ($messages === []) {
+ return;
+ }
+
+ $this->transactional(function () use ($messages): void {
+ $columnsLength = $this->config['keep_index'] ? 9 : 8;
+ $batchSize = (int)floor(self::MAX_UNSIGNED_SMALL_INT / $columnsLength);
+
+ $rows = [];
+ foreach ($messages as $message) {
+ $data = $this->eventSerializer->serialize($message->event());
+
+ try {
+ $streamName = $message->header(StreamNameHeader::class)->streamName;
+ } catch (HeaderNotFound $e) {
+ throw new MissingDataForStorage($e->name, $e);
+ }
+
+ $eventId = $message->hasHeader(EventIdHeader::class)
+ ? $message->header(EventIdHeader::class)->eventId
+ : Uuid::uuid7()->toString();
+
+ $row = [
+ 'stream' => $streamName,
+ 'playhead' => $message->hasHeader(PlayheadHeader::class)
+ ? $message->header(PlayheadHeader::class)->playhead
+ : null,
+ 'event_id' => $eventId,
+ 'event_name' => $data->name,
+ 'event_payload' => $data->payload,
+ 'recorded_on' => $message->hasHeader(RecordedOnHeader::class)
+ ? $message->header(RecordedOnHeader::class)->recordedOn
+ : $this->clock->now(),
+ 'archived' => $message->hasHeader(ArchivedHeader::class),
+ 'custom_headers' => $this->headersSerializer->serialize($this->getCustomHeaders($message)),
+ ];
+
+ if ($this->config['keep_index']) {
+ try {
+ $row['id'] = $message->header(IndexHeader::class)->index;
+ } catch (HeaderNotFound $e) {
+ throw new MissingDataForStorage($e->name, $e);
+ }
+ }
+
+ $rows[] = $row;
+
+ if (count($rows) !== $batchSize) {
+ continue;
+ }
+
+ $this->executeSave($rows);
+ $rows = [];
+ }
+
+ if ($rows !== []) {
+ $this->executeSave($rows);
+ }
+
+ if (!$this->config['keep_index'] || $this->driverName() !== 'pgsql') {
+ return;
+ }
+
+ $this->connection->statement(sprintf(
+ "SELECT setval('%s', (SELECT MAX(id) FROM %s));",
+ sprintf('%s_id_seq', $this->config['table_name']),
+ $this->config['table_name'],
+ ));
+ });
+ }
+
+ public function transactional(Closure $function): void
+ {
+ if ($this->hasLock || !$this->config['locking']) {
+ $this->connection->transaction($function);
+
+ return;
+ }
+
+ $this->connection->transaction(function () use ($function): void {
+ $this->lock();
+
+ try {
+ $function();
+ } finally {
+ $this->unlock();
+ }
+ });
+ }
+
+ /** @return list */
+ public function streams(): array
+ {
+ /** @var list $streams */
+ $streams = $this->connection
+ ->table($this->config['table_name'])
+ ->select('stream')
+ ->distinct()
+ ->orderBy('stream')
+ ->pluck('stream')
+ ->all();
+
+ return $streams;
+ }
+
+ public function remove(Criteria|null $criteria = null): void
+ {
+ $builder = $this->connection->table($this->config['table_name']);
+
+ $this->applyCriteria($builder, $criteria ?? new Criteria());
+
+ $builder->delete();
+ }
+
+ public function archive(Criteria|null $criteria = null): void
+ {
+ $builder = $this->connection->table($this->config['table_name']);
+
+ $this->applyCriteria($builder, $criteria ?? new Criteria());
+
+ $builder->update(['archived' => true]);
+ }
+
+ public function supportSubscription(): bool
+ {
+ return $this->driverName() === 'pgsql'
+ && class_exists(PDO::class)
+ && method_exists($this->connection, 'getPdo');
+ }
+
+ public function setupSubscription(): void
+ {
+ if (!$this->supportSubscription()) {
+ return;
+ }
+
+ $functionName = $this->createTriggerFunctionName();
+
+ $this->connection->statement(sprintf(
+ <<<'SQL'
+ CREATE OR REPLACE FUNCTION %1$s() RETURNS TRIGGER AS $$
+ BEGIN
+ PERFORM pg_notify('%2$s', NEW.stream::text);
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql;
+ SQL,
+ $functionName,
+ $this->config['table_name'],
+ ));
+
+ $this->connection->statement(sprintf(
+ 'DROP TRIGGER IF EXISTS notify_trigger ON %s;',
+ $this->config['table_name'],
+ ));
+
+ $this->connection->statement(sprintf(
+ 'CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON %1$s FOR EACH ROW EXECUTE PROCEDURE %2$s();',
+ $this->config['table_name'],
+ $functionName,
+ ));
+ }
+
+ public function wait(int $timeoutMilliseconds): void
+ {
+ if (!$this->supportSubscription()) {
+ return;
+ }
+
+ $this->connection->statement(sprintf('LISTEN "%s"', $this->config['table_name']));
+
+ if (!$this->connection instanceof Connection) {
+ return;
+ }
+
+ $this->connection->getPdo()->pgsqlGetNotify(PDO::FETCH_ASSOC, $timeoutMilliseconds);
+ }
+
+ /** @return list