diff --git a/HANDOFF.md b/HANDOFF.md index 3a2efa0..de14d65 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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**. diff --git a/README.md b/README.md index 4a7928d..5277ba4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Fisher.Tests/Events/natural_keys.cs b/src/Fisher.Tests/Events/natural_keys.cs index 19c0bb6..9e744d2 100644 --- a/src/Fisher.Tests/Events/natural_keys.cs +++ b/src/Fisher.Tests/Events/natural_keys.cs @@ -45,6 +45,7 @@ private DocumentStore StoreFor(bool conjoined) { o.DatabaseSchemaName = "tenanted"; o.Events.TenancyStyle = TenancyStyle.Conjoined; + o.Schema.For().MultiTenanted(); } o.Projections.Add(new OrderProjection(), ProjectionLifecycle.Inline); @@ -201,6 +202,60 @@ await Should.ThrowAsync(() => check.Events.FetchForWritingByNaturalKey("ORD-ARCHIVED", Token)); } + /// + /// 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. + /// + [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("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(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("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("ORD-SHARED", Token)).Id.ShouldBe(again); + } + [Fact] public async Task an_unknown_key_is_refused_by_name() { diff --git a/src/Fisher/Events/EventGraph.cs b/src/Fisher/Events/EventGraph.cs index 6b994dc..a1e9997 100644 --- a/src/Fisher/Events/EventGraph.cs +++ b/src/Fisher/Events/EventGraph.cs @@ -128,6 +128,11 @@ internal string QuotedNaturalKeyTableName(Type aggregateType) private static string NaturalKeySuffixFor(Type aggregateType) => $"natural_key_{ToEventTypeName(aggregateType.Name)}"; + /// + /// The natural keys the registered aggregate projections declare. + /// + internal IReadOnlyList NaturalKeyDefinitions() => _options.Projections.NaturalKeys; + internal IEnumerable BuildNaturalKeyTables( IEnumerable definitions) => definitions.Select(definition => new NaturalKeyTable(this, definition)); diff --git a/src/Fisher/Events/Storage/EventAuxiliaryOperationsForSqlite.cs b/src/Fisher/Events/Storage/EventAuxiliaryOperationsForSqlite.cs index 352ea75..c5871fd 100644 --- a/src/Fisher/Events/Storage/EventAuxiliaryOperationsForSqlite.cs +++ b/src/Fisher/Events/Storage/EventAuxiliaryOperationsForSqlite.cs @@ -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;