Skip to content

Latest commit

 

History

History
155 lines (122 loc) · 15.7 KB

AGENTS.md

File metadata and controls

155 lines (122 loc) · 15.7 KB

AGENTS.md

This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.

Project Overview

Alberto is a DCB (Dynamic Consistency Boundary) Event Store for .NET 10.0. The repository is a monorepo containing packable core libraries (/src), example applications (/apps), an operator CLI (/tools), and tests (/tests).

There is no frontend in this repository. The event store is a library plus a terminal CLI.

Build & Run Commands

Full Stack (Recommended)

dotnet run --project apps/Alberto.AppHost

Aspire starts PostgreSQL, runs the Orders migrations, then starts the Orders API. K6 load tests are registered as a manually-triggered resource in the Aspire dashboard.

.NET Backend

dotnet build                    # Build all projects
dotnet test                     # Run all xUnit tests
dotnet test --filter "FullyQualifiedName~TestName"  # Run single test

Postgres-backed tests use Testcontainers and need a running Docker daemon.

Load Tests

cd tests/Alberto.Orders.LoadTests
npm install && npm run build
npm run test:smoke              # Quick validation
npm run test:load               # Full load test

Operator CLI

dotnet run --project tools/Alberto.Cli -- status

Benchmarks

dotnet run -c Release --project benchmarks/Alberto.Benchmarks -- --filter '*Append*'

Architecture

Directory Structure

/src/                           # Core libraries: 10 packable, 3 not
  Alberto/                      # Event store abstractions, control loop, middleware
  Alberto.Commands/             # Command handling
  Alberto.Commands.Analyzers/   # Roslyn analyzers for the command pipeline  (not packable)
  Alberto.InMemory/             # In-memory backend (dev/test)
  Alberto.Postgres/             # PostgreSQL backend, migrations
  Alberto.EntityFramework/      # EF-backed projections
  Alberto.Messaging/            # Transactional outbox abstractions
  Alberto.Messaging.Postgres/   # PostgreSQL outbox store
  Alberto.Telemetry/            # OpenTelemetry instrumentation
  Alberto.Testing/              # In-memory test helpers for consumers
  Alberto.Testing.Xunit/        # xUnit v3 fixtures and collection definitions
  Alberto.Admin/                # IAdminReader/IAdminOperator: parked      (not packable)
  Alberto.Admin.Postgres/       # Its only implementation: parked          (not packable)

/apps/                          # Example applications
  Alberto.AppHost/              # Aspire orchestration
  Alberto.Examples.Shared/      # MutationResult and the Result→GraphQL error mapping
  ServiceDefaults/              # Shared Aspire service configuration
  Alberto.Orders/               # Orders example
    Alberto.Orders/             # The module: Contracts/, Features/ (one folder per slice), Platform/
    Alberto.Orders.Api/         # Host: Program.cs, tenant interceptor
    Alberto.Orders.Migrations/  # EF migrations runner
  Alberto.Payments/             # Payments example
    Alberto.Payments/           # The module, same shape as Orders

/tools/
  Alberto.Cli/                  # Operator CLI (Spectre.Console + System.CommandLine)

/tests/
  Alberto.Tests/                # Unit + Testcontainers integration tests (xUnit 3)
  Alberto.Tests.SampleEvents/   # Sample [EventType] records in a separate assembly, for
                                #   assembly-scanning tests
  Alberto.AotSmoke/             # Console app published with PublishAot in CI and run against
                                #   Postgres; exits non-zero on failure (see "Native AOT" below)
  Alberto.Examples.Tests/       # Tests over the Orders/Payments examples, incl. GraphQL
                                #   schema snapshots
  Alberto.Orders.LoadTests/     # K6 load tests (TypeScript)

/benchmarks/
  Alberto.Benchmarks/           # BenchmarkDotNet append/read/checkpoint benchmarks
  Alberto.Benchmarks.Core/      # Shared harness: event plans, BDN import, result comparison
  Alberto.Benchmarks.Compare/   # CLI that diffs two benchmark runs and renders a report

Note: apps/Alberto.Payments is in the solution and builds, but it has no host of its own and is not orchestrated by the AppHost. Its slices are registered by the Orders API host, which serves their GraphQL fields alongside Orders'.

