Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ equivalent for and never will.
[CLAUDE.md](CLAUDE.md) has the architecture and the SQLite traps. This document is the compliance
scoreboard and the things that are true right now but not obvious from either.

**2986 tests green on net9.0 and net10.0** — 2915 in `Fisher.Tests`, 40 in
**2990 tests green on net9.0 and net10.0** — 2919 in `Fisher.Tests`, 40 in
`Fisher.AspNetCore.Tests` and 31 in `Fisher.EntityFrameworkCore.Tests`. 779 of
them are shared cross-store compliance tests — 548 event sourcing and 231 document.
On JasperFx **2.98.0** / Weasel **9.48.0**.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ process**. There is no server to install, nothing to provision, and nothing to k
> `Fisher.AspNetCore` and `Fisher.EntityFrameworkCore` companion packages all work and are tested.
>
> Fisher passes **all 63 suites and 779 tests** it enrolls from `JasperFx.Events.ComplianceTests`,
> the shared cross-store suite Marten and Polecat also enroll in, alongside its own 2,915.
> the shared cross-store suite Marten and Polecat also enroll in, alongside its own 2,919.
>
> That suite pins **API portability, not behavioural equivalence** — code written against one store
> compiles and runs against another. It does not pin that the three behave identically, and they do
Expand Down
55 changes: 55 additions & 0 deletions src/Fisher.Tests/Events/natural_keys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ private DocumentStore StoreFor(bool conjoined)
{
o.DatabaseSchemaName = "tenanted";
o.Events.TenancyStyle = TenancyStyle.Conjoined;
o.Schema.For<Order>().MultiTenanted();
}

o.Projections.Add(new OrderProjection(), ProjectionLifecycle.Inline);
Expand Down Expand Up @@ -201,6 +202,60 @@ await Should.ThrowAsync<UnknownNaturalKeyException>(() =>
check.Events.FetchForWritingByNaturalKey<Order, string>("ORD-ARCHIVED", Token));
}

/// <summary>
/// fisher#522: the lookup has no foreign key, so a tombstone has to remove the row itself or the
/// key can never be claimed again.
/// </summary>
[Fact]
public async Task a_tombstoned_streams_natural_key_can_be_claimed_by_a_new_stream()
{
var streamId = await StartOrder("ORD-TOMB");

await using (var session = _store.LightweightSession())
{
session.Events.TombstoneStream(streamId);
await session.SaveChangesAsync(Token);
}

var newStream = await StartOrder("ORD-TOMB");

await using var check = _store.LightweightSession();
(await check.Events.FetchForWritingByNaturalKey<Order, string>("ORD-TOMB", Token))
.Id.ShouldBe(newStream);
}

[Fact]
public async Task under_conjoined_tenancy_only_the_tombstoned_tenants_key_goes()
{
await using var store = StoreFor(conjoined: true);
await store.ApplyAllConfiguredChangesToDatabaseAsync(Token);

var streamId = Guid.NewGuid();
foreach (var tenant in new[] { "a", "b" })
{
await using var session = store.LightweightSession(tenant);
session.Events.StartStream<Order>(tenant == "a" ? streamId : Guid.NewGuid(), new OrderPlaced("ORD-SHARED"));
await session.SaveChangesAsync(Token);
}

await using (var session = store.LightweightSession("a"))
{
session.Events.TombstoneStream(streamId);
await session.SaveChangesAsync(Token);
}

// Tenant b keeps its key
await using (var b = store.LightweightSession("b"))
{
await b.Events.FetchForWritingByNaturalKey<Order, string>("ORD-SHARED", Token);
}

// Tenant a can claim it again
var again = await StartOrder("ORD-SHARED", store, "a");
await using var a = store.LightweightSession("a");
(await a.Events.FetchForWritingByNaturalKey<Order, string>("ORD-SHARED", Token)).Id.ShouldBe(again);
}

[Fact]
public async Task an_unknown_key_is_refused_by_name()
{
Expand Down
5 changes: 5 additions & 0 deletions src/Fisher/Events/EventGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ internal string QuotedNaturalKeyTableName(Type aggregateType)
private static string NaturalKeySuffixFor(Type aggregateType)
=> $"natural_key_{ToEventTypeName(aggregateType.Name)}";

/// <summary>
/// The natural keys the registered aggregate projections declare.
/// </summary>
internal IReadOnlyList<NaturalKeyDefinition> NaturalKeyDefinitions() => _options.Projections.NaturalKeys;

internal IEnumerable<NaturalKeyTable> BuildNaturalKeyTables(
IEnumerable<NaturalKeyDefinition> definitions)
=> definitions.Select(definition => new NaturalKeyTable(this, definition));
Expand Down
16 changes: 16 additions & 0 deletions src/Fisher/Events/Storage/EventAuxiliaryOperationsForSqlite.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ select seq_id from {_events.EventsTableName}
""");
}

// The stream's natural key goes with it (fisher#522): the lookup table has no foreign key, so
// nothing else would remove the row, and a key that outlived its stream could never be claimed
// again. Scoped to the tenant under conjoined tenancy, where other tenants keep theirs.
var streamColumn = _events.StreamIdentity == JasperFx.Events.StreamIdentity.AsGuid ? "stream_id" : "stream_key";
var tenantTerm = _events.TenancyStyle == JasperFx.MultiTenancy.TenancyStyle.Conjoined
? " and tenant_id = @tenant_id"
: "";

foreach (var definition in _events.NaturalKeyDefinitions())
{
builder.Append($"""
delete from {_events.QuotedNaturalKeyTableName(definition.AggregateType)}
where {streamColumn} = @id{tenantTerm};
""");
}

builder.Append($"""
delete from {_events.EventsTableName}
where stream_id = @id and tenant_id = @tenant_id;
Expand Down
Loading