Skip to content

Commit f420cbe

Browse files
committed
feat(WP-4.1): dotnet sourceflow CLI
- System.CommandLine command tree (SourceFlowCli): db migrate|script| index sync, dlq list|show|replay|discard|purge, projection skip| repartition|rebuild|drop, topology export|validate, query (--sql, --param, --validate, --explain, --format table|json|csv) and schema export|check. - Store commands run from the global tool against PostgreSQL (--connection/--schema, replay through the outbox); model commands run inside the service via SourceFlowCli.TryRunAsync (ADR-031). The Bank sample hosts it (dotnet run -- sourceflow ...). - Projection admin takes the partition leases and refuses while a node runs them; IDocumentAdmin drops a generation's documents in every tenant (in-memory, PostgreSQL via the system connection). - Tests UT-CLI-01..30 (in-memory) and E2E-25..28 (PostgreSQL).
1 parent e5806f9 commit f420cbe

20 files changed

Lines changed: 1619 additions & 13 deletions

File tree

‎Directory.Packages.props‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.15" />
1212
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.0.0" />
1313
<PackageVersion Include="Microsoft.Extensions.Compliance.Redaction" Version="10.0.0" />
14+
<PackageVersion Include="System.CommandLine" Version="2.0.0" />
1415
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.15" />
1516
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.15" />
1617
<PackageVersion Include="System.IO.Hashing" Version="10.0.0" />
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# ADR-031: The CLI runs store commands as a global tool and model commands inside the service
2+
3+
| | |
4+
|---|---|
5+
| **Status** | Accepted |
6+
| **Date** | 2026-11-11 |
7+
| **Deciders** | v3 architecture review; WP-4.1 implementation |
8+
| **Amends** | WBS WP-4.1; [doc 06](../06-Projections-and-Subscriptions.md) §6 (`projection rebuild`) |
9+
10+
## Context
11+
12+
`dotnet sourceflow` has two kinds of command:
13+
14+
- **Store commands** need only the database. These are `db migrate|script`, `dlq list|show|replay|discard|purge` and `projection skip|repartition|rebuild|drop`.
15+
- **Model commands** need the service's model. This means its modules, JSON contracts, routing and projections. These are `db index sync`, `query`, `schema check|export` and `topology export|validate`.
16+
17+
A global tool can't see a service's model without loading the service's assembly by reflection. That would mean finding its modules, guessing its routing (which lives in `Program.cs`, not in modules), and matching its dependency versions. It would be fragile, and it would bypass the source-generated, AOT-safe model (ADR-004).
18+
19+
## Decision
20+
21+
1. **One command tree, two hosts.** `SourceFlow.Tool` holds the commands in System.CommandLine (`SourceFlowCli`).
22+
2. **The global tool** (`dotnet sourceflow …`) runs store commands against PostgreSQL. It takes `--connection` (or `SOURCEFLOW_CONNECTION`), `--schema` and, for row-level security, `--system-connection`. Model commands explain how to run them inside the service.
23+
3. **Inside a service**, `Program.cs` calls `if (await SourceFlowCli.TryRunAsync(args, app.Services) is { } code) return code;` before `app.Run()`. Every command then works with the service's own storage, model, routing and query service: `dotnet run --project <service> -- sourceflow schema check --baseline schemas.lock.json`. The Bank sample does this.
24+
4. **Without a transport, the global tool replays dead letters through the outbox.** It enqueues the original envelope, with the same message id, so the inbox still protects. The running service's relay then sends it.
25+
5. **Projection admin needs the leases.** `projection skip`, `repartition` and `rebuild` take the partitions' leases as `sourceflow-cli`. They refuse while a running node holds them, so subscriptions must be stopped first; `skip` runs on the node that holds the lease.
26+
6. **`projection rebuild <name@version>` rebuilds in place.** It drops the generation's documents in every tenant (`IDocumentAdmin`) and resets its checkpoints, and the service replays it from the start on its next run. Blue/green generation switching with collection aliases (doc 06 §6, steps 3 and 4) is still to come. It is tracked with E2E-08 under WP-2.7.
27+
28+
## Consequences
29+
30+
- CI can check schemas with the service's own build, and nothing is loaded by reflection.
31+
- Operators can run store commands without deploying anything.
32+
- An in-place rebuild serves an empty, growing read model while it catches up. Use a version bump (a new generation) when that matters, until alias switching lands.