Key Patterns

  • Event Sourcing with DCB: Append-only event log with dynamic consistency boundaries
  • Vertical slices in the examples: apps/Alberto.Orders and apps/Alberto.Payments are sliced by behaviour, not layer. One folder per slice under Features/, holding that slice's input type, state record, evolver, decision function, boundary and GraphQL operation. Slices share the event log and nothing else, no shared state record, no shared evolver, no base state. Contracts/ (events, status enums, problem codes, tag keys) and Platform/ (DI, DbContext, EF migrations) are the two deliberate exceptions, named so they cannot be mistaken for domain code that happens to be shared. Five slices fold OrderCreated, each projecting a different part of it; that duplication is the pattern working. See docs/architecture/vertical-slices.md
  • Async Processing: ControlLoop polls the event log and dispatches through a middleware chain to projections/reactors. See docs/architecture/async-processing.md
  • Middleware: MiddlewareRunner builds both the single-event (ConsumeEventContext) and batch (BatchConsumeContext) chains. Retry/dead-letter logic is shared via RetryAndDeadLetterCore behind IMiddlewareContext
  • Zero-downtime projection rebuilds: opt in with .WithRebuilds(). RebuildCoordinator replays the log into a shadow copy of a projection's state under its own checkpoint, then swaps versions in one transaction. Driven by alberto ops rebuild start|status|promote|abort
  • Multi-Tenant: X-Tenant-Id header propagation, tenant-isolated queries, tenant leases
  • Store imprint: single-tenant and multi-tenant are two disjoint migration sets sharing one journal, so .WithTenancy() cannot be added to or removed from a store that already has data. There is no bridging script and no tenant_id backfill. PostgresMigrator.Migrate refuses the wrong set before running anything, throwing AlbertoStoreMismatchException (ALB0021). The mode is read from alberto_store_imprint (a table the migrator creates itself, since the check that must precede every script cannot depend on a script), falling back to sniffing alberto_events for a tenant_id column, which covers both stores predating the imprint and stores left half-migrated. A store with no alberto_events is fresh and may become either mode. Pointing a module at an empty database is deliberately not covered: nothing contradicts, so it migrates cleanly and serves an empty store
  • Projection tenancy: a state store's tenancy is fixed when the store is built and decided by the schema, not by the caller: a module that declared .WithTenancy() is migrated with tenant_id NOT NULL in its primary key, so a store built without one fails every write with 42P10, and the reverse mismatch fails with 42703. AddProjection therefore takes a Func<string?, IStateStore<TState>>: the projection builds one store per tenant and routes each event to the store for the tenant it carries. A cross-tenant aggregate on a tenant-enabled module stores its single blended document under TenantScope.CrossTenant ("*"), which readers pass too: resolvers resolve the writer's own factory from DI ({moduleKey}:{processorId}) so the only thing they decide is which tenant to read
  • Tenant sharding (opt-in, not the default): a module's tenants can be spread over several PostgreSQL databases with .WithTenancy(t => t.AcrossPostgresDatabases(...)), row-level tenancy still applying inside each one. Each shard is a complete module registered under the DI key {moduleKey}#{shardId} (composed only by ShardKey); ShardRoutingEventStore picks the database per call from TenantShardResolver, which reads the alberto_tenant_shards catalog in a separate control database. Positions are per database. Never compare one shard's with another's. See docs/architecture/tenant-sharding.md
  • Discarding a rebuild version: neither promotion nor abort deletes the version it discards. A reader resolves the active version and then queries it, so a flip that deleted the superseded rows would strand any reader holding the old number between those two steps. Instead the transition only makes the version unreachable; RebuildCoordinator's sweep reclaims it via IProjectionRebuildCoordinatorStore.DiscardStateVersionAsync once ReclaimGracePeriod (2× ProjectionVersions.RefreshInterval) has elapsed since CompletedAt. Abort gets the same treatment for a second reason: a shadow loop only learns of the abort on its next poll, so its last writes land after the transition and the sweep is what actually removes them
  • Leases and fencing: checkpoint writes can be fenced against a held lease via IFencedCheckpointStore
  • Transactional outbox: IOutboxStore with pending → processing → delivered/failed, claimed via FOR UPDATE SKIP LOCKED under a claim lease (claim_id, claimed_by, claim_expires_at). A relay that dies mid-delivery does not strand its row: ClaimPendingAsync re-claims any processing entry whose claim_expires_at has passed or was never set. RetryFailedAsync is a separate operator action and matches failed only
  • Message transports are pluggable, and Alberto ships none: IMessageTransport is a three-method adapter seam. Alberto guarantees the message is durable and handed over at least once; everything past PublishAsync belongs to the application. No broker binding ships, for two reasons: a binding puts a broker client version in the release matrix for the sake of an adapter that is about twenty lines of code (a worked one lives in tests/Alberto.Tests.Messaging.Rebus), and it picks a winner among buses Alberto has no basis to prefer. Rebus, MassTransit, Wolverine and a hand-rolled client are indistinguishable from where Alberto sits, and InMemoryTransport is the only implementation in the repo. There is also no inbound path, by design: receiving belongs to a bus, whose handler turns a message into a command that appends events. That is a scope boundary, not a gap: Alberto is an event store, not a messaging framework. Postgres-as-broker is reached by giving your bus a Postgres transport, which is why Alberto.Messaging.Postgres is outbox-only. See docs/architecture/message-transports.md
  • GraphQL (Orders example only): HotChocolate 15.x
  • Native AOT and trimming: Alberto, Alberto.Commands, Alberto.Postgres, Alberto.Messaging, Alberto.Messaging.Postgres and Alberto.Telemetry set IsAotCompatible (and EnableConfigurationBindingGenerator), so an IL2xxx/IL3xxx warning fails their build. Every event-type fact (id, version, tags, JSON contract) is read through an IEventTypeRegistry: WithEventsFrom(assembly) builds one by reflection and is annotated [RequiresUnreferencedCode]/[RequiresDynamicCode]; WithEvents(registry) takes one built with EventTypeRegistry.CreateBuilder() and JsonTypeInfo from the app's own JsonSerializerContext, and is the AOT path. Any other API that serializes a caller's type by reflection is annotated the same way and has a JsonTypeInfo<T> overload (PostgresStateStore<TState>, messaging Map). The reflection-parity test in EventTypeRegistryTests and tests/Alberto.AotSmoke (the aot-smoke CI job) are what keep the two paths equal. The builder's typed overloads (Add(ctx.Default.X, e => [new("order", e.OrderId)])) take the tag extractor without a cast; because a hand-written extractor restates the [Tag] attributes, EventTypeRegistryVerifier.Verify(registry) in Alberto.Testing checks the two agree (plus id and version against [EventType]) and belongs in one test per shipped registry. Do not "fix" a new analyzer warning with a suppression unless the justification is a real guarantee, as in ReflectionJson (feature switch) or AlbertoConfigurationScanner (type-level annotation)

