diff --git a/Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md b/Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md index 2f17939c..47377ff7 100644 --- a/Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md +++ b/Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md @@ -72,6 +72,8 @@ project would drift from its own packages after the first bump. Templates point - `SkillsAreWellFormedTests` holds every skill to the release's rules (name, description, the internal marker, no stamped version, markdown only, links inside the skill), every docs link in a skill to a published page, and the marketplace on `main` to the one the release expects. +- `SkillsCoverMigrationGuidesTests` holds `trax-upgrade` to every page under `migration-guides/`: a guide the skill + does not link fails, naming the guide. - `Trax.Docs/tools/Trax.Skills.Publish` is the stage the `Skills` workflow runs; it applies the same rules and refuses to write a tree that breaks one. Its tests are in `StagerTests.cs` and `SkillRulesTests.cs`. - `CompiledSnippetsTests.cs` and `EveryCSharpFenceIsMarkedTests.cs` in Trax.Docs.Snippets.Tests cover a skill's diff --git a/Trax.Docs/skills/trax-design-a-machine/SKILL.md b/Trax.Docs/skills/trax-design-a-machine/SKILL.md new file mode 100644 index 00000000..99aba8f3 --- /dev/null +++ b/Trax.Docs/skills/trax-design-a-machine/SKILL.md @@ -0,0 +1,247 @@ +--- +name: trax-design-a-machine +description: Use before writing or changing a Trax state machine (a Machine subclass, Trax.StateMachine, Trax.StateMachine.Persistence), or when a process spans several requests or users, waits on people, or walks a user through steps such as a checkout, a wizard, an approval or a game turn. Covers train or machine, the builder and host rules that decide the machine's layout, invoking trains from a state, Rule and Reduction (SetAll, new Rule.Custom), spawning child machines, undoing an effect and where machine files go. +metadata: + internal: true +--- + +# Design a Trax machine + +A machine is a thing that waits: it sits in a named state until someone sends it an event, and the server stores it +after every move. A train is one operation that runs to its end. Most of what goes wrong with a machine is a layout +the builder refuses, found after the states were drawn. Read the rules below before drawing any state. + +## First: train or machine + +- **Nobody waits in the middle:** write a train (`trax-write-a-train`). A long train is not a reason for a machine; + [checkpoints](https://traxsharp.net/docs/core/checkpoints-and-branches) resume it. +- **A person or another system acts several times, some actions only in some states:** a machine. +- **Both, usually:** the machine decides when the work happens, a train does it. A state invokes the train. + +[Train or machine](https://traxsharp.net/docs/statemachine/train-or-machine) has the full comparison. + +## Decide before writing states + +1. **Who owns an instance.** User-owned (the default): a user starts it and sends its events. `SystemOwned()`: only + host code (`IMachineInstances.Start`) creates one and only outcomes and `IMachineInstances.Advance` move it; it + cannot be served to clients. +2. **Served or host-only.** `[TraxMachine]` serves it under the `machines` GraphQL root. A served machine is all + data (no delegate guards or reducers), declares a rule on every event and on `Start`, and declares `Get`, `Watch` + and `Discard` owner-only or stricter. The host refuses to start until it does + ([Author and serve a machine](https://traxsharp.net/docs/statemachine/authoring)). +3. **Does a state run work.** A state that invokes a train needs Postgres or SQLite (not InMemory), + `AddJunctionProgress()`, `AddMediator(...)`, and a scheduler on this host or one sharing the database. Without a + scheduler the run stays queued and no outcome arrives; the host does not check. The `trax-hub` template has all + of it; `trax-api` has none. +4. **One irreversible effect, or trains.** `RunsOnce()` binds the machine's one exactly-once effect (a + charge) to one edge. Work with several stages is invoked trains. + +## The rules that shape the machine + +`Build()` refuses a machine that breaks any of these the first time anything reads it: the host's startup checks, or +`new RunMachine().ExportIr()` in a test. The ones marked *startup* are checked only by a host that registers the +machine, so a test that only builds the machine misses them; start the host in a test too. + +| Rule | Checked | +|---|---| +| An ordinary event may not enter a state an outcome enters. A self-loop is fine, and so is an edge into an outcome target that itself invokes a train. | `Build` | +| An outcome may not go to the start state, or to the `RunsOnce` effect's target. | `Build` | +| An event any caller may send (no rule, `AnyAuthenticated()`, `Anonymous()`) may not copy from its input a field the server writes: an outcome's fields, the effect's receipt, a child's id, a field a role-gated event writes. A custom reducer counts as copying every field. | `Build` | +| Every invoking state has at least one `OnDone`, exactly one `OnFailed` and exactly one `OnCancelled`. One train or child per state. The train is named by its interface; its output is a record with properties, not a tuple. | `Build` | +| An event has the same rule on every edge. A role-gated event may not enter the start state from another state. | `Build` | +| At most one `RunsOnce`; only its own edge enters its target; its trigger is sent on that edge only; it neither runs from nor lands in an invoking state. | `Build` | +| On a user-owned machine, no outcome and no timer enters a state that invokes a train or spawns a child. | *startup* | +| On a user-owned machine, every event entering an invoking state requires at least the roles and policies of the train's `[TraxAuthorize]`. | *startup* | + +Together they force one layout on a user-owned machine that invokes trains: **each user action is its own event, +from a resting state into a working state that invokes the train for it, and the outcome brings the instance back to +a resting state.** The start state is a plain state the user leaves with an event. A failure goes to a state with a +`Retry` event back into the working state, because the scheduler never retries an invoked run. + +```csharp compile +using System.Text.Json.Nodes; +using Trax.Effect.Attributes; +using Trax.StateMachine; +using Trax.StateMachine.Persistence; +using static Trax.StateMachine.Rules; + +public enum RunState { New, Loading, Turn, Resolving, Broken, Over } + +public enum RunTrigger { Begin, Play, Retry } + +public sealed record RunContext +{ + public string RunId { get; init; } = ""; + public int? Version { get; init; } + public int? Slot { get; init; } + public int? Target { get; init; } +} + +public sealed record EmptyStart; + +public sealed record BeginInput +{ + public string RunId { get; init; } = ""; +} + +public sealed record PlayInput +{ + public int Slot { get; init; } + public int Target { get; init; } +} + +[TraxMachine] +public sealed class RunMachine : Machine +{ + protected override void Configure(IMachineBuilder m) + { + m.Id("run") + .Version(1) + .StartsAt(RunState.New, () => new JsonObject()) + .Start(s => s.Reduce(Keep()).AnyAuthenticated()) + .Get(r => r.OwnerOnly()) + .Watch(r => r.OwnerOnly()) + .Discard(r => r.OwnerOnly()); + + // The start state is plain: the user leaves it with an event into a working state. + m.In(RunState.New) + .Context() + .On(RunTrigger.Begin) + .AnyAuthenticated() + .WithInput() + .Reduce(Set((RunContext c) => c.RunId).FromInput((BeginInput i) => i.RunId)) + .To(RunState.Loading); + + m.In(RunState.Loading) + .Context() + .Invokes(ctx => + new LoadRunInput(ctx["runId"]!.GetValue())) + .OnDone(RunState.Turn, + reduce: Set((RunContext c) => c.Version).FromInput((LoadRunOutput o) => o.Version)) + .OnFailed(RunState.Broken) + .OnCancelled(RunState.Broken); + + // A resting state. The user's action is its own event into the state that runs it. + m.In(RunState.Turn) + .Context() + .On(RunTrigger.Play) + .AnyAuthenticated() + .WithInput() + .Reduce(SetAll( + Set((RunContext c) => c.Slot).FromInput((PlayInput i) => i.Slot), + Set((RunContext c) => c.Target).FromInput((PlayInput i) => i.Target))) + .To(RunState.Resolving); + + m.In(RunState.Resolving) + .Context() + .Invokes(ctx => + new PlayCardInput( + ctx["runId"]!.GetValue(), + ctx["version"]!.GetValue(), + ctx["slot"]!.GetValue(), + ctx["target"]!.GetValue())) + .OnDone(RunState.Over, + when: Input((PlayCardOutput o) => o.Finished).IsTrue(), + reduce: Set((RunContext c) => c.Version).FromInput((PlayCardOutput o) => o.Version)) + .OnDone(RunState.Turn, + reduce: Set((RunContext c) => c.Version).FromInput((PlayCardOutput o) => o.Version)) + .OnFailed(RunState.Broken) + .OnCancelled(RunState.Broken); + + // The scheduler never retries an invoked run: the user does, by entering the state again. + m.In(RunState.Broken).Context().On(RunTrigger.Retry).AnyAuthenticated().To(RunState.Loading); + m.In(RunState.Over).Context(); + } +} +``` + +`Play` copies `slot` and `target`, which no outcome writes; the outcomes write `version`, which no open event +copies. A system-owned machine may chain outcomes straight into the next invoking state instead. + +## Known mistakes + +```csharp fragment +// Wrong: Rule.Custom is a record, not a factory method. Same for Reduction.Custom. +.When(Rule.Custom("can-afford")) +// Right: construct it, and bind the C# handler on the machine builder. +.When(new Rule.Custom("can-afford")) +m.CustomGuard("can-afford", (context, input) => /* ... */ true); +``` + +```csharp fragment +// Wrong: a custom reducer to copy several input fields. On an open event it counts as copying every +// field, so Build refuses it as soon as an outcome writes any field. +.Reduce(new Reduction.Custom("stash-play")) +// Right: SetAll combines Set reductions, each field once. There is no other combinator for reductions. +.Reduce(SetAll(Set((RunContext c) => c.Slot).FromInput((PlayInput i) => i.Slot), + Set((RunContext c) => c.Target).FromInput((PlayInput i) => i.Target))) +``` + +```csharp fragment +// Wrong: Turn is an outcome target, so no event from another state may enter it (Build refuses); +// and on a user-owned machine an outcome may not enter Resolving, which invokes a train (startup refuses). +m.In(RunState.Broken).On(RunTrigger.Retry).To(RunState.Turn); +.OnDone(RunState.Resolving) +// Right: Retry enters Loading, which invokes the train that brings the instance back to Turn; +// Play goes from Turn into Resolving, and Resolving's outcome comes back to Turn. +``` + +- **A custom rule in the browser.** The generated twin has no handler for a custom rule: it counts it as passing on + a snapshot the server sent and predicts nothing that needs it. The server's answer decides. Use the vocabulary + where it fits; it has no arithmetic and cannot compare two fields, which is what custom rules are for. +- **A delegate on a served machine.** `When(Func...)`, `Reduce(Func...)` and `Holds` run but cannot be exported, so + the machine has no IR, no twin and no typed API. Use them only on a host-only machine with no twin + ([Delegates and declarative rules](https://traxsharp.net/docs/statemachine/delegates-and-declarative-rules)). +- **A refusal is not an exception.** A guard that declines answers a typed `problem` (`GUARD_FAILED` with the + `Because` text); nothing is thrown and nothing is written. + +## Invoking a train + +The train is a `ServiceTrain` behind an interface. Every junction is an `EffectJunction` (it reads the cancel flag +when the instance leaves the state) and is safe to run twice: the run is queued once but can execute more than once. +The output is a record, never holds a `[TraxSensitive]` member, and the train is not `[TraxBroadcast]` on a +user-owned machine. The host refuses each of these at startup. + +The run has no caller: `TraxPrincipal` cannot be injected. A junction that acts on the user's data injects +`InvokedRun` and checks the data against its `UserKey`, the owner of the instance that queued the run. Reduce +pointers (ids, versions, fingerprints) into the context, not rows: a snapshot past 64 KiB is refused and the run goes +to `OnFailed`. [Invoke a train from a state](https://traxsharp.net/docs/statemachine/invoking-trains) and +[Invoked runs](https://traxsharp.net/docs/statemachine/invoked-runs#host-refusals) list every refusal. + +## Rules, reductions and context + +- Guards: `When(Rule)` from `Input(...)` (the event's input) and `Field(...)` (the context), with `All` and `Any`. + Reductions: `Keep()`, `Clear()`, `Reset()`, `Set(...).FromInput(...)` or `.ToValue(...)`, and `SetAll(...)`. + Fields are member expressions, never strings + ([Rules](https://traxsharp.net/docs/sdk-reference/statemachine-api/rules)). +- Each state's context and each event's input is a record (`Context()`, `WithInput()`); the JSON names are + camelCase. An event's input is checked as strictly as its type, a nested record included, so several values for a + train go in one input record. A context is described one level deep, so the twin types a nested record or a list + of records loosely. `object`, `JsonNode` and `[Flags]` enums are refused + ([Context and input schemas](https://traxsharp.net/docs/statemachine/context-schemas)). + +## Children and undoing + +- **A sub-flow with its own events:** `Spawns(c => c.ChildId)` on the parent's state, + with the same `OnDone`/`OnFailed`/`OnCancelled`. The child marks its end states `Ends()` and `Fails()`, its start + state waits for an event, and it is owned the way its parent is + ([Spawn a child machine](https://traxsharp.net/docs/statemachine/spawning-child-machines)). +- **Undo an effect that ran** (a refund): a second machine whose one effect is the undo, started from the receipt + the first recorded. Before the effect runs, discard or reset instead + ([Undo an effect](https://traxsharp.net/docs/statemachine/undo-an-effect)). + +## Where the files go + +`Machines//` beside the trains: the machine, its state and trigger enums and its context at the top, every +input and context part in `Models/`. `trax machine generate --assembly --ir-out machines/ --corpus-out +machines/` writes the IR and the differential corpus under `machines//` at the solution root; commit them, +embed the corpus, and run `trax machine check` with the same arguments in CI. The frontend's twin comes from +`trax client` (`trax-generate-a-client`). +[Organize machines in a project](https://traxsharp.net/docs/statemachine/organize-machines) has the tree. + +## Next + +- Serve it and secure it: `trax-expose-over-api`. Call it from a frontend: `trax-generate-a-client`. Test it: + `trax-test-your-app`. +- Every builder method: [Machine](https://traxsharp.net/docs/sdk-reference/statemachine-api/machine). Every result + code: [Result codes](https://traxsharp.net/docs/sdk-reference/statemachine-api/result-codes). diff --git a/Trax.Docs/skills/trax-expose-over-api/SKILL.md b/Trax.Docs/skills/trax-expose-over-api/SKILL.md new file mode 100644 index 00000000..b47ab65b --- /dev/null +++ b/Trax.Docs/skills/trax-expose-over-api/SKILL.md @@ -0,0 +1,219 @@ +--- +name: trax-expose-over-api +description: Use when exposing Trax trains, query models or state machines over GraphQL, or setting up who may call them. Covers AddTraxGraphQL and UseTraxGraphQL, [TraxQuery], [TraxMutation], [TraxQueryModel], [TraxMachine], the required [TraxAuthorize] or [TraxAllowAnonymous] posture, API key, JWT and OIDC auth, AddTraxPrincipalAccessor and injecting TraxPrincipal, persisted operations and client documents, Origin and CORS for browser clients and WebSocket subscriptions, error masking and the TRAX_* error codes a caller sees, and registration order. Also use when a GraphQL call is refused with TRAX_AUTHORIZATION, PERSISTED_OPERATION_REQUIRED or Unexpected Execution Error, or a browser socket gets a 403. +metadata: + internal: true +--- + +# Expose trains and machines over the API + +`Trax.Api.GraphQL` serves a host's trains, query models and machines as one GraphQL endpoint, +`/trax/graphql` by default. It is closed by design: every surface declares who may reach it or the +host refuses to start, a caller with no credential reaches only what you marked public, and a +refusal says nothing about what exists. Treat each closed default as the requirement. If something +is refused, find out why; never open a default to make a call work. +[The GraphQL API](https://traxsharp.net/docs/api) explains the model, and +[Secure a GraphQL host](https://traxsharp.net/docs/security/secure-a-graphql-host) is the whole +setup in one `Program.cs`. Read that page before writing the host. + +## Decide first + +1. **What each surface is.** Business logic is a train: `[TraxQuery]` (under `discover`) or + `[TraxMutation]` (under `dispatch`). A table read the caller filters and pages is a query model, + `[TraxQueryModel]` on an EF Core entity. A process across requests is a machine, `[TraxMachine]`. + A train with none of these is not on the API. + [Query models and query trains](https://traxsharp.net/docs/api/query-models-and-query-trains) + compares the two reads. +2. **Who may call each one.** `[TraxAuthorize]` (any signed-in caller, or a role or policy) or + `[TraxAllowAnonymous]`. Default to `[TraxAuthorize]`. A public surface is the user's decision: + ask, and say that anyone on the network can then call it. +3. **How callers sign in.** API keys for services and scripts (`Trax.Api.Auth.ApiKey`), JWTs from + an identity provider (`Trax.Api.Auth.Jwt`), interactive browser sign-in with a cookie + (`Trax.Api.Auth.Oidc`). Which provider, issuer and audience production uses is the user's to + say; never invent production credentials. +4. **Run or queue.** `GraphQLOperation.Run` answers with the output. `Queue` writes a work queue + entry for a scheduler, which needs a database the scheduler shares; on the in-memory provider a + queue is refused. See [Queue or run](https://traxsharp.net/docs/api#queue-or-run). +5. **Persisted operations.** Required to expose any machine, and the way a production frontend + ships: the host runs only documents it registered. Ask the user before turning them off for a + host that has them. + +## Steps + +1. **Packages.** `Trax.Api.GraphQL` plus one or more of `Trax.Api.Auth.ApiKey`, `.Jwt`, `.Oidc`, + all at the version the other `Trax.*` packages have. The namespaces agents get wrong: + + | Type or call | Namespace | + |---|---| + | `AddTraxGraphQL`, `UseTraxGraphQL` | `Trax.Api.GraphQL.Extensions` | + | `AddTraxPrincipalAccessor`, `TraxPrincipal`, `TraxCaller` | `Trax.Api.Auth` | + | `AddTraxApiKeyAuth` / `AddTraxJwtAuth` / `AddTraxOidcAuth` | `Trax.Api.Auth.ApiKey` / `.Jwt` / `.Oidc` | + | `[TraxQuery]`, `[TraxMutation]`, `GraphQLOperation`, `[TraxQueryModel]`, `[TraxMachine]`, `[TraxAuthorize]`, `[TraxAllowAnonymous]` | `Trax.Effect.Attributes` | + | `TrainException` | `Trax.Core.Exceptions` | + | `UsePersistedOperations` | `Trax.Api.GraphQL.PersistedOperations.Extensions` | + +2. **Write the host.** Demo credentials exist only inside `IsDevelopment()` and carry + `do-not-use-in-production`; the host refuses to start with one registered anywhere else. Where the + production credentials come from (hashed keys from a secret store, an authority) is the user's + call; [Secure a GraphQL host](https://traxsharp.net/docs/security/secure-a-graphql-host#1-write-the-host) + shows both branches. + + ```csharp compile=api + using Trax.Api.Auth; + using Trax.Api.Auth.ApiKey; + using Trax.Api.GraphQL.Extensions; + using Trax.Effect.Data.Postgres.Extensions; + using Trax.Effect.Extensions; + using Trax.Mediator.Extensions; + + var builder = WebApplication.CreateBuilder(args); + + if (builder.Environment.IsDevelopment()) + builder.Services.AddTraxApiKeyAuth(keys => + keys.Add("member-key-do-not-use-in-production", id: "member", "Member")); + // Production credentials go here, from a secret store. Ask the user which. + + builder.Services.AddAuthentication(); // the auth call above is conditional, + builder.Services.AddTraxPrincipalAccessor(); // so register what it would bring + builder.Services.AddAuthorization(); // and every policy a gate names + + builder.Services.AddTrax(trax => trax + .AddEffects(effects => effects.UsePostgres(builder.Configuration.GetConnectionString("Trax")!)) + .AddMediator(typeof(Program).Assembly)); + + builder.Services.AddTraxGraphQL(); // after AddTrax, which registered the trains + + var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseTraxGraphQL(); + app.Run(); + ``` + +3. **Expose a train and give it a posture.** The junction reads the caller from DI. A train gated + by `[TraxAuthorize]` refuses an anonymous caller before its junctions are built, so `TraxPrincipal` + is always there. Its `Id` is scheme-qualified (`TraxApiKey:member`); key ownership on it as is. + + ```csharp compile=api + using Trax.Api.Auth; + using Trax.Core.Exceptions; + using Trax.Core.Functional; + using Trax.Core.Junction; + using Trax.Effect.Attributes; + using Trax.Effect.Services.ServiceTrain; + + public record RenameNoteInput(string NoteId, string Title); + + public record RenamedNote(string NoteId, string Title, string RenamedBy); + + public interface IRenameNoteTrain : IServiceTrain; + + [TraxAuthorize(Roles = "Member")] + [TraxMutation(GraphQLOperation.Run)] + public class RenameNoteTrain : ServiceTrain, IRenameNoteTrain + { + protected override Task> Junctions() => + Chain().Resolve(); + } + + internal class RenameNoteJunction(TraxPrincipal user) : Junction + { + public override Task Run(RenameNoteInput input) => + string.IsNullOrWhiteSpace(input.Title) + ? throw new TrainException("A note needs a title.") + : Task.FromResult(new RenamedNote(input.NoteId, input.Title, user.Id)); + } + ``` + + Field names, namespaces and input rules are on + [TraxQuery and TraxMutation](https://traxsharp.net/docs/sdk-reference/graphql-api/trax-graphql-attribute). +4. **Query models.** `[TraxQueryModel]` and a posture on the entity, the entity in a `DbSet` on its + context, and `AddTraxGraphQL(graphql => graphql.AddDbContext())`. Every entity it reaches + through a navigation needs a posture too. `[TraxAuthorize]` decides who may read the type, not + which rows: scope rows with a query filter over the caller. See + [Expose an entity as a query model](https://traxsharp.net/docs/api/expose-an-entity-as-a-query-model) + and [What the attributes do not cover](https://traxsharp.net/docs/security/authorization-rules#what-the-attributes-do-not-cover). +5. **Machines.** `[TraxMachine]` on the concrete machine is the only switch. Every event, the start, + the read and the discard need a rule, and the host must call `UsePersistedOperations`, or it + refuses to start. Design the machine with `trax-design-a-machine`; the served operations are on + [Machines root](https://traxsharp.net/docs/sdk-reference/graphql-api/machines). +6. **Live updates.** A train callers watch over a subscription carries `[TraxBroadcast]`. To let only + the caller who started a run follow it, use `[TraxRunProgress]` + ([Run progress](https://traxsharp.net/docs/sdk-reference/graphql-api/run-progress)). +7. **Persisted operations and client documents.** A frontend writes `.graphql` documents, + `trax client --documents` turns them into `trax.documents.json`, and the host registers them at + boot with `UsePersistedOperations(po => po.SingleNode().ClientDocuments(""))`, which requires + persisted documents by default. `SingleNode()` claims one process serves the endpoint; with more, + it is `UseRabbitMqInvalidation(...)`. Steps are in + [Fetch only what a component needs](https://traxsharp.net/docs/api/fetch-only-what-a-component-needs) + and [Turn on persisted operations](https://traxsharp.net/docs/api/turn-on-persisted-operations); + the client side is `trax-generate-a-client`. +8. **Browser clients.** Browsers send `Origin`; Node clients and curl do not, so a Node test that + passes says nothing about the browser. A socket upgrade with an `Origin` is accepted from the + endpoint's own host, or from the origins your CORS default policy names, or those set with + `AllowSocketOrigins(...)`. Which production origins to allow is the user's call: ask. HTTP CORS is + the host's own ASP.NET Core policy. A browser sends its socket credential in `connection_init` + (`authToken`), not as a header. See + [Allowed origins](https://traxsharp.net/docs/sdk-reference/graphql-api/subscriptions#allowed-origins) + and [Authentication](https://traxsharp.net/docs/sdk-reference/graphql-api/subscriptions#authentication). +9. **Start it and call it without a credential.** A missing posture, an unregistered policy or a + misplaced demo key stops the host with a message naming the fix + ([Startup refusals](https://traxsharp.net/docs/security/startup-refusals)). An anonymous call to a + gated train must answer `TRAX_AUTHORIZATION`; test that as well as the allowed call. + +## What a caller sees + +Error messages are masked on purpose. A caller branches on `extensions.code`, never on the +message. A plain `TrainException`'s message passes through as `TRAX_TRAIN_ERROR`; a subclass of it +shows `The train failed.`; any other exception, `InvalidOperationException` included, shows +`Unexpected Execution Error`. So a message the caller should read is a plain +`new TrainException("...")`, never a type of your own. Every authorization refusal is `TRAX_AUTHORIZATION` with +`Not authorized.`, naming no train, role or policy. The full list is on +[Error codes](https://traxsharp.net/docs/sdk-reference/graphql-api/error-codes). To see what really +failed, read the server log and the run's metadata row (`trax-troubleshoot`). Do not switch on +exception details in the response to read it. + +## Known mistakes + +- **HotChocolate's attributes.** Trax refuses them on what it checks, and they mean nothing on a train. + + ```csharp fragment + [Authorize(Roles = "Member")] // wrong: HotChocolate's, not read on a train + [TraxAuthorize(Roles = "Member")] // right + ``` + +- **The principal accessor namespace.** `AddTraxPrincipalAccessor` is `using Trax.Api.Auth;`, not an + auth package's namespace. Every `AddTrax*Auth` call runs it; call it yourself when those calls are + conditional, or the host refuses to start outside Development, naming the junction that needs + `Trax.Api.Auth.TraxPrincipal`. Call `AddAuthentication()` unconditionally for the same reason. +- **`TraxPrincipal` in a junction the scheduler runs.** A queued run, a scheduled run and a train a + machine invokes have no HTTP caller, and resolving `TraxPrincipal` throws. Carry the initiator in + the input, inject `TraxCaller` (whose `Principal` may be null), or, in a train a machine invokes, + inject `InvokedRun` (`Trax.StateMachine.Persistence`) for the instance's owner. Never put a token + in a machine's context for a train to check. See + [Junctions the scheduler also runs](https://traxsharp.net/docs/security/inject-the-current-principal#junctions-the-scheduler-also-runs). +- **A train registered after `AddTraxGraphQL()`.** The host refuses to start, naming it. Register the + trains in `AddTrax(...)` first. Auth calls may come before or after; see + [Registration order](https://traxsharp.net/docs/sdk-reference/graphql-api/add-trax-graphql#registration-order). +- **Only mutations.** `AddTraxGraphQL() found no GraphQL queries to expose.` A schema needs a query: + a `[TraxQuery]` train, a query model, or the gated `operations` queries. +- **Gating the whole endpoint on the route.** A convention passed to `UseTraxGraphQL(configure: ...)` + is enforced, but the startup checks do not see it. Use the builder's `RequireAuthorization()`. +- **Exposing `operations`.** `ExposeOperationQueries()` and `ExposeOperationMutations()` need a gate + with a role or policy, `GateOperations(roles: "Operator")`; they reveal execution inputs and drive + the scheduler. The host also needs `AddScheduler(...)` for them. +- **A text query refused with `PERSISTED_OPERATION_REQUIRED`.** The host is working. Register the + document as a client document, or use the generated client; ask the user before anything else. +- **A browser socket answered `403` through a dev proxy.** Vite's `changeOrigin: true` makes `Host` + differ from `Origin`. Leave `changeOrigin` off for the GraphQL path. +- **`X-Api-Key` in `connection_init`.** That is the HTTP header's name. The payload key is + `authToken` (or `apiKey`); anything else is no credential and the socket is closed with `4403`. + +## Ask the user, never decide alone + +Any change that makes a surface public, lets the endpoint run documents it did not register, admits +another origin in production, returns exception detail to callers, opens the schema outside +Development, opens the `operations` namespace or the console without a gate, or decides where +production credentials come from. Say what it exposes and to whom, and wait for the answer. +[Where Trax checks authorization](https://traxsharp.net/docs/security/where-authorization-is-checked) +says why each default is closed. diff --git a/Trax.Docs/skills/trax-generate-a-client/SKILL.md b/Trax.Docs/skills/trax-generate-a-client/SKILL.md new file mode 100644 index 00000000..812b1009 --- /dev/null +++ b/Trax.Docs/skills/trax-generate-a-client/SKILL.md @@ -0,0 +1,169 @@ +--- +name: trax-generate-a-client +description: Use when a frontend or a Node script calls a Trax API, or when working with trax client, the client manifest (--trax-export-client, RunTraxAsync, TraxClientExport), trax.documents.json, .graphql documents, generated .g.ts files, configureTrax, the generated machine hooks (useCheckout, useRememberedCheckout and the like), useServerMachine, createMachineExecutor, createFormView or @traxsharp/state-machine. Covers exporting the manifest, generating and checking the client, writing the documents a train needs, driving a machine from React or TypeScript, forms from a machine, persisted ids, and why a socket works from Node but gets 403 in the browser. +metadata: + internal: true +--- + +# Generate and use a Trax client + +A frontend never writes a GraphQL request by hand. The host exports a **client manifest**, `trax client` turns it +into typed TypeScript, and every request goes out by a persisted id the host registered. A host with persisted +operations required (the default) refuses a document sent as text, so a hand-written `fetch` with a `query` string +fails however right it looks. + +## How the pieces fit + +| Who | Writes | Read by | +|---|---|---| +| The host, started with `--trax-export-client ` | `manifest.json`: every machine's documents and IR, every exposed train's field, arguments and types | `trax client` | +| The frontend | `.graphql` documents, one named operation each, for the trains it calls | `trax client --documents` | +| `trax client` | `src/trax/*.g.ts`, each machine's twin and hooks, `trax.documents.json` | the frontend; the host (`ClientDocuments`) | + +The host generates documents only for machines and run progress. **A frontend reaches a train only through a +document it wrote** (ADR 0054): there is no generated function for a train until a `.graphql` file asks for one. + +## 1. Let the host export its manifest + +The namespaces are easy to guess wrong: `RunTraxAsync` is in `Trax.Api.GraphQL.PersistedOperations.Extensions`, +`TraxClientExport` in `Trax.Effect.Configuration.ClientExport`. + +```csharp compile +using Trax.Api.GraphQL.Extensions; +using Trax.Api.GraphQL.PersistedOperations.Extensions; +using Trax.Effect.Configuration.ClientExport; + +builder.Services.AddTraxGraphQL(graphql => + graphql.UsePersistedOperations(persisted => + persisted.SingleNode().ClientDocuments("trax.documents.json"))); + +var app = builder.Build(); + +if (!TraxClientExport.Requested) +{ + // Migrate or seed your own database here: an export opens none. +} + +app.UseTraxGraphQL(); +await app.RunTraxAsync(args); +``` + +- `RunTraxAsync` serves as `RunAsync` does, unless the process was started with `--trax-export-client`; then it runs + the startup checks, writes the manifest and exits. A host that calls `app.Run()` instead refuses to start when + asked to export. +- `ClientDocuments(path)` is relative to the content root. The host checks every document at startup and refuses to + start, naming it, when the file is missing or a document no longer fits the schema. The book copies the file + into the build output and passes `Path.Combine(AppContext.BaseDirectory, "trax.documents.json")`. +- Export with the production configuration: a machine's IR, and so its ids, can depend on it. + +```bash +ASPNETCORE_ENVIRONMENT=Production dotnet run --project Api -- --trax-export-client ../web/src/trax/manifest.json +``` + +The path is relative to the project folder `dotnet run` starts in. + +## 2. Write the documents a train needs + +One named `query` or `mutation` per `.graphql` file, rooted at `discover` (query trains, query models) or +`dispatch` (mutation trains), with the train's namespace if it has one. Select only what the component needs. A +filter or sort is written in the document, only its values are variables; no directives. The rules and examples are +in [Fetch only what a component needs](https://traxsharp.net/docs/api/fetch-only-what-a-component-needs). + +## 3. Generate, commit, check + +```bash +dotnet tool run trax client --manifest src/trax/manifest.json --out src/trax --documents 'src/graphql/*.graphql' +``` + +- Install the tool as a local tool so CI runs the pinned version (`dotnet new tool-manifest`, + `dotnet tool install Trax.Cli`). It needs `node` 22 or later. The app installs + `@traxsharp/state-machine` and `graphql-ws`, at the version of its `Trax.*` packages. +- Commit everything under `--out`, the manifest included. Never edit a `.g.ts` file. +- In CI, export the manifest again and run the same command with `--check`; it fails naming each stale file. +- After any change to a train, a query model, a machine or a document: export, generate, then build the host again + so it picks up the new `trax.documents.json`. **Deploy the host before the frontend.** +- `trax client` writes each machine's twin itself (`/.machine.g.ts`). `trax machine generate` is for the IR + and corpus on the .NET side; do not point its `--twin-out` into the same folder. + +Every option and every file it writes: [trax client](https://traxsharp.net/docs/reference/cli#frontend-clients-trax-client). + +## 4. Connect once, then call + +Call `configureTrax` before any hook or function runs, and again when another user signs in. A credential goes in +both `headers` (HTTP) and `connectionParams` (the socket: a browser's WebSocket carries no custom headers). `user` is +the signed-in user's id; the remembered machines are kept under it. + +```ts from=Trax.Samples/book/Shopfront/web/src/connection.ts +export async function signIn(user: string): Promise { +// ... + configureTrax({ + url: `${HUB}/trax/graphql`, + socketUrl: `${HUB.replace("http", "ws")}/trax/graphql`, + headers: () => ({ Authorization: `Bearer ${token}` }), + connectionParams: () => ({ authToken: token }), + user, + }); +} +``` + +`socketUrl` is an absolute `ws://` or `wss://` URL. Each document is then a function named after its operation, +exported from `src/trax/index.g`, taking the operation's variables and answering exactly the fields it selected +(`placeOrder(input)` for `mutation PlaceOrder($input: PlaceOrderInput!)`). Its doc comment says what a caller needs. + +## 5. Drive a machine + +From React, use the generated hooks: `use({ id })` opens one instance or starts one, and +`useRemembered()` keeps the signed-in user's instance across visits (it throws when `configureTrax` got no +`user`). + +```tsx +const checkout = useRememberedCheckout(); +const { status, error, state, context, pending, problem, send, can, forget } = checkout; + + +``` + +- **The server decides; the twin predicts.** `send` shows the twin's prediction at once, the server applies the + event and its answer replaces it. A refusal rolls back and sets `problem` (`GUARD_FAILED` carries the machine's + `Because` text). `send` never throws. +- **`can` is a hint.** A custom rule (`new Rule.Custom(...)` on the server) has no handler in the twin and counts as + passing, so decide such a button from the context or let the server refuse. +- **`error` means stopped for good:** `GONE`, `CLIENT_OUTDATED` (the page is older than the host: reload), or + `CONNECTION_CHANGED` after `configureTrax` was called again. +- **A list or nested record in the context is typed loosely** (`unknown`): name its items with the input type from + `types.g.ts`, as the book's `linesOf` does. +- Outside React (a Node script, a test), `createMachineExecutor(endpoint, { machines: [{ machine, documents }] })` + from `@traxsharp/state-machine/client` sends the same operations by id, with the twin and the generated + `Documents` array from `.client.g.ts`. + +A wizard whose form owns the data asks the twin instead of a server: `createFormView(twin, { steps, toContext })` +from `@traxsharp/state-machine`, then `can`, `advance`, `serialize` and `resume` +([Drive a form from a machine](https://traxsharp.net/docs/statemachine/drive-a-form-from-a-machine)). A snapshot +stored or compared is the [canonical wire](https://traxsharp.net/docs/statemachine/canonical-wire) that `serialize` +writes; never build one by hand. + +## Known mistakes + +- **Copying a `documents.json` import from an older page.** The documents of a machine are the generated + `Documents` export (or the generated hooks, which pass them). Nothing writes a `.documents.json`. +- **A socket that works from Node and gets `403` in the browser.** A browser sends `Origin` on the upgrade, and the + host accepts only its own host or the origins it allows (its CORS default policy's `WithOrigins`, or + `AllowSocketOrigins`). Node sends no `Origin`, so a Node test proves nothing about the page. Behind Vite's + `server.proxy`, `changeOrigin: true` rewrites `Host` and every upgrade is refused: leave `changeOrigin` off for + the GraphQL path. If the frontend really is on another origin, the host's allowlist names it; that is a host + change, so ask the user ([Subscriptions](https://traxsharp.net/docs/sdk-reference/graphql-api/subscriptions)). +- **A text query because the id is unknown.** Write the `.graphql` document and regenerate. Never ask for persisted + operations, introspection or masking to be turned off; the generated `schema.graphql` and `graphql.config.yml` + give the editor what introspection would. +- **A frontend deployed first.** Its new ids are not registered yet. An old page keeps working through a deploy + for the grace period, then gets `CLIENT_OUTDATED`. + +## Next + +- The machine itself: `trax-design-a-machine`. The host's auth, CORS and persisted operations: + `trax-expose-over-api`. +- [Drive a machine instance from React](https://traxsharp.net/docs/statemachine/drive-a-machine-instance-from-react), + [Call a machine from TypeScript](https://traxsharp.net/docs/statemachine/call-a-machine-from-typescript), + [Client manifest](https://traxsharp.net/docs/sdk-reference/statemachine-api/client-manifest), + [TypeScript package](https://traxsharp.net/docs/sdk-reference/statemachine-api/typescript-package), and the + book's [A generated client](https://traxsharp.net/docs/book/a-generated-client). diff --git a/Trax.Docs/skills/trax-schedule-work/SKILL.md b/Trax.Docs/skills/trax-schedule-work/SKILL.md new file mode 100644 index 00000000..064434f2 --- /dev/null +++ b/Trax.Docs/skills/trax-schedule-work/SKILL.md @@ -0,0 +1,176 @@ +--- +name: trax-schedule-work +description: Use when a Trax app runs trains on a schedule, in a queue or on workers, or changes how it does. Covers AddScheduler, manifests (Schedule, Every, Cron, ScheduleOnce, ThenInclude, Include), ITraxScheduler and TriggerAsync, the work queue and mode QUEUE, retries and dead letters, scheduler option value ranges such as polling intervals and worker counts, which database providers support what (Postgres, SQLite, InMemory), local workers, AddTraxWorker, remote runners, Lambda and SQS, deployment topologies, the leader lock and the liveness health check. +metadata: + internal: true +--- + +# Schedule work with Trax + +`Trax.Scheduler` runs trains without a caller waiting: on an interval or a cron (a **manifest**), once +later, after another manifest succeeds, or when something queues a run. Every run goes through one +work queue, a dispatcher hands it to a job submitter, and a worker runs it and records it like any +other run. [Scheduling](https://traxsharp.net/docs/scheduler) explains the parts, and +[Schedule a train](https://traxsharp.net/docs/scheduler/setup) builds a host from nothing. + +## Decide first + +1. **Which database.** This decides most of what works. + + | Provider | What the scheduler does there | + |---|---| + | `UsePostgres()` | Everything, across any number of processes: the ManifestManager, the job dispatcher, local workers, standalone workers, metadata cleanup. | + | `UseSqlite()` | The full scheduler, in **one process**. Writes take the file's lock, so the local worker pool starts one worker unless you set `WorkerCount`. Nothing coordinates a second process on the same file. | + | `UseInMemory()` | Tests and prototypes only. No job dispatcher and no work queue reader: the in-memory ManifestManager runs due manifests inline. Retry delays, decision replay and the stale-run reapers do not run, a GraphQL `mode: QUEUE` is refused with `TRAX_QUEUE_UNAVAILABLE`, and a machine cannot invoke a train. | + + More than one Trax process against the same data (a hub and workers, two replicas) needs + Postgres. [SQLite and Postgres](https://traxsharp.net/docs/effect/sqlite-and-postgres) has the + comparison. The `trax-scheduler` and `trax-hub` templates start on `UseInMemory()`; + [Switch a template to Postgres](https://traxsharp.net/docs/reference/switch-a-template-to-postgres) + before relying on queues, retries or dead letters. + +2. **Where trains run.** Pick the topology before writing `Program.cs`; + [Deployment topologies](https://traxsharp.net/docs/reference/deployment-topologies) lists five, + each with its registrations and a sample. + + | Shape | Registration | + |---|---| + | One host schedules and runs | `AddScheduler()` on a database provider. Local workers start by default. | + | A hub that only schedules, workers that only run | Hub: `OverrideSubmitter(s => s.AddScoped())`, which starts no local workers. Workers: `services.AddTraxWorker()` with `AddTrax`, `UsePostgres` and `AddMediator`. | + | Some trains elsewhere | `UseRemoteWorkers`, `UseSqsWorkers` (`Trax.Scheduler.Sqs`) or `UseLambdaWorkers` (`Trax.Scheduler.Lambda`), routed per train with `ForTrain()` or `[TraxRemote]`. Untouched trains stay local. | + | No scheduler at all | Trains run inline in the request. Nothing here applies. | + + [Choosing where trains run](https://traxsharp.net/docs/scheduler/remote-execution#which-model-should-i-use) + compares the remote models. + +3. **Code or runtime.** Manifests declared on the builder are seeded at every start, upserted by + external id. Manifests created at run time through `ITraxScheduler` live only in the database. + A host that declares any manifest in code deletes, at its next start, every manifest it owns that + the code does not declare. So a host that also creates manifests at run time calls + `PruneOrphanedManifests(false)` + ([Orphan manifest cleanup](https://traxsharp.net/docs/scheduler/orphan-manifest-cleanup)). + +## Steps + +1. Give the train an input type that implements `IManifestProperties` (the scheduler stores it), + and register the train with `AddMediator`. `Schedule` refuses a train the mediator did not + register. +2. Register the scheduler after `AddEffects` and `AddMediator`; `AddScheduler` only compiles on + the builder `AddMediator` returns. Add the liveness check where an orchestrator probes the host: + + ```csharp compile + using Trax.Effect.Data.Postgres.Extensions; + using Trax.Effect.Extensions; + using Trax.Mediator.Extensions; + using Trax.Scheduler.Extensions; + using Trax.Scheduler.Services.Scheduling; + + builder.Services.AddTrax(trax => + trax.AddEffects(effects => effects.UsePostgres(connectionString)) + .AddMediator(assembly) + .AddScheduler(scheduler => + scheduler + .Schedule( + "extract-orders", + new ExtractOrdersInput(), + Every.Minutes(5), + options => options.MaxRetries(3) + ) + .ThenInclude("load-orders", new LoadOrdersInput()) + .Schedule( + "nightly-report", + new NightlyReportInput(), + Cron.Daily(hour: 3) + ) + ) + ); + + builder.Services.AddHealthChecks().AddTraxSchedulerLiveness(); + ``` + +3. Change a global option only after reading its range in + [Value Ranges](https://traxsharp.net/docs/sdk-reference/scheduler-api/add-scheduler#value-ranges). + A value outside it stops the host at build, with every problem listed. +4. At run time inject `ITraxScheduler`: `TriggerAsync(externalId)` runs a manifest now and leaves + its schedule alone, `TriggerAsync(externalId, delay)` runs it later, `ScheduleOnceAsync` makes a + one-off that disables itself, `CancelAsync` cancels its runs, and `RequeueDeadLetterAsync` or + `AcknowledgeDeadLetterAsync` resolve a dead letter + ([ITraxScheduler](https://traxsharp.net/docs/sdk-reference/scheduler-api/i-trax-scheduler)). + To queue one run of a train with no manifest, use `ITrainExecutionService.QueueAsync` + ([ITrainExecutionService](https://traxsharp.net/docs/sdk-reference/mediator-api/i-train-execution-service)). +5. Run the host and read its startup log. A scheduler that cannot run refuses to start, and + [Startup errors](https://traxsharp.net/docs/scheduler/startup-errors) lists each message. + +## Option ranges worth knowing + +| Option | Range | Default | +|---|---|---| +| `PollingInterval`, `ManifestManagerPollingInterval`, `JobDispatcherPollingInterval` | 1 second to 30 days | 5 s manifest manager, 2 s dispatcher | +| `DefaultJobTimeout`, `StalePendingTimeout`, `StaleInProgressTimeout` | 1 second to 10 years | 20 min, 20 min, 60 min | +| `DefaultRetryDelay`, `MaxRetryDelay` | 0 to 10 years | 5 min, 1 hour | +| `RetryBackoffMultiplier` | finite, at least 1 | 2.0 | +| `ConfigureLocalWorkers`: `WorkerCount` | 1 to 256 | processor count; 1 on SQLite | +| `ConfigureLocalWorkers`: `PollingInterval` | above 0, up to 30 days | 1 s | +| `ConfigureLocalWorkers`: `VisibilityTimeout` | 1 second to 10 years | 30 min | + +The polling services never wait less than one second between cycles. The dispatcher also wakes as +soon as work is queued (on Postgres from any host, on SQLite from the same process), so a shorter +poll buys nothing. + +## Known mistakes + +- **A sub-second dispatcher interval.** `JobDispatcherPollingInterval(TimeSpan.FromMilliseconds(500))` + refuses to start. Leave the default, or use one second or more. +- **Testing retries on the in-memory provider.** It has no retry delay and no reapers, so the test + proves nothing. Run retry and dead-letter tests on Postgres + ([How retries work](https://traxsharp.net/docs/scheduler/how-retries-work)). +- **Two processes on one SQLite file.** Each runs its own ManifestManager and dispatcher with + nothing between them. Use Postgres for a second process. +- **A worker pool twice.** `AddTraxWorker()` beside an `AddScheduler()` that already runs local + workers is refused. Tune the scheduler's pool with `ConfigureLocalWorkers` instead, or make the + hub schedule-only with `OverrideSubmitter`. +- **`[TraxRemote]` with nowhere to send it.** A scheduler with a `[TraxRemote]` train and no + `UseRemoteWorkers`, `UseSqsWorkers` or `UseLambdaWorkers` refuses to start; it does not fall back + to running the train locally. +- **Expecting a code change to win.** Settings saved from the operations console or the + `updateScheduler` mutation are stored in `trax.scheduler_config`, and a stored value beats the + builder value on every host until it is changed or the row is deleted. The log warns when one + replaces a builder value ([Stored settings](https://traxsharp.net/docs/sdk-reference/scheduler-api/add-scheduler#stored-settings)). +- **Changing `DefaultMaxRetries` for existing jobs.** It applies to manifests created after the + change. State `MaxRetries` on the manifest to have its code value written at every start. +- **Renaming an external id.** It creates a new manifest, and the next start prunes the old one with + its run history. +- **Setting `SchedulerConfiguration` properties from application code.** Their setters are hidden + from completion and are for tests (the + [E2E page](https://traxsharp.net/docs/cross-cutting/e2e-testing#choose-how-the-scheduler-runs-under-test) + turns the ManifestManager off this way). Configure through the builder or the operations surface. +- **A machine that invokes a train, with no scheduler anywhere.** Entering the state only queues the + run. A scheduler on some host sharing the database must dispatch it, or no outcome ever arrives. + `trax-design-a-machine` covers the rest. + +## Several hosts on one Postgres + +- The ManifestManager runs on one host at a time, the one holding a Postgres advisory lock. The + dispatcher runs on every host and claims work queue rows with row locks, so `MaxActiveJobs` is + counted per host: N hosts can reach N times the limit + ([Concurrency](https://traxsharp.net/docs/scheduler/concurrency)). +- A fleet on Trax.Effect 1.57.2 or earlier takes a different lock key from newer hosts. Stop every + host before starting the first upgraded one + ([Upgrade schedulers from Trax.Effect 1.57.2 or earlier](https://traxsharp.net/docs/migration-guides/scheduler-leader-lock)). +- Point the liveness probe at the health endpoint, so a scheduler that stops dispatching is + restarted ([Scheduler liveness](https://traxsharp.net/docs/scheduler/health-and-liveness)). +- With remote workers, raise `MaxConcurrentDispatch` (default 1), or each dispatch waits for the + previous HTTP call. + +## Read next + +- [How the scheduler decides when a manifest runs](https://traxsharp.net/docs/scheduler/scheduling-options): + misfires, variance, exclusions, groups, timeouts. +- [Dependent trains](https://traxsharp.net/docs/scheduler/dependent-trains) and + [Run a job once, later](https://traxsharp.net/docs/scheduler/delayed-jobs). +- [Resolve a dead letter](https://traxsharp.net/docs/scheduler/dead-letters-and-cleanup). +- [Run trains on Lambda](https://traxsharp.net/docs/scheduler/run-trains-on-lambda) and + [Run trains on SQS workers](https://traxsharp.net/docs/scheduler/run-trains-on-sqs-workers). +- [9. Scheduled work](https://traxsharp.net/docs/book/scheduled-work): the book adds a scheduler to + a running app. +- Testing a scheduler host: `trax-test-your-app`. diff --git a/Trax.Docs/skills/trax-start-a-project/SKILL.md b/Trax.Docs/skills/trax-start-a-project/SKILL.md new file mode 100644 index 00000000..0702dcfd --- /dev/null +++ b/Trax.Docs/skills/trax-start-a-project/SKILL.md @@ -0,0 +1,148 @@ +--- +name: trax-start-a-project +description: Use when creating a new app on Trax .NET or adding Trax to an existing ASP.NET Core app. Covers choosing between the trax-api, trax-hub and trax-scheduler dotnet new templates, installing Trax.Samples.Templates, scaffolding from a GraphQL or OpenAPI schema with trax generate, the project layout, registration order in Program.cs, moving off UseInMemory to SQLite or Postgres, and pinning every Trax package at one version in Directory.Packages.props. +metadata: + internal: true +--- + +# Start a Trax project + +Use this when the task is a new app on Trax, or the first Trax packages in an existing one. Load `trax` first if you +have not; it holds the rules that hold everywhere. + +## Decide first + +**From a template, from a schema, or into an existing app.** + +| Situation | Start with | +|---|---| +| A new app, no existing API to match | A template: `dotnet new trax-hub` (or `trax-api`, `trax-scheduler`) | +| A new app that implements an existing GraphQL SDL or OpenAPI 2.0/3.0 document | `trax generate` (below), which scaffolds `trax-hub` plus a trains library | +| An existing ASP.NET Core app | No template. Add the packages and follow [Getting started](https://traxsharp.net/docs/getting-started) from step 2 | + +**Which template.** Pick by what the app has to do now, not by what the brief calls it. + +| The app | Template | +|---|---| +| Serves trains over GraphQL, each run when it is called, nothing on a timer | `trax-api` | +| Runs trains on a schedule, with the operations console, and serves none of its own | `trax-scheduler` | +| Both, or a state machine that invokes trains, or `mode: QUEUE` mutations | `trax-hub` | + +A [state machine that invokes trains](https://traxsharp.net/docs/statemachine/invoking-trains) needs a scheduler, +`AddJunctionProgress()` and a database. `trax-hub` has the first two; `trax-api` has neither. Starting from +`trax-api` and adding them later works, but it is the wrong default. Details per template are on +[Project templates](https://traxsharp.net/docs/reference/templates#which-one-to-start-from). + +## Steps + +1. **Install the templates at the release you will use.** `dotnet new install Trax.Samples.Templates` installs the + latest, for your user, not for the project. To match a version the app already uses: + `dotnet new install Trax.Samples.Templates::`. +2. **Create the project.** `dotnet new trax-hub --name MyCompany.Hub`. The template takes no other parameters. + Run it once (`dotnet run`, then open `http://localhost:5400/trax/graphql`) and run its tests + (`dotnet test tests/MyCompany.Hub.Tests`; `dotnet test` at the root finds only the app) before changing anything. +3. **Read [Organize trains in a project](https://traxsharp.net/docs/core/organize-trains)** before writing a train. + One folder per train under `Trains/`, its own input type, an `ITrain` interface, junctions in `Junctions/`, + other types in `Models/`, shared code only once two trains need it. +4. **Keep the sample trains** (`HelloWorld`, `Lookup`) until your first train is in and matches their layout file + for file. They are the style reference; the host tests exercise them. Delete them, and the `hello-world` schedule + in `Program.cs`, after that. Adding a train is adding its folder: + [Add your own trains to a template](https://traxsharp.net/docs/reference/add-trains-to-a-template). +5. **Choose a database before the first feature that needs one.** The templates use `UseInMemory()`: runs vanish on + restart, two processes never share them, `mode: QUEUE` is refused with `TRAX_QUEUE_UNAVAILABLE`, and invoked runs + never run. See [Move off the in-memory provider](#move-off-the-in-memory-provider). +6. **Before deploying**, read + [Prepare a template for deployment](https://traxsharp.net/docs/reference/prepare-a-template-for-deployment). The + demo keys and the console exist only in Development, and the host refuses to start elsewhere with a demo key + registered. Register real credentials; do not remove the `IsDevelopment()` checks to get past it. + +## trax generate + +`trax generate` is for an app that implements an existing schema. It always needs one: + +```bash +dotnet tool install Trax.Cli --version +trax generate --schema ./schema.graphql --output ./MyProject --name MyProject +``` + +Options are `--schema`, `--output`, `--name` (all required), `--type graphql|openapi` and `--force`. It runs +`dotnet new trax-hub`, so the template must be installed, and writes `MyProject/MyProject.Hub/` and a class library +`MyProject/MyProject.Trains/` with one train per operation and junctions that throw `NotImplementedException`. +OpenAPI 3.1 is refused; export 3.0. `--force` is refused for the current directory, its parents and any folder with +a `.git` in it. The library's csproj carries the Trax versions itself, copied from the hub's +`Directory.Packages.props`, so a later bump changes both. See [CLI](https://traxsharp.net/docs/reference/cli#usage). + +## Registration order + +`Program.cs` in `trax-hub` is the working order, each call commented with the error its removal raises. What must +come first: `AddTrax()` before `AddTraxGraphQL()`, and every train registered (by `AddMediator`) before +`AddTraxGraphQL()`. Every exposed train carries `[TraxAuthorize]` or `[TraxAllowAnonymous]`. Everything else is +order-free. When the host refuses to start, the message names the call; the full table is +[Registration order](https://traxsharp.net/docs/reference/registration-order). + +`AddMediator(typeof(Program).Assembly)` registers every train in that assembly. Trains moved into a class library +need `AddMediator(typeof(SomeTypeInTheLibrary).Assembly)` instead, or they do not exist +([Structuring your application](https://traxsharp.net/docs/reference/project-layout)). + +## Move off the in-memory provider + +Replace `UseInMemory()` in `AddEffects` and add the provider package at the version every other `Trax.*` package has. +Trax creates and migrates its own `trax` tables at startup. SQLite, for one host: + +```csharp compile +using Trax.Effect.Data.Sqlite.Extensions; +using Trax.Effect.Extensions; +using Trax.Effect.JunctionProvider.Progress.Extensions; +using Trax.Effect.Provider.Parameter.Extensions; +using Trax.Mediator.Extensions; +using Trax.Scheduler.Extensions; + +builder.Services.AddTrax(trax => + trax.AddEffects(effects => + effects + .UseSqlite( + builder.Configuration.GetConnectionString("TraxDatabase") + ?? throw new InvalidOperationException("Set ConnectionStrings:TraxDatabase.") + ) + .SaveTrainParameters() + .AddJunctionProgress() + ) + .AddMediator(typeof(Program).Assembly) + .AddScheduler() +); +``` + +Postgres, for more than one process on one database: `UsePostgres(...)` from `Trax.Effect.Data.Postgres` +(namespace `Trax.Effect.Data.Postgres.Extensions`), steps on +[Switch a template to Postgres](https://traxsharp.net/docs/reference/switch-a-template-to-postgres). SQLite does not +coordinate dispatch across processes ([UseSqlite](https://traxsharp.net/docs/sdk-reference/configuration/use-sqlite)). + +The app's own `AppDbContext` is separate and stays on `UseInMemoryDatabase("app")` until you move it too. + +## Packages + +Every `Trax.*` package is at one version, pinned once in `Directory.Packages.props`, which the templates already +use. `dotnet add package --version ` writes the pin there. Never float (`Version="1.*"`). If the +app pins EF Core, Npgsql, HotChocolate or `Microsoft.Extensions.*` itself and restore fails with `NU1605` or +`NU1109`, raise only the packages the error names to the floors on +[Packages](https://traxsharp.net/docs/reference/packages#requirements). + +## Known mistakes + +- **Deleting `HelloWorld` and `Lookup` first**, then writing trains from memory: five types in one file, no train + folders, junctions shared before a second train needed them. Keep the samples until your train matches them. +- **Choosing `trax-api` for a machine that invokes trains.** It has no scheduler and no `AddJunctionProgress()`; + the run is queued and nothing ever picks it up. Use `trax-hub` on SQLite or Postgres. +- **Copying the samples' `public` junctions.** The template's junctions are `public`; the organize page makes them + `internal`, which is what to write in your own trains. +- **Pointing `AppDbContext` at Trax's SQLite file.** `EnsureCreated()` sees Trax's tables, decides the database + exists and creates none of yours, with no error. Give the app its own file. +- **SQLite files in `data/`** beside the `Data/` source folder: on macOS and Windows those are one folder. Use a + folder such as `App_Data/`, and create it before the host opens the file. +- **A new package at a different version**, or the latest one from `dotnet add package` without `--version`. Match + the version the other `Trax.*` packages have. +- **Running `dotnet test` at the project root** and reading "no tests" as passing. Name the test project. + +## Pages + +> [AddTrax](https://traxsharp.net/docs/sdk-reference/configuration/add-trax) | [UseInMemory](https://traxsharp.net/docs/sdk-reference/configuration/use-in-memory) | [UseSqlite](https://traxsharp.net/docs/sdk-reference/configuration/use-sqlite) | [UsePostgres](https://traxsharp.net/docs/sdk-reference/configuration/use-postgres) | [SaveTrainParameters](https://traxsharp.net/docs/sdk-reference/configuration/save-train-parameters) | [AddJunctionProgress](https://traxsharp.net/docs/sdk-reference/configuration/add-junction-progress) | [AddMediator](https://traxsharp.net/docs/sdk-reference/configuration/add-mediator) | [AddScheduler](https://traxsharp.net/docs/sdk-reference/scheduler-api/add-scheduler) | [AddTraxGraphQL](https://traxsharp.net/docs/sdk-reference/graphql-api/add-trax-graphql) diff --git a/Trax.Docs/skills/trax-test-your-app/SKILL.md b/Trax.Docs/skills/trax-test-your-app/SKILL.md new file mode 100644 index 00000000..4a5b2ce6 --- /dev/null +++ b/Trax.Docs/skills/trax-test-your-app/SKILL.md @@ -0,0 +1,179 @@ +--- +name: trax-test-your-app +description: Use when writing or fixing tests for a .NET app built on Trax, or adding a test project to one. Covers unit-testing a junction, testing a train through ITrainBus with UseInMemory, testing a deciding train with ScriptedDecider, checking a state machine with MachineConformance, end-to-end host tests with WebApplicationFactory on Postgres, testing scheduler retries and dead letters, what the template test projects already do, and adopting the architecture guards (Trax.Core.Testing, Trax.Mediator.Testing, Trax.Effect.Data.Testing, Trax.Api.GraphQL.Testing, TrainGuardFixture, HygieneGuardFixture, TraxInvariants) so project rules fail a test. +metadata: + internal: true +--- + +# Test an app built on Trax + +Trax apps are tested at four levels, each cheaper than the next: a junction on its own, a train +through the train bus on the in-memory provider, the whole host started in the test process, and +architecture guards that check the project's shape. Pick the cheapest level that can fail for the +reason you care about. [Unit-test a junction](https://traxsharp.net/docs/cross-cutting/testing) +starts the testing pages, and [2. Testing PlaceOrder](https://traxsharp.net/docs/book/testing-place-order) +builds a test project step by step. + +## Decide first + +| You are testing | Level | Page | +|---|---|---| +| What one junction returns or throws | Construct it, call `Run`. No container, no database. | [Unit-test a junction](https://traxsharp.net/docs/cross-cutting/testing) | +| A train's chain, its output, its failure, its run record | A `ServiceCollection` with the app's `AddTrax` registration on `UseInMemory()`, run through `ITrainBus` | [Test a train with in-memory effects](https://traxsharp.net/docs/cross-cutting/test-a-train) | +| Each track of a train that asks a decider | `ScriptedDecider` (`Trax.Core.Decisions`), plus `ChainVerification.Verify` over `DeclaredChain()` | [Test a train's decisions](https://traxsharp.net/docs/core/test-train-decisions) | +| A machine's definition against its committed files | `MachineConformance.Check` (`Trax.StateMachine.Testing`) | [Trax.StateMachine.Testing](https://traxsharp.net/docs/sdk-reference/testing/effect-statemachine-testing) | +| A machine's rules, ownership and refusals as a caller sees them | The host over GraphQL | [12.9 Test it](https://traxsharp.net/docs/book/checkout-is-a-state-machine/test-it) | +| Startup, auth, the Production/Development split, GraphQL, the scheduler | `WebApplicationFactory` | [Test a host end to end](https://traxsharp.net/docs/cross-cutting/e2e-testing) | +| Folder and type rules across the project | Guard fixtures, below | [Adopt the architecture guards](https://traxsharp.net/docs/reference/adopt-the-architecture-guards) | + +Then pick the data provider by what the code under test runs. `UseInMemory()` runs no SQL and +has no job dispatcher, no retry delay and no reapers: a test of raw SQL, Postgres locking, +queued runs, retries or dead letters on it proves nothing. Use Postgres there, on a database the +suite owns ([Give the suite its own database](https://traxsharp.net/docs/cross-cutting/e2e-testing#give-the-suite-its-own-database)). + +## What the template tests already do + +Every template (`trax-api`, `trax-hub`, `trax-scheduler`) generates an NUnit project under +`tests/` that references the app. Keep it and add to it; its files are the pattern. + +| File | Shows | +|---|---| +| `UnitTests/FetchDataJunctionTests` (not in `trax-scheduler`) | A junction built with `NullLogger.Instance` and called directly | +| `IntegrationTests/HelloWorldTrainTests` | A train run through `ITrainBus` on `UseInMemory().SaveTrainParameters()`, then its `Metadata` row found by a unique input value | +| `IntegrationTests/HostTests` | The host started in Development and in Production; the demo key works only in Development, and the console is a 404 in Production | + +`dotnet test` at the template's root finds only the app project. Name the test project: +`dotnet test tests/MyApp.Tests`. The template test project references no Trax testing package; +add the ones the guards below need. + +## Steps for a new train + +1. Unit-test each junction that holds logic. Pass fakes for its interfaces; assert the exception + for each refusal, since a throw is how the train fails. +2. Add one train test per track: build the container in `[SetUp]` and dispose it in + `[TearDown]`, so each test gets an empty store. `RunAsync` returns the output, or throws + what the failing junction threw. +3. If the train asks a decider, script each answer, and add the `ChainVerification` test so every + declared track is checked, not only the ones a script took. +4. If it is exposed over GraphQL, add a host test for the caller's view: who may call it, and the + error code a refusal carries. + +## Adopt the architecture guards + +Each guard package ships abstract NUnit fixtures with the tests written. Subclass one, keep +`[TestFixture]` on the subclass, supply what it asks for: + +```csharp compile=tests +using System.Reflection; +using MyApp.Trains.PlaceOrder; +using NUnit.Framework; +using Trax.Core.Testing.Fixtures; +using Trax.Mediator.Testing; + +namespace MyApp.Tests; + +[TestFixture] +public sealed class MyAppTrainGuards : TrainGuardFixture +{ + protected override IReadOnlyList TrainAssemblies => [typeof(IPlaceOrderTrain).Assembly]; +} + +[TestFixture] +public sealed class MyAppHygieneGuards : HygieneGuardFixture; +``` + +| Package | Fixture | Fails when | +|---|---|---| +| `Trax.Mediator.Testing` | `TrainGuardFixture` | A train has no train interface of its own, or two unrelated ones | +| `Trax.Core.Testing` | `HygieneGuardFixture` | A test under `tests/` uses `[Ignore]`, a fixed `Task.Delay` or `Thread.Sleep`, NUnit's classic asserts including `Assert.That`, or checks a substitute right after `StartAsync` without waiting | +| `Trax.Effect.Data.Testing` | `DomainDataLayerGuardFixture` | A domain data context does not derive `DomainDataContext`, has no companion interface or shares a schema; a migration context has pending model changes; a per-user entity has no owner filter, or query code switches it off (the owner-scope census) | +| `Trax.Api.GraphQL.Testing` | `CrossSchemaGuardFixture` | A cross-schema edge is invalid, does not batch, or a `[Parent]` resolver does not declare what it reads | + +Things that trip a first run: + +- **No solution file.** The source-scanning guards find the repository root by walking up from + the test assembly to a folder holding a `*.slnx`, and throw without one. The templates ship + none: `dotnet new sln`, then `dotnet sln add` both projects. +- **`HygieneGuardFixture` fails the template's own tests**, which assert with `Assert.That`. Move + them to a fluent library (Trax uses AwesomeAssertions), or leave that fixture out. +- **A guard that inspected nothing fails.** Wrong `SourceScanRoots` or `TrainAssemblies` show up as + "inspected no files" or "found no train"; the roots are relative to the folder with the `*.slnx`. +- **`RepoConventionGuardFixture` is not for an app.** It expects `Directory.Build.props` to pin the + version `1.99.99`, Trax's own local-development placeholder. Skip it, or set + `ExpectedDirectoryBuildPropsVersion` to your own. + +### What no guard checks + +No guard checks the layout from +[Organize trains in a project](https://traxsharp.net/docs/core/organize-trains): not the folder a +file is in, not that junctions are `internal`, not that each train has its own input type. To make +those fail a test, write the check yourself. A reflection test over the app's assembly covers the +two most often broken: + +```csharp compile=tests +using AwesomeAssertions; +using MyApp.Trains.PlaceOrder; +using NUnit.Framework; +using Trax.Core.Junction; + +namespace MyApp.Tests; + +[TestFixture] +public class LayoutTests +{ + private static readonly Type[] Junctions = typeof(IPlaceOrderTrain) + .Assembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false }) + .Where(t => + t.GetInterfaces() + .Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IJunction<,>)) + ) + .ToArray(); + + [Test] + public void Junctions_AreInternal() => + Junctions.Where(t => t.IsVisible).Should().BeEmpty("a train's junctions are its own"); + + [Test] + public void Junctions_LiveInAJunctionsFolder() => + Junctions + .Where(t => t.Namespace?.EndsWith(".Junctions", StringComparison.Ordinal) != true) + .Should() + .BeEmpty("each train keeps its junctions in Trains//Junctions/"); +} +``` + +Internal junctions need `` in the app's csproj for the +unit tests to construct them. The template's sample junctions are `public`; making them `internal` +as the layout page says is part of adopting this test. + +The data-layer guards also cannot see whether an owner filter compares the right column. For each +per-user entity, add a cross-user test against the running API, as +[Configure the owner-scope census](https://traxsharp.net/docs/reference/adopt-the-architecture-guards#configure-the-owner-scope-census) +describes. + +## Known mistakes + +- **Asserting a scheduler behaviour on InMemory.** Retries, backoff and dead letters need Postgres; + see [Test a cron manifest's retries](https://traxsharp.net/docs/cross-cutting/e2e-testing#test-a-cron-manifests-retries) + for why a triggered cron job retries only at its next occurrence, and how to test it anyway. +- **Cleaning tables between tests while the scheduler runs.** Take the newest `metadata.id` before + acting and read only rows after it + ([Choose how the scheduler runs under test](https://traxsharp.net/docs/cross-cutting/e2e-testing#choose-how-the-scheduler-runs-under-test)). +- **Waiting with `Task.Delay`.** Poll for the row or the state with a timeout. `HygieneGuardFixture` + refuses an unmarked fixed delay. +- **A host test against the development database.** Point the factory at the suite's own database + with `UseSetting` on the connection string key the host reads. +- **Leaving a run stuck without saying so.** On Postgres, run `TraxInvariants` after each test, + once the host has stopped, and mark a test that leaves a stuck run on purpose with + `[LeavesStuckRuns("why")]` ([Check the database after each test](https://traxsharp.net/docs/reference/adopt-the-architecture-guards#check-the-database-after-each-test)). +- **Testing a JWT-authenticated host with hand-made tokens.** `Trax.Api.Auth.Jwt.Testing` signs + test tokens and serves their keys ([TestJwksServer](https://traxsharp.net/docs/sdk-reference/api-auth/test-jwks-server)). + +## Read next + +- [Architecture guards](https://traxsharp.net/docs/reference/architecture-guards): every check and + why it exists. +- [Make a junction cancellable](https://traxsharp.net/docs/cross-cutting/make-a-junction-cancellable#5-test-it): + testing that a junction stops. +- [Testing](https://traxsharp.net/docs/sdk-reference/testing): every testing package. diff --git a/Trax.Docs/skills/trax-troubleshoot/SKILL.md b/Trax.Docs/skills/trax-troubleshoot/SKILL.md new file mode 100644 index 00000000..d92af55e --- /dev/null +++ b/Trax.Docs/skills/trax-troubleshoot/SKILL.md @@ -0,0 +1,118 @@ +--- +name: trax-troubleshoot +description: Use when a Trax host will not start, throws at registration or startup, or a Trax run, GraphQL call, scheduled job or state-machine event fails and the cause is unclear. Covers pasted Trax errors such as "registered trains cannot run", "cannot be built", "Could not find train with input type", "Call AddEffects(...) before AddMediator(...)", "AddScheduler() requires a data provider", "exposure authorization check failed", TraxPrincipalNotAvailableException, TRAX_* and PERSISTED_OPERATION_* error codes, Unexpected Execution Error, runs stuck or Failed, jobs that never execute, and database or provider problems with Postgres, SQLite or InMemory. Maps the message to the docs page that fixes it, shows how to read a run's record in trax.metadata, trax.log and the operations console, and lists what to collect before asking the user. +metadata: + internal: true +--- + +# Troubleshoot a Trax app + +This skill routes. It holds no fixes of its own: every fix is on the page a message points to, and +that page is the one to follow. Find the page, read the entry, then change code. + +## First: read the whole message + +- **Trax refuses loudly and names the cause.** A host with a chain that can never run, a + registration in the wrong order, a missing provider or a missing security posture stops before + it serves a request, and the message says what to change. Read every line of it: a chain + refusal lists every broken train, not only the first. +- **Many messages end with a link**, `See https://traxsharp.net/docs/cross-cutting/troubleshooting#...`. + Open it. Without a link, search + [Troubleshooting](https://traxsharp.net/docs/cross-cutting/troubleshooting) for a distinctive + phrase: each entry is headed by the message as the source writes it. +- **Do not make the error go away by switching a check off.** Skipping the startup chain check, + removing a posture, turning off persisted operations or Origin checks, or moving a credential out + of the Development branch hides the fault and opens the host. If the right fix really is to relax + something, ask the user and say what it costs. + +## Map the message to its page + +| The message begins, or the symptom is | Read | +|---|---| +| ` of registered trains cannot run:` | [Startup chain check](https://traxsharp.net/docs/core/startup-chain-check#refusals), then the troubleshooting entry for each line's kind | +| `... cannot be built: its constructor needs '...', which is not registered` | [Troubleshooting](https://traxsharp.net/docs/cross-cutting/troubleshooting#x-cannot-be-built-its-constructor-needs-y-which-is-not-registered) | +| `Junction '...' (train '...') needs '...' as its input, but nothing earlier in the chain produced one` | [Troubleshooting](https://traxsharp.net/docs/cross-cutting/troubleshooting#junction-x-train-y-needs-z-as-its-input-but-nothing-earlier-in-the-chain-produced-one) | +| `Could not find train with input type (...)` | [Troubleshooting](https://traxsharp.net/docs/cross-cutting/troubleshooting#could-not-find-train-with-input-type-x) | +| `Call AddEffects(...) before AddMediator(...).`, `AddTraxGraphQL() requires AddTrax() to be called first`, a train registered after `AddTraxGraphQL()` | [Registration order](https://traxsharp.net/docs/reference/registration-order) | +| `AddScheduler() requires a data provider`, a scheduler value out of range, a manifest group or batch conflict, a runner with no posture | [Scheduler startup errors](https://traxsharp.net/docs/scheduler/startup-errors) and [Value ranges](https://traxsharp.net/docs/sdk-reference/scheduler-api/add-scheduler#value-ranges) | +| `Invalid cron expression`, `A schedule interval must be at least one second.` | [Troubleshooting](https://traxsharp.net/docs/cross-cutting/troubleshooting) | +| `Trax GraphQL exposure authorization check failed:`, a policy `not registered`, an `operations` namespace with no gate, a `do-not-use-in-production` key outside Development | [Startup refusals](https://traxsharp.net/docs/security/startup-refusals) | +| `UseTraxApiDashboard() ...` | [Add the console to a host](https://traxsharp.net/docs/dashboard/add-the-console-to-a-host#troubleshooting) | +| `Unable to resolve service for type 'Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider'` | [Registration order](https://traxsharp.net/docs/reference/registration-order#missing-registrations-and-what-they-raise) | +| `TraxPrincipalNotAvailableException` | [Inject the current principal](https://traxsharp.net/docs/security/inject-the-current-principal) | +| A GraphQL error with an `extensions.code` (`TRAX_AUTHORIZATION`, `TRAX_TRAIN_ERROR`, `TRAX_INVALID_INPUT`, `PERSISTED_OPERATION_REQUIRED`, `CLIENT_OUTDATED`, ...) | [Error codes](https://traxsharp.net/docs/sdk-reference/graphql-api/error-codes) | +| `Unexpected Execution Error`, or `The train failed.` | The real failure is masked from the caller on purpose. Read the run's record (below) and the server log. [Error codes](https://traxsharp.net/docs/sdk-reference/graphql-api/error-codes) says which exceptions pass through | +| A machine answer with a `problem` (`GUARD_FAILED`, `NO_TRANSITION`, `FORBIDDEN`, ...) | [Result codes](https://traxsharp.net/docs/sdk-reference/statemachine-api/result-codes): a refusal is data, nothing was written | +| A browser WebSocket upgrade answered `403`, while a Node client works | [Allowed origins](https://traxsharp.net/docs/sdk-reference/graphql-api/subscriptions#allowed-origins) | +| A run shows `Failed` though the train looked fine | [Troubleshooting](https://traxsharp.net/docs/cross-cutting/troubleshooting#train-completes-but-metadata-shows-failed) | +| Scheduled jobs never run, with no error | [Troubleshooting](https://traxsharp.net/docs/cross-cutting/troubleshooting#scheduled-jobs-dont-execute-no-errors) | + +An error from a skill's own area (a train, a machine, the API) usually has a known trap in that +skill too: `trax-write-a-train`, `trax-design-a-machine`, `trax-expose-over-api`, +`trax-schedule-work`. + +## Provider and database problems + +Check which provider the host uses (`UseInMemory`, `UseSqlite`, `UsePostgres` in `AddEffects`) +before anything else. + +- **InMemory** keeps runs in the process and loses them on restart. Nothing dispatches the work + queue, so a queued run is refused, and a machine that invokes trains needs Postgres or SQLite. +- **SQLite** serializes writes at the file and fits one process on one machine. Two Trax + processes on one file, such as a hub and a worker, need Postgres. + [SQLite and Postgres](https://traxsharp.net/docs/effect/sqlite-and-postgres) has the table. +- **Postgres**: the Trax tables live in the `trax` schema, and applied migrations are listed in + `trax.migrations`. A schema older than the packages is a migration that did not run; see + [Schema migrations](https://traxsharp.net/docs/reference/schema-migrations) and + [Run migrations outside startup](https://traxsharp.net/docs/effect/run-migrations-outside-startup). +- Moving a template off InMemory is [Switch a template to Postgres](https://traxsharp.net/docs/reference/switch-a-template-to-postgres). + +## Read a run's record + +Every run of a `ServiceTrain` writes a row to `trax.metadata`; it is the whole record of the run. +The tables are listed on [Trax data model](https://traxsharp.net/docs/effect/data-model#tables). + +1. **Find the run.** Its `external_id` is what a mutation answers and what crosses process + boundaries. `name` is the train's interface full name (`MyApp.Trains.IOrderTrain`), not the class. +2. **Read its failure fields**: `train_state`, `failure_junction`, `failure_exception`, + `failure_reason`, `stack_trace`. What each holds is on + [Metadata](https://traxsharp.net/docs/effect/metadata#failure-fields). +3. **Read its log lines.** With `AddDataContextLogging()`, every host on the database writes to + `trax.log`, and each line written during a run names it in `metadata_id`. There is no timestamp + column, levels are lowercase, and `category` is the class name, not the interface. The queries + and traps are on + [Debug across processes with the log table](https://traxsharp.net/docs/effect/debug-across-processes-with-the-log-table). + + ```sql + select external_id, name, train_state, failure_junction, failure_exception, failure_reason + from trax.metadata where external_id = ''; + + select l.id, l.level, l.category, l.message, l.exception + from trax.log l join trax.metadata m on m.id = l.metadata_id + where m.external_id = '' order by l.id; + ``` + +4. **Steps and decisions**, if the host records them: `trax.junction_run` (with `AddJunctionEvents()`) + says which step failed and how; `trax.decision` (with `AddDecisionRecording()`) what each + decision answered. +5. **The operations console**, if the host serves it, shows the same record: + [A run](https://traxsharp.net/docs/dashboard/a-run) draws it on its chain with the failing step, + [Runs](https://traxsharp.net/docs/dashboard/runs) lists failed runs, queued entries and dead + letters, and [System](https://traxsharp.net/docs/dashboard/system#logs) reads the log table. + +A missing log line is not proof nothing was logged: the log table drops entries under bursts. +Check the console or the host's own log output too. + +## Collect this before asking the user + +When the pages do not settle it, ask once, with everything the user would otherwise ask for: + +- the full message, every line, exactly as printed, and whether it came at registration, at host + start or on a request; +- the Trax package version (every `Trax.*` package should be at one version) and the data provider; +- the environment the host ran in (Development or not), since demo credentials exist only there; +- the registration section of `Program.cs`, in order; +- for a failed run: its `external_id`, its `trax.metadata` failure fields and its `trax.log` lines; +- for a GraphQL failure: the `extensions.code`, the operation, and whether the caller was a + browser, a Node client or the IDE; +- which docs entry you followed and what happened when you did. diff --git a/Trax.Docs/skills/trax-upgrade/SKILL.md b/Trax.Docs/skills/trax-upgrade/SKILL.md new file mode 100644 index 00000000..3fd92ad2 --- /dev/null +++ b/Trax.Docs/skills/trax-upgrade/SKILL.md @@ -0,0 +1,106 @@ +--- +name: trax-upgrade +description: Use when moving an app built on Trax .NET from one Trax version to another, bumping or updating Trax packages (Trax.Core, Trax.Effect, Trax.Mediator, Trax.Scheduler, Trax.Api, Trax.StateMachine, Trax.Cli, @traxsharp/state-machine), or fixing a restore, build or startup failure that followed such a bump. Finds the current and target versions, lists every Trax migration guide between them, applies each, handles the NU1109 and NU1605 package floors and the schema migrations applied at startup, then restores, builds, tests and reports what could not be applied. +metadata: + internal: true +--- + +# Upgrade Trax + +Use this when the task is a Trax version bump, or a failure that started with one. Load `trax` first if you have +not. Most upgrades are a package bump: every Trax package releases at one version, and `UsePostgres` and +`UseSqlite` apply the schema migrations when the host starts. The [migration guides](https://traxsharp.net/docs/migration-guides) +cover the releases that need more. + +## Decide first + +- **The current version.** Read every `Trax.*` entry in `Directory.Packages.props` (`PackageVersion`), or in each + `.csproj` (`PackageReference Version=`) if the app does not use central package management. Since Trax 1.61.0 + they are all one version. If they differ, the app is either older than 1.61.0, when each package had its own + version and the older guides name the package, or it has drifted; compare each package with the guides one by one. + An app on `Trax.Effect.StateMachine*` is on 1.67.0 or older. +- **The target version.** The one the user names; otherwise the latest release (`dotnet list package --outdated` + lists it). Never a prerelease unless asked. Release notes for each version since 1.61.0 are on the `v` + releases at [TraxSharp/Trax](https://github.com/TraxSharp/Trax/releases). +- **Everything that carries a Trax version.** All of these move together: + - `Directory.Packages.props`, and any `.csproj` with a Trax `Version=`. A trains library made by `trax generate` + pins its own versions in its csproj, because the hub's `Directory.Packages.props` does not reach it. + - The `trax` tool, `Trax.Cli`, in `.config/dotnet-tools.json` or `./dotnet-tools.json`. + - `@traxsharp/state-machine` in a frontend's `package.json`. It releases at the same version as the NuGet packages. + +## Steps + +1. **List the guides that apply.** A guide applies when its release is newer than the current version and no newer + than the target. Read each one whole before editing, oldest first: some say to read another first. +2. **Bump every Trax version in one edit** to the target. A renamed package (see the table) changes its id too. + `dotnet tool update Trax.Cli --version ` for the tool, `npm install @traxsharp/state-machine@` for + the frontend. +3. **Restore.** On `NU1605` or `NU1109`, raise only the packages the error names, to the floors on + [Packages](https://traxsharp.net/docs/reference/packages#requirements). If the app commits `packages.lock.json`, + regenerate it with `dotnet restore --force-evaluate`. +4. **Apply each guide**, oldest first. Several changes compile unchanged and only show at runtime, so a green build + does not mean a guide is done. +5. **Build with warnings as errors**, then fix what is left from the guides, not by guessing at the new API. +6. **Regenerate what the CLI wrote**: machine twins (`trax machine generate`, then `trax machine check`) and the + frontend client (`trax client ... --check`), with the arguments the repository already uses (its CI or + `package.json` scripts). A twin or client from the old version is out of date after a machine wire change. +7. **Test.** Name each test project (`dotnet test tests/.Tests`), then start the host once against a copy of + a real database, not production, so the schema migrations run. If it refuses to start, read the message: it + names the cause and usually the page. +8. **Report** what you changed per guide, what you could not apply and why, and every step that needs a person: + a database procedure, a deploy order, clients that must ship with the hosts. + +## Every migration guide + +Newest first. "Applies from" is the release that needs it. + +| Guide | Applies from | +|---|---| +| [Move to the Trax.StateMachine packages](https://traxsharp.net/docs/migration-guides/trax-statemachine-packages) | Trax 1.68.0 (the guide says "the first release after 1.67.0") | +| [Reach trains through your own documents](https://traxsharp.net/docs/migration-guides/reach-trains-through-your-own-documents) | Trax 1.67.0 | +| [Adapt to the machine wire changes](https://traxsharp.net/docs/migration-guides/machine-wire-changes) | Trax 1.64.0 | +| [Regenerate twins after the trax machine generate defaults changed](https://traxsharp.net/docs/migration-guides/trax-machine-generate-defaults) | Trax 1.63.1 | +| [Move off the generic state-machine mutations](https://traxsharp.net/docs/migration-guides/generic-state-machine-mutations) | Trax 1.63.1 | +| [Upgrade schedulers from Trax.Effect 1.57.2 or earlier](https://traxsharp.net/docs/migration-guides/scheduler-leader-lock) | Trax.Effect 1.57.3 | +| [Migrate to qualified principal ids](https://traxsharp.net/docs/migration-guides/qualified-principal-ids) | Trax.Api 1.44.0 | +| [Adapt to the enqueue and outcome changes](https://traxsharp.net/docs/migration-guides/enqueue-and-outcome-changes) | Trax.Core 1.7.2, Trax.Effect 1.54.2, Trax.Mediator 1.22.1, Trax.Scheduler 1.32.2, Trax.Api 1.43.2 | +| [Move trains off RunInternal and Activate](https://traxsharp.net/docs/migration-guides/runinternal-and-activate) | Trax.Core 1.7.2, Trax.Effect 1.54.2 | +| [Upgrade to HotChocolate 16](https://traxsharp.net/docs/migration-guides/hotchocolate-16) | Trax.Api 1.41.0; its attribute refusal from Trax.Api 1.43.0 | +| [Migrate from Hangfire](https://traxsharp.net/docs/migration-guides/migrate-from-hangfire) | Trax.Scheduler 1.0.0; only for builds before it that called `UseHangfire` | + +Database procedures. These apply on a condition, not at a version: + +| Procedure | When | +|---|---| +| [Repair a partial migration 014](https://traxsharp.net/docs/migration-guides/repair-partial-migration-014) | Postgres migration 014 (in Trax.Effect from 1.0.0) stopped partway and the next start fails on `CREATE TABLE trax.manifest_group` | +| [Install pg_trgm before migration 063](https://traxsharp.net/docs/migration-guides/install-pg-trgm) | Crossing Trax.Effect 1.60.0 on Postgres, when the role Trax migrates with cannot create the `pg_trgm` extension | +| [Turn off state-machine change notices](https://traxsharp.net/docs/migration-guides/turn-off-snapshot-change-notices) | Optional, from Trax 1.63.0 (migration 080) on Postgres, when instance writes contend on the notify lock | + +## Schema migrations + +`UsePostgres` and `UseSqlite` apply every pending migration when they are called during service registration, so the +first host on the new version migrates the database for every host on it. Postgres scripts run without a +transaction, so one that stops partway keeps what it ran and runs again from the top at the next start; one that +cannot take its table lock in five seconds is retried, then startup fails with `55P03`. A host that calls `SkipMigrations()` must run them itself before the new version serves +traffic. What each migration changes that a host can see is on +[Schema migrations](https://traxsharp.net/docs/reference/schema-migrations); read the sections between the two +versions, and take a backup first. + +## Known mistakes + +- **Bumping only the package with the feature you want.** Every `Trax.*` package goes to the target together, + including the tool, the npm engine and a generated trains library. +- **Bumping the whole `10.0.x` line** because restore failed on `Microsoft.Extensions.*`. Raise only the packages + the `NU1109`/`NU1605` error names. A blanket bump pulls `Microsoft.AspNetCore.Authentication.JwtBearer` and + `Microsoft.IdentityModel.*` forward and changes JWKS refresh behavior. +- **Stopping at a green build.** The enqueue, principal-id and machine-wire changes alter runtime behavior and + stored data; follow each guide to its end. +- **A rolling deploy across Trax.Effect 1.57.3** on a Postgres scheduler fleet. Old and new hosts take different + advisory locks and both lead; stop every old host first. +- **Leaving `Trax.Effect.StateMachine*` beside `Trax.StateMachine*`.** Swap the ids and namespaces; the old + packages are deprecated and get no releases. +- **Getting past a refusal by turning a check off.** If the host now refuses to start (an exposed train without + `[TraxAuthorize]`, HotChocolate's `[Authorize]` where Trax's attribute belongs), apply the guide; + never remove the gate, disable masking or skip migrations to make it start. +- **Deploying hosts without their clients** after a machine wire change. Regenerate the twins and clients and ship + them with the hosts. diff --git a/Trax.Docs/skills/trax-write-a-decider/SKILL.md b/Trax.Docs/skills/trax-write-a-decider/SKILL.md new file mode 100644 index 00000000..cf5aa6d6 --- /dev/null +++ b/Trax.Docs/skills/trax-write-a-decider/SKILL.md @@ -0,0 +1,236 @@ +--- +name: trax-write-a-decider +description: Use when a Trax train has to choose between paths, branch, route, triage, approve or decline, or when a business rule in a train changes often or may move to a model. Covers when a choice belongs in a decider rather than an if in a junction, Switch, Gate, Scale and Decide, writing an IDecider or RuleDecider, confidence thresholds and Otherwise tracks, checkpoints and Parallel branches, running a new decider in shadow, decision replay on requeue and retry, and testing decisions with ScriptedDecider. +metadata: + internal: true +--- + +# Write a decider + +A train that does different work on different runs declares every track it can take in its +chain, and a **decider** picks one per run by answering a typed question about a value in +Memory. Every track is part of the declaration, so the startup check verifies all of them, and +each decision is recorded and can be replayed. [Decisions](https://traxsharp.net/docs/core/decisions) +explains the model; this skill is when to use it, the steps and the traps. Load +`trax-write-a-train` too if the train does not exist yet. + +## Decide first: decider or `if` + +| The choice | Where it goes | +|---|---| +| Changes which junctions run (refund or escalate, approve or review) | Tracks on a `Switch`, `Gate` or `Scale`, and a decider | +| A rule that changes often, or that a model may take over | A decider (`RuleDecider` now), so the rule is one place and can be swapped in shadow | +| Only computes one junction's own output, and every run takes the same steps after it | An `if` inside that junction is fine | +| Picks a whole train that stands on its own | A junction that dispatches it through `ITrainBus` is fine | + +Never branch in `Junctions()` itself: `TrainInput` throws `ChainDeclarationException` while the +chain is declared. And do not hide a choice of path in a junction that calls one service or +another: the startup check cannot see those paths and nothing records why a run took one. + +## Pick the question + +| Question | Declare | Routed by | +|---|---|---| +| Which of these options? | an enum | `Switch` | +| Where on an ordered scale? | an enum, lowest to highest | `Scale` | +| How likely is yes? | a marker class | `Gate` | + +A model judges by the words, so put them on the type with `[Asks]` and `[Description]`. A step +can ask its own question, as below, or one `Decide(q => q.Choice().YesNo())` can +ask several in one call and later `Switch`, `Gate` route on the answers +([asking first](https://traxsharp.net/docs/core/decisions#asking-inline-or-asking-first)). + +## Steps + +1. **Declare the question and the tracks.** All types are in `Trax.Core.Decisions`. Every track + must end with whatever the steps after the switch need; here each produces the output. + + ```csharp compile=loan + using System.ComponentModel; + using Trax.Core.Decisions; + + namespace Lending.Trains.UnderwriteLoan; + + public record LoanApplication(string ApplicantId, int CreditScore, decimal DebtToIncome); + + public record LoanDecision(string Outcome); + + [Asks("How should this loan application be underwritten?")] + public enum Underwriting + { + [Description("Strong credit and low debt. Make an offer.")] + Approve, + + [Description("Fails the credit policy. Decline with the required notice.")] + Decline, + + [Description("Borderline. A human underwriter reviews it.")] + ManualReview, + } + ``` + + ```csharp compile=loan + using Lending.Trains.UnderwriteLoan.Junctions; + using Trax.Core.Functional; + using Trax.Effect.Services.ServiceTrain; + + namespace Lending.Trains.UnderwriteLoan; + + public interface IUnderwriteLoanTrain : IServiceTrain; + + public class UnderwriteLoanTrain + : ServiceTrain, + IUnderwriteLoanTrain + { + protected override Task> Junctions() => + Switch(tracks => + tracks + .When( + Underwriting.Approve, + t => t.Chain(), + requireConfidence: 0.95 + ) + .When( + Underwriting.Decline, + t => t.Chain(), + requireConfidence: 0.95 + ) + .When(Underwriting.ManualReview, t => t.Chain()) + ) + .Resolve(); + } + ``` + + ```csharp compile=loan + using Trax.Effect.Services.EffectJunction; + + namespace Lending.Trains.UnderwriteLoan.Junctions; + + // One file each in Junctions/, as for any train. Real ones do the work of their track. + internal class MakeOfferJunction : EffectJunction + { + public override Task Run(LoanApplication input) => Task.FromResult(new LoanDecision("offer")); + } + + internal class SendNoticeJunction : EffectJunction + { + public override Task Run(LoanApplication input) => Task.FromResult(new LoanDecision("declined")); + } + + internal class AssignUnderwriterJunction : EffectJunction + { + public override Task Run(LoanApplication input) => Task.FromResult(new LoanDecision("review")); + } + ``` + +2. **Set the bars per track.** A choice below its track's `requireConfidence` goes to + `Otherwise`, or fails the run when there is none. Give the track with the gravest consequence + the highest bar. Leave `Otherwise` out when every outcome has consequences and guessing is + worse than failing. Adding a track moves every confidence on that switch, so recheck the bars + ([when the answer is not followed](https://traxsharp.net/docs/core/decisions#when-the-answer-is-not-followed)). + +3. **Register a decider.** One `IDecider` in the container serves every decision. Policy is a + `RuleDecider`, which is always certain; a model is `AddNimbleDecider`; your own implements + `IDecider` ([Write a decider](https://traxsharp.net/docs/core/write-a-decider)). Add + `AddDecisionRecording()` after the data provider so decisions are recorded and replayed. A + host with `AddScheduler`, `AddTraxJobRunner` or `AddTraxWorker` refuses to start without it + when any of its trains asks a decider: + + ```csharp compile=loan + using Lending.Trains.UnderwriteLoan; + using Trax.Core.Decisions; + using Trax.Effect.Data.Extensions; + using Trax.Effect.Data.Postgres.Extensions; + using Trax.Effect.Extensions; + using Trax.Mediator.Extensions; + + services.AddSingleton( + new RuleDecider().Choice(application => + application switch + { + { CreditScore: >= 740, DebtToIncome: <= 0.36m } => Underwriting.Approve, + { CreditScore: < 580 } or { DebtToIncome: > 0.5m } => Underwriting.Decline, + _ => Underwriting.ManualReview, + } + ) + ); + + services.AddTrax(trax => + trax.AddEffects(effects => effects.UsePostgres(connectionString).AddDecisionRecording()) + .AddMediator(typeof(UnderwriteLoanTrain).Assembly) + ); + ``` + +4. **Test each track** (below), then start the host. A fault inside a track is reported as + `track 'Approve', step 1: ...`. + +## A new decider in shadow + +To move from rules to a model, or between model versions, run the candidate as a shadow: it is +asked every question beside the live decider, its answers are recorded with whether they agreed, +and it never changes a run. Give it its own type (`public interface ICandidateDecider : IDecider;`), +register it, and add `.Shadow()` to the `Decide` questions, or to the tracks of +a step that asks its own question. Keep `WaitForShadows` short (default five seconds; every run can +wait that long). Switch over by registering it as the live `IDecider`. +[Try a decider in shadow](https://traxsharp.net/docs/core/try-a-decider-in-shadow). + +## Replay + +With `AddDecisionRecording()`, a requeued run (`requeueExecution`, the console's Re-queue), a +manifest's retry and a dead letter's requeue replay the recorded answers instead of asking again, +so the repeat takes the tracks the original took. `queueTrain` and a direct run never replay. An +answer is replayed only when the state hashes the same and the answer is younger than +`ReplayAnswersFor` (24 hours). So: + +- **Answer from the state alone.** The hash covers the state's value, not what a decider looks up + elsewhere. A state that carries a timestamp or a generated id never replays. +- **Renaming the enum or marker type changes the question key**; keep the old one with + `[Asks("...", Key = "OldName")]`. Rewording the question or its options asks afresh too. +- Register a `StateHashKey` (32 bytes or more, the same on every host that may repeat a run). +- Register your own `IDecisionObserver` before `AddTrax`; one registered after it, with decision + recording on, makes the host refuse to start. + +[Decision replay and state hash rules](https://traxsharp.net/docs/core/decision-replay) and +[Recover a deciding train](https://traxsharp.net/docs/effect/recover-a-deciding-train). + +## Checkpoints and branches + +`Checkpoint()` stores one value from Memory, with the tracks taken before it, so a retry, +a dead letter's requeue or `resumeExecution` resumes after it (`requeueExecution` reruns from the +top). `TState` is a sealed record of ids and small values, never rows or services. Commit before +the checkpoint: it refuses to write while the data context has unsaved changes. Steps after it run +again on every resume, so they must tolerate running twice. `Parallel` branches each get their own +scope and transaction: branches compute and return values, and the step after the join writes. +[Checkpoints and branches](https://traxsharp.net/docs/core/checkpoints-and-branches). + +## Known mistakes + +| Wrong | Right | +|---|---| +| `if` over the input in `Junctions()` | A `Switch`, `Gate` or `Scale` with a decider | +| `t => Chain()` inside a track | `t => t.Chain()`: a track is written on the parameter it is handed | +| A junction after the switch that needs a value only one track produces | Make every track produce it; the check refuses the chain otherwise | +| `.Shadow()` on a `Switch` that routes an earlier `Decide` | Put the shadow on the `Decide`; a step that asks nothing refuses it | +| A decider that changes `request.State`, or reads the database to answer | Read the state and leave it; put what it needs into the state | +| Expecting `Otherwise` to catch a decider that throws or answers badly | Both fail the run whatever fallbacks exist: a bad answer as `Transient`, a declaration fault as `Permanent`. `Otherwise` only takes low confidence and members with no track | + +## Testing + +`ScriptedDecider` answers what the test says and keeps every request in `Requests`: +`new ScriptedDecider().Choose(Underwriting.Approve, confidence: 0.62)`, registered as the +`IDecider`, sends a run below Approve's bar, which fails it here since there is no `Otherwise`. +To check every track, verify the declared chain: +`ChainVerification.Verify(train.DeclaredChain(), typeof(LoanApplication), typeof(LoanDecision))` +(`Trax.Core.Monad`) returns no faults. See +[Test a train's decisions](https://traxsharp.net/docs/core/test-train-decisions) and `trax-test-your-app`. + +## Pages + +[Switch](https://traxsharp.net/docs/sdk-reference/train-methods/switch), +[Gate](https://traxsharp.net/docs/sdk-reference/train-methods/gate), +[Scale](https://traxsharp.net/docs/sdk-reference/train-methods/scale), +[Decide](https://traxsharp.net/docs/sdk-reference/train-methods/decide), +[Checkpoint](https://traxsharp.net/docs/sdk-reference/train-methods/checkpoint), +[Parallel](https://traxsharp.net/docs/sdk-reference/train-methods/parallel), +[AddDecisionRecording](https://traxsharp.net/docs/sdk-reference/configuration/add-decision-recording). +What is stored, and replay in full: [Decision records](https://traxsharp.net/docs/effect/decisions). diff --git a/Trax.Docs/skills/trax-write-a-train/SKILL.md b/Trax.Docs/skills/trax-write-a-train/SKILL.md new file mode 100644 index 00000000..eca6fe63 --- /dev/null +++ b/Trax.Docs/skills/trax-write-a-train/SKILL.md @@ -0,0 +1,212 @@ +--- +name: trax-write-a-train +description: Use when writing, adding, changing or splitting a Trax train or junction (ServiceTrain, Train, EffectJunction, Junction, Junctions(), Chain, Resolve, Memory), or when a train needs an error the caller can read, cancellation, a nested train, or a fix for a startup chain check refusal. Covers where each file goes, the train's own input type, its interface, internal junctions, how values pass between junctions, which exception reaches a caller, and registration with AddMediator. +metadata: + internal: true +--- + +# Write a train + +A train is a class whose `Junctions()` declares a chain of junctions. Each junction takes one +value from Memory and puts one back. The host reads every chain at startup and refuses to start +when one cannot run. [Trains and junctions](https://traxsharp.net/docs/core/trains-and-junctions) +explains the model; this skill is the order to do things in and the mistakes to avoid. + +If the train has to choose between paths, load `trax-write-a-decider` as well. If the work spans +several requests or waits on a person, it may be a machine: load `trax-design-a-machine`. + +## Decide first + +- **`ServiceTrain` or `Train`.** Anything the host runs (through `ITrainBus`, GraphQL, the + scheduler or a machine) is a `ServiceTrain` from `Trax.Effect.Services.ServiceTrain`, + with its own interface. A plain `Train` from `Trax.Core.Train` has no database, no + container and no hooks; use it only for code that runs with no host, constructed with `new`. +- **`EffectJunction` or `Junction`.** In a `ServiceTrain`, use `EffectJunction` + (`Trax.Effect.Services.EffectJunction`): it is recorded, and a cancel from another host can stop + the run before it. A plain `Junction` works there but is invisible to the junction providers. An + `EffectJunction` in a plain `Train` throws `TrainException`. +- **The input and output types.** One input type per train, never shared: `ITrainBus.RunAsync` + finds the train by its input's type and reaches only the first train scanned for it. + +## Steps + +1. **Make the folder.** Read [Organize trains in a project](https://traxsharp.net/docs/core/organize-trains) + before writing anything. Each train gets `Trains//`: + + ```text + Trains/PlaceOrder/ + ├── PlaceOrderRequest.cs # input, public + ├── OrderPlaced.cs # output, public + ├── IPlaceOrderTrain.cs # interface, public + ├── PlaceOrderTrain.cs # train, public + ├── Junctions/ # one file per junction, internal + └── Models/ # internal unless the input or output is made of it + ``` + + One type per file. If the project came from a template, keep its sample trains (`HelloWorld`, + `Lookup`) until yours are written and copy their folder layout; their junctions are `public`, + but yours follow the page and are `internal`. + +2. **Write the input, output and interface.** Then one junction per step, each `internal`, taking + dependencies through its constructor. A type only the junctions hand each other goes in + `Models/` as `internal`. Types that share a fence below are still one file each. + + ```csharp compile=train + namespace Shop.Trains.PlaceOrder; + + public record PlaceOrderRequest(string Sku, int Quantity); + + public record OrderPlaced(long OrderId, decimal Total); + ``` + + ```csharp compile=train + using Trax.Effect.Services.ServiceTrain; + + namespace Shop.Trains.PlaceOrder; + + public interface IPlaceOrderTrain : IServiceTrain; + ``` + + ```csharp compile=train + namespace Shop.Trains.PlaceOrder.Models; + + internal record PricedOrder(string Sku, int Quantity, decimal Total); + ``` + + ```csharp compile=train + using Shop.Trains.PlaceOrder.Models; + using Trax.Core.Exceptions; + using Trax.Effect.Services.EffectJunction; + + namespace Shop.Trains.PlaceOrder.Junctions; + + internal class PriceOrderJunction(ICatalog catalog) + : EffectJunction + { + public override async Task Run(PlaceOrderRequest input) + { + if (input.Quantity <= 0) + throw new TrainException("Quantity must be at least 1."); + + var price = await catalog.PriceOf(input.Sku, CancellationToken); + return new PricedOrder(input.Sku, input.Quantity, price * input.Quantity); + } + } + + internal class SaveOrderJunction(IOrderStore orders) : EffectJunction + { + public override async Task Run(PricedOrder input) + { + var id = await orders.Save(input.Sku, input.Quantity, input.Total, CancellationToken); + return new OrderPlaced(id, input.Total); + } + } + ``` + +3. **Declare the chain.** `Junctions()` names junctions and ends in `Resolve()`, nothing else: + + ```csharp compile=train + using Shop.Trains.PlaceOrder.Junctions; + using Trax.Core.Functional; + using Trax.Effect.Services.ServiceTrain; + + namespace Shop.Trains.PlaceOrder; + + public class PlaceOrderTrain : ServiceTrain, IPlaceOrderTrain + { + protected override Task> Junctions() => + Chain().Chain().Resolve(); + } + ``` + + Each junction's output lands in Memory under its declared type, and the next junction whose + input is that type receives it. A value not in Memory is taken from the container. The + train's output must be in Memory when `Resolve()` runs. `Extract`, `AddServices` and + `ShortCircuit` cover the rest: [How a chain runs](https://traxsharp.net/docs/core/building-chains). + +4. **Register it.** `AddMediator` scans assemblies and registers every `ServiceTrain` under its + interface. It comes after `AddEffects` and is called once: + + ```csharp compile=train + using Shop.Trains.PlaceOrder; + using Trax.Effect.Data.InMemory.Extensions; + using Trax.Effect.Extensions; + using Trax.Mediator.Extensions; + + services.AddTrax(trax => + trax.AddEffects(effects => effects.UseInMemory()) + .AddMediator(typeof(PlaceOrderTrain).Assembly) + ); + ``` + + Callers inject `IPlaceOrderTrain` or call `ITrainBus.RunAsync(request, ct)`. + Exposing it over GraphQL (`[TraxQuery]`, `[TraxMutation]`, `[TraxAuthorize]`) is + `trax-expose-over-api`. + +5. **Start the host and read what it says.** The [startup chain check](https://traxsharp.net/docs/core/startup-chain-check) + names the train, the step and the missing type for every fault at once. Fix what it names; + do not reach for `SkipChainVerification()`. + +## Errors + +A junction fails by throwing; the rest of the chain is skipped and the caller gets that exception. +In the same process the caller sees it unchanged. A GraphQL caller does not: only an exception +whose type is exactly `TrainException` (`Trax.Core.Exceptions`) passes its message through, as +`TRAX_TRAIN_ERROR`. A subclass of it reads "The train failed.", and anything else, including +`InvalidOperationException`, is masked as "Unexpected Execution Error". + +```csharp fragment +throw new InvalidOperationException("Out of stock"); // wrong: the API caller sees a masked error +throw new TrainException("Out of stock"); // right: the caller reads "Out of stock" +``` + +The message goes to the client as written, so it carries no secret and no other user's data. +`TrainException` takes a message only; to keep the cause, log it before you throw. Do not catch +an exception to return a default value, and do not turn error masking off. Details: +[Handle errors in trains](https://traxsharp.net/docs/cross-cutting/handle-errors-in-trains) and +[Error codes](https://traxsharp.net/docs/sdk-reference/graphql-api/error-codes). + +## Cancellation + +The run's token is the junction's `CancellationToken` property, set before `Run` is called. Pass +it to every async call, call `CancellationToken.ThrowIfCancellationRequested()` in long loops, +and never catch the `OperationCanceledException`. Code that starts a run passes its own token +(`trainBus.RunAsync(input, ct)`). See +[Make a junction cancellable](https://traxsharp.net/docs/cross-cutting/make-a-junction-cancellable). + +```csharp fragment +public override Task Run(QuoteRequest input, CancellationToken ct) // wrong: no such overload +public override Task Run(QuoteRequest input) // right: use CancellationToken +``` + +## Known mistakes + +| Wrong | Right | +|---|---| +| Input, interface, train and junctions in one file, or several trains in one folder | One type per file, one folder per train, as in step 1 | +| `public class ...Junction` | `internal class ...Junction`; the rest of the app talks to the interface | +| A shared base junction or shared input record written before a second train needs it | Share late, and only a generic junction, in `Trains/Shared/Junctions/`. One that grows conditionals per caller goes back to one per train | +| Two trains taking the same input type | A type per train | +| `if (TrainInput.X) ...` in `Junctions()` | `TrainInput` throws while the chain is declared. Read the input in a junction, or route with a decider (`trax-write-a-decider`) | +| Two junctions that both return `string` or `int` | Distinct types (`FirstName`, `OrderId`): Memory holds one value per type and the second overwrites the first | +| A junction returning the object it changed, to keep it in Memory | Memory holds references: change it in place and return `Unit` (from `Trax.Core.Functional`) | +| `[Inject]` properties on a junction | Constructor parameters. A junction's properties are never filled | +| Overriding `Run` on a `ServiceTrain`, or ending the chain with `Resolve(value)` | Both are refused. Work before or after the chain is a junction at its head or tail: [Move work into the chain](https://traxsharp.net/docs/core/move-work-into-the-chain) | +| Running a child train by passing the parent's `Metadata` to `RunAsync` | A junction that takes `ITrainBus` and calls `RunAsync` with the child's input and its `CancellationToken` | +| Installing `Trax.Core.Analyzers` for chain errors | It is deprecated and reports nothing. The startup chain check replaces it ([analyzer](https://traxsharp.net/docs/core/analyzer)) | + +## Testing + +`trax-test-your-app` covers testing trains. A chain can be checked without a host: +`ChainVerification.Verify(train.DeclaredChain(), typeof(TIn), typeof(TOut))` (`Trax.Core.Monad`) +returns the faults the startup check would report. + +## Pages + +[ServiceTrain](https://traxsharp.net/docs/sdk-reference/trains-and-junctions/service-train), +[EffectJunction](https://traxsharp.net/docs/sdk-reference/trains-and-junctions/effect-junction), +[TrainException](https://traxsharp.net/docs/sdk-reference/trains-and-junctions/train-exception), +[AddMediator](https://traxsharp.net/docs/sdk-reference/configuration/add-mediator) and the +[Train methods](https://traxsharp.net/docs/sdk-reference/train-methods). For the model behind +them: [Trains and junctions](https://traxsharp.net/docs/core/trains-and-junctions) and +[Memory](https://traxsharp.net/docs/core/memory). diff --git a/Trax.Docs/skills/trax/SKILL.md b/Trax.Docs/skills/trax/SKILL.md new file mode 100644 index 00000000..67552a7c --- /dev/null +++ b/Trax.Docs/skills/trax/SKILL.md @@ -0,0 +1,91 @@ +--- +name: trax +description: Use before writing or changing code in a .NET app that references Trax packages (Trax.Core, Trax.Effect, Trax.Mediator, Trax.Scheduler, Trax.Api, Trax.StateMachine) or the @traxsharp/state-machine npm package. Says what Trax is, which packages and template to use, the rules that hold everywhere, how to check these skills match the installed release, and which Trax skill to load for the task. +metadata: + internal: true +--- + +# Trax + +Trax .NET is a headless web framework. Business work is a **train**: a class that runs a chain of small typed steps +called **junctions**, where a failing junction stops the rest. Everything else attaches to that one class. The +database records every run, the mediator and scheduler decide when and where it runs, the GraphQL API exposes it, and +a state machine walks a user through a longer process that runs trains along the way. +[What Trax does](https://traxsharp.net/docs/what-trax-does) maps each capability to its page, and the +[Glossary](https://traxsharp.net/docs/reference/glossary) gives each Trax name its plain meaning. + +Every docs page is also served as raw markdown: append `.md` to its URL. +[llms.txt](https://traxsharp.net/llms.txt) lists every page, with full-text bundles per section. + +## First: check these skills match the app + +This file's `metadata.trax-version` is the Trax release these skills were written for. Find the app's Trax version +in `Directory.Packages.props` (or the `.csproj` if it does not use central package management): every `Trax.*` +package is at one version. + +- **Same version:** go on. +- **The app is older:** the skills may describe APIs it does not have. Tell the user, and suggest installing the + skills from the app's release: + `npx skills add https://github.com/TraxSharp/Trax/tree/skills-v` (add `-a claude-code` for Claude Code, + whose plugin always follows the latest release). +- **The app is newer:** update the skills (`npx skills update`, or `/plugin update trax@traxsharp` in Claude Code). +- **The user wants to move the app to a newer Trax:** load `trax-upgrade`. + +## Rules that hold everywhere + +- **Every Trax package is at one version.** A new `Trax.*` package goes in at the version the others already have. + The packages target `net10.0` only. +- **Use Trax's own types where Trax has the concept.** `Either` and `Unit` come from `Trax.Core.Functional`; + do not add a functional library for them. A train or query model is gated with `[TraxAuthorize]` or opened with + `[TraxAllowAnonymous]`; HotChocolate's `[Authorize]` is refused in their place. When you need a type for something + Trax does, look for Trax's first. +- **The host checks itself at startup.** A chain that can never run, a registration in the wrong order or a missing + provider makes the host refuse to start, and the message names the cause and usually the page that fixes it. + Read it before changing anything. [Registration order](https://traxsharp.net/docs/reference/registration-order) + lists what must come before what. +- **Security defaults are closed on purpose.** Never turn off authentication, authorization, error masking, + persisted-operation requirements or an Origin check to make something work. If a request is refused, find out + why; if the fix really is to open something, ask the user first. +- **Follow the docs over the code you find.** An app can drift from the documented layout. When existing code + disagrees with a Trax docs page, the page is the pattern; say so rather than copy the drift. + +## Which packages + +Pick the highest layer you need, then one data provider. Each package brings the layers below it. +[Packages](https://traxsharp.net/docs/reference/packages) lists every one with the namespace to import. + +| You want | Install | +|---|---| +| Typed pipelines only, no DI, no database | `Trax.Core` | +| Trains recorded in a database | `Trax.Effect` and one of `Trax.Effect.Data.Postgres`, `.Sqlite`, `.InMemory` | +| Dispatch by input type, the startup chain check | add `Trax.Mediator` | +| Schedules, a work queue, retries, dead letters | add `Trax.Scheduler` | +| A GraphQL API over the trains | add `Trax.Api.GraphQL` and an auth package (`Trax.Api.Auth.ApiKey`, `.Jwt`, `.Oidc`) | +| The operations console | add `Trax.Api.Dashboard` | +| State machines | `Trax.StateMachine.Persistence` (server), `@traxsharp/state-machine` (TypeScript client) | + +## Before writing code + +Read these, in order, the first time you work in a Trax app. They are short, and each one decides something the +code depends on. + +1. [Getting started](https://traxsharp.net/docs/getting-started): one train from nothing to GraphQL. +2. [Project templates](https://traxsharp.net/docs/reference/templates): which template, and what it generates. +3. [Organize trains in a project](https://traxsharp.net/docs/core/organize-trains): where every file goes. +4. [Train or machine](https://traxsharp.net/docs/statemachine/train-or-machine), if the work spans more than one + request. + +## Which skill next + +| Task | Skill | +|---|---| +| Start a new app, or add Trax to one | `trax-start-a-project` | +| Write or change a train or junction | `trax-write-a-train` | +| A train has to choose between paths, or a rule changes often | `trax-write-a-decider` | +| A process spans several requests or users, or waits on people | `trax-design-a-machine` | +| Expose trains or machines over GraphQL, set up auth | `trax-expose-over-api` | +| Call the API from a frontend, generate a client | `trax-generate-a-client` | +| Run trains on a schedule, in a queue, or on workers | `trax-schedule-work` | +| Test any of the above | `trax-test-your-app` | +| Move the app to a newer Trax | `trax-upgrade` | +| The host will not start, or a run fails and the cause is unclear | `trax-troubleshoot` | diff --git a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-design-a-machine/SKILL.cs b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-design-a-machine/SKILL.cs new file mode 100644 index 00000000..857dd83a --- /dev/null +++ b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-design-a-machine/SKILL.cs @@ -0,0 +1,22 @@ +// The trains the skill's machine invokes, which the skill names but does not show. +using Trax.Effect.Services.ServiceTrain; + +public sealed record LoadRunInput(string RunId); + +public sealed record LoadRunOutput +{ + public int Version { get; init; } +} + +public interface ILoadRunTrain : IServiceTrain; + +public sealed record PlayCardInput(string RunId, int Version, int Slot, int Target); + +public sealed record PlayCardOutput +{ + public int Version { get; init; } + + public bool Finished { get; init; } +} + +public interface IPlayCardTrain : IServiceTrain; diff --git a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-schedule-work/SKILL.cs b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-schedule-work/SKILL.cs new file mode 100644 index 00000000..4f76aff3 --- /dev/null +++ b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-schedule-work/SKILL.cs @@ -0,0 +1,16 @@ +// What the example in the trax-schedule-work skill takes as given: the trains it schedules. +using Trax.Core.Functional; +using Trax.Effect.Models.Manifest; +using Trax.Effect.Services.ServiceTrain; + +public record ExtractOrdersInput : IManifestProperties; + +public interface IExtractOrdersTrain : IServiceTrain; + +public record LoadOrdersInput : IManifestProperties; + +public interface ILoadOrdersTrain : IServiceTrain; + +public record NightlyReportInput : IManifestProperties; + +public interface INightlyReportTrain : IServiceTrain; diff --git a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-test-your-app/SKILL.cs b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-test-your-app/SKILL.cs new file mode 100644 index 00000000..cba18c09 --- /dev/null +++ b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-test-your-app/SKILL.cs @@ -0,0 +1,10 @@ +// What the examples in the trax-test-your-app skill take as given: a train in the app under test. +using Trax.Core.Functional; +using Trax.Effect.Services.ServiceTrain; + +namespace MyApp.Trains.PlaceOrder +{ + public record PlaceOrderInput; + + public interface IPlaceOrderTrain : IServiceTrain; +} diff --git a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-write-a-train/SKILL.cs b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-write-a-train/SKILL.cs new file mode 100644 index 00000000..7003f019 --- /dev/null +++ b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Context/skills/trax-write-a-train/SKILL.cs @@ -0,0 +1,11 @@ +// What the examples in skills/trax-write-a-train/SKILL.md take as given: the app's own services +// the junctions are built with. +public interface ICatalog +{ + Task PriceOf(string sku, CancellationToken cancellationToken); +} + +public interface IOrderStore +{ + Task Save(string sku, int quantity, decimal total, CancellationToken cancellationToken); +} diff --git a/Trax.Docs/tests/Trax.Docs.Tests/Tests/SkillsCoverMigrationGuidesTests.cs b/Trax.Docs/tests/Trax.Docs.Tests/Tests/SkillsCoverMigrationGuidesTests.cs new file mode 100644 index 00000000..1a1f300c --- /dev/null +++ b/Trax.Docs/tests/Trax.Docs.Tests/Tests/SkillsCoverMigrationGuidesTests.cs @@ -0,0 +1,91 @@ +using Trax.Skills.Publish; + +namespace Trax.Docs.Tests.Tests; + +/// +/// Every page under migration-guides/ is linked from the trax-upgrade skill, as a +/// full https://traxsharp.net/docs/migration-guides/<slug> URL. +/// +/// The skill is how an agent bumping Trax in someone else's repository finds the guides +/// between two versions. It lists them by hand, with the release each applies from, so a guide +/// added without a line there is one the agent never reads, and the upgrade it describes is +/// skipped without anyone noticing. The section index, migration-guides.md, sits beside +/// the folder rather than in it, so it is not a guide here. +/// +/// Enforces Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md. +/// +[Property( + "adr", + "Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md" +)] +[TestFixture] +public class SkillsCoverMigrationGuidesTests +{ + private const string Adr = + "Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md"; + + private const string Skill = "skills/trax-upgrade/SKILL.md"; + + private const string Prefix = "https://traxsharp.net/docs/migration-guides/"; + + [Test] + public void EveryMigrationGuide_IsLinkedFromTheUpgradeSkill() + { + var guides = Guides().ToList(); + guides + .Should() + .NotBeEmpty("the guard reads migration-guides/; finding none means it checks nothing"); + + var linked = Linked(File.ReadAllText(RepoRoot.Combine(Skill.Split('/')))); + var missing = guides.Where(g => !linked.Contains(g)).ToList(); + + missing + .Should() + .BeEmpty( + $"{Skill} is how an agent upgrading an app finds the guides between two versions, " + + $"so it links every guide as {Prefix}, with the release it applies " + + "from (or the condition, for a database procedure). See " + + Adr + + ". Not linked:\n " + + string.Join("\n ", missing.Select(g => $"migration-guides/{g}.md")) + ); + } + + [TestCase( + "[Guide](https://traxsharp.net/docs/migration-guides/hotchocolate-16)", + "hotchocolate-16" + )] + [TestCase( + "[Guide](https://traxsharp.net/docs/migration-guides/install-pg-trgm#1-check)", + "install-pg-trgm" + )] + public void Linked_ReadsTheSlug(string markdown, string slug) => + Linked(markdown).Should().Contain(slug); + + [TestCase("[Guide](/docs/migration-guides/hotchocolate-16)")] + [TestCase("`https://traxsharp.net/docs/migration-guides/hotchocolate-16`")] + [TestCase("[Upgrading](https://traxsharp.net/docs/migration-guides)")] + public void Linked_IgnoresWhatIsNotAFullLinkToAGuide(string markdown) => + Linked(markdown).Should().BeEmpty(); + + /// The slug of every page under migration-guides/, in ordinal order. + private static IEnumerable Guides() + { + var folder = RepoRoot.Combine("migration-guides"); + return Directory.Exists(folder) + ? Directory + .EnumerateFiles(folder, "*.md", SearchOption.TopDirectoryOnly) + .Select(f => Path.GetFileNameWithoutExtension(f)) + .Order(StringComparer.Ordinal) + : []; + } + + /// The guide slugs links to, anchors dropped. + private static HashSet Linked(string markdown) => + SkillRules + .Links(markdown) + .Where(t => t.StartsWith(Prefix, StringComparison.Ordinal)) + .Select(t => t[Prefix.Length..].Split('#')[0]) + .Where(slug => slug.Length > 0) + .ToHashSet(StringComparer.Ordinal); +}