Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow for non unique state IDs #144

Merged
merged 14 commits into from
Jul 23, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
{
public function up()
{
// If they've already migrated under the previous migration name, just skip
if (Schema::hasTable($this->tableName())) {
throw new RuntimeException('The create_verbs_* migrations have been renamed. See <https://verbs.thunk.dev/docs/reference/upgrading>');
}

Schema::create($this->tableName(), function (Blueprint $table) {
$table->snowflakeId();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,29 @@
{
public function up()
{
// If they've already migrated under the previous migration name, just skip
if (Schema::hasTable($this->tableName())) {
throw new RuntimeException('The create_verbs_* migrations have been renamed. See <https://verbs.thunk.dev/docs/reference/upgrading>');

return;
}

Schema::create($this->tableName(), function (Blueprint $table) {
// The 'id' column needs to be set up differently depending
// on if you're using Snowflakes vs. ULIDs/etc.
$idColumn = Id::createColumnDefinition($table)->primary();
$table->snowflakeId();

// The 'state_id' column needs to be set up differently depending on
// if you're using Snowflakes vs. ULIDs/etc.
Id::createColumnDefinition($table, 'state_id');

$table->string('type')->index();
$table->json('data');

$table->snowflake('last_event_id')->nullable();

$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();

$table->unique([$idColumn->get('name', 'id'), 'type']);
$table->index(['state_id', 'type']);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
{
public function up()
{
// If they've already migrated under the previous migration name, just skip
if (Schema::hasTable($this->tableName())) {
throw new RuntimeException('The create_verbs_* migrations have been renamed. See <https://verbs.thunk.dev/docs/reference/upgrading>');
}

Schema::create($this->tableName(), function (Blueprint $table) {
$table->snowflakeId();

Expand Down
20 changes: 20 additions & 0 deletions docs/upgrading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## Upgrading to `v0.5.0`

The structure of the `verbs_snapshots` table changed after version `0.4.5` to better account for
non-Snowflake IDs (like ULIDs/etc). This change included:

- Adding a new `id` column that is unique to the **snapshot** (the ID column had previously
been mapped to the **state**, which caused issues if the different states of different types
had the same ID)
- Replaced the existing `id` column with a `state_id` that is not a primary index (allowing
non-unique state IDs)
- Changed the `unique` index on `state_id` and `type` to a regular index to allow for future features
that may let you store multiple snapshots per state
- Added an `expires_at` column to allow for snapshot purging in the future

For more details about the change, please [see the Verbs PR](https://github.com/hirethunk/verbs/pull/144)
that applied these changes.

If you’re having trouble figuring out how to migrate your existing data, please check this page in
a few days, or [ask on Discord](https://discord.gg/hDhZmD6ZC9) — we hope to have a sample migration
ready shortly!
19 changes: 14 additions & 5 deletions src/Lifecycle/MetadataManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
namespace Thunk\Verbs\Lifecycle;

use Illuminate\Support\Collection;
use stdClass;
use Thunk\Verbs\Event;
use Thunk\Verbs\Metadata;
use Thunk\Verbs\State;
use UnexpectedValueException;
use WeakMap;

Expand Down Expand Up @@ -42,15 +44,22 @@ public function getLastResults(Event $event): Collection
return $this->getEphemeral($event, '_last_results', new Collection());
}

public function getEphemeral(Event $event, ?string $key = null, mixed $default = null): mixed
public function getEphemeral(Event|State $target, ?string $key = null, mixed $default = null): mixed
{
return data_get($this->ephemeral[$event] ?? [], $key, $default);
$ephemeral = data_get($this->ephemeral[$target] ?? [], $key, $not_found = new stdClass());

if ($ephemeral === $not_found) {
$this->setEphemeral($target, $key, $default);
$ephemeral = $default;
}
Comment on lines +51 to +54
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the standard behavior of the $default value in one of these getters, but is convenient here. I could see an argument for making this more explicit in the calling code, but it would add a number of new lines of boilerplate…

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is fine


return $ephemeral;
}

public function setEphemeral(Event $event, string $key, mixed $value): static
public function setEphemeral(Event|State $target, string $key, mixed $value): static
{
$this->ephemeral[$event] ??= [];
$this->ephemeral[$event][$key] = $value;
$this->ephemeral[$target] ??= [];
$this->ephemeral[$target][$key] = $value;

return $this;
}
Expand Down
31 changes: 20 additions & 11 deletions src/Lifecycle/SnapshotStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@

class SnapshotStore implements StoresSnapshots
{
public function __construct(
protected MetadataManager $metadata,
protected Serializer $serializer,
) {}

public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type): ?State
{
$snapshot = VerbSnapshot::whereKey(Id::from($id))->whereType($type)->first();
$snapshot = VerbSnapshot::firstWhere([
'state_id' => Id::from($id),
'type' => $type,
]);

return $snapshot?->state();
}
Expand All @@ -41,11 +49,11 @@ public function write(array $states): bool
return true;
}

$values = collect(static::formatForWrite($states))
->unique('id')
->all();

return VerbSnapshot::upsert($values, 'id', ['data', 'last_event_id', 'updated_at']);
return VerbSnapshot::upsert(
values: collect($states)->unique()->map($this->formatForWrite(...))->all(),
uniqueBy: ['id'],
update: ['data', 'last_event_id', 'updated_at']
);
}

public function reset(): bool
Expand All @@ -55,15 +63,16 @@ public function reset(): bool
return true;
}

protected static function formatForWrite(array $states): array
protected function formatForWrite(State $state): array
{
return array_map(fn (State $state) => [
'id' => Id::from($state->id),
return [
'id' => $this->metadata->getEphemeral($state, 'id', snowflake_id()),
'state_id' => Id::from($state->id),
'type' => $state::class,
'data' => app(Serializer::class)->serialize($state),
'data' => $this->serializer->serialize($state),
'last_event_id' => Id::tryFrom($state->last_event_id),
'created_at' => now(),
'updated_at' => now(),
], $states);
];
}
}
18 changes: 5 additions & 13 deletions src/Models/VerbSnapshot.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Thunk\Verbs\Lifecycle\MetadataManager;
use Thunk\Verbs\State;
use Thunk\Verbs\Support\Serializer;
use UnexpectedValueException;

/**
* @property int $id
* @property int|string $state_id
* @property string $data
* @property int|null $last_event_id
* @property CarbonInterface $created_at
Expand All @@ -29,9 +30,11 @@ public function getTable()
public function state(): State
{
$this->state ??= app(Serializer::class)->deserialize($this->type, $this->data);
$this->state->id = $this->id;
$this->state->id = $this->state_id;
$this->state->last_event_id = $this->last_event_id;

app(MetadataManager::class)->setEphemeral($this->state, 'id', $this->id);

return $this->state;
}

Expand All @@ -40,17 +43,6 @@ public function scopeType($query, string $type)
return $query->where('type', $type);
}

public function getKeyType()
{
$id_type = strtolower(config('verbs.id_type', 'snowflake'));

return match ($id_type) {
'snowflake' => 'int',
'ulid', 'uuid' => 'string',
'default' => throw new UnexpectedValueException("Unknown Verbs ID type: '{$id_type}'"),
};
}

public function getIncrementing()
{
return false;
Expand Down
13 changes: 6 additions & 7 deletions src/VerbsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,6 @@ public function configurePackage(Package $package): void
MakeVerbEventCommand::class,
MakeVerbStateCommand::class,
ReplayCommand::class,
)
->hasMigrations(
'create_verb_events_table',
'create_verb_snapshots_table',
'create_verb_state_events_table',
);
}

Expand Down Expand Up @@ -141,9 +136,13 @@ classDiscriminatorResolver: new ClassDiscriminatorFromClassMetadata(new ClassMet
$this->app->alias(SnapshotStore::class, StoresSnapshots::class);
}

public function boot()
public function packageBooted()
{
parent::boot();
$this->publishes([
__DIR__.'/../database/migrations/' => database_path('migrations'),
], "{$this->package->shortName()}-migrations");

$this->loadMigrationsFrom(__DIR__.'/../database/migrations');

if ($this->app->has('livewire')) {
$manager = $this->app->make('livewire');
Expand Down
54 changes: 54 additions & 0 deletions tests/Feature/DatabaseSnapshotTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

use Thunk\Verbs\Attributes\Autodiscovery\StateId;
use Thunk\Verbs\Event;
use Thunk\Verbs\Facades\Verbs;
use Thunk\Verbs\Models\VerbSnapshot;
use Thunk\Verbs\State;

it('only stores one snapshot per state', function () {
$state1_id = snowflake_id();
$state2_id = snowflake_id();

DatabaseSnapshotTestEvent::fire(message: 'state 1, message 1', sid: $state1_id);
DatabaseSnapshotTestEvent::fire(message: 'state 1, message 2', sid: $state1_id);
DatabaseSnapshotTestEvent::fire(message: 'state 2, message 1', sid: $state2_id);
DatabaseSnapshotTestEvent::fire(message: 'state 2, message 2', sid: $state2_id);

Verbs::commit();

$snapshots = VerbSnapshot::all();

expect($snapshots)->toHaveCount(2)
->and($snapshots->firstWhere('state_id', $state1_id)->state()->last_message)->toBe('state 1, message 2')
->and($snapshots->firstWhere('state_id', $state2_id)->state()->last_message)->toBe('state 2, message 2');

DatabaseSnapshotTestEvent::fire(message: 'state 1, message 3', sid: $state1_id);
DatabaseSnapshotTestEvent::fire(message: 'state 2, message 3', sid: $state2_id);

Verbs::commit();

$snapshots = VerbSnapshot::all();

expect($snapshots)->toHaveCount(2)
->and($snapshots->firstWhere('state_id', $state1_id)->state()->last_message)->toBe('state 1, message 3')
->and($snapshots->firstWhere('state_id', $state2_id)->state()->last_message)->toBe('state 2, message 3');
});

class DatabaseSnapshotTestEvent extends Event
{
public function __construct(
public string $message,
#[StateId(DatabaseSnapshotTestState::class)] public ?int $sid,
) {}

public function apply(DatabaseSnapshotTestState $state)
{
$state->last_message = $this->message;
}
}

class DatabaseSnapshotTestState extends State
{
public string $last_message = '';
}
40 changes: 40 additions & 0 deletions tests/Unit/SnapshotStoreTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

use Glhd\Bits\Bits;
use Ramsey\Uuid\UuidInterface;
use Symfony\Component\Uid\AbstractUid;
use Thunk\Verbs\Contracts\StoresSnapshots;
use Thunk\Verbs\Models\VerbSnapshot;
use Thunk\Verbs\State;

it('can store multiple different states with the same ID', function () {
$store = app(StoresSnapshots::class);

$state1 = new SnapshotStoreTestStateOne(1, 'state one');
$state2 = new SnapshotStoreTestStateTwo(1, 'state two');

$store->write([$state1, $state2]);

$snapshot1 = VerbSnapshot::where(['type' => SnapshotStoreTestStateOne::class, 'state_id' => 1])->sole();
$snapshot2 = VerbSnapshot::where(['type' => SnapshotStoreTestStateTwo::class, 'state_id' => 1])->sole();

expect($snapshot1)->state()->name->toBe('state one')
->and($snapshot2)->state()->name->toBe('state two')
->and($snapshot1)->id->not()->toBe($snapshot2->id);
});

class SnapshotStoreTestStateOne extends State
{
public function __construct(
public Bits|UuidInterface|AbstractUid|int|string|null $id,
public string $name,
) {}
}

class SnapshotStoreTestStateTwo extends State
{
public function __construct(
public Bits|UuidInterface|AbstractUid|int|string|null $id,
public string $name,
) {}
}