Admin surface

The operator surface is the CLI in tools/Alberto.Cli. There is no admin HTTP API. src/Alberto.Admin holds the IAdminReader/IAdminOperator abstraction the CLI's 14 command files are built on (it serves no endpoint) and AddAlbertoPostgresAdmin in src/Alberto.Admin.Postgres is its only implementation.

The whole admin surface is parked, not missing, and that includes its two projects. A GraphQL admin API, an MCP server, a React console and a BFF live on feature/admin-surface, held out of 1.0 so their field and tool names are not frozen by semver. Alberto.Admin and Alberto.Admin.Postgres are both IsPackable=false for the same reason: shipping IAdminReader/IAdminOperator at 1.0 would freeze the abstraction under semver before the things that consume it exist. They build, they are in the solution, they are tested, and the CLI references them by project. They just do not go to nuget.org. Unparking is IsPackable=true plus capturing PublicAPI.Shipped.txt (the analyzer is gated on IsPackable, so it is inert until then).

Alberto.Admin.Postgres exists only because Alberto.Postgres is packable. PostgresAdminDataAccess, PostgresAdminOperator and PostgresAdminServiceCollectionExtensions used to live there, which made its nupkg carry an unresolvable Alberto.Admin dependency and 33 public members returning parked types. The three files keep namespace Alberto.Postgres so no consumer's usings changed, and they reach back for internals (SchemaQualifier) via InternalsVisibleTo.

Do not rebuild the front doors on main. Extend that branch. Keep IAdminReader/IAdminOperator additive when changing them here, or the branch stops merging cleanly.

  • Per-processor mutations go through the core interfaces: ICheckpointStore (SaveAsync, ResetAsync, RewindAsync) and IDeadLetterStore (CountAsync, ClearAsync, MarkForRetryAsync).
  • PostgresAdminDataAccess (src/Alberto.Admin.Postgres) holds the inspection queries and the composite transactional mutations (RetryByRewindAsync, ReleaseTenantLeasesAsync) that span multiple tables and so cannot be composed from per-processor interfaces.
  • SaveAsync is monotonic by design (GREATEST). RewindAsync is the deliberate escape hatch for operator-initiated rewinds and is the only way to move a checkpoint backwards.
  • Sharded modules: ShardResolver turns --shard/--all-shards plus .alberto/config.json into the databases a command runs against. Reads fan out by default; mutations refuse without a selection. alberto shards list|where|assign manages the catalog. Shard connection strings live in config, never in the catalog table.

Technology Stack

Layer Technology
Framework .NET 10.0, ASP.NET Core
GraphQL (Orders example) HotChocolate 15.1.15
Database PostgreSQL (Npgsql 10.0.2)
Migrations DbUp-PostgreSQL 7.0.1 (event store), EF Core 10.0.7 (Orders)
Observability OpenTelemetry 1.15.3
Testing xUnit v3 3.2.2, FluentAssertions 8.9.0, Testcontainers 4.11.0
CLI Spectre.Console 0.55.2, System.CommandLine 2.0.7
Load Testing K6 with TypeScript

Package Management

  • NuGet: Centralized versions in Directory.Packages.props
  • npm: Per-project package.json files

Configuration

  • Solution file: Alberto.slnx (modern .NET format)
  • Build settings: Directory.Build.props

Known Gaps

Documented so they are not mistaken for working features:

(None currently. The two rebuild-window gaps that were listed here are closed. See "Discarding a rebuild version" above.)