‎docs/v3.0.0/ADRs/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ Every ADR is **Proposed** until the v3 architecture review. "Traces" lists the [
4343
| [ADR-028](ADR-028-postgresql-payload-columns-and-adapter-refinements.md) | PostgreSQL payload columns are `json`, plus adapter refinements | Accepted |
4444
| [ADR-029](ADR-029-schema-catalog-derived-from-serializer-metadata.md) | The schema catalog is derived at startup from the serializer's source-generated metadata | Accepted |
4545
| [ADR-030](ADR-030-domain-queries-bind-at-startup.md) | Domain queries are checked in two stages: shape at build time, binding at startup | Accepted |
46+
| [ADR-031](ADR-031-cli-runs-store-commands-globally-and-model-commands-in-the-service.md) | The CLI runs store commands as a global tool and model commands inside the service | Accepted |

‎docs/v3.0.0/implementation/STATUS.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ Updated in the PR that completes a work package (CONTRIBUTING.md). Status values
44

55
## Progress summary
66

7-
Updated 2026-11-11. **32 of 63 work packages are done (51%)**, 1 in progress and 30 not started. By estimated effort, 61 of 138.5 engineer-weeks are done (44%).
7+
Updated 2026-11-11. **33 of 63 work packages are done (52%)**, 1 in progress and 29 not started. By estimated effort, 64 of 138.5 engineer-weeks are done (46%).
88

99
| Milestone | Done | In progress | Todo | Effort done (ew) |
1010
|-----------|------|-------------|------|------------------|
1111
| M0 Foundations | 5 | 0 | 0 | 4 of 4 |
1212
| M1 Core | 15 | 0 | 0 | 37 of 37 |
1313
| M2 PostgreSQL | 10 | 1 | 0 | 17 of 19 |
1414
| M3 Cloud transports | 0 | 0 | 7 | 0 of 19 |
15-
| M4 Tooling and operations | 1 | 0 | 6 | 2.5 of 16.5 |
15+
| M4 Tooling and operations | 2 | 0 | 5 | 5.5 of 16.5 |
1616
| M5 Security and migration | 1 | 0 | 4 | 0.5 of 11 |
1717
| Track S: Workflow DSL and Studio | 0 | 0 | 13 | 0 of 32 |
1818

@@ -27,12 +27,13 @@ Completed in this round:
2727
- WP-2.8: PostgreSQL index sync for `[Queryable]` fields.
2828
- WP-1.R and WP-2.R: v2 core and v2 EF stores retired after every plan 07 row was satisfied. This needed about 80 new v3 tests, the Stop subscription policy, the DC-2 guard, and dead-letter show/purge/filters.
2929
- WP-5.3: redaction wiring (taxonomy, Erasing/HMAC redactors, `LogPayloads`).
30+
- WP-4.1: the `dotnet sourceflow` CLI (ADR-031).
3031

3132
Still in progress:
3233

3334
- WP-2.7 (`SourceFlow.Storage.EntityFrameworkCore` read models): rebuilding a projection into a new schema, which waits for the blue/green rebuild tooling.
3435

35-
Not started: the v2 cloud retirement (3.R), every cloud transport (M3), the CLI, health and dashboards, SQL Server, event partitioning, chaos and load testing, payload protection, crypto-shredding (redaction is done), the v2 migration tool, and the whole Studio track.
36+
Not started: the v2 cloud retirement (3.R), every cloud transport (M3), health and dashboards, SQL Server, event partitioning, chaos and load testing, payload protection, crypto-shredding (redaction is done), the v2 migration tool, and the whole Studio track.
3637

3738
| WP | Title | Estimate (ew) | Status | Notes |
3839
|----|-------|---------------|--------|-------|
@@ -62,7 +63,7 @@ Not started: the v2 cloud retirement (3.R), every cloud transport (M3), the CLI,
6263
| WP-2.4 | Outbox, inbox, checkpoints, leases, dead letters, process state, documents | 2.5 | done | Head-of-key claim, fenced complete/retry, database-clock leases, fenced checkpoint upsert, inbox with stored result, process state CAS, versioned documents, dead letters with the full envelope; SCK-29..58 green. Batched inbox/outbox retention cleanup pending (with WP-4.3 operations) |
6364
| WP-2.5 | Snapshots and aggregate cache | 1.5 | done | Background snapshot writer (bounded, drop-oldest, drained on shutdown), MemoryCache aggregate cache (ReadTail, Optimistic with rejection confirmation and no shortcut for If-Match), cache and snapshot metrics; UT-SNAP-01..10, SCK-51..53/55 on in-memory and PostgreSQL (with and without RLS), parallel deposits on PostgreSQL in both cache modes. PERF-04 moves to the WP-2.10 baseline |
6465
| WP-2.6 | Tenancy with row-level security | 1 | done | V002 tenant_isolation policies; TenantIsolation.RowLevelSecurity enables and forces RLS (migrator applies and verifies the state); session-scoped set_config; system (BYPASSRLS) data source for the global log; tenant-aware buckets. The whole SCK passes under RLS through a DML-only role; SCK-59/60/M07, Bank transfer E2E under RLS. Per-tenant metric tags (bounded top-N) move to WP-4.3 |
65-
| WP-2.7 | `SourceFlow.Storage.EntityFrameworkCore` read models | 2 | in progress | Core session-projection SPI (ISessionProjection, IProjectionUnitOfWork, IStorageSession.FlushAsync for ES-R1), generator support (GEN-41/42), IEfProjection, EfSessionContextFactory on the session connection and transaction, UseEntityFrameworkProjections, IdempotentUpsert; UT-EF-01..04, SCK-61..64 inline and async on PostgreSQL. Rebuild with schema-per-generation waits for the blue/green rebuild tooling (doc 06 §6) |
66+
| WP-2.7 | `SourceFlow.Storage.EntityFrameworkCore` read models | 2 | in progress | Core session-projection SPI (ISessionProjection, IProjectionUnitOfWork, IStorageSession.FlushAsync for ES-R1), generator support (GEN-41/42), IEfProjection, EfSessionContextFactory on the session connection and transaction, UseEntityFrameworkProjections, IdempotentUpsert; UT-EF-01..04, SCK-61..64 inline and async on PostgreSQL. In-place rebuild is available (`projection rebuild`, WP-4.1). Remaining: blue/green generations with collection-alias switching and EF schema-per-generation (doc 06 §6), E2E-08 |
6667
| WP-2.8 | SFQL on PostgreSQL (incl. joins and grouping) + index sync + domain queries | 3.5 | done | PostgreSqlQueryEngine (`IQueryEngine`): one parameterised statement per page (every request value a typed `$n` parameter, I-3), keyset predicate and ORDER BY outside a derived table, `COLLATE "C"` text ordering, horizon predicate, per-version rename CASE, tenant predicates (G-9), `NULLIF` division, read-only transaction with `statement_timeout` (`QueryTimeout`, default 5 s) and the RLS tenant setting; aggregate and schema sources stay in the interpreter. Cross-provider equivalence with the interpreter on the same data (QRY-01..20, incl. joins, grouping/having, distinct, subqueries, keyset pages interchangeable between engines), golden SQL (QRY-42/43), injection corpus (QRY-44), no-literal CsCheck property (QRY-60). Catalog now types enums by their JSON (number or string). Domain queries run through the engine (ExecuteAsync and `/queries`). `/query/explain` shows the parameterised SQL. `PostgreSqlIndexSync` (plan/apply, CONCURRENTLY by default): one partial expression index per `[Queryable]` field over `events` (per type) and `documents` (per collection), using the compiler's exact expression, `sfq_` hash names, stale ones dropped; QRY-45 shows the planner using it. The CLI command (`db index sync`) is WP-4.1 |
6768
| WP-2.9 | Storage conformance kit complete | 2 | done | SCK-01..58 in SourceFlow.Testing, run on in-memory, PostgreSQL and PostgreSQL with RLS. New in this package: SCK-23 (in-flight writes invisible), SCK-37 (batched retention via IStoreRetention, plus the host retention loop), SCK-54 (CsCheck property: snapshot + tail = full fold), PostgreSQL SCK-28 (LISTEN loss and reconnect), and SCK-L01 CsCheck linearisability of appends and the outbox. SCK-36 is covered by SCK-34. SCK-20 (one round trip) moves to WP-2.10, and SCK-65..72 (partitioned events) to WP-4.5 |
6869
| WP-2.10 | Performance baseline | 1 | done | benchmarks/SourceFlow.Benchmarks (BenchmarkDotNet PERF-01..04, 06; measurement harness PERF-05, 08, 10); baselines in benchmarks/baselines and the report in implementation/perf-baseline.md. One-round-trip PostgreSQL commit (SCK-20), batch-aware document projections and adaptive subscription batches. Deviations logged with owners (PERF-01/02 allocations, PERF-10 borderline). PERF-07 waits for M3 and PERF-09 for WP-4.7 |
@@ -74,7 +75,7 @@ Not started: the v2 cloud retirement (3.R), every cloud transport (M3), the CLI,
7475
| WP-3.5 | Provisioning, topology export, DLQ ingestor, IaC modules | 2.5 | todo | |
7576
| WP-3.6 | Cross-service E2E per transport | 1.5 | todo | |
7677
| WP-3.R | Retire v2 cloud providers | 0.5 | todo | |
77-
| WP-4.1 | `SourceFlow.Tool` (`dotnet sourceflow`) | 3 | todo | |
78+
| WP-4.1 | `SourceFlow.Tool` (`dotnet sourceflow`) | 3 | done | `dotnet sourceflow` (System.CommandLine, ADR-031): `db migrate|script|index sync`, `dlq list|show|replay|discard|purge`, `projection skip|repartition|rebuild|drop`, `topology export|validate`, `query [--sql] [--param] [--validate] [--explain] [--format table|json|csv]`, `schema export --format lock|jsonschema|asyncapi` and `schema check --baseline`. Store commands run from the global tool against PostgreSQL; model commands run inside the service via `SourceFlowCli.TryRunAsync` (the Bank sample does this). `IDocumentAdmin` drops collections. UT-CLI-01..30, E2E-25..28. `migrate v2` is WP-5.4; blue/green alias switching is WP-2.7 |
7879
| WP-4.2 | `SourceFlow.AspNetCore` (incl. the per-service query API) | 2.5 | done | MapCommand (Idempotency-Key, If-Match, ETag, consistency token, ProblemDetails); `AddSourceFlowQueryApi` + `MapSourceFlowQueryApi` (`/query`, `/validate`, `/explain`, `/schema`) and `MapDomainQueries` (`/queries`, `/queries/{name}/v{n}`): fail-closed without an authorization decision (SFQ0504, checked when the pipeline is built), transport guard (64 KB, JSON only, depth 16 → 413/415/400), per-principal rate and concurrency limits and a service limit (SFQ0502/0503, Retry-After), permissions and tenant from claims, problem+json without input echo, security headers, NDJSON with trailer, CSV with formula neutralisation, OpenAPI metadata. UT-ASP-01..06, 21..36. The Bank demo now uses it (anonymous only by explicit opt-in). QRY-53..56 (API end to end on PostgreSQL) and the nightly DAST scan remain with WP-4.7 |
7980
| WP-4.3 | Health checks, dashboards and alerts | 2 | todo | |
8081
| WP-4.4 | `SourceFlow.Storage.SqlServer` + SFQL SQL Server compiler | 4 | todo | |

‎samples/Bank/Bank.Api/Bank.Api.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<ItemGroup>
99
<ProjectReference Include="..\..\..\src\Hosting\SourceFlow.AspNetCore\SourceFlow.AspNetCore.csproj" />
1010
<ProjectReference Include="..\..\..\src\Storage\SourceFlow.Storage.PostgreSQL\SourceFlow.Storage.PostgreSQL.csproj" />
11+
<ProjectReference Include="..\..\..\src\Tooling\SourceFlow.Tool\SourceFlow.Tool.csproj" />
1112
<ProjectReference Include="..\Bank.Contracts\Bank.Contracts.csproj" />
1213
<ProjectReference Include="..\Bank.Accounts\Bank.Accounts.csproj" />
1314
<ProjectReference Include="..\Bank.Transfers\Bank.Transfers.csproj" />

‎samples/Bank/Bank.Api/Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ COPY samples/Directory.Build.props samples/
1010
COPY src/Core/ src/Core/
1111
COPY src/Hosting/ src/Hosting/
1212
COPY src/Storage/SourceFlow.Storage.PostgreSQL/ src/Storage/SourceFlow.Storage.PostgreSQL/
13+
COPY src/Tooling/SourceFlow.Tool/ src/Tooling/SourceFlow.Tool/
1314
COPY samples/Bank/ samples/Bank/
1415
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
1516
dotnet publish samples/Bank/Bank.Api/Bank.Api.csproj -c Release -o /app -p:UseAppHost=false

‎samples/Bank/Bank.Api/Program.cs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@
3737

3838
WebApplication app = builder.Build();
3939

40+
// `dotnet run -- sourceflow <command>` runs the SourceFlow CLI with this service's model and storage (ADR-031).
41+
if (await SourceFlow.Tool.SourceFlowCli.TryRunAsync(args, app.Services) is { } exitCode)
42+
{
43+
return exitCode;
44+
}
45+
4046
app.MapGet("/health", (RequestDelegate)(http => http.Response.WriteAsync("ok", http.RequestAborted)));
4147

4248
// Commands (doc 03 §5.1): POST with a JSON body; route values fill the ids. Responses carry ETag and SourceFlow-Consistency.
@@ -55,6 +61,7 @@
5561
}
5662

