diff --git a/Dockerfile b/Dockerfile index 8b9e21f3..7efcb985 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,15 +29,6 @@ COPY framework/SimpleModule.Storage/*.csproj framework/SimpleModule.Storage/ COPY framework/SimpleModule.Storage.Local/*.csproj framework/SimpleModule.Storage.Local/ COPY framework/SimpleModule.Storage.Azure/*.csproj framework/SimpleModule.Storage.Azure/ COPY framework/SimpleModule.Storage.S3/*.csproj framework/SimpleModule.Storage.S3/ -COPY framework/SimpleModule.Agents/*.csproj framework/SimpleModule.Agents/ -COPY framework/SimpleModule.AI.Anthropic/*.csproj framework/SimpleModule.AI.Anthropic/ -COPY framework/SimpleModule.AI.AzureOpenAI/*.csproj framework/SimpleModule.AI.AzureOpenAI/ -COPY framework/SimpleModule.AI.Ollama/*.csproj framework/SimpleModule.AI.Ollama/ -COPY framework/SimpleModule.AI.OpenAI/*.csproj framework/SimpleModule.AI.OpenAI/ -COPY framework/SimpleModule.Rag/*.csproj framework/SimpleModule.Rag/ -COPY framework/SimpleModule.Rag.StructuredRag/*.csproj framework/SimpleModule.Rag.StructuredRag/ -COPY framework/SimpleModule.Rag.VectorStore.InMemory/*.csproj framework/SimpleModule.Rag.VectorStore.InMemory/ -COPY framework/SimpleModule.Rag.VectorStore.Postgres/*.csproj framework/SimpleModule.Rag.VectorStore.Postgres/ # ServiceDefaults COPY SimpleModule.ServiceDefaults/*.csproj SimpleModule.ServiceDefaults/ @@ -54,44 +45,26 @@ COPY modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/*.csproj modules/O COPY modules/OpenIddict/src/SimpleModule.OpenIddict/*.csproj modules/OpenIddict/src/SimpleModule.OpenIddict/ COPY modules/Permissions/src/SimpleModule.Permissions.Contracts/*.csproj modules/Permissions/src/SimpleModule.Permissions.Contracts/ COPY modules/Permissions/src/SimpleModule.Permissions/*.csproj modules/Permissions/src/SimpleModule.Permissions/ -COPY modules/Products/src/SimpleModule.Products.Contracts/*.csproj modules/Products/src/SimpleModule.Products.Contracts/ -COPY modules/Products/src/SimpleModule.Products/*.csproj modules/Products/src/SimpleModule.Products/ -COPY modules/Orders/src/SimpleModule.Orders.Contracts/*.csproj modules/Orders/src/SimpleModule.Orders.Contracts/ -COPY modules/Orders/src/SimpleModule.Orders/*.csproj modules/Orders/src/SimpleModule.Orders/ COPY modules/Admin/src/SimpleModule.Admin.Contracts/*.csproj modules/Admin/src/SimpleModule.Admin.Contracts/ COPY modules/Admin/src/SimpleModule.Admin/*.csproj modules/Admin/src/SimpleModule.Admin/ -COPY modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/*.csproj modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/ -COPY modules/PageBuilder/src/SimpleModule.PageBuilder/*.csproj modules/PageBuilder/src/SimpleModule.PageBuilder/ COPY modules/Settings/src/SimpleModule.Settings.Contracts/*.csproj modules/Settings/src/SimpleModule.Settings.Contracts/ COPY modules/Settings/src/SimpleModule.Settings/*.csproj modules/Settings/src/SimpleModule.Settings/ COPY modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/*.csproj modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/ COPY modules/AuditLogs/src/SimpleModule.AuditLogs/*.csproj modules/AuditLogs/src/SimpleModule.AuditLogs/ -COPY modules/Marketplace/src/SimpleModule.Marketplace.Contracts/*.csproj modules/Marketplace/src/SimpleModule.Marketplace.Contracts/ -COPY modules/Marketplace/src/SimpleModule.Marketplace/*.csproj modules/Marketplace/src/SimpleModule.Marketplace/ COPY modules/FileStorage/src/SimpleModule.FileStorage.Contracts/*.csproj modules/FileStorage/src/SimpleModule.FileStorage.Contracts/ COPY modules/FileStorage/src/SimpleModule.FileStorage/*.csproj modules/FileStorage/src/SimpleModule.FileStorage/ COPY modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/*.csproj modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/ COPY modules/FeatureFlags/src/SimpleModule.FeatureFlags/*.csproj modules/FeatureFlags/src/SimpleModule.FeatureFlags/ COPY modules/Tenants/src/SimpleModule.Tenants.Contracts/*.csproj modules/Tenants/src/SimpleModule.Tenants.Contracts/ COPY modules/Tenants/src/SimpleModule.Tenants/*.csproj modules/Tenants/src/SimpleModule.Tenants/ -COPY modules/Agents/src/SimpleModule.Agents.Contracts/*.csproj modules/Agents/src/SimpleModule.Agents.Contracts/ -COPY modules/Agents/src/SimpleModule.Agents.Module/*.csproj modules/Agents/src/SimpleModule.Agents.Module/ COPY modules/BackgroundJobs/src/SimpleModule.BackgroundJobs.Contracts/*.csproj modules/BackgroundJobs/src/SimpleModule.BackgroundJobs.Contracts/ COPY modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/*.csproj modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/ COPY modules/Localization/src/SimpleModule.Localization.Contracts/*.csproj modules/Localization/src/SimpleModule.Localization.Contracts/ COPY modules/Localization/src/SimpleModule.Localization/*.csproj modules/Localization/src/SimpleModule.Localization/ -COPY modules/Rag/src/SimpleModule.Rag.Contracts/*.csproj modules/Rag/src/SimpleModule.Rag.Contracts/ -COPY modules/Rag/src/SimpleModule.Rag.Module/*.csproj modules/Rag/src/SimpleModule.Rag.Module/ COPY modules/Email/src/SimpleModule.Email.Contracts/*.csproj modules/Email/src/SimpleModule.Email.Contracts/ COPY modules/Email/src/SimpleModule.Email/*.csproj modules/Email/src/SimpleModule.Email/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting/ -COPY modules/Chat/src/SimpleModule.Chat.Contracts/*.csproj modules/Chat/src/SimpleModule.Chat.Contracts/ -COPY modules/Chat/src/SimpleModule.Chat/*.csproj modules/Chat/src/SimpleModule.Chat/ -COPY modules/Map/src/SimpleModule.Map.Contracts/*.csproj modules/Map/src/SimpleModule.Map.Contracts/ -COPY modules/Map/src/SimpleModule.Map/*.csproj modules/Map/src/SimpleModule.Map/ -COPY modules/Datasets/src/SimpleModule.Datasets.Contracts/*.csproj modules/Datasets/src/SimpleModule.Datasets.Contracts/ -COPY modules/Datasets/src/SimpleModule.Datasets/*.csproj modules/Datasets/src/SimpleModule.Datasets/ RUN dotnet restore template/SimpleModule.Host/SimpleModule.Host.csproj @@ -110,19 +83,13 @@ COPY modules/AuditLogs/src/SimpleModule.AuditLogs/package.json modules/AuditLogs COPY modules/Dashboard/src/SimpleModule.Dashboard/package.json modules/Dashboard/src/SimpleModule.Dashboard/ COPY modules/FeatureFlags/src/SimpleModule.FeatureFlags/package.json modules/FeatureFlags/src/SimpleModule.FeatureFlags/ COPY modules/FileStorage/src/SimpleModule.FileStorage/package.json modules/FileStorage/src/SimpleModule.FileStorage/ -COPY modules/Marketplace/src/SimpleModule.Marketplace/package.json modules/Marketplace/src/SimpleModule.Marketplace/ COPY modules/OpenIddict/src/SimpleModule.OpenIddict/package.json modules/OpenIddict/src/SimpleModule.OpenIddict/ -COPY modules/Orders/src/SimpleModule.Orders/package.json modules/Orders/src/SimpleModule.Orders/ -COPY modules/PageBuilder/src/SimpleModule.PageBuilder/package.json modules/PageBuilder/src/SimpleModule.PageBuilder/ -COPY modules/Products/src/SimpleModule.Products/package.json modules/Products/src/SimpleModule.Products/ COPY modules/Settings/src/SimpleModule.Settings/package.json modules/Settings/src/SimpleModule.Settings/ COPY modules/Tenants/src/SimpleModule.Tenants/package.json modules/Tenants/src/SimpleModule.Tenants/ COPY modules/Users/src/SimpleModule.Users/package.json modules/Users/src/SimpleModule.Users/ COPY modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/package.json modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/ COPY modules/Email/src/SimpleModule.Email/package.json modules/Email/src/SimpleModule.Email/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/package.json modules/RateLimiting/src/SimpleModule.RateLimiting/ -COPY modules/Chat/src/SimpleModule.Chat/package.json modules/Chat/src/SimpleModule.Chat/ -COPY modules/Map/src/SimpleModule.Map/package.json modules/Map/src/SimpleModule.Map/ COPY packages/SimpleModule.Client/package.json packages/SimpleModule.Client/ COPY packages/SimpleModule.Theme.Default/package.json packages/SimpleModule.Theme.Default/ COPY packages/SimpleModule.TsConfig/package.json packages/SimpleModule.TsConfig/ diff --git a/Dockerfile.worker b/Dockerfile.worker index 33524add..f701f58e 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -1,9 +1,7 @@ # SimpleModule.Worker — background-job consumer. # # Kept separate from the main Dockerfile (which builds the Host / ASP.NET -# process). The Worker is the only image that runs IModuleJob handlers, so -# it's the only image that needs native GIS tooling (GDAL, SpatiaLite, -# tippecanoe) required by the Datasets module's jobs. +# process). The Worker is the only image that runs IModuleJob handlers. # # Build from the repo root: # docker build -f Dockerfile.worker -t simplemodule-worker . @@ -37,15 +35,6 @@ COPY framework/SimpleModule.Storage/*.csproj framework/SimpleModule.Storage/ COPY framework/SimpleModule.Storage.Local/*.csproj framework/SimpleModule.Storage.Local/ COPY framework/SimpleModule.Storage.Azure/*.csproj framework/SimpleModule.Storage.Azure/ COPY framework/SimpleModule.Storage.S3/*.csproj framework/SimpleModule.Storage.S3/ -COPY framework/SimpleModule.Agents/*.csproj framework/SimpleModule.Agents/ -COPY framework/SimpleModule.AI.Anthropic/*.csproj framework/SimpleModule.AI.Anthropic/ -COPY framework/SimpleModule.AI.AzureOpenAI/*.csproj framework/SimpleModule.AI.AzureOpenAI/ -COPY framework/SimpleModule.AI.Ollama/*.csproj framework/SimpleModule.AI.Ollama/ -COPY framework/SimpleModule.AI.OpenAI/*.csproj framework/SimpleModule.AI.OpenAI/ -COPY framework/SimpleModule.Rag/*.csproj framework/SimpleModule.Rag/ -COPY framework/SimpleModule.Rag.StructuredRag/*.csproj framework/SimpleModule.Rag.StructuredRag/ -COPY framework/SimpleModule.Rag.VectorStore.InMemory/*.csproj framework/SimpleModule.Rag.VectorStore.InMemory/ -COPY framework/SimpleModule.Rag.VectorStore.Postgres/*.csproj framework/SimpleModule.Rag.VectorStore.Postgres/ # ServiceDefaults COPY SimpleModule.ServiceDefaults/*.csproj SimpleModule.ServiceDefaults/ @@ -64,40 +53,26 @@ COPY modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/*.csproj modules/O COPY modules/OpenIddict/src/SimpleModule.OpenIddict/*.csproj modules/OpenIddict/src/SimpleModule.OpenIddict/ COPY modules/Permissions/src/SimpleModule.Permissions.Contracts/*.csproj modules/Permissions/src/SimpleModule.Permissions.Contracts/ COPY modules/Permissions/src/SimpleModule.Permissions/*.csproj modules/Permissions/src/SimpleModule.Permissions/ -COPY modules/Products/src/SimpleModule.Products.Contracts/*.csproj modules/Products/src/SimpleModule.Products.Contracts/ -COPY modules/Products/src/SimpleModule.Products/*.csproj modules/Products/src/SimpleModule.Products/ -COPY modules/Orders/src/SimpleModule.Orders.Contracts/*.csproj modules/Orders/src/SimpleModule.Orders.Contracts/ -COPY modules/Orders/src/SimpleModule.Orders/*.csproj modules/Orders/src/SimpleModule.Orders/ COPY modules/Admin/src/SimpleModule.Admin.Contracts/*.csproj modules/Admin/src/SimpleModule.Admin.Contracts/ COPY modules/Admin/src/SimpleModule.Admin/*.csproj modules/Admin/src/SimpleModule.Admin/ -COPY modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/*.csproj modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/ -COPY modules/PageBuilder/src/SimpleModule.PageBuilder/*.csproj modules/PageBuilder/src/SimpleModule.PageBuilder/ COPY modules/Settings/src/SimpleModule.Settings.Contracts/*.csproj modules/Settings/src/SimpleModule.Settings.Contracts/ COPY modules/Settings/src/SimpleModule.Settings/*.csproj modules/Settings/src/SimpleModule.Settings/ COPY modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/*.csproj modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/ COPY modules/AuditLogs/src/SimpleModule.AuditLogs/*.csproj modules/AuditLogs/src/SimpleModule.AuditLogs/ -COPY modules/Marketplace/src/SimpleModule.Marketplace.Contracts/*.csproj modules/Marketplace/src/SimpleModule.Marketplace.Contracts/ -COPY modules/Marketplace/src/SimpleModule.Marketplace/*.csproj modules/Marketplace/src/SimpleModule.Marketplace/ COPY modules/FileStorage/src/SimpleModule.FileStorage.Contracts/*.csproj modules/FileStorage/src/SimpleModule.FileStorage.Contracts/ COPY modules/FileStorage/src/SimpleModule.FileStorage/*.csproj modules/FileStorage/src/SimpleModule.FileStorage/ COPY modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/*.csproj modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/ COPY modules/FeatureFlags/src/SimpleModule.FeatureFlags/*.csproj modules/FeatureFlags/src/SimpleModule.FeatureFlags/ COPY modules/Tenants/src/SimpleModule.Tenants.Contracts/*.csproj modules/Tenants/src/SimpleModule.Tenants.Contracts/ COPY modules/Tenants/src/SimpleModule.Tenants/*.csproj modules/Tenants/src/SimpleModule.Tenants/ -COPY modules/Agents/src/SimpleModule.Agents.Contracts/*.csproj modules/Agents/src/SimpleModule.Agents.Contracts/ -COPY modules/Agents/src/SimpleModule.Agents.Module/*.csproj modules/Agents/src/SimpleModule.Agents.Module/ COPY modules/BackgroundJobs/src/SimpleModule.BackgroundJobs.Contracts/*.csproj modules/BackgroundJobs/src/SimpleModule.BackgroundJobs.Contracts/ COPY modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/*.csproj modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/ COPY modules/Localization/src/SimpleModule.Localization.Contracts/*.csproj modules/Localization/src/SimpleModule.Localization.Contracts/ COPY modules/Localization/src/SimpleModule.Localization/*.csproj modules/Localization/src/SimpleModule.Localization/ -COPY modules/Rag/src/SimpleModule.Rag.Contracts/*.csproj modules/Rag/src/SimpleModule.Rag.Contracts/ -COPY modules/Rag/src/SimpleModule.Rag.Module/*.csproj modules/Rag/src/SimpleModule.Rag.Module/ COPY modules/Email/src/SimpleModule.Email.Contracts/*.csproj modules/Email/src/SimpleModule.Email.Contracts/ COPY modules/Email/src/SimpleModule.Email/*.csproj modules/Email/src/SimpleModule.Email/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting/ -COPY modules/Datasets/src/SimpleModule.Datasets.Contracts/*.csproj modules/Datasets/src/SimpleModule.Datasets.Contracts/ -COPY modules/Datasets/src/SimpleModule.Datasets/*.csproj modules/Datasets/src/SimpleModule.Datasets/ RUN dotnet restore template/SimpleModule.Worker/SimpleModule.Worker.csproj @@ -112,46 +87,15 @@ RUN dotnet publish template/SimpleModule.Worker/SimpleModule.Worker.csproj \ --no-restore \ -p:ErrorOnDuplicatePublishOutputFiles=false -# felt/tippecanoe is not in Debian repos, so build it from source. Output is -# ~5 MB of binaries depending only on libsqlite3-0 + zlib1g — both present in -# the runtime image. -FROM debian:bookworm-slim AS tippecanoe-builder -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - ca-certificates \ - git \ - libsqlite3-dev \ - zlib1g-dev \ - && rm -rf /var/lib/apt/lists/* \ - && git clone --depth 1 https://github.com/felt/tippecanoe.git /tmp/tippecanoe \ - && make -C /tmp/tippecanoe -j"$(nproc)" \ - && make -C /tmp/tippecanoe install PREFIX=/opt/tippecanoe - FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime WORKDIR /app -# Native GIS tooling for the Datasets module's background jobs: -# gdal-bin → gdal_translate, gdalwarp, ogr2ogr, ogrinfo -# libsqlite3-mod-spatialite → SpatiaLite extension for GeoPackage reads -# unzip → .kmz and zipped Shapefile bundles -# gdal-bin pulls libproj / libgeos / libnetcdf (~300 MB), unavoidable for -# real GIS support. RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - ca-certificates \ - gdal-bin \ - libsqlite3-mod-spatialite \ - unzip \ + && apt-get install -y --no-install-recommends ca-certificates \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && groupadd --system --gid 1001 workergroup \ && useradd --system --uid 1001 --gid workergroup --create-home workeruser -# Copy the whole staging bin/ to pick up every binary felt/tippecanoe ships -# (tippecanoe, tippecanoe-decode, tippecanoe-overzoom, tippecanoe-enumerate, -# tippecanoe-json-tool, tile-join) without hard-coding names. -COPY --from=tippecanoe-builder /opt/tippecanoe/bin/ /usr/local/bin/ - COPY --from=build --chown=workeruser:workergroup /app/publish . RUN mkdir -p /app/data /app/storage && chown workeruser:workergroup /app/data /app/storage diff --git a/SimpleModule.slnx b/SimpleModule.slnx index ddce6f58..c2dba1d3 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -1,4 +1,4 @@ - + @@ -14,15 +14,6 @@ - - - - - - - - - @@ -36,21 +27,6 @@ - - - - - - - - - - - - - - - @@ -71,21 +47,11 @@ - - - - - - - - - - @@ -101,31 +67,11 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/cli/SimpleModule.Cli/Commands/New/NewAgentCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewAgentCommand.cs deleted file mode 100644 index 37792dff..00000000 --- a/cli/SimpleModule.Cli/Commands/New/NewAgentCommand.cs +++ /dev/null @@ -1,173 +0,0 @@ -using SimpleModule.Cli.Infrastructure; -using Spectre.Console; -using Spectre.Console.Cli; - -namespace SimpleModule.Cli.Commands.New; - -public sealed class NewAgentCommand : Command -{ - public override int Execute(CommandContext context, NewAgentSettings settings) - { - var agentName = settings.ResolveName(); - - var solution = SolutionContext.Discover(); - if (solution is null) - { - AnsiConsole.MarkupLine( - "[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]" - ); - return 1; - } - - if (solution.ExistingModules.Count == 0) - { - AnsiConsole.MarkupLine( - "[red]No modules found. Create a module first with 'sm new module'.[/]" - ); - return 1; - } - - var moduleName = settings.ResolveModule(solution.ExistingModules); - - if (!solution.ExistingModules.Contains(moduleName, StringComparer.OrdinalIgnoreCase)) - { - AnsiConsole.MarkupLine( - $"[red]Module '{Markup.Escape(moduleName)}' not found. Available: {Markup.Escape(string.Join(", ", solution.ExistingModules))}[/]" - ); - return 1; - } - - var moduleDir = solution.GetModuleProjectPath(moduleName); - var agentsDir = Path.Combine(moduleDir, "Agents"); - var knowledgeDir = Path.Combine(agentsDir, "Knowledge"); - - var ops = new List<(string Path, string Content)> - { - ( - Path.Combine(agentsDir, $"{agentName}Agent.cs"), - GenerateAgentDefinition(moduleName, agentName) - ), - ( - Path.Combine(agentsDir, $"{agentName}ToolProvider.cs"), - GenerateToolProvider(moduleName, agentName) - ), - ( - Path.Combine(agentsDir, $"{agentName}KnowledgeSource.cs"), - GenerateKnowledgeSource(moduleName, agentName) - ), - }; - - if (settings.DryRun) - { - AnsiConsole.MarkupLine("[yellow]Dry run — no files will be written.[/]"); - foreach (var (path, _) in ops) - { - var rel = Path.GetRelativePath(solution.RootPath, path); - AnsiConsole.MarkupLine($" [green]CREATE[/] {Markup.Escape(rel)}"); - } - - AnsiConsole.MarkupLine( - $" [green]CREATE[/] {Markup.Escape(Path.GetRelativePath(solution.RootPath, knowledgeDir))}/" - ); - return 0; - } - - Directory.CreateDirectory(agentsDir); - Directory.CreateDirectory(knowledgeDir); - - foreach (var (path, content) in ops) - { - if (File.Exists(path)) - { - AnsiConsole.MarkupLine( - $" [yellow]SKIP[/] {Markup.Escape(Path.GetRelativePath(solution.RootPath, path))} (already exists)" - ); - continue; - } - - File.WriteAllText(path, content); - AnsiConsole.MarkupLine( - $" [green]CREATE[/] {Markup.Escape(Path.GetRelativePath(solution.RootPath, path))}" - ); - } - - AnsiConsole.MarkupLine( - $"\n[green]Agent '{Markup.Escape(agentName)}' created in module '{Markup.Escape(moduleName)}'.[/]" - ); - return 0; - } - - private static string GenerateAgentDefinition(string moduleName, string agentName) => - $$""" - using SimpleModule.Core.Agents; - - namespace SimpleModule.{{moduleName}}.Agents; - - public class {{agentName}}Agent : IAgentDefinition - { - public string Name => "{{ToKebabCase(agentName)}}"; - - public string Description => "{{agentName}} agent for {{moduleName}}"; - - public string Instructions => - \"\"\" - You are a helpful assistant for the {{moduleName}} module. - Use the available tools to answer user questions. - \"\"\"; - } - """; - - private static string GenerateToolProvider(string moduleName, string agentName) => - $$""" - using SimpleModule.Core.Agents; - - namespace SimpleModule.{{moduleName}}.Agents; - - public class {{agentName}}ToolProvider : IAgentToolProvider - { - [AgentTool(Description = "Example tool — replace with real implementation")] - public Task ExampleTool(string query) => - Task.FromResult($"Result for: {query}"); - } - """; - - private static string GenerateKnowledgeSource(string moduleName, string agentName) => - $$""" - using SimpleModule.Core.Rag; - - namespace SimpleModule.{{moduleName}}.Agents; - - public class {{agentName}}KnowledgeSource : IKnowledgeSource - { - public string CollectionName => "{{ToKebabCase(agentName)}}-knowledge"; - - public Task> GetDocumentsAsync( - CancellationToken cancellationToken - ) => - Task.FromResult>( - [ - new( - "{{moduleName}} Overview", - "Add your {{moduleName}} module knowledge documents here." - ), - ] - ); - } - """; - - private static string ToKebabCase(string pascalCase) - { - var chars = new List(); - for (var i = 0; i < pascalCase.Length; i++) - { - if (i > 0 && char.IsUpper(pascalCase[i])) - { - chars.Add('-'); - } - - chars.Add(char.ToLowerInvariant(pascalCase[i])); - } - - return new string(chars.ToArray()); - } -} diff --git a/cli/SimpleModule.Cli/Commands/New/NewAgentSettings.cs b/cli/SimpleModule.Cli/Commands/New/NewAgentSettings.cs deleted file mode 100644 index 75bdc7f0..00000000 --- a/cli/SimpleModule.Cli/Commands/New/NewAgentSettings.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.ComponentModel; -using Spectre.Console; -using Spectre.Console.Cli; - -namespace SimpleModule.Cli.Commands.New; - -public sealed class NewAgentSettings : CommandSettings -{ - [CommandArgument(0, "[name]")] - [Description("Agent name in PascalCase (e.g. CustomerSupport)")] - public string? Name { get; set; } - - [CommandOption("--module ")] - [Description("Target module name (e.g. Products)")] - public string? Module { get; set; } - - [CommandOption("--dry-run")] - [Description("Show what would be created without writing any files")] - public bool DryRun { get; set; } - - public string ResolveName() - { - if (string.IsNullOrWhiteSpace(Name)) - { - Name = AnsiConsole.Ask("Agent name (PascalCase):"); - } - - if (string.IsNullOrWhiteSpace(Name)) - { - throw new InvalidOperationException("Agent name cannot be empty."); - } - - if (!char.IsUpper(Name[0])) - { - throw new InvalidOperationException( - $"'{Name}' is not PascalCase. Did you mean '{char.ToUpperInvariant(Name[0])}{Name[1..]}'?" - ); - } - - return Name; - } - - public string ResolveModule(IReadOnlyList existingModules) - { - if (string.IsNullOrWhiteSpace(Module)) - { - Module = AnsiConsole.Prompt( - new SelectionPrompt() - .Title("Which module should the agent be added to?") - .AddChoices(existingModules) - ); - } - - return Module; - } -} diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 6513db82..9440fba8 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -15,8 +15,8 @@ config.SetApplicationVersion(VersionCommand.ResolveVersion()); config.AddExample("new", "project", "MyApp"); - config.AddExample("new", "module", "Products"); - config.AddExample("new", "feature", "CreateProduct", "--module", "Products"); + config.AddExample("new", "module", "Customers"); + config.AddExample("new", "feature", "CreateCustomer", "--module", "Customers"); config.AddExample("dev"); config.AddExample("list"); config.AddExample("doctor", "--fix"); @@ -35,14 +35,11 @@ newBranch .AddCommand("module") .WithDescription("Scaffold a new module") - .WithExample("new", "module", "Products"); + .WithExample("new", "module", "Customers"); newBranch .AddCommand("feature") .WithDescription("Add a feature to an existing module") - .WithExample("new", "feature", "CreateProduct", "--module", "Products"); - newBranch - .AddCommand("agent") - .WithDescription("Add an AI agent to an existing module"); + .WithExample("new", "feature", "CreateCustomer", "--module", "Customers"); } ); diff --git a/cli/SimpleModule.Cli/Templates/HostTemplates.cs b/cli/SimpleModule.Cli/Templates/HostTemplates.cs index d24fb83a..c07fae75 100644 --- a/cli/SimpleModule.Cli/Templates/HostTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/HostTemplates.cs @@ -95,15 +95,6 @@ public static string ProgramCs() || line.Contains("MapDefaultEndpoints", StringComparison.Ordinal) || line.Contains("Storage.Local", StringComparison.Ordinal) || line.Contains("AddLocalStorage", StringComparison.Ordinal) - || line.Contains("SimpleModule.Agents", StringComparison.Ordinal) - || line.Contains("SimpleModule.AI.", StringComparison.Ordinal) - || line.Contains("SimpleModule.Rag", StringComparison.Ordinal) - || line.Contains("AddOllamaAI", StringComparison.Ordinal) - || line.Contains("AddInMemoryVectorStore", StringComparison.Ordinal) - || line.Contains("AddSimpleModuleRag", StringComparison.Ordinal) - || line.Contains("AddStructuredRag", StringComparison.Ordinal) - || line.Contains("AddSimpleModuleAgents", StringComparison.Ordinal) - || line.Contains("AddRagExtraction", StringComparison.Ordinal) ); lines = TemplateExtractor.CollapseBlankLines(lines); diff --git a/docker-compose.yml b/docker-compose.yml index 9a0f99ef..97f4ed4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,8 @@ services: # All IModuleJob execution lives in the worker service below. BackgroundJobs__WorkerMode: Producer volumes: - # Shared with the worker so uploaded dataset blobs are visible to - # both producer (web upload path) and consumer (background job path). + # Shared with the worker so uploaded blobs are visible to both + # producer (web upload path) and consumer (background job path). - storage_data:/app/storage depends_on: postgres: diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md index 3b56edab..69a89085 100644 --- a/docs/CONSTITUTION.md +++ b/docs/CONSTITUTION.md @@ -61,7 +61,7 @@ Modules can expose configurable behavior via the `IModuleOptions` marker interfa ```csharp // Module defines options -public class ProductsModuleOptions : IModuleOptions +public class CustomersModuleOptions : IModuleOptions { public int DefaultPageSize { get; set; } = 10; public int MaxPageSize { get; set; } = 100; @@ -70,11 +70,11 @@ public class ProductsModuleOptions : IModuleOptions // Host app configures them builder.AddSimpleModule(o => { - o.ConfigureProducts(p => p.MaxPageSize = 50); + o.ConfigureCustomers(p => p.MaxPageSize = 50); }); // Module reads them via IOptions -public class BrowseEndpoint(IOptions options) : IViewEndpoint { ... } +public class BrowseEndpoint(IOptions options) : IViewEndpoint { ... } ``` **Rules:** @@ -566,7 +566,7 @@ The `tools/` directory holds non-module .NET utilities consumed by the host, the ### Sub-projects -A sub-project is an additional assembly inside a module, used when a module owns multiple optional providers (e.g., `SimpleModule.Agents.AI.Anthropic`). Rules: +A sub-project is an additional assembly inside a module, used when a module owns multiple optional providers (e.g., `SimpleModule.FileStorage.S3`). Rules: - Lives at `modules/{Name}/src/SimpleModule.{Name}.{Suffix}/`. - Name matches `SimpleModule.{Name}.{Suffix}`. diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index 59bf3ea2..532bcd3f 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -62,7 +62,6 @@ export default defineConfig({ { text: 'File Storage', link: '/guide/file-storage' }, { text: 'Localization', link: '/guide/localization' }, { text: 'Error Pages', link: '/guide/error-pages' }, - { text: 'AI Agents & RAG', link: '/guide/ai-agents' }, ], }, ], diff --git a/docs/site/advanced/deployment.md b/docs/site/advanced/deployment.md index 2c80789a..daaa16fa 100644 --- a/docs/site/advanced/deployment.md +++ b/docs/site/advanced/deployment.md @@ -281,12 +281,12 @@ SimpleModule relies on EF Core migrations per module — there is no `EnsureCrea ```bash # Add a migration for a specific module's DbContext dotnet ef migrations add InitialCreate \ - --project modules/Products/src/Products \ + --project modules/Customers/src/Customers \ --startup-project template/SimpleModule.Host # Apply migrations dotnet ef database update \ - --project modules/Products/src/Products \ + --project modules/Customers/src/Customers \ --startup-project template/SimpleModule.Host ``` diff --git a/docs/site/advanced/source-generator.md b/docs/site/advanced/source-generator.md index 0076a875..687e0a10 100644 --- a/docs/site/advanced/source-generator.md +++ b/docs/site/advanced/source-generator.md @@ -59,7 +59,6 @@ The generator scans both referenced assemblies and the current compilation for: | **Permission Classes** | Sealed classes implementing `IModulePermissions` | | **Feature Flag Classes** | Classes implementing `IModuleFeatures` | | **Module Options** | `[ModuleOptions]`-annotated classes | -| **Agents** | `[Agent]` classes, `IAgentToolProvider` implementations, knowledge sources | | **Interceptors** | Classes implementing `ISaveChangesInterceptor` | | **Vogen Value Objects** | Types with Vogen value object markers | @@ -80,11 +79,7 @@ internal readonly record struct DiscoveryData( ImmutableArray Interceptors, ImmutableArray VogenValueObjects, ImmutableArray ModuleOptions, - ImmutableArray AgentDefinitions, - ImmutableArray AgentToolProviders, - ImmutableArray KnowledgeSources, ImmutableArray ContractsAssemblyNames, - bool HasAgentsAssembly, string HostAssemblyName ); ``` @@ -110,8 +105,7 @@ Discovery/ │ ├── PermissionFeatureFinder.cs │ ├── DbContextFinder.cs │ ├── InterceptorFinder.cs -│ ├── VogenFinder.cs -│ └── AgentFinder.cs +│ └── VogenFinder.cs └── Records/ # equatable value-type records used in DiscoveryData ``` @@ -131,7 +125,6 @@ The generator feeds `DiscoveryData` through a pipeline of **emitters**, each res | `SettingsExtensionsEmitter` | `SettingsExtensions.g.cs` | Collects settings definitions from modules that implement `ConfigureSettings` | | `ModuleOptionsEmitter` | `ModuleOptionsExtensions.g.cs` | Binds `[ModuleOptions]` classes to configuration sections | | `ContractRegistryEmitter` | `ContractRegistry.g.cs` | Registers each `I{Name}Contracts` against its implementation | -| `AgentExtensionsEmitter` | `AgentExtensions.g.cs` | Registers agents, tool providers, and knowledge sources (when the Agents framework assembly is referenced) | | `LocalizationExtensionsEmitter` | `LocalizationExtensions.g.cs` | Aggregates localization resources across modules | | `RoutesEmitter` | `ModuleRoutes.g.cs` | Strongly-typed C# route constants | | `TypeScriptRoutesEmitter` | `TypeScriptRoutes.g.cs` | Embedded TypeScript route constants for the ClientApp | @@ -214,7 +207,6 @@ public class ModuleDiscovererGenerator : IIncrementalGenerator new ValueConverterConventionsEmitter(), new DbContextRegistryEmitter(), new ContractRegistryEmitter(), - new AgentExtensionsEmitter(), new LocalizationExtensionsEmitter(), new RoutesEmitter(), new TypeScriptRoutesEmitter(), @@ -252,8 +244,8 @@ The generator performs **topological sorting** of modules based on their contrac // Phase 1: No dependencies ((IModule)s_Dashboard_DashboardModule).ConfigureServices(services, configuration); -// Phase 2: Depends on Products -((IModule)s_Orders_OrdersModule).ConfigureServices(services, configuration); +// Phase 2: Depends on Users +((IModule)s_Admin_AdminModule).ConfigureServices(services, configuration); ``` ## Debugging Tips diff --git a/docs/site/advanced/type-generation.md b/docs/site/advanced/type-generation.md index 6b361d02..cc20116d 100644 --- a/docs/site/advanced/type-generation.md +++ b/docs/site/advanced/type-generation.md @@ -36,16 +36,16 @@ modules/{Module}/src/SimpleModule.{Module}/types.ts By default, **all public types** in `*.Contracts` assemblies are treated as DTOs and included in TypeScript generation. You do not need to add any attributes to your types. -For example, this class in `Products.Contracts`: +For example, this class in `Customers.Contracts`: ```csharp -namespace SimpleModule.Products.Contracts; +namespace SimpleModule.Customers.Contracts; -public class Product +public class Customer { - public ProductId Id { get; set; } + public CustomerId Id { get; set; } public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } + public string Email { get; set; } = string.Empty; } ``` @@ -84,7 +84,7 @@ To exclude a type in a Contracts assembly from TypeScript generation, apply `[No ```csharp using SimpleModule.Core; -namespace SimpleModule.Products.Contracts; +namespace SimpleModule.Customers.Contracts; [NoDtoGeneration] public class InternalHelper @@ -105,7 +105,7 @@ public sealed class NoDtoGenerationAttribute : Attribute { } ``` ::: tip When to use [NoDtoGeneration] -Use it for types that live in a Contracts assembly but are not meant for the frontend -- for example, contract interfaces like `IProductContracts`, internal helper types, or types used only for inter-module communication. +Use it for types that live in a Contracts assembly but are not meant for the frontend -- for example, contract interfaces like `ICustomerContracts`, internal helper types, or types used only for inter-module communication. ::: ## Running Type Generation @@ -130,30 +130,30 @@ Each module gets its own `types.ts` file at: modules/{ModuleName}/src/SimpleModule.{ModuleName}/types.ts ``` -For example, the Products module produces: +For example, the Customers module produces: ``` -modules/Products/src/SimpleModule.Products/types.ts +modules/Customers/src/SimpleModule.Customers/types.ts ``` The file is marked as auto-generated and should not be edited manually: ```typescript // Auto-generated from [Dto] types — do not edit -export interface CreateProductRequest { +export interface CreateCustomerRequest { name: string; - price: number; + email: string; } -export interface Product { +export interface Customer { id: number; name: string; - price: number; + email: string; } -export interface UpdateProductRequest { +export interface UpdateCustomerRequest { name: string; - price: number; + email: string; } ``` @@ -200,7 +200,7 @@ When a property references another `[Dto]` type, the generator resolves it to th ### Value Objects -Vogen value objects (strongly-typed IDs, etc.) are mapped to their **underlying primitive type**. For example, a `ProductId` wrapping `int` maps to `number` in TypeScript. +Vogen value objects (strongly-typed IDs, etc.) are mapped to their **underlying primitive type**. For example, a `CustomerId` wrapping `int` maps to `number` in TypeScript. ### Unknown Types @@ -211,26 +211,26 @@ Any type not recognized by the mapping rules falls back to `any`. Import the generated types directly in your React components: ```tsx -import type { Product, CreateProductRequest } from '../types'; +import type { Customer, CreateCustomerRequest } from '../types'; interface BrowseProps { - products: Product[]; + customers: Customer[]; } -export default function Browse({ products }: BrowseProps) { +export default function Browse({ customers }: BrowseProps) { return ( - + - {products.map((product) => ( - - - + {customers.map((customer) => ( + + + ))} @@ -247,12 +247,12 @@ The source generator embeds TypeScript interfaces as **comments inside C# files* // #if SIMPLEMODULE_TS /* -// @module Products +// @module Customers -export interface Product { +export interface Customer { id: number; name: string; - price: number; + email: string; } */ diff --git a/docs/site/cli/new-project.md b/docs/site/cli/new-project.md index 2829de52..0d897241 100644 --- a/docs/site/cli/new-project.md +++ b/docs/site/cli/new-project.md @@ -70,7 +70,7 @@ Framework code (`Core`, `Database`, `Generator`) is consumed from NuGet packages ```bash cd MyApp -sm new module Products # add another module under src/modules/ +sm new module Customers # add another module under src/modules/ dotnet build # build the solution dotnet run --project src/MyApp.Host ``` diff --git a/docs/site/cli/overview.md b/docs/site/cli/overview.md index 7d9e716c..bec044c7 100644 --- a/docs/site/cli/overview.md +++ b/docs/site/cli/overview.md @@ -23,7 +23,6 @@ Once installed, the `sm` command is available globally from any terminal. | `sm new project [name]` | Scaffold a new SimpleModule solution | | `sm new module [name]` | Create a module with contracts, endpoints, and tests | | `sm new feature [name]` | Add a feature endpoint to an existing module | -| `sm new agent [name]` | Add an AI agent to an existing module | | `sm dev` | Start the full dev environment (dotnet watch + Vite HMR) | | `sm list` | List discovered modules in the current project with their route prefixes | | `sm install ` | Install a SimpleModule package from NuGet | diff --git a/docs/site/frontend/components.md b/docs/site/frontend/components.md index 390f61a3..7e44861c 100644 --- a/docs/site/frontend/components.md +++ b/docs/site/frontend/components.md @@ -111,17 +111,17 @@ This avoids class conflicts (e.g., `p-4` and `p-2` don't both end up in the DOM ```tsx import { Card, CardContent, PageShell } from '@simplemodule/ui'; -import type { Product } from '../types'; +import type { Customer } from '../types'; -export default function Browse({ products }: { products: Product[] }) { +export default function Browse({ customers }: { customers: Customer[] }) { return ( - +
- {products.map((p) => ( - + {customers.map((c) => ( + - {p.name} - ${p.price.toFixed(2)} + {c.name} + {c.email} ))} @@ -141,11 +141,11 @@ function CreateForm() {
- - + + Name is required - + ); @@ -157,13 +157,13 @@ function CreateForm() { ```tsx import { DataGridPage } from '@simplemodule/ui'; -export default function Manage({ products }) { +export default function Manage({ customers }) { return ( ); } diff --git a/docs/site/frontend/overview.md b/docs/site/frontend/overview.md index 877e9e18..78d0fad3 100644 --- a/docs/site/frontend/overview.md +++ b/docs/site/frontend/overview.md @@ -12,9 +12,9 @@ The frontend architecture follows a modular pattern that mirrors the backend: ``` Browser Request - --> ASP.NET route handler calls Inertia.Render("Products/Browse", props) + --> ASP.NET route handler calls Inertia.Render("Customers/Browse", props) --> Inertia middleware renders static HTML shell with embedded JSON props - --> React ClientApp dynamically imports SimpleModule.Products.pages.js + --> React ClientApp dynamically imports SimpleModule.Customers.pages.js --> Component hydrates with server-provided props ``` @@ -36,12 +36,12 @@ Each module compiles its React pages into a single ES module bundle (`{ModuleNam Every module that has a UI builds a `{ModuleName}.pages.js` file into its `wwwroot/` directory. This file exports a `pages` record that maps route names to React components: ```ts -// modules/Products/src/SimpleModule.Products/Pages/index.ts +// modules/Customers/src/SimpleModule.Customers/Pages/index.ts export const pages: Record = { - 'Products/Browse': () => import('./Browse'), - 'Products/Manage': () => import('./Manage'), - 'Products/Create': () => import('./Create'), - 'Products/Edit': () => import('./Edit'), + 'Customers/Browse': () => import('./Browse'), + 'Customers/Manage': () => import('./Manage'), + 'Customers/Create': () => import('./Create'), + 'Customers/Edit': () => import('./Edit'), }; ``` @@ -102,10 +102,10 @@ export async function resolvePage(name: string) { } ``` -For a route name like `Products/Browse`: -1. The module name `Products` is extracted from the first segment -2. The bundle `/_content/SimpleModule.Products/SimpleModule.Products.pages.js` is dynamically imported -3. The `pages` record is looked up for the key `Products/Browse` +For a route name like `Customers/Browse`: +1. The module name `Customers` is extracted from the first segment +2. The bundle `/_content/SimpleModule.Customers/SimpleModule.Customers.pages.js` is dynamically imported +3. The `pages` record is looked up for the key `Customers/Browse` 4. Lazy entries (functions) are resolved, eager entries are returned directly A cache-buster query parameter is appended from a `` tag when present, ensuring browsers pick up new builds without stale caches. @@ -125,16 +125,16 @@ The `@simplemodule/client` package (`packages/SimpleModule.Client/`) provides th The source generator discovers C# types marked with the `[Dto]` attribute and embeds TypeScript interface definitions. The `scripts/extract-ts-types.mjs` script extracts these into `.ts` files under `ClientApp/types/`, giving React components full type safety over server-provided props: ```tsx -import type { Product } from '../types'; +import type { Customer } from '../types'; -export default function Browse({ products }: { products: Product[] }) { +export default function Browse({ customers }: { customers: Customer[] }) { return ( - - {products.map((p) => ( - + + {customers.map((c) => ( + - {p.name} - ${p.price.toFixed(2)} + {c.name} + {c.email} ))} diff --git a/docs/site/frontend/pages.md b/docs/site/frontend/pages.md index 3180994c..bd844ec8 100644 --- a/docs/site/frontend/pages.md +++ b/docs/site/frontend/pages.md @@ -11,12 +11,12 @@ Every module that renders UI must maintain a **pages registry** -- a `Pages/inde Each module exports a `pages` record from `Pages/index.ts`: ```ts -// modules/Products/src/SimpleModule.Products/Pages/index.ts +// modules/Customers/src/SimpleModule.Customers/Pages/index.ts export const pages: Record = { - 'Products/Browse': () => import('./Browse'), - 'Products/Manage': () => import('./Manage'), - 'Products/Create': () => import('./Create'), - 'Products/Edit': () => import('./Edit'), + 'Customers/Browse': () => import('./Browse'), + 'Customers/Manage': () => import('./Manage'), + 'Customers/Create': () => import('./Create'), + 'Customers/Edit': () => import('./Edit'), }; ``` @@ -27,17 +27,17 @@ Each key matches the component name passed to `Inertia.Render()` on the server s On the backend, view endpoints call `Inertia.Render` with a component name: ```csharp -// modules/Products/src/SimpleModule.Products/Pages/BrowseEndpoint.cs +// modules/Customers/src/SimpleModule.Customers/Pages/BrowseEndpoint.cs public class BrowseEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( "/browse", - async (IProductContracts products) => + async (ICustomerContracts customers) => Inertia.Render( - "Products/Browse", // <-- This key must exist in Pages/index.ts - new { products = await products.GetAllProductsAsync() } + "Customers/Browse", // <-- This key must exist in Pages/index.ts + new { customers = await customers.GetAllCustomersAsync() } ) ) .AllowAnonymous(); @@ -45,14 +45,14 @@ public class BrowseEndpoint : IViewEndpoint } ``` -The string `"Products/Browse"` is the key that `resolvePage` looks up in the module's `pages` record. If the key does not exist, `resolvePage` throws an explicit error and the ClientApp surfaces a toast notification. +The string `"Customers/Browse"` is the key that `resolvePage` looks up in the module's `pages` record. If the key does not exist, `resolvePage` throws an explicit error and the ClientApp surfaces a toast notification. ::: danger Missing entries throw an explicit error -If you add a new `IViewEndpoint` with `Inertia.Render("Products/Something")` but forget to add a matching entry in `Pages/index.ts`: +If you add a new `IViewEndpoint` with `Inertia.Render("Customers/Something")` but forget to add a matching entry in `Pages/index.ts`: - The endpoint compiles and runs fine on the server - Navigating to that page causes `resolvePage` to throw: - `Error: Page "Products/Something" not found in module "Products". Available pages: ...` + `Error: Page "Customers/Something" not found in module "Customers". Available pages: ...` - The ClientApp surfaces this via a toast notification (not a silent 404), and the error is logged to the browser console **Always add the pages registry entry immediately when creating a new view endpoint** -- the error is visible, but it still breaks navigation for users. @@ -75,8 +75,8 @@ For every `IViewEndpoint` that calls `Inertia.Render("ModuleName/PageName", ...) { public void Map(IEndpointRouteBuilder app) { - app.MapGet("/{id}", (int id, IProductContracts products) => - Inertia.Render("Products/Details", new { product = ... })); + app.MapGet("/{id}", (int id, ICustomerContracts customers) => + Inertia.Render("Customers/Details", new { customer = ... })); } } ``` @@ -84,13 +84,13 @@ For every `IViewEndpoint` that calls `Inertia.Render("ModuleName/PageName", ...) 2. **Create the React component** in the module's `Pages/` directory: ```tsx - // modules/Products/src/SimpleModule.Products/Pages/Details.tsx + // modules/Customers/src/SimpleModule.Customers/Pages/Details.tsx import { PageShell } from '@simplemodule/ui'; - import type { Product } from '../types'; + import type { Customer } from '../types'; - export default function Details({ product }: { product: Product }) { + export default function Details({ customer }: { customer: Customer }) { return ( - + {/* ... */} ); @@ -101,11 +101,11 @@ For every `IViewEndpoint` that calls `Inertia.Render("ModuleName/PageName", ...) ```ts export const pages: Record = { - 'Products/Browse': () => import('./Browse'), - 'Products/Manage': () => import('./Manage'), - 'Products/Create': () => import('./Create'), - 'Products/Edit': () => import('./Edit'), - 'Products/Details': () => import('./Details'), // [!code ++] + 'Customers/Browse': () => import('./Browse'), + 'Customers/Manage': () => import('./Manage'), + 'Customers/Create': () => import('./Create'), + 'Customers/Edit': () => import('./Edit'), + 'Customers/Details': () => import('./Details'), // [!code ++] }; ``` @@ -146,8 +146,8 @@ On failure: ``` === Pages Registry Validation === -Module: Products - Missing in Pages/index.ts: Products/Details +Module: Customers + Missing in Pages/index.ts: Customers/Details Found 1 module(s) with mismatches Please update the Pages/index.ts files to match C# endpoints. @@ -170,10 +170,10 @@ The pages registry supports both lazy and eager component imports: ```ts // Lazy (recommended) -- component is loaded on demand -'Products/Browse': () => import('./Browse'), +'Customers/Browse': () => import('./Browse'), // Eager -- component is bundled into the pages.js file -'Products/Browse': Browse, +'Customers/Browse': Browse, ``` Lazy imports are recommended because they allow Vite to code-split within the module bundle. The `resolvePage` function handles both patterns transparently. diff --git a/docs/site/frontend/vite.md b/docs/site/frontend/vite.md index 2a2f82cc..b15fdf3e 100644 --- a/docs/site/frontend/vite.md +++ b/docs/site/frontend/vite.md @@ -23,7 +23,7 @@ In a modular monolith, each module is independently deployable. If every module Every module uses the `defineModuleConfig` helper from `@simplemodule/client/module`: ```ts -// modules/Products/src/SimpleModule.Products/vite.config.ts +// modules/Customers/src/SimpleModule.Customers/vite.config.ts import { defineModuleConfig } from '@simplemodule/client/module'; export default defineModuleConfig(import.meta.dirname); @@ -116,7 +116,7 @@ Each module declares React and React-DOM as **peer dependencies** since they are ```json { "private": true, - "name": "@simplemodule/products", + "name": "@simplemodule/customers", "version": "0.0.0", "scripts": { "build": "cross-env VITE_MODE=prod vite build", @@ -153,9 +153,9 @@ The `npm run dev` command starts the complete development environment using the npm run dev | ├── dotnet run --project template/SimpleModule.Host - ├── npm run watch (in modules/Products/src/SimpleModule.Products/) - ├── npm run watch (in modules/Orders/src/SimpleModule.Orders/) + ├── npm run watch (in modules/Customers/src/SimpleModule.Customers/) ├── npm run watch (in modules/Users/src/SimpleModule.Users/) + ├── npm run watch (in modules/Admin/src/SimpleModule.Admin/) └── npm run watch (in template/SimpleModule.Host/ClientApp/) ``` @@ -222,12 +222,12 @@ npm run dev:build After building, each module's `wwwroot/` directory contains: ``` -modules/Products/src/SimpleModule.Products/wwwroot/ - SimpleModule.Products.pages.js # The module's page bundle - simplemodule.products.css # Any CSS assets (named from module) +modules/Customers/src/SimpleModule.Customers/wwwroot/ + SimpleModule.Customers.pages.js # The module's page bundle + simplemodule.customers.css # Any CSS assets (named from module) ``` -These files are served as static content via ASP.NET's `_content/SimpleModule.{ModuleName}/` path, which is how `resolvePage` finds them at `/_content/SimpleModule.Products/SimpleModule.Products.pages.js`. +These files are served as static content via ASP.NET's `_content/SimpleModule.{ModuleName}/` path, which is how `resolvePage` finds them at `/_content/SimpleModule.Customers/SimpleModule.Customers.pages.js`. ## Next Steps diff --git a/docs/site/getting-started/introduction.md b/docs/site/getting-started/introduction.md index 7ca89bf7..8b2b2330 100644 --- a/docs/site/getting-started/introduction.md +++ b/docs/site/getting-started/introduction.md @@ -64,11 +64,11 @@ This means: - **Full React ecosystem** -- use any React library. The framework doesn't limit what you can do on the client. ```typescript -// modules/Products/src/SimpleModule.Products/Pages/index.ts +// modules/Customers/src/SimpleModule.Customers/Pages/index.ts export const pages: Record = { - 'Products/Browse': () => import('./Browse'), - 'Products/Manage': () => import('./Manage'), - 'Products/Create': () => import('./Create'), + 'Customers/Browse': () => import('./Browse'), + 'Customers/Manage': () => import('./Manage'), + 'Customers/Create': () => import('./Create'), }; ``` @@ -94,22 +94,12 @@ Modules cannot reference each other's implementation projects. They depend on `. The `sm` command-line tool handles scaffolding and project health: ```bash -sm new project MyApp # scaffold a new SimpleModule solution -sm new module Products # create a module with contracts, endpoints, tests -sm new feature Products/Browse # add a feature to an existing module -sm doctor --fix # validate project structure, auto-fix issues +sm new project MyApp # scaffold a new SimpleModule solution +sm new module Customers # create a module with contracts, endpoints, tests +sm new feature Customers/Browse # add a feature to an existing module +sm doctor --fix # validate project structure, auto-fix issues ``` -### AI Agents & RAG - -Build AI-powered features with built-in support for multiple LLM providers, tool calling, and retrieval-augmented generation: - -- **Multi-provider AI** -- Anthropic (Claude), OpenAI, Azure OpenAI, and Ollama behind a unified `IChatClient` interface -- **Agent runtime** -- define agents with custom instructions, tools, and guardrails. Supports streaming (SSE) responses -- **Tool discovery** -- mark methods with `[AgentTool]` for automatic tool registration -- **RAG pipeline** -- index documents into a vector store and inject relevant context into agent conversations -- **Built-in safety** -- rate limiting, token tracking, PII redaction, and prompt injection detection - ### File Storage A pluggable file storage abstraction with providers for local filesystem, AWS S3, and Azure Blob Storage. The FileStorage module adds HTTP upload/download endpoints, folder browsing, and a database-backed file registry. @@ -132,9 +122,9 @@ SimpleModule supports multiple database providers with automatic schema isolatio | Provider | Isolation strategy | Use case | |----------|-------------------|----------| -| SQLite | Table prefixes (`Products_Items`) | Local development, testing | -| PostgreSQL | Schemas (`products.items`) | Production | -| SQL Server | Schemas (`products.items`) | Production | +| SQLite | Table prefixes (`Customers_Items`) | Local development, testing | +| PostgreSQL | Schemas (`customers.items`) | Production | +| SQL Server | Schemas (`customers.items`) | Production | Each module registers its database context through `ModuleDbContextInfo`. The framework handles schema creation and provider-specific configuration. @@ -145,14 +135,14 @@ The core workflow is straightforward: **1. Define a module** ```csharp -[Module("Products", RoutePrefix = "products")] -public sealed class ProductsModule : IModule +[Module("Customers", RoutePrefix = "customers")] +public sealed class CustomersModule : IModule { public static void ConfigureServices( IServiceCollection services, IConfiguration configuration) { - services.AddScoped(); + services.AddScoped(); } } ``` @@ -160,26 +150,26 @@ public sealed class ProductsModule : IModule **2. Add endpoints** ```csharp -public sealed class BrowseProducts : IViewEndpoint +public sealed class BrowseCustomers : IViewEndpoint { public static void Map(IEndpointRouteBuilder app) => app.MapGet("/", Handler); - private static IResult Handler(IProductContracts products) => - Inertia.Render("Products/Browse", new + private static IResult Handler(ICustomerContracts customers) => + Inertia.Render("Customers/Browse", new { - Products = products.GetAll() + Customers = customers.GetAll() }); } ``` **3. Build the project** -The Roslyn source generator runs during compilation. It discovers `ProductsModule`, finds `BrowseProducts`, and generates the wiring code. You can see the generated files in your IDE under Analyzers. +The Roslyn source generator runs during compilation. It discovers `CustomersModule`, finds `BrowseCustomers`, and generates the wiring code. You can see the generated files in your IDE under Analyzers. **4. Everything is registered** -When you call `AddSimpleModule()`, the generated `AddModules()` runs and invokes `ProductsModule.ConfigureServices()`. When you call `UseSimpleModule()`, the generated `MapModuleEndpoints()` maps `BrowseProducts` under the `/products` route prefix, and `CollectModuleMenuItems()` gathers any menu items the module registered. All at compile time. All type-safe. +When you call `AddSimpleModule()`, the generated `AddModules()` runs and invokes `CustomersModule.ConfigureServices()`. When you call `UseSimpleModule()`, the generated `MapModuleEndpoints()` maps `BrowseCustomers` under the `/customers` route prefix, and `CollectModuleMenuItems()` gathers any menu items the module registered. All at compile time. All type-safe. ::: tip Zero Configuration You don't write registration code, startup configuration, or reflection-based discovery logic. Add a class, implement an interface, build. The generator handles the rest. @@ -195,8 +185,6 @@ You don't write registration code, startup configuration, or reflection-based di | Build tooling | Vite, Tailwind CSS 4 | | Source generation | Roslyn incremental generators | | Component library | Radix UI | -| AI | Anthropic, OpenAI, Azure OpenAI, Ollama via Microsoft.Extensions.AI | -| RAG | Vector search with PostgreSQL or in-memory stores | | Testing | xUnit.v3, FluentAssertions, Bogus, Playwright, BenchmarkDotNet, NBomber | | Database | SQLite, PostgreSQL, SQL Server via EF Core | | File storage | Local, AWS S3, Azure Blob Storage | diff --git a/docs/site/getting-started/project-structure.md b/docs/site/getting-started/project-structure.md index 6e55ebc4..f68b4ffd 100644 --- a/docs/site/getting-started/project-structure.md +++ b/docs/site/getting-started/project-structure.md @@ -23,12 +23,12 @@ When you run `sm new project MyApp`, the resulting solution looks like this: MyApp/ ├── src/ │ ├── modules/ # Your feature modules -│ │ ├── SimpleModule.Products/ +│ │ ├── SimpleModule.Customers/ │ │ │ ├── src/ -│ │ │ │ ├── SimpleModule.Products/ -│ │ │ │ └── SimpleModule.Products.Contracts/ +│ │ │ │ ├── SimpleModule.Customers/ +│ │ │ │ └── SimpleModule.Customers.Contracts/ │ │ │ └── tests/ -│ │ │ └── SimpleModule.Products.Tests/ +│ │ │ └── SimpleModule.Customers.Tests/ │ │ └── ... │ └── MyApp.Host/ # Host application │ ├── ClientApp/ @@ -52,39 +52,21 @@ SimpleModule/ │ ├── SimpleModule.Generator/ │ ├── SimpleModule.Database/ │ ├── SimpleModule.Hosting/ -│ ├── SimpleModule.Agents/ # AI agent runtime and registry -│ ├── SimpleModule.AI.Anthropic/ # Claude API provider -│ ├── SimpleModule.AI.OpenAI/ # OpenAI API provider -│ ├── SimpleModule.AI.AzureOpenAI/ # Azure OpenAI provider -│ ├── SimpleModule.AI.Ollama/ # Ollama local model provider -│ ├── SimpleModule.Rag/ # RAG pipeline and knowledge store -│ ├── SimpleModule.Rag.StructuredRag/ # Structured RAG pipeline -│ ├── SimpleModule.Rag.VectorStore.InMemory/ # In-memory vector store (dev) -│ ├── SimpleModule.Rag.VectorStore.Postgres/ # PostgreSQL vector store │ ├── SimpleModule.Storage/ # Storage provider abstraction │ ├── SimpleModule.Storage.Local/ # Local filesystem storage │ ├── SimpleModule.Storage.S3/ # AWS S3 storage │ └── SimpleModule.Storage.Azure/ # Azure Blob storage ├── modules/ # Demo / built-in modules (framework repo only) │ ├── Admin/ -│ ├── Agents/ │ ├── AuditLogs/ │ ├── BackgroundJobs/ -│ ├── Chat/ │ ├── Dashboard/ -│ ├── Datasets/ │ ├── Email/ │ ├── FeatureFlags/ │ ├── FileStorage/ │ ├── Localization/ -│ ├── Map/ -│ ├── Marketplace/ │ ├── OpenIddict/ -│ ├── Orders/ -│ ├── PageBuilder/ │ ├── Permissions/ -│ ├── Products/ -│ ├── Rag/ │ ├── RateLimiting/ │ ├── Settings/ │ ├── Tenants/ @@ -164,27 +146,6 @@ Multi-provider database support built on EF Core. Handles: Module registration infrastructure and Inertia page rendering. Exposes the two user-facing entry points that your host's `Program.cs` calls -- `builder.AddSimpleModule()` and `await app.UseSimpleModule()` -- which in turn invoke the generated `AddModules()`, `MapModuleEndpoints()`, and `CollectModuleMenuItems()` methods. Handles service collection extensions, endpoint routing integration, module lifecycle management, and renders the static HTML shell with embedded JSON props for React hydration. -### SimpleModule.Agents - -AI agent runtime and orchestration. Provides `IAgentRegistry` for agent discovery, `AgentChatService` for chat (streaming and non-streaming), `IAgentToolProvider` with `[AgentTool]` attribute for tool discovery, and middleware for rate limiting, token tracking, and guardrails (PII redaction, prompt injection detection). - -### SimpleModule.AI.* - -AI provider integrations implementing `IChatClient` from Microsoft.Extensions.AI: - -- **SimpleModule.AI.Anthropic** -- Claude API via the Anthropic SDK -- **SimpleModule.AI.OpenAI** -- OpenAI API -- **SimpleModule.AI.AzureOpenAI** -- Azure OpenAI Service -- **SimpleModule.AI.Ollama** -- Ollama for local model inference - -### SimpleModule.Rag - -Retrieval-augmented generation pipeline. Defines `IRagPipeline` for querying a knowledge base and `IKnowledgeStore` for indexing documents. Includes `KnowledgeIndexingHostedService` for background indexing with deduplication. - -- **SimpleModule.Rag.StructuredRag** -- Structured RAG implementation (table, graph, algorithm, catalogue, chunk formats) -- **SimpleModule.Rag.VectorStore.InMemory** -- In-memory vector store for development and testing -- **SimpleModule.Rag.VectorStore.Postgres** -- PostgreSQL-backed vector store for production - ### SimpleModule.Storage File storage abstraction with `IStorageProvider` interface (save, get, delete, exists, list). Three provider implementations: @@ -206,50 +167,50 @@ Development utilities including hot reload support, diagnostic middleware, and d Every module follows a **three-project pattern**: implementation, contracts, and tests. Project directories and assembly names use the `SimpleModule.{Name}` prefix (enforced by diagnostic SM0052). ``` -modules/Products/ # (framework repo layout -- use src/modules/SimpleModule.Products/ in a CLI-scaffolded app) +modules/Customers/ # (framework repo layout -- use src/modules/SimpleModule.Customers/ in a CLI-scaffolded app) ├── src/ -│ ├── SimpleModule.Products/ # Implementation (private) -│ │ ├── SimpleModule.Products.csproj -│ │ ├── ProductsModule.cs # Module class with [Module] attribute -│ │ ├── ProductsDbContext.cs # EF Core DbContext (module root) -│ │ ├── ProductService.cs # IProductContracts implementation (module root) -│ │ ├── EntityConfigurations/ # IEntityTypeConfiguration classes +│ ├── SimpleModule.Customers/ # Implementation (private) +│ │ ├── SimpleModule.Customers.csproj +│ │ ├── CustomersModule.cs # Module class with [Module] attribute +│ │ ├── CustomersDbContext.cs # EF Core DbContext (module root) +│ │ ├── CustomerService.cs # ICustomerContracts implementation (module root) +│ │ ├── EntityConfigurations/ # IEntityTypeConfiguration classes │ │ ├── Endpoints/ -│ │ │ └── Products/ -│ │ │ ├── BrowseProducts.cs # GET /products -│ │ │ ├── CreateProduct.cs # POST /products/create -│ │ │ └── ManageProduct.cs # GET /products/{id} -│ │ ├── Pages/ # React components live alongside their view endpoints -│ │ │ ├── index.ts # React page registry -│ │ │ ├── Browse.tsx # React page component -│ │ │ ├── BrowseEndpoint.cs # Matching IViewEndpoint +│ │ │ └── Customers/ +│ │ │ ├── BrowseCustomers.cs # GET /customers +│ │ │ ├── CreateCustomer.cs # POST /customers/create +│ │ │ └── ManageCustomer.cs # GET /customers/{id} +│ │ ├── Pages/ # React components live alongside their view endpoints +│ │ │ ├── index.ts # React page registry +│ │ │ ├── Browse.tsx # React page component +│ │ │ ├── BrowseEndpoint.cs # Matching IViewEndpoint │ │ │ ├── Create.tsx │ │ │ ├── CreateEndpoint.cs │ │ │ ├── Edit.tsx │ │ │ ├── EditEndpoint.cs │ │ │ ├── Manage.tsx │ │ │ └── ManageEndpoint.cs -│ │ ├── vite.config.ts # Vite library mode config -│ │ └── package.json # npm package with peer deps -│ └── SimpleModule.Products.Contracts/ # Public API (shared) -│ ├── SimpleModule.Products.Contracts.csproj -│ ├── IProductContracts.cs # Contract interface -│ ├── Product.cs # [Dto] public record -│ ├── CreateProductRequest.cs # [Dto] request shape -│ ├── UpdateProductRequest.cs # [Dto] request shape -│ ├── ProductId.cs # Strongly-typed id -│ ├── ProductsConstants.cs # Shared constants -│ └── Events/ # Cross-module event records +│ │ ├── vite.config.ts # Vite library mode config +│ │ └── package.json # npm package with peer deps +│ └── SimpleModule.Customers.Contracts/ # Public API (shared) +│ ├── SimpleModule.Customers.Contracts.csproj +│ ├── ICustomerContracts.cs # Contract interface +│ ├── Customer.cs # [Dto] public record +│ ├── CreateCustomerRequest.cs # [Dto] request shape +│ ├── UpdateCustomerRequest.cs # [Dto] request shape +│ ├── CustomerId.cs # Strongly-typed id +│ ├── CustomersConstants.cs # Shared constants +│ └── Events/ # Cross-module event records └── tests/ - └── SimpleModule.Products.Tests/ # Test project - ├── SimpleModule.Products.Tests.csproj + └── SimpleModule.Customers.Tests/ # Test project + ├── SimpleModule.Customers.Tests.csproj └── Endpoints/ - └── BrowseProductsTests.cs + └── BrowseCustomersTests.cs ``` There is no separate `Views/` directory -- React components (`*.tsx`) live directly in `Pages/` next to their matching `*Endpoint.cs` view endpoints. Likewise the DbContext and the contracts service implementation sit at the module root rather than inside `Data/` or `Services/` folders. -### Implementation Project (`SimpleModule.Products/`) +### Implementation Project (`SimpleModule.Customers/`) This is the private implementation. No other module should reference this project directly. It contains: @@ -270,60 +231,60 @@ The `.csproj` file uses `Microsoft.NET.Sdk` with a framework reference to ASP.NE - + ``` -### Contracts Project (`SimpleModule.Products.Contracts/`) +### Contracts Project (`SimpleModule.Customers.Contracts/`) -The public face of the module. Other modules depend on this project when they need to interact with Products. Types live at the root of the contracts project (no `Dtos/` folder). It contains: +The public face of the module. Other modules depend on this project when they need to interact with Customers. Types live at the root of the contracts project (no `Dtos/` folder). It contains: -- **Contract interface** (`IProductContracts`) -- methods other modules can call -- **Public record types** marked with `[Dto]` -- `Product`, `CreateProductRequest`, `UpdateProductRequest`, strongly-typed ids such as `ProductId`, and shared constants in `ProductsConstants` +- **Contract interface** (`ICustomerContracts`) -- methods other modules can call +- **Public record types** marked with `[Dto]` -- `Customer`, `CreateCustomerRequest`, `UpdateCustomerRequest`, strongly-typed ids such as `CustomerId`, and shared constants in `CustomersConstants` - **`Events/`** -- cross-module event records published through the event bus ```csharp -// IProductContracts.cs -public interface IProductContracts +// ICustomerContracts.cs +public interface ICustomerContracts { - Task> GetAllAsync(CancellationToken cancellationToken); - Task GetByIdAsync(ProductId id, CancellationToken cancellationToken); - Task CreateAsync(CreateProductRequest request, CancellationToken cancellationToken); + Task> GetAllAsync(CancellationToken cancellationToken); + Task GetByIdAsync(CustomerId id, CancellationToken cancellationToken); + Task CreateAsync(CreateCustomerRequest request, CancellationToken cancellationToken); } ``` ```csharp -// Product.cs +// Customer.cs [Dto] -public sealed record Product(ProductId Id, string Name, decimal Price, string? Description); +public sealed record Customer(CustomerId Id, string Name, string Email, string? Notes); -// CreateProductRequest.cs +// CreateCustomerRequest.cs [Dto] -public sealed record CreateProductRequest(string Name, decimal Price, string? Description); +public sealed record CreateCustomerRequest(string Name, string Email, string? Notes); ``` ::: warning Contracts Are the Boundary The contracts project must never reference the implementation project. It depends only on `SimpleModule.Core`. This ensures that modules cannot access each other's internals -- the compiler enforces the boundary. ::: -### Test Project (`SimpleModule.Products.Tests/`) +### Test Project (`SimpleModule.Customers.Tests/`) An xUnit.v3 test project with access to the shared test infrastructure: ```csharp -public sealed class BrowseProductsTests( +public sealed class BrowseCustomersTests( SimpleModuleWebApplicationFactory factory) : IClassFixture { [Fact] - public async Task BrowseProducts_WithProducts_ReturnsAll() + public async Task BrowseCustomers_WithCustomers_ReturnsAll() { // Arrange var client = factory.CreateAuthenticatedClient(); // Act - var response = await client.GetAsync("/products"); + var response = await client.GetAsync("/customers"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -369,7 +330,7 @@ ClientApp/ └── ... ``` -The page resolver in `app.tsx` dynamically imports module bundles based on the route name. When Inertia navigates to `Products/Browse`, it resolves to `/_content/Products/Products.pages.js` and loads the corresponding React component. +The page resolver in `app.tsx` dynamically imports module bundles based on the route name. When Inertia navigates to `Customers/Browse`, it resolves to `/_content/Customers/Customers.pages.js` and loads the corresponding React component. ```typescript // Simplified page resolution logic @@ -422,29 +383,29 @@ Modules communicate through two mechanisms: Module A depends on Module B's contracts project and calls its interface methods: ``` -modules/Orders/src/SimpleModule.Orders/SimpleModule.Orders.csproj - └── references → modules/Products/src/SimpleModule.Products.Contracts/ +modules/Invoices/src/SimpleModule.Invoices/SimpleModule.Invoices.csproj + └── references → modules/Customers/src/SimpleModule.Customers.Contracts/ ``` ```csharp -// In an Orders endpoint -public sealed class CreateOrder : IEndpoint +// In an Invoices endpoint +public sealed class CreateInvoice : IEndpoint { public static void Map(IEndpointRouteBuilder app) => app.MapPost("/", Handler); private static async Task Handler( - CreateOrderRequest request, - IProductContracts products, // injected from Products module - IOrderContracts orders, + CreateInvoiceRequest request, + ICustomerContracts customers, // injected from Customers module + IInvoiceContracts invoices, CancellationToken cancellationToken) { - var product = await products.GetByIdAsync(request.ProductId, cancellationToken); - if (product is null) + var customer = await customers.GetByIdAsync(request.CustomerId, cancellationToken); + if (customer is null) return TypedResults.NotFound(); - var order = await orders.CreateAsync(request, cancellationToken); - return TypedResults.Created($"/orders/{order.Id}", order); + var invoice = await invoices.CreateAsync(request, cancellationToken); + return TypedResults.Created($"/invoices/{invoice.Id}", invoice); } } ``` @@ -454,14 +415,14 @@ public sealed class CreateOrder : IEndpoint For loose coupling, modules publish events through Wolverine's `IMessageBus`: ```csharp -// Publisher (in Orders module) -await bus.PublishAsync(new OrderCreatedEvent(order.Id, order.ProductId)); +// Publisher (in Invoices module) +await bus.PublishAsync(new InvoiceCreatedEvent(invoice.Id, invoice.CustomerId)); -// Handler (in Products module, or any module) -- discovered by naming convention -public sealed class UpdateStockOnOrderCreated(IProductContracts products) +// Handler (in Customers module, or any module) -- discovered by naming convention +public sealed class UpdateBalanceOnInvoiceCreated(ICustomerContracts customers) { - public Task Handle(OrderCreatedEvent evt, CancellationToken ct) => - products.ReduceStockAsync(evt.ProductId, ct); + public Task Handle(InvoiceCreatedEvent evt, CancellationToken ct) => + customers.IncrementBalanceAsync(evt.CustomerId, ct); } ``` diff --git a/docs/site/getting-started/quick-start.md b/docs/site/getting-started/quick-start.md index c27c4f26..d6c0119b 100644 --- a/docs/site/getting-started/quick-start.md +++ b/docs/site/getting-started/quick-start.md @@ -80,36 +80,36 @@ Open [https://localhost:5001](https://localhost:5001). With the CLI installed and your project running, add a new module: ```bash -sm new module Products +sm new module Customers ``` This creates three projects following the standard module pattern: ``` -src/modules/Products/ +src/modules/Customers/ ├── src/ -│ ├── Products/ # Module implementation -│ │ ├── Products.csproj -│ │ ├── ProductsModule.cs # [Module] class with ConfigureServices -│ │ ├── ProductsConstants.cs # Module constants -│ │ ├── ProductsDbContext.cs # EF Core DbContext -│ │ ├── ProductService.cs # Default IProductContracts implementation +│ ├── Customers/ # Module implementation +│ │ ├── Customers.csproj +│ │ ├── CustomersModule.cs # [Module] class with ConfigureServices +│ │ ├── CustomersConstants.cs # Module constants +│ │ ├── CustomersDbContext.cs # EF Core DbContext +│ │ ├── CustomerService.cs # Default ICustomerContracts implementation │ │ ├── Endpoints/ -│ │ │ └── Products/ +│ │ │ └── Customers/ │ │ │ └── GetAllEndpoint.cs # Starter endpoint │ │ └── tsconfig.json -│ └── Products.Contracts/ # Public interface for other modules -│ ├── Products.Contracts.csproj -│ ├── IProductContracts.cs # Contract interface -│ ├── Product.cs # Shared DTO with [Dto] attribute +│ └── Customers.Contracts/ # Public interface for other modules +│ ├── Customers.Contracts.csproj +│ ├── ICustomerContracts.cs # Contract interface +│ ├── Customer.cs # Shared DTO with [Dto] attribute │ └── Events/ -│ └── ProductCreatedEvent.cs # Contract-level event +│ └── CustomerCreatedEvent.cs # Contract-level event └── tests/ - └── Products.Tests/ # xUnit test project - ├── Products.Tests.csproj + └── Customers.Tests/ # xUnit test project + ├── Customers.Tests.csproj ├── GlobalUsings.cs - ├── Unit/ProductServiceTests.cs - └── Integration/ProductsEndpointTests.cs + ├── Unit/CustomerServiceTests.cs + └── Integration/CustomersEndpointTests.cs ``` The CLI also: @@ -124,14 +124,14 @@ The CLI also: ### The Generated Module Class ```csharp -[Module("Products", RoutePrefix = "products")] -public sealed class ProductsModule : IModule +[Module("Customers", RoutePrefix = "customers")] +public sealed class CustomersModule : IModule { public static void ConfigureServices( IServiceCollection services, IConfiguration configuration) { - services.AddScoped(); + services.AddScoped(); } } ``` @@ -139,14 +139,14 @@ public sealed class ProductsModule : IModule ### The Generated Contract ```csharp -// In Products.Contracts -public interface IProductContracts +// In Customers.Contracts +public interface ICustomerContracts { - Task> GetAllAsync(CancellationToken cancellationToken); + Task> GetAllAsync(CancellationToken cancellationToken); } [Dto] -public sealed record ProductDto(int Id, string Name, decimal Price); +public sealed record CustomerDto(int Id, string Name, string Email); ``` ::: info The `[Dto]` Attribute @@ -155,17 +155,17 @@ Marking a type with `[Dto]` tells the source generator to include it in JSON ser ## Adding a Feature -Add a browsing feature to the Products module: +Add a browsing feature to the Customers module: ```bash -sm new feature Browse --module Products +sm new feature Browse --module Customers ``` Run `sm new feature` with no arguments for an interactive prompt that asks for the feature name, module, HTTP method, and route. This scaffolds: -- A C# endpoint class (`Endpoints/Products/BrowseEndpoint.cs`) +- A C# endpoint class (`Endpoints/Customers/BrowseEndpoint.cs`) - A React page component (`Views/Browse.tsx`) - An entry in the page registry (`Pages/index.ts`) @@ -178,11 +178,11 @@ public sealed class BrowseEndpoint : IViewEndpoint app.MapGet("/", Handler); private static async Task Handler( - IProductContracts products, + ICustomerContracts customers, CancellationToken cancellationToken) { - var items = await products.GetAllAsync(cancellationToken); - return Inertia.Render("Products/Browse", new { Products = items }); + var items = await customers.GetAllAsync(cancellationToken); + return Inertia.Render("Customers/Browse", new { Customers = items }); } } ``` @@ -193,22 +193,22 @@ public sealed class BrowseEndpoint : IViewEndpoint import { Head } from "@inertiajs/react"; interface Props { - products: Array<{ + customers: Array<{ id: number; name: string; - price: number; + email: string; }>; } -export default function Browse({ products }: Props) { +export default function Browse({ customers }: Props) { return ( <> - -

Products

+ +

Customers

    - {products.map((product) => ( -
  • - {product.name} - ${product.price} + {customers.map((customer) => ( +
  • + {customer.name} - {customer.email}
  • ))}
@@ -220,14 +220,14 @@ export default function Browse({ products }: Props) { ### The Page Registry ```typescript -// src/modules/Products/src/Products/Pages/index.ts +// src/modules/Customers/src/Customers/Pages/index.ts export const pages: Record = { - "Products/Browse": () => import("@/Views/Browse"), + "Customers/Browse": () => import("@/Views/Browse"), }; ``` ::: danger Don't Forget the Page Registry -Every `IViewEndpoint` that calls `Inertia.Render("Products/Something", ...)` **must** have a matching entry in `Pages/index.ts`. If you forget, the endpoint works on the server but silently 404s on the client with no error message. +Every `IViewEndpoint` that calls `Inertia.Render("Customers/Something", ...)` **must** have a matching entry in `Pages/index.ts`. If you forget, the endpoint works on the server but silently 404s on the client with no error message. Run `npm run validate-pages` to catch mismatches. ::: @@ -243,13 +243,13 @@ dotnet test Run tests for a specific module: ```bash -dotnet test --filter "FullyQualifiedName~Products" +dotnet test --filter "FullyQualifiedName~Customers" ``` Run a single test method: ```bash -dotnet test --filter "FullyQualifiedName~BrowseProducts_ReturnsOk" +dotnet test --filter "FullyQualifiedName~BrowseCustomers_ReturnsOk" ``` The test infrastructure provides: @@ -259,14 +259,14 @@ The test infrastructure provides: - **`FakeDataGenerators`** -- Bogus-based fakers for all module DTOs ```csharp -public sealed class BrowseProductsTests( +public sealed class BrowseCustomersTests( SimpleModuleWebApplicationFactory factory) : IClassFixture { [Fact] - public async Task BrowseProducts_ReturnsOk() + public async Task BrowseCustomers_ReturnsOk() { var client = factory.CreateAuthenticatedClient(); - var response = await client.GetAsync("/products"); + var response = await client.GetAsync("/customers"); response.StatusCode.Should().Be(HttpStatusCode.OK); } } @@ -285,7 +285,7 @@ This starts: - The SimpleModule host app on **http://localhost:8080** - A **PostgreSQL** instance for production-like database behavior -::: tip Development vs Production Database +::: tip Development vs Customerion Database During local development with `npm run dev`, the app uses SQLite by default -- no database server needed. Docker Compose switches to PostgreSQL to match production behavior. ::: @@ -294,7 +294,7 @@ During local development with `npm run dev`, the app uses SQLite by default -- n | Command | What it does | | ------------------------ | ----------------------------------------------- | | `npm run dev` | Start backend + all frontend watchers | -| `npm run build` | Production build (minified, optimized) | +| `npm run build` | Customerion build (minified, optimized) | | `npm run dev:build` | Dev build (unminified, source maps) | | `npm run check` | Lint + format check (Biome) | | `npm run check:fix` | Auto-fix lint + formatting | diff --git a/docs/site/guide/ai-agents.md b/docs/site/guide/ai-agents.md deleted file mode 100644 index 3834870e..00000000 --- a/docs/site/guide/ai-agents.md +++ /dev/null @@ -1,252 +0,0 @@ ---- -outline: deep ---- - -# AI Agents - -SimpleModule includes a framework for building AI-powered agents with tool calling, RAG (retrieval-augmented generation), and streaming responses. The agent system supports multiple AI providers and is configured through the standard settings system. - -## Architecture - -The agent stack has three layers: - -1. **AI Providers** (`SimpleModule.AI.*`) -- `IChatClient` implementations for different LLM providers -2. **Agent Runtime** (`SimpleModule.Agents`) -- orchestration, tool discovery, session management, and middleware -3. **RAG Pipeline** (`SimpleModule.Rag`) -- knowledge indexing and retrieval for context injection - -## Setting Up an AI Provider - -Register one AI provider in your host application. Each provider reads from a dedicated configuration section. - -### Anthropic (Claude) - -```csharp -builder.Services.AddAnthropicAI(builder.Configuration); -``` - -```json -{ - "AI": { - "Anthropic": { - "ApiKey": "sk-ant-...", - "Model": "claude-sonnet-4-20250514" - } - } -} -``` - -::: warning Model selection not yet wired -`AnthropicOptions.Model` is bound from configuration, but `AddAnthropicAI` currently constructs the client with only the API key and returns `client.Messages` as the `IChatClient`. The configured `Model` value is not passed to the client today — model selection happens at call time or is left to the SDK default. Track this if you rely on the configured model taking effect. -::: - -### OpenAI - -```csharp -builder.Services.AddOpenAI(builder.Configuration); -``` - -```json -{ - "AI": { - "OpenAI": { - "ApiKey": "sk-...", - "Model": "gpt-4o" - } - } -} -``` - -### Azure OpenAI - -```csharp -builder.Services.AddAzureOpenAI(builder.Configuration); -``` - -```json -{ - "AI": { - "AzureOpenAI": { - "Endpoint": "https://your-resource.openai.azure.com", - "DeploymentName": "gpt-4o", - "ApiKey": "your-key" - } - } -} -``` - -### Ollama (Local) - -```csharp -builder.Services.AddOllamaAI(builder.Configuration); -``` - -```json -{ - "AI": { - "Ollama": { - "Endpoint": "http://localhost:11434", - "Model": "llama3.2" - } - } -} -``` - -## Registering the Agent Runtime - -```csharp -builder.Services.AddSimpleModuleAgents(builder.Configuration); -``` - -`AddSimpleModuleAgents` itself registers only: - -- `AgentOptions` -- bound from the `Agents:` configuration section -- `AgentChatService` -- handles chat requests (streaming and non-streaming) - -The rest of the stack is wired in separately: - -- **`IAgentRegistry`** and concrete `IAgentDefinition` implementations are registered by the source generator (`AgentExtensionsEmitter`) when it discovers agents in referenced assemblies. -- **`IAgentSessionStore`** lives in the separate `SimpleModule.Agents.Module` package; add that module if you need persisted conversation history. -- **`IAgentMiddleware`** and **`IAgentGuardrail`** are contracts only. `AgentMiddlewarePipeline` runs whatever implementations you register in DI — no middleware or guardrails ship as defaults. Register your own (for logging, rate limiting, PII redaction, etc.) explicitly. - -## Defining an Agent - -Implement `IAgentDefinition` in your module: - -```csharp -public class ProductAssistant : IAgentDefinition -{ - public string Name => "product-assistant"; - public string Description => "Helps users find and manage products"; - public string Instructions => """ - You are a product catalog assistant. - Help users search, compare, and manage products. - """; - - // Optional overrides - public int? MaxTokens => 2048; - public float? Temperature => 0.5f; - public bool? EnableRag => true; - public string? RagCollectionName => "products"; -} -``` - -## Creating Agent Tools - -Implement `IAgentToolProvider` and mark methods with `[AgentTool]`: - -```csharp -public class ProductTools(IProductContracts products) : IAgentToolProvider -{ - [AgentTool(Name = "search_products", Description = "Search the product catalog")] - public async Task> SearchAsync(string query, CancellationToken ct) - { - return await products.SearchAsync(query, ct); - } - - [AgentTool(Name = "get_product", Description = "Get product details by ID")] - public async Task GetByIdAsync(int id, CancellationToken ct) - { - return await products.GetByIdAsync(id, ct); - } -} -``` - -Tools are automatically discovered via DI and converted to AI function definitions that the LLM can call. - -## Chat API - -### Non-Streaming - -```csharp -var response = await agentChatService.ChatAsync( - "product-assistant", - new AgentChatRequest { Message = "Find me all products under $50" }, - cancellationToken); -``` - -### Streaming (SSE) - -```csharp -await foreach (var chunk in agentChatService.ChatStreamAsync( - "product-assistant", - new AgentChatRequest { Message = "Compare these two products" }, - cancellationToken)) -{ - // Send chunk to client via SSE -} -``` - -## RAG Integration - -When `EnableRag` is `true` on an agent definition, the runtime automatically: - -1. Queries the knowledge base via `IRagPipeline.QueryAsync()` -2. Injects matching knowledge chunks into the system message -3. Sends the enriched context to the LLM - -### Setting Up RAG - -```csharp -builder.Services.AddSimpleModuleRag(builder.Configuration); -``` - -```json -{ - "Rag": { - "DefaultTopK": 5, - "MinScore": 0.7, - "EmbeddingDimension": 1536, - "IndexOnStartup": true - } -} -``` - -Choose a vector store: - -```csharp -// Development -builder.Services.AddInMemoryVectorStore(); - -// Production -builder.Services.AddPostgresVectorStore(builder.Configuration); -``` - -## Agent Configuration - -`AddSimpleModuleAgents` binds `AgentOptions` from the `Agents:` configuration section. These are plain `IOptions` values — **not** admin-UI `SettingDefinition`s — so they are configured via `appsettings.json` (or any standard configuration source) and only take effect on app start / options reload. - -| Key | Default | Description | -|-----|---------|-------------| -| `Agents:Enabled` | `true` | Global kill switch | -| `Agents:MaxTokens` | `4096` | Default max tokens per response | -| `Agents:Temperature` | `0.7` | Default sampling temperature (`float`) | -| `Agents:EnableRag` | `true` | Enable RAG context injection | -| `Agents:EnableStreaming` | `true` | Allow streaming responses | -| `Agents:SessionTimeout` | `00:30:00` | Idle timeout for agent sessions | -| `Agents:RateLimit:RequestsPerMinute` | `60` | Rate limit per user | -| `Agents:RateLimit:TokensPerMinute` | `100000` | Token rate limit per user | - -Example: - -```json -{ - "Agents": { - "Enabled": true, - "MaxTokens": 4096, - "Temperature": 0.7, - "EnableRag": true, - "EnableStreaming": true, - "SessionTimeout": "00:30:00", - "RateLimit": { - "RequestsPerMinute": 60, - "TokensPerMinute": 100000 - } - } -} -``` - -## Next Steps - -- [File Storage](/guide/file-storage) -- storing files for RAG knowledge indexing -- [Settings](/guide/settings) -- runtime configuration for agent behavior -- [Modules](/guide/modules) -- structuring agent tools within modules diff --git a/docs/site/guide/contracts.md b/docs/site/guide/contracts.md index 66e58ed7..0ae749e5 100644 --- a/docs/site/guide/contracts.md +++ b/docs/site/guide/contracts.md @@ -22,18 +22,18 @@ This means you can refactor a module's internals freely without breaking other m Each module has a separate contracts project alongside its main project: ``` -modules/Products/ +modules/Customers/ src/ - Products.Contracts/ # Public API -- other modules depend on this - Products.Contracts.csproj - IProductContracts.cs # The interface - Product.cs # Shared DTO - ProductId.cs # Strongly-typed ID - CreateProductRequest.cs # Request DTO - UpdateProductRequest.cs # Request DTO - Products/ # Private implementation -- no one depends on this - ProductsModule.cs - ProductService.cs # Implements IProductContracts + Customers.Contracts/ # Public API -- other modules depend on this + Customers.Contracts.csproj + ICustomerContracts.cs # The interface + Customer.cs # Shared DTO + CustomerId.cs # Strongly-typed ID + CreateCustomerRequest.cs # Request DTO + UpdateCustomerRequest.cs # Request DTO + Customers/ # Private implementation -- no one depends on this + CustomersModule.cs + CustomerService.cs # Implements ICustomerContracts ... ``` @@ -61,35 +61,35 @@ Keep contracts projects minimal. They should contain only the interface, DTOs, e Each module exposes a single contract interface that defines all operations other modules can call: ```csharp -namespace SimpleModule.Products.Contracts; +namespace SimpleModule.Customers.Contracts; -public interface IProductContracts +public interface ICustomerContracts { - Task> GetAllProductsAsync(); - Task GetProductByIdAsync(ProductId id); - Task> GetProductsByIdsAsync(IEnumerable ids); - Task CreateProductAsync(CreateProductRequest request); - Task UpdateProductAsync(ProductId id, UpdateProductRequest request); - Task DeleteProductAsync(ProductId id); + Task> GetAllCustomersAsync(); + Task GetCustomerByIdAsync(CustomerId id); + Task> GetCustomersByIdsAsync(IEnumerable ids); + Task CreateCustomerAsync(CreateCustomerRequest request); + Task UpdateCustomerAsync(CustomerId id, UpdateCustomerRequest request); + Task DeleteCustomerAsync(CustomerId id); } ``` -The source generator discovers the concrete implementation of this interface and auto-registers it as a scoped service. You never write `services.AddScoped()` by hand -- the generated `AddModules()` method handles it. +The source generator discovers the concrete implementation of this interface and auto-registers it as a scoped service. You never write `services.AddScoped()` by hand -- the generated `AddModules()` method handles it. -### Orders Contract +### Invoices Contract -Here is another example showing the Orders module contract: +Here is another example showing a hypothetical Invoices module contract: ```csharp -namespace SimpleModule.Orders.Contracts; +namespace SimpleModule.Invoices.Contracts; -public interface IOrderContracts +public interface IInvoiceContracts { - Task> GetAllOrdersAsync(); - Task GetOrderByIdAsync(OrderId id); - Task CreateOrderAsync(CreateOrderRequest request); - Task UpdateOrderAsync(OrderId id, UpdateOrderRequest request); - Task DeleteOrderAsync(OrderId id); + Task> GetAllInvoicesAsync(); + Task GetInvoiceByIdAsync(InvoiceId id); + Task CreateInvoiceAsync(CreateInvoiceRequest request); + Task UpdateInvoiceAsync(InvoiceId id, UpdateInvoiceRequest request); + Task DeleteInvoiceAsync(InvoiceId id); } ``` @@ -98,23 +98,23 @@ public interface IOrderContracts DTOs (Data Transfer Objects) defined in contracts projects are the data shapes shared between modules. They should be plain classes with public properties: ```csharp -namespace SimpleModule.Products.Contracts; +namespace SimpleModule.Customers.Contracts; -public class Product +public class Customer { - public ProductId Id { get; set; } + public CustomerId Id { get; set; } public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } + public string Email { get; set; } = string.Empty; } ``` ```csharp -namespace SimpleModule.Products.Contracts; +namespace SimpleModule.Customers.Contracts; -public class CreateProductRequest +public class CreateCustomerRequest { public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } + public string Email { get; set; } = string.Empty; } ``` @@ -125,11 +125,11 @@ Modules use [Vogen](https://github.com/SteveDunn/Vogen) to define strongly-typed ```csharp using Vogen; -namespace SimpleModule.Products.Contracts; +namespace SimpleModule.Customers.Contracts; [ValueObject( conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] -public readonly partial struct ProductId; +public readonly partial struct CustomerId; ``` The `Conversions` flags generate JSON and EF Core value converters automatically, so the ID type works seamlessly in API serialization and database queries. @@ -142,32 +142,34 @@ Strongly-typed IDs belong in the contracts project, not in Core. They are domain When one module needs to use another module's data, it depends on the contracts project -- never the implementation. -For example, the Orders module uses `ProductId` from Products and `UserId` from Users: +For example, a hypothetical Invoices module uses `CustomerId` from Customers and `UserId` from Users: ```csharp -// Orders.Contracts/OrderItem.cs -using SimpleModule.Products.Contracts; +// Invoices.Contracts/InvoiceLine.cs +using SimpleModule.Customers.Contracts; -namespace SimpleModule.Orders.Contracts; +namespace SimpleModule.Invoices.Contracts; -public class OrderItem +public class InvoiceLine { - public ProductId ProductId { get; set; } - public int Quantity { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Amount { get; set; } } ``` ```csharp -// Orders.Contracts/Order.cs +// Invoices.Contracts/Invoice.cs +using SimpleModule.Customers.Contracts; using SimpleModule.Users.Contracts; -namespace SimpleModule.Orders.Contracts; +namespace SimpleModule.Invoices.Contracts; -public class Order +public class Invoice { - public OrderId Id { get; set; } - public UserId UserId { get; set; } - public List Items { get; set; } = new(); + public InvoiceId Id { get; set; } + public CustomerId CustomerId { get; set; } + public UserId IssuedBy { get; set; } + public List Lines { get; set; } = new(); public decimal Total { get; set; } public DateTime CreatedAt { get; set; } } @@ -176,17 +178,17 @@ public class Order The project file makes the dependency explicit: ```xml - + - + ``` -The source generator detects these inter-module dependencies and ensures modules are registered in the correct order (Products before Orders). +The source generator detects these inter-module dependencies and ensures modules are registered in the correct order (Customers before Invoices). ## The `[Dto]` Attribute @@ -249,24 +251,24 @@ The TypeScript generation pipeline converts C# DTO types into TypeScript interfa 2. **TypeScript definitions are embedded** in the generated source as string resources 3. **`extract-ts-types.mjs`** extracts the definitions and writes a single `types.ts` per module -The generated TypeScript files are placed in each module's primary source project (e.g. `modules/Products/src/SimpleModule.Products/types.ts`): +The generated TypeScript files are placed in each module's primary source project (e.g. `modules/Customers/src/SimpleModule.Customers/types.ts`): ```typescript // Auto-generated from [Dto] types -- do not edit -export interface CreateProductRequest { +export interface CreateCustomerRequest { name: string; - price: number; + email: string; } -export interface Product { +export interface Customer { id: number; name: string; - price: number; + email: string; } -export interface UpdateProductRequest { +export interface UpdateCustomerRequest { name: string; - price: number; + email: string; } ``` @@ -280,8 +282,8 @@ Some public types in contracts assemblies should not have TypeScript interfaces ```csharp [NoDtoGeneration] -public sealed record OrderCreatedEvent( - OrderId OrderId, UserId UserId, decimal Total) : IEvent; +public sealed record InvoiceCreatedEvent( + InvoiceId InvoiceId, CustomerId CustomerId, decimal Total) : IEvent; ``` The attribute can be applied to classes, structs, and interfaces: @@ -300,15 +302,16 @@ Domain events are also defined in contracts projects so that handlers in other m ```csharp using SimpleModule.Core.Events; +using SimpleModule.Customers.Contracts; using SimpleModule.Users.Contracts; -namespace SimpleModule.Orders.Contracts.Events; +namespace SimpleModule.Invoices.Contracts.Events; -public sealed record OrderCreatedEvent( - OrderId OrderId, UserId UserId, decimal Total) : IEvent; +public sealed record InvoiceCreatedEvent( + InvoiceId InvoiceId, CustomerId CustomerId, decimal Total) : IEvent; ``` -Any module can handle `OrderCreatedEvent` by declaring a handler class with a `Handle` method — Wolverine discovers it by naming convention — without depending on the Orders implementation. +Any module can handle `InvoiceCreatedEvent` by declaring a handler class with a `Handle` method — Wolverine discovers it by naming convention — without depending on the Invoices implementation. ## Summary diff --git a/docs/site/guide/database.md b/docs/site/guide/database.md index 125c8283..ac5b4322 100644 --- a/docs/site/guide/database.md +++ b/docs/site/guide/database.md @@ -12,9 +12,9 @@ Three database providers are supported out of the box: | Provider | Schema Isolation | Detection Heuristic | |----------|-----------------|---------------------| -| **SQLite** | Table name prefixes (`Products_Products`) | Connection string contains `Data Source=` | -| **PostgreSQL** | Database schemas (`products.Products`) | Connection string contains `Host=` | -| **SQL Server** | Database schemas (`products.Products`) | Connection string contains `Initial Catalog=`, `Server=.\`, or `Server=(` | +| **SQLite** | Table name prefixes (`Customers_Customers`) | Connection string contains `Data Source=` | +| **PostgreSQL** | Database schemas (`customers.Customers`) | Connection string contains `Host=` | +| **SQL Server** | Database schemas (`customers.Customers`) | Connection string contains `Initial Catalog=`, `Server=.\`, or `Server=(` | The provider is auto-detected from the connection string. You can also set it explicitly in configuration: @@ -52,46 +52,46 @@ Each module registers its own `DbContext` using the `AddModuleDbContext` exte ```csharp [Module( - ProductsConstants.ModuleName, - RoutePrefix = ProductsConstants.RoutePrefix, - ViewPrefix = "/products" + CustomersConstants.ModuleName, + RoutePrefix = CustomersConstants.RoutePrefix, + ViewPrefix = "/customers" )] -public class ProductsModule : IModule +public class CustomersModule : IModule { public void ConfigureServices( IServiceCollection services, IConfiguration configuration) { - services.AddModuleDbContext( - configuration, ProductsConstants.ModuleName); + services.AddModuleDbContext( + configuration, CustomersConstants.ModuleName); } } ``` -### The ProductsDbContext +### The CustomersDbContext Here is a complete example of a module DbContext: ```csharp -public class ProductsDbContext( - DbContextOptions options, +public class CustomersDbContext( + DbContextOptions options, IOptions dbOptions ) : DbContext(options) { - public DbSet Products => Set(); + public DbSet Customers => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.ApplyConfiguration(new ProductConfiguration()); - modelBuilder.ApplyModuleSchema("Products", dbOptions.Value); + modelBuilder.ApplyConfiguration(new CustomerConfiguration()); + modelBuilder.ApplyModuleSchema("Customers", dbOptions.Value); } protected override void ConfigureConventions( ModelConfigurationBuilder configurationBuilder) { configurationBuilder - .Properties() - .HaveConversion(); + .Properties() + .HaveConversion(); } } ``` @@ -113,19 +113,19 @@ The `ApplyModuleSchema` extension method automatically applies the correct isola **SQLite** -- Prefixes all table names with the module name: ``` -Products_Products -Products_Categories -Orders_Orders -Orders_OrderItems +Customers_Customers +Customers_Addresses +Users_Users +Users_Roles ``` **PostgreSQL / SQL Server** -- Creates separate schemas: ```sql -products.Products -products.Categories -orders.Orders -orders.OrderItems +customers.Customers +customers.Addresses +users.Users +users.Roles ``` The implementation: @@ -176,28 +176,26 @@ After partitioning tables, `ApplyModuleSchema` calls a private `ApplyEntityConve Use `IEntityTypeConfiguration` to define entity mappings. Keep these in an `EntityConfigurations` directory in your module: ```csharp -public class ProductConfiguration : IEntityTypeConfiguration +public class CustomerConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder builder) + public void Configure(EntityTypeBuilder builder) { - builder.HasKey(p => p.Id); - builder.Property(p => p.Id).ValueGeneratedOnAdd(); - builder.Property(p => p.Name).IsRequired(); - builder.Property(p => p.Price).HasColumnType("decimal(18,2)"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).ValueGeneratedOnAdd(); + builder.Property(c => c.Name).IsRequired(); + builder.Property(c => c.Email).IsRequired().HasMaxLength(256); - builder.HasData(GenerateSeedProducts()); + builder.HasData(GenerateSeedCustomers()); } - private static Product[] GenerateSeedProducts() + private static Customer[] GenerateSeedCustomers() { var id = 0; - var faker = new Faker() + var faker = new Faker() .UseSeed(54321) - .RuleFor(p => p.Id, _ => ProductId.From(++id)) - .RuleFor(p => p.Name, f => f.Commerce.ProductName()) - .RuleFor(p => p.Price, f => decimal.Parse( - f.Commerce.Price(10, 1000), - CultureInfo.InvariantCulture)); + .RuleFor(c => c.Id, _ => CustomerId.From(++id)) + .RuleFor(c => c.Name, f => f.Person.FullName) + .RuleFor(c => c.Email, f => f.Internet.Email()); return faker.Generate(10).ToArray(); } @@ -209,8 +207,8 @@ Apply configurations in `OnModelCreating`: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.ApplyConfiguration(new ProductConfiguration()); - modelBuilder.ApplyModuleSchema("Products", dbOptions.Value); + modelBuilder.ApplyConfiguration(new CustomerConfiguration()); + modelBuilder.ApplyModuleSchema("Customers", dbOptions.Value); } ``` @@ -329,17 +327,17 @@ For development, `EnsureCreated()` is called automatically. You do not need to r For production deployments, use EF Core migrations scoped to each module: ```bash -# Generate a migration for the Products module +# Generate a migration for the Customers module dotnet ef migrations add InitialCreate \ - --project modules/Products/src/Products \ + --project modules/Customers/src/Customers \ --startup-project template/SimpleModule.Host \ - --context ProductsDbContext + --context CustomersDbContext # Apply migrations dotnet ef database update \ - --project modules/Products/src/Products \ + --project modules/Customers/src/Customers \ --startup-project template/SimpleModule.Host \ - --context ProductsDbContext + --context CustomersDbContext ``` Each module manages its own migration history independently, since each has its own `DbContext` with its own schema. diff --git a/docs/site/guide/endpoints.md b/docs/site/guide/endpoints.md index 36d27109..64a4641b 100644 --- a/docs/site/guide/endpoints.md +++ b/docs/site/guide/endpoints.md @@ -21,9 +21,9 @@ Inside `Map`, you use ASP.NET Minimal API methods (`MapGet`, `MapPost`, `MapPut` ### Example: Full CRUD -Here is the complete set of API endpoints from the Products module. The module's `RoutePrefix` is `"/api/products"`, so `"/"` maps to `/api/products` and `"/{id}"` maps to `/api/products/{id}`. +Here is a complete set of API endpoints for a Customers module. The module's `RoutePrefix` is `"/api/customers"`, so `"/"` maps to `/api/customers` and `"/{id}"` maps to `/api/customers/{id}`. -**GET all products:** +**GET all customers:** ```csharp public class GetAllEndpoint : IEndpoint @@ -31,10 +31,10 @@ public class GetAllEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapGet( "/", - (IProductContracts productContracts) => - CrudEndpoints.GetAll(productContracts.GetAllProductsAsync) + (ICustomerContracts customerContracts) => + CrudEndpoints.GetAll(customerContracts.GetAllCustomersAsync) ) - .RequirePermission(ProductsPermissions.View); + .RequirePermission(CustomersPermissions.View); } ``` @@ -46,11 +46,11 @@ public class GetByIdEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapGet( "/{id}", - (ProductId id, IProductContracts productContracts) => + (CustomerId id, ICustomerContracts customerContracts) => CrudEndpoints.GetById( - () => productContracts.GetProductByIdAsync(id)) + () => customerContracts.GetCustomerByIdAsync(id)) ) - .RequirePermission(ProductsPermissions.View); + .RequirePermission(CustomersPermissions.View); } ``` @@ -63,9 +63,9 @@ public class CreateEndpoint : IEndpoint app.MapPost( "/", async ( - CreateProductRequest request, - IValidator validator, - IProductContracts productContracts + CreateCustomerRequest request, + IValidator validator, + ICustomerContracts customerContracts ) => { var validation = await validator.ValidateAsync(request); @@ -75,12 +75,12 @@ public class CreateEndpoint : IEndpoint } return await CrudEndpoints.Create( - () => productContracts.CreateProductAsync(request), - p => $"{ProductsConstants.RoutePrefix}/{p.Id}" + () => customerContracts.CreateCustomerAsync(request), + p => $"{CustomersConstants.RoutePrefix}/{p.Id}" ); } ) - .RequirePermission(ProductsPermissions.Create); + .RequirePermission(CustomersPermissions.Create); } ``` @@ -93,10 +93,10 @@ public class UpdateEndpoint : IEndpoint app.MapPut( "/{id}", async ( - ProductId id, - UpdateProductRequest request, - IValidator validator, - IProductContracts productContracts + CustomerId id, + UpdateCustomerRequest request, + IValidator validator, + ICustomerContracts customerContracts ) => { var validation = await validator.ValidateAsync(request); @@ -106,10 +106,10 @@ public class UpdateEndpoint : IEndpoint } return await CrudEndpoints.Update( - () => productContracts.UpdateProductAsync(id, request)); + () => customerContracts.UpdateCustomerAsync(id, request)); } ) - .RequirePermission(ProductsPermissions.Update); + .RequirePermission(CustomersPermissions.Update); } ``` @@ -121,11 +121,11 @@ public class DeleteEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapDelete( "/{id}", - (ProductId id, IProductContracts productContracts) => + (CustomerId id, ICustomerContracts customerContracts) => CrudEndpoints.Delete( - () => productContracts.DeleteProductAsync(id)) + () => customerContracts.DeleteCustomerAsync(id)) ) - .RequirePermission(ProductsPermissions.Delete); + .RequirePermission(CustomersPermissions.Delete); } ``` @@ -151,10 +151,10 @@ public class BrowseEndpoint : IViewEndpoint { app.MapGet( "/browse", - async (IProductContracts products) => + async (ICustomerContracts customers) => Inertia.Render( - "Products/Browse", - new { products = await products.GetAllProductsAsync() } + "Customers/Browse", + new { customers = await customers.GetAllCustomersAsync() } ) ) .AllowAnonymous(); @@ -162,7 +162,7 @@ public class BrowseEndpoint : IViewEndpoint } ``` -The first argument to `Inertia.Render` is the component name (e.g., `"Products/Browse"`). This must match an entry in the module's `Pages/index.ts` registry on the frontend side. +The first argument to `Inertia.Render` is the component name (e.g., `"Customers/Browse"`). This must match an entry in the module's `Pages/index.ts` registry on the frontend side. ### Example: Create View with Form Handling @@ -173,22 +173,22 @@ public class CreateEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { - app.MapGet("/create", () => Inertia.Render("Products/Create")); + app.MapGet("/create", () => Inertia.Render("Customers/Create")); app.MapPost( "/", async ( [FromForm] string name, - [FromForm] decimal price, - IProductContracts products + [FromForm] string email, + ICustomerContracts customers ) => { - var request = new CreateProductRequest + var request = new CreateCustomerRequest { - Name = name, Price = price + Name = name, Email = email }; - await products.CreateProductAsync(request); - return TypedResults.Redirect("/products/manage"); + await customers.CreateCustomerAsync(request); + return TypedResults.Redirect("/customers/manage"); } ) .DisableAntiforgery(); @@ -205,40 +205,40 @@ public class EditEndpoint : IViewEndpoint { app.MapGet( "/{id}/edit", - async (ProductId id, IProductContracts products) => + async (CustomerId id, ICustomerContracts customers) => { - var product = await products.GetProductByIdAsync(id); - if (product is null) + var customer = await customers.GetCustomerByIdAsync(id); + if (customer is null) return TypedResults.NotFound(); - return Inertia.Render("Products/Edit", new { product }); + return Inertia.Render("Customers/Edit", new { customer }); } ); app.MapPost( "/{id}", async ( - ProductId id, + CustomerId id, [FromForm] string name, - [FromForm] decimal price, - IProductContracts products + [FromForm] string email, + ICustomerContracts customers ) => { - var request = new UpdateProductRequest + var request = new UpdateCustomerRequest { - Name = name, Price = price + Name = name, Email = email }; - await products.UpdateProductAsync(id, request); - return TypedResults.Redirect($"/products/{id}/edit"); + await customers.UpdateCustomerAsync(id, request); + return TypedResults.Redirect($"/customers/{id}/edit"); } ) .DisableAntiforgery(); app.MapDelete( "/{id}", - async (ProductId id, IProductContracts products) => + async (CustomerId id, ICustomerContracts customers) => { - await products.DeleteProductAsync(id); - return TypedResults.Redirect("/products/manage"); + await customers.DeleteCustomerAsync(id); + return TypedResults.Redirect("/customers/manage"); } ); } @@ -246,7 +246,7 @@ public class EditEndpoint : IViewEndpoint ``` ::: warning -When adding a new `IViewEndpoint`, you **must** also register the corresponding component in your module's `Pages/index.ts`. The Inertia component name in `Inertia.Render("Products/Edit", ...)` must have a matching key in the pages record. If you forget, the page will silently 404 on the client side with no error. +When adding a new `IViewEndpoint`, you **must** also register the corresponding component in your module's `Pages/index.ts`. The Inertia component name in `Inertia.Render("Customers/Edit", ...)` must have a matching key in the pages record. If you forget, the page will silently 404 on the client side with no error. Run `npm run validate-pages` to verify all endpoints have matching frontend entries. ::: @@ -257,8 +257,8 @@ The source generator automatically discovers all classes implementing `IEndpoint The generated code creates route groups with the appropriate prefixes: -- `IEndpoint` classes are grouped under the module's `RoutePrefix` (e.g., `/api/products`) with `RequireAuthorization()` applied by default -- `IViewEndpoint` classes are grouped under the module's `ViewPrefix` (e.g., `/products`) with `RequireAuthorization()` and `ExcludeFromDescription()` applied by default +- `IEndpoint` classes are grouped under the module's `RoutePrefix` (e.g., `/api/customers`) with `RequireAuthorization()` applied by default +- `IViewEndpoint` classes are grouped under the module's `ViewPrefix` (e.g., `/customers`) with `RequireAuthorization()` and `ExcludeFromDescription()` applied by default To allow anonymous access to a specific endpoint, chain `.AllowAnonymous()` after the route definition. @@ -279,16 +279,16 @@ Most parameters bind automatically without any attributes: ```csharp // Route parameter: int id binds from {id} in the route template -app.MapGet("/{id}", (ProductId id) => ...); +app.MapGet("/{id}", (CustomerId id) => ...); // Query parameter: string? search binds from ?search=... app.MapGet("/", (string? search, int page = 1) => ...); // JSON body: complex type binds from request body for POST/PUT -app.MapPost("/", (CreateProductRequest request) => ...); +app.MapPost("/", (CreateCustomerRequest request) => ...); // DI services: auto-injected when registered in the container -app.MapGet("/", (IProductContracts products) => ...); +app.MapGet("/", (ICustomerContracts customers) => ...); // Special types: auto-bound by the framework app.MapGet("/", (HttpContext context, CancellationToken ct, @@ -307,8 +307,8 @@ DI services are auto-injected. You do **not** need `[FromServices]` -- it is noi // CORRECT: scalar form fields require [FromForm] app.MapPost("/", async ( [FromForm] string name, - [FromForm] decimal price, - IProductContracts products) => ...); + [FromForm] string email, + ICustomerContracts customers) => ...); ``` **`[FromQuery]`** when a parameter name conflicts with a route parameter, or to rename: @@ -359,17 +359,17 @@ app.MapPost("/", async (HttpContext context) => ```csharp // API: complex type auto-binds from JSON body, service auto-injected -app.MapPost("/", async (CreateProductRequest request, - IProductContracts products) => ...); +app.MapPost("/", async (CreateCustomerRequest request, + ICustomerContracts customers) => ...); // API: route param + body + DI -app.MapPut("/{id}", async (int id, UpdateProductRequest request, - IProductContracts products) => ...); +app.MapPut("/{id}", async (int id, UpdateCustomerRequest request, + ICustomerContracts customers) => ...); // View: scalar form data requires [FromForm] app.MapPost("/", async ([FromForm] string name, - [FromForm] decimal price, - IProductContracts products) => ...); + [FromForm] string email, + ICustomerContracts customers) => ...); // Query: arrays bind from repeated keys app.MapGet("/tags", (int[] q) => $"tag1: {q[0]}, tag2: {q[1]}"); @@ -393,9 +393,9 @@ app.MapPost("/", async (HttpContext context) => }); // BAD: [FromServices] is unnecessary noise -app.MapGet("/", ([FromServices] IProductContracts products) => ...); +app.MapGet("/", ([FromServices] ICustomerContracts customers) => ...); // GOOD: DI services auto-inject -app.MapGet("/", (IProductContracts products) => ...); +app.MapGet("/", (ICustomerContracts customers) => ...); ``` ## Validation @@ -405,12 +405,12 @@ Request validation uses **[FluentValidation](https://docs.fluentvalidation.net/) ```csharp using FluentValidation; -public sealed class CreateRequestValidator : AbstractValidator +public sealed class CreateRequestValidator : AbstractValidator { public CreateRequestValidator() { - RuleFor(x => x.Name).NotEmpty().WithMessage("Product name is required."); - RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero."); + RuleFor(x => x.Name).NotEmpty().WithMessage("Customer name is required."); + RuleFor(x => x.Email).NotEmpty().EmailAddress().WithMessage("A valid email is required."); } } ``` @@ -418,7 +418,7 @@ public sealed class CreateRequestValidator : AbstractValidator(); +services.AddValidatorsFromAssemblyContaining(); ``` Endpoints inject `IValidator`, call `ValidateAsync`, and convert failures to the framework's `ValidationException`: @@ -438,15 +438,15 @@ if (!validation.IsValid) By convention, endpoints are organized in the module's directory structure: ``` -modules/Products/src/SimpleModule.Products/ +modules/Customers/src/SimpleModule.Customers/ Endpoints/ - Products/ + Customers/ GetAllEndpoint.cs GetByIdEndpoint.cs CreateEndpoint.cs - CreateRequestValidator.cs # AbstractValidator + CreateRequestValidator.cs # AbstractValidator UpdateEndpoint.cs - UpdateRequestValidator.cs # AbstractValidator + UpdateRequestValidator.cs # AbstractValidator DeleteEndpoint.cs Pages/ BrowseEndpoint.cs # IViewEndpoint — sits next to Browse.tsx diff --git a/docs/site/guide/error-pages.md b/docs/site/guide/error-pages.md index afc1bbdf..59e7aa52 100644 --- a/docs/site/guide/error-pages.md +++ b/docs/site/guide/error-pages.md @@ -63,28 +63,28 @@ If an exception occurs so early that Inertia can't render (e.g., DI resolution f Throw the framework exceptions from services or endpoints; the handler takes care of the status code and response shape. ```csharp -public async Task GetProductAsync(ProductId id) +public async Task GetCustomerAsync(CustomerId id) { - var product = await db.Products.FindAsync(id); - if (product is null) + var customer = await db.Customers.FindAsync(id); + if (customer is null) { - throw new NotFoundException("Product", id); + throw new NotFoundException("Customer", id); } - return product; + return customer; } ``` ```csharp -public async Task CancelOrderAsync(OrderId id, UserId actor) +public async Task DeactivateCustomerAsync(CustomerId id, UserId actor) { - var order = await db.Orders.FindAsync(id); - if (order is null) + var customer = await db.Customers.FindAsync(id); + if (customer is null) { - throw new NotFoundException("Order", id); + throw new NotFoundException("Customer", id); } - if (order.OwnerId != actor) + if (customer.OwnerId != actor) { - throw new ForbiddenException("You cannot cancel another user's order."); + throw new ForbiddenException("You cannot deactivate another tenant's customer."); } // ... } @@ -125,11 +125,11 @@ Assert on the status code and, for Inertia flows, the component name: ```csharp [Fact] -public async Task Missing_product_returns_404_problem_details() +public async Task Missing_customer_returns_404_problem_details() { using var client = factory.CreateAuthenticatedClient(); - var response = await client.GetAsync("/api/products/99999"); + var response = await client.GetAsync("/api/customers/99999"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); var problem = await response.Content.ReadFromJsonAsync(); diff --git a/docs/site/guide/events.md b/docs/site/guide/events.md index 3e40255d..275eafd7 100644 --- a/docs/site/guide/events.md +++ b/docs/site/guide/events.md @@ -15,7 +15,7 @@ Modules communicate without direct references by publishing events. SimpleModule ```csharp using SimpleModule.Core.Events; -public sealed record OrderCreatedEvent(OrderId OrderId, UserId UserId, decimal Total) : IEvent; +public sealed record CustomerCreatedEvent(CustomerId CustomerId, UserId CreatedBy, string Email) : IEvent; ``` ### Publishing with IMessageBus @@ -25,22 +25,22 @@ Inject Wolverine's `IMessageBus` and call `PublishAsync`: ```csharp using Wolverine; -public sealed partial class OrderService( - OrdersDbContext db, +public sealed partial class CustomerService( + CustomersDbContext db, IMessageBus bus, - ILogger logger -) : IOrderContracts + ILogger logger +) : ICustomerContracts { - public async Task CreateOrderAsync(CreateOrderRequest request) + public async Task CreateCustomerAsync(CreateCustomerRequest request) { - var order = new Order { UserId = request.UserId, Total = request.Total }; + var customer = new Customer { Name = request.Name, Email = request.Email }; - db.Orders.Add(order); + db.Customers.Add(customer); await db.SaveChangesAsync(); - await bus.PublishAsync(new OrderCreatedEvent(order.Id, order.UserId, order.Total)); + await bus.PublishAsync(new CustomerCreatedEvent(customer.Id, request.CreatedBy, customer.Email)); - return order; + return customer; } } ``` @@ -56,10 +56,10 @@ If two services form a cycle through the bus (for example, a settings service wh Wolverine discovers handlers by **naming convention**: a public class whose type or name ends with `Handler` / `Consumer`, with a method named `Handle` / `Consume` / `HandleAsync` that takes the event as its first parameter. No interface, no DI registration. ```csharp -public sealed class OrderCreatedNotificationHandler(INotificationService notifications) +public sealed class CustomerCreatedNotificationHandler(INotificationService notifications) { - public Task Handle(OrderCreatedEvent evt, CancellationToken ct) => - notifications.SendAsync(evt.UserId, $"Order {evt.OrderId} confirmed", ct); + public Task Handle(CustomerCreatedEvent evt, CancellationToken ct) => + notifications.SendAsync(evt.CreatedBy, $"Customer {evt.CustomerId} created", ct); } ``` @@ -70,15 +70,15 @@ Handlers resolve through the request scope, so injected services (DbContext, log Entities that derive from `AuditableAggregateRoot` (or implement `IHasDomainEvents`) can queue events that are flushed via `IMessageBus` after `SaveChangesAsync()` succeeds. This keeps write logic transactional: events only fire if the save commits. ```csharp -public sealed class Order : AuditableAggregateRoot +public sealed class Customer : AuditableAggregateRoot { - public decimal Total { get; set; } - public OrderStatus Status { get; set; } + public string Name { get; set; } = string.Empty; + public CustomerStatus Status { get; set; } - public void Confirm() + public void Activate() { - Status = OrderStatus.Confirmed; - AddDomainEvent(new OrderConfirmedEvent(Id, Total)); + Status = CustomerStatus.Active; + AddDomainEvent(new CustomerActivatedEvent(Id, Name)); } } ``` @@ -102,7 +102,7 @@ Wolverine is running in-memory here. For work that must survive a restart, use t ### Keep Handlers Focused -A handler should do one thing. If `OrderCreatedEvent` needs to send an email, update a search index, and invalidate caches, write three handlers. Wolverine invokes them independently. +A handler should do one thing. If `CustomerCreatedEvent` needs to send a welcome email, update a search index, and invalidate caches, write three handlers. Wolverine invokes them independently. ### Be Idempotent @@ -113,18 +113,18 @@ An event may be replayed (retry logic, re-run of a background job). Handlers sho Audit logging, metrics, cache invalidation, and similar cross-cutting concerns should catch their own exceptions. Reserve rethrown exceptions for failures the caller actually needs to know about. ```csharp -public sealed class OrderMetricsHandler(IMetrics metrics, ILogger logger) +public sealed class CustomerMetricsHandler(IMetrics metrics, ILogger logger) { - public Task Handle(OrderCreatedEvent evt, CancellationToken ct) + public Task Handle(CustomerCreatedEvent evt, CancellationToken ct) { try { - metrics.Increment("orders.created", tags: new { evt.UserId }); + metrics.Increment("customers.created", tags: new { evt.CreatedBy }); } #pragma warning disable CA1031 catch (Exception ex) { - logger.LogWarning(ex, "Failed to record order metrics"); + logger.LogWarning(ex, "Failed to record customer metrics"); } #pragma warning restore CA1031 return Task.CompletedTask; @@ -144,13 +144,13 @@ Instantiate the handler directly. No DI container is required. ```csharp [Fact] -public async Task OrderCreatedNotificationHandler_sends_confirmation() +public async Task CustomerCreatedNotificationHandler_sends_confirmation() { var notifications = Substitute.For(); - var handler = new OrderCreatedNotificationHandler(notifications); + var handler = new CustomerCreatedNotificationHandler(notifications); await handler.Handle( - new OrderCreatedEvent(OrderId.From(1), UserId.From(42), 99.99m), + new CustomerCreatedEvent(CustomerId.From(1), UserId.From(42), "test@example.com"), CancellationToken.None ); @@ -164,14 +164,16 @@ In service-level tests, substitute `IMessageBus` and assert on the recorded call ```csharp [Fact] -public async Task CreateOrder_publishes_order_created_event() +public async Task CreateCustomer_publishes_customer_created_event() { var bus = Substitute.For(); - var service = new OrderService(db, bus, NullLogger.Instance); + var service = new CustomerService(db, bus, NullLogger.Instance); - var order = await service.CreateOrderAsync(new CreateOrderRequest(UserId.From(42), 99.99m)); + var customer = await service.CreateCustomerAsync( + new CreateCustomerRequest("Alice", "alice@example.com") { CreatedBy = UserId.From(42) } + ); - await bus.Received().PublishAsync(Arg.Is(e => e.OrderId == order.Id)); + await bus.Received().PublishAsync(Arg.Is(e => e.CustomerId == customer.Id)); } ``` diff --git a/docs/site/guide/file-storage.md b/docs/site/guide/file-storage.md index 3c9698b2..81b8b4d4 100644 --- a/docs/site/guide/file-storage.md +++ b/docs/site/guide/file-storage.md @@ -158,6 +158,5 @@ All paths are normalized to forward slashes internally. The `StoragePathHelper` ## Next Steps -- [AI Agents](/guide/ai-agents) -- using file storage with RAG knowledge indexing - [Configuration](/reference/configuration) -- all storage configuration options - [Deployment](/advanced/deployment) -- production storage configuration diff --git a/docs/site/guide/inertia.md b/docs/site/guide/inertia.md index c01d1841..d20039dc 100644 --- a/docs/site/guide/inertia.md +++ b/docs/site/guide/inertia.md @@ -21,10 +21,10 @@ The Inertia integration in SimpleModule has three layers: On the first request (full page load), the flow is: ``` -Browser GET /products/browse +Browser GET /customers/browse ↓ ASP.NET route handler - → Inertia.Render("Products/Browse", { products: [...] }) + → Inertia.Render("Customers/Browse", { customers: [...] }) ↓ InertiaResult.ExecuteAsync() → Serializes page data (component, props, url, version) as JSON @@ -39,8 +39,8 @@ HtmlFileInertiaPageRenderer (default IInertiaPageRenderer) ↓ Browser receives HTML → React's createInertiaApp hydrates the page - → resolvePage() imports Products.pages.js - → "Products/Browse" component renders with props + → resolvePage() imports Customers.pages.js + → "Customers/Browse" component renders with props ``` ### Subsequent Navigation @@ -49,10 +49,10 @@ On subsequent navigation (Inertia XHR requests), the flow is shorter: ``` Browser clicks Inertia link - → XHR GET /products/browse (with X-Inertia header) + → XHR GET /customers/browse (with X-Inertia header) ↓ ASP.NET route handler - → Inertia.Render("Products/Browse", { products: [...] }) + → Inertia.Render("Customers/Browse", { customers: [...] }) ↓ InertiaResult.ExecuteAsync() → Detects X-Inertia header @@ -78,10 +78,10 @@ public class BrowseEndpoint : IViewEndpoint { app.MapGet( "/browse", - async (IProductContracts products) => + async (ICustomerContracts customers) => Inertia.Render( - "Products/Browse", - new { products = await products.GetAllProductsAsync() } + "Customers/Browse", + new { customers = await customers.GetAllCustomersAsync() } ) ); } @@ -89,7 +89,7 @@ public class BrowseEndpoint : IViewEndpoint ``` Parameters: -- **`component`** -- the page name (e.g., `"Products/Browse"`). Must match an entry in the module's `Pages/index.ts`. +- **`component`** -- the page name (e.g., `"Customers/Browse"`). Must match an entry in the module's `Pages/index.ts`. - **`props`** -- an anonymous object or any serializable type. Serialized as camelCase JSON. ### Props Serialization @@ -98,14 +98,14 @@ Props are serialized using `System.Text.Json` with `JsonNamingPolicy.CamelCase`: ```csharp // Server -Inertia.Render("Products/Edit", new { product }); +Inertia.Render("Customers/Edit", new { customer }); // Client receives: -// { "component": "Products/Edit", "props": { "product": { "id": 1, "name": "..." } } } +// { "component": "Customers/Edit", "props": { "customer": { "id": 1, "name": "..." } } } ``` ::: warning -Property names are automatically converted to camelCase. A C# property `ProductName` becomes `productName` in JavaScript. +Property names are automatically converted to camelCase. A C# property `CustomerName` becomes `customerName` in JavaScript. ::: ### Shared Data @@ -249,10 +249,10 @@ export async function resolvePage(name: string) { } ``` -For a component name like `"Products/Browse"`: -1. Extracts module name: `"Products"` -2. Imports `/_content/Products/Products.pages.js` -3. Looks up `"Products/Browse"` in the `pages` export +For a component name like `"Customers/Browse"`: +1. Extracts module name: `"Customers"` +2. Imports `/_content/Customers/Customers.pages.js` +3. Looks up `"Customers/Browse"` in the `pages` export 4. Supports lazy loading via function entries ### Module Pages Registry @@ -260,12 +260,12 @@ For a component name like `"Products/Browse"`: Each module exports a `pages` record in `Pages/index.ts`: ```typescript -// modules/Products/src/SimpleModule.Products/Pages/index.ts +// modules/Customers/src/SimpleModule.Customers/Pages/index.ts export const pages: Record = { - 'Products/Browse': () => import('./Browse'), - 'Products/Manage': () => import('./Manage'), - 'Products/Create': () => import('./Create'), - 'Products/Edit': () => import('./Edit'), + 'Customers/Browse': () => import('./Browse'), + 'Customers/Manage': () => import('./Manage'), + 'Customers/Create': () => import('./Create'), + 'Customers/Edit': () => import('./Edit'), }; ``` @@ -281,16 +281,16 @@ Page components receive props from the server as React props: import { PageHeader } from '@simplemodule/ui/components'; interface BrowseProps { - products: Product[]; + customers: Customer[]; } -export default function Browse({ products }: BrowseProps) { +export default function Browse({ customers }: BrowseProps) { return (
- +
    - {products.map((p) => ( -
  • {p.name} - ${p.price}
  • + {customers.map((c) => ( +
  • {c.name} - {c.email}
  • ))}
@@ -316,7 +316,7 @@ Instead of showing the default "must receive a valid Inertia response" error, a ## Full Example -Here is the complete flow for a Products/Browse page: +Here is the complete flow for a Customers/Browse page: **1. Endpoint (C#):** @@ -327,10 +327,10 @@ public class BrowseEndpoint : IViewEndpoint { app.MapGet( "/browse", - async (IProductContracts products) => + async (ICustomerContracts customers) => Inertia.Render( - "Products/Browse", - new { products = await products.GetAllProductsAsync() } + "Customers/Browse", + new { customers = await customers.GetAllCustomersAsync() } ) ).AllowAnonymous(); } @@ -342,7 +342,7 @@ public class BrowseEndpoint : IViewEndpoint ```typescript // Pages/index.ts export const pages: Record = { - 'Products/Browse': () => import('./Browse'), + 'Customers/Browse': () => import('./Browse'), }; ``` @@ -350,12 +350,12 @@ export const pages: Record = { ```tsx // Pages/Browse.tsx -export default function Browse({ products }: { products: Product[] }) { +export default function Browse({ customers }: { customers: Customer[] }) { return (
-

Products

- {products.map((p) => ( -
{p.name}
+

Customers

+ {customers.map((c) => ( +
{c.name}
))}
); @@ -364,13 +364,13 @@ export default function Browse({ products }: { products: Product[] }) { **4. What happens at runtime:** -1. User navigates to `/products/browse` +1. User navigates to `/customers/browse` 2. ASP.NET matches the route, calls the endpoint handler -3. `IProductContracts.GetAllProductsAsync()` fetches products from the database -4. `Inertia.Render("Products/Browse", { products })` serializes the page data +3. `ICustomerContracts.GetAllCustomersAsync()` fetches customers from the database +4. `Inertia.Render("Customers/Browse", { customers })` serializes the page data 5. On initial load: `HtmlFileInertiaPageRenderer` writes the pre-split `index.html` shell with the JSON injected into `
NamePriceEmail
{product.name}${product.price.toFixed(2)}
{customer.name}{customer.email}