test(event-subscriber): the projection is sourced from register records (refs #153)

Ports, schema and failing tests for the subscriber half of S-19b-2, ahead of the
implementation.

The subscriber now listens on the `objecten` kanaal instead of `zaken`. An Objecten
notification carries no record data — only the object URL — so the record is read back
through the ACL (§8.1), and the zaak-shaped surface goes away: IsZaakCreated /
IsZaakStatusSet / ZaakUrl / ZaakId and ToEntry's `Resource == "status"` mapping are replaced
by IsRegisterRecordWritten + ObjectUrl.

The notification log now holds the projected row itself (register id, status, reference),
so a rebuild is a replay with no mapping rules and no upstream reads. The migration drops
the old columns rather than renaming them — EF scaffolded renames that would have carried
ZGW values into columns meaning something else — and empties both tables, since a
pre-slice row is neither reprojectable nor re-derivable from the new source.

Red: HandleAsync recognises a register write but does not yet read or project it, so the
seven projection assertions fail on an empty store.
This commit is contained in:
not
2026-08-28 12:32:15 +02:00
parent 566ef7dd64
commit 142ed454aa
12 changed files with 336 additions and 184 deletions
@@ -13,9 +13,8 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
db.ProcessedNotifications.Add(new ProcessedNotificationRow
{
Key = notification.Key,
Actie = notification.Actie,
ZaakId = notification.ZaakId,
Resource = notification.Resource,
RegisterId = notification.RegisterId,
Status = notification.Status,
Reference = notification.Reference,
ReceivedAt = DateTimeOffset.UtcNow,
});
@@ -36,6 +35,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
=> await db.ProcessedNotifications
.OrderBy(r => r.ReceivedAt)
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource, r.Reference))
.Select(r => new RecordedNotification(r.Key, r.RegisterId, r.Status, r.Reference))
.ToListAsync(ct);
}
@@ -0,0 +1,87 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Projection.ReadModel;
#nullable disable
namespace Projection.ReadModel.Migrations
{
[DbContext(typeof(ProjectionDbContext))]
[Migration("20260828103132_ProjectionSourcedFromObjecten")]
partial class ProjectionSourcedFromObjecten
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b =>
{
b.Property<string>("Key")
.HasColumnType("text")
.HasColumnName("key");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("RegisterId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("register_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Key");
b.ToTable("processed_notifications", (string)null);
});
modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("Bsn")
.HasColumnType("text")
.HasColumnName("bsn");
b.Property<string>("NaamPlaceholder")
.HasColumnType("text")
.HasColumnName("naam_placeholder");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id");
b.ToTable("register_projection", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Projection.ReadModel.Migrations
{
/// <summary>
/// S-19b-2 (ADR-0030): the notification log stops describing ZGW zaak events and starts holding
/// the projected register row itself (register id, status, reference).
/// </summary>
/// <remarks>
/// The old columns are dropped and the new ones added rather than renamed. EF scaffolded renames
/// (<c>resource</c> → <c>register_id</c>, <c>zaak_id</c> → <c>status</c>), which would carry ZGW
/// values into columns that mean something else entirely — "zaak"/"status" as a register id, a
/// zaak uuid as a register status — and a rebuild would then project that garbage.
///
/// Both tables are emptied instead. A pre-existing row describes a zaak event the new projector
/// cannot reproject, and the registrations behind those rows have no RegisterRecord in Objecten
/// (only approvals wrote one before this slice), so they are not re-derivable from the new source
/// either. The projection is a derived artefact (§8.4) and repopulates as register writes arrive.
/// </remarks>
public partial class ProjectionSourcedFromObjecten : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ponytail: drops the pre-slice register rather than backfilling it. Fine while stacks are
// ephemeral (a fresh `docker compose up` is the norm). If a long-lived environment ever
// needs to keep them, backfill by walking Objecten's objects instead of replaying the log.
migrationBuilder.Sql("DELETE FROM processed_notifications;");
migrationBuilder.Sql("DELETE FROM register_projection;");
migrationBuilder.DropColumn(name: "actie", table: "processed_notifications");
migrationBuilder.DropColumn(name: "zaak_id", table: "processed_notifications");
migrationBuilder.DropColumn(name: "resource", table: "processed_notifications");
migrationBuilder.AddColumn<string>(
name: "register_id",
table: "processed_notifications",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "status",
table: "processed_notifications",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM processed_notifications;");
migrationBuilder.Sql("DELETE FROM register_projection;");
migrationBuilder.DropColumn(name: "register_id", table: "processed_notifications");
migrationBuilder.DropColumn(name: "status", table: "processed_notifications");
migrationBuilder.AddColumn<string>(
name: "actie", table: "processed_notifications", type: "text", nullable: false, defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "zaak_id", table: "processed_notifications", type: "text", nullable: false, defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "resource", table: "processed_notifications", type: "text", nullable: false, defaultValue: "");
}
}
}
@@ -28,11 +28,6 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("text")
.HasColumnName("key");
b.Property<string>("Actie")
.IsRequired()
.HasColumnType("text")
.HasColumnName("actie");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
@@ -41,15 +36,15 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Resource")
b.Property<string>("RegisterId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("resource");
.HasColumnName("register_id");
b.Property<string>("ZaakId")
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("zaak_id");
.HasColumnName("status");
b.HasKey("Key");
@@ -34,9 +34,8 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
e.ToTable("processed_notifications");
e.HasKey(r => r.Key);
e.Property(r => r.Key).HasColumnName("key");
e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
e.Property(r => r.Resource).HasColumnName("resource").IsRequired();
e.Property(r => r.RegisterId).HasColumnName("register_id").IsRequired();
e.Property(r => r.Status).HasColumnName("status").IsRequired();
e.Property(r => r.Reference).HasColumnName("reference");
e.Property(r => r.ReceivedAt).HasColumnName("received_at");
});
@@ -56,18 +55,20 @@ public sealed class RegisterEntryRow
public string? NaamPlaceholder { get; set; }
}
/// <summary>An accepted notification, retained so the projection can be rebuilt without OpenZaak (§8.1).</summary>
/// <summary>An accepted notification, retained so the projection can be rebuilt without reading
/// Objecten or ZGW (§8.1, §8.4). Since S-19b-2 it holds the projected row itself — the register
/// record's id, status and reference — so a rebuild is a replay with no mapping rules (ADR-0030).</summary>
public sealed class ProcessedNotificationRow
{
public required string Key { get; set; }
public required string Actie { get; set; }
public required string ZaakId { get; set; }
/// <summary>The ZGW resource (e.g. <c>zaak</c> or <c>status</c>) — retained so a rebuild reprojects
/// the right status without reading OpenZaak (S-09b).</summary>
public required string Resource { get; set; }
/// <summary>The registration this record is for (the zaak id) — the projection row's key.</summary>
public required string RegisterId { get; set; }
/// <summary>The zaak reference (identificatie), retained so a rebuild reprojects it without the ACL (#78).</summary>
/// <summary>The register status the record carried (INGEDIEND / INGESCHREVEN).</summary>
public required string Status { get; set; }
/// <summary>The citizen-facing reference the record carried — matches the submit confirmation (#78).</summary>
public string? Reference { get; set; }
public DateTimeOffset ReceivedAt { get; set; }