5763
app.Run();
64+
return 0;
5865

5966
static bool Flag(IConfiguration configuration, string key, bool fallback)
6067
{

‎src/Core/SourceFlow.Net/Hosting/SourceFlowBuilder.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ public SourceFlowBuilder UseInMemoryStorage(InMemoryStore? store = null)
159159
return UseStorage(sp =>
160160
{
161161
InMemoryStore s = store ?? new InMemoryStore(sp.GetService<TimeProvider>());
162-
return new StoragePorts(s, s, s, s, s, s, s, s, s, s) { Retention = s, Nodes = s, Schemas = s };
162+
return new StoragePorts(s, s, s, s, s, s, s, s, s, s) { Retention = s, Nodes = s, Schemas = s, DocumentAdmin = s };
163163
});
164164
}
165165

‎src/Core/SourceFlow.Net/Runtime/SourceFlowRuntime.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ public sealed record StoragePorts(
6363
/// <summary>Retention cleanup, when the adapter supports it (doc 05 §9).</summary>
6464
public IStoreRetention? Retention { get; init; }
6565

66+
/// <summary>Projection data administration, when the adapter supports it (doc 06 §6).</summary>
67+
public IDocumentAdmin? DocumentAdmin { get; init; }
68+
6669
/// <summary>The schema registry, when the adapter supports it (doc 14 §2.2); startup registers this service's schemas.</summary>
6770
public ISchemaStore? Schemas { get; init; }
6871

‎src/Core/SourceFlow.Net/Storage/InMemory/InMemoryStore.cs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ public sealed class InMemoryStore :
2121
IStoreNotifications,
2222
IStoreRetention,
2323
INodeRegistry,
24-
ISchemaStore
24+
ISchemaStore,
25+
IDocumentAdmin
2526
{
2627
private readonly Lock _gate = new();
2728
private readonly TimeProvider _time;
@@ -164,6 +165,23 @@ public ValueTask<GlobalPosition> GetAsync(string subscription, int partition, Ca
164165
}
165166
}
166167

168+
// ------------------------------------------------------------------ IDocumentAdmin
169+
170+
/// <inheritdoc />
171+
public ValueTask<int> DropCollectionAsync(string collection, CancellationToken cancellationToken)
172+
{
173+
lock (_gate)
174+
{
175+
var keys = _documents.Keys.Where(k => k.Collection == collection).ToArray();
176+
foreach (var key in keys)
177+
{
178+
_documents.Remove(key);
179+
}
180+
181+
return ValueTask.FromResult(keys.Length);
182+
}
183+
}
184+
167185
// ------------------------------------------------------------------ ISchemaStore
168186

169187
/// <inheritdoc />

0 commit comments

Comments
 (0)