wf-order-saga
wf-order-saga — the ORDER-LOB workflow demo.
7 source files2 test files

Get it
// not one of the apps the toolchain ships: this one lives in the // repository. Clone it, then: $ cd demo/wf-order-saga $ osy launch
The app
app.osy8 lines
// wf-order-saga — the ORDER-LOB workflow demo. app WfOrderSaga { use Osyrin.Ui; model "model/**/*.osy"; tests "tests/**/*.test.osy"; data "data/**/*.json"; }
model/actions.osy18 lines
// The board's verbs, as ordinary app functions. void PayOrder(Order o) { OrderLifecycle.RaisePay(o); } void ShipOrder(Order o, string tracking) { OrderLifecycle.For(o).Fulfil.Ship(tracking); } void DeliverOrder(Order o) { OrderLifecycle.RaiseDeliver(o); } void CancelOrder(Order o) { OrderLifecycle.RaiseCancel(o); } void RefundReturn(ReturnShipment r) { ReturnFlow.RaiseRefund(r); } void PlaceOrder(string reference, decimal total) { var customer = Customer.First(); new Order { Reference = reference, Customer = customer, Total = total }; UnitOfWork.Commit(); }
model/identity.osy55 lines
// The demo's identity model — WHO the staff are, and on what authority. [Role] enum AppRole { Authenticator, Fulfillment, Manager } [Principal] entity Staff { [Required, MaxLength(100)] string Name; [Required, MaxLength(200), Unique] string Email; [MaxLength(200)] string? PasswordHash; security { allow read when IsAuthenticated; allow read, create when IsAuthenticator; allow create when IsManager; allow update when IsManager; deny read PasswordHash when !IsAuthenticator; // nobody but the auth flow ever sees the hash } } entity RoleGrant { [Required] Staff Grantee; [Required] AppRole Level = AppRole.Fulfillment; security { allow read when IsAuthenticated; allow create when IsAuthenticator; // …the signup, for the first grant allow create, update, delete when IsManager; // all three verbs, or the weakest is the way in } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); policy IsManager => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Manager); [AuthMethod] string Signup(string email, string password) { var s = new Staff { Name = email, Email = email, PasswordHash = Security.HashPassword(password) }; return Security.IssueJwt(s.Id, s.Email); } [AuthMethod] string Login(string email, string password) { var s = Staff.Where(x => x.Email == email).FirstOrDefault(); if (s == null) { Security.VerifyPassword(password); return ""; } if (s.PasswordHash == null) { return ""; } if (Security.VerifyPassword(password, s.PasswordHash)) { return Security.IssueJwt(s.Id, s.Email); } return ""; } app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, };
model/order.osy175 lines
// ORDER LIFECYCLE — the order-LOB saga: a payment step, a fulfillment slot, shipping, delivery, and cancel-from-anywhere // with a COMPENSATING RETURN. Cancelling a SHIPPED order can't just flip a flag — the goods are gone — so it runs a // child return workflow and awaits it, landing the order in `Returning` only once the refund lands. That awaited child // (`await Workflow.Run`) PARKS the order workflow durably and resumes it when the return terminals (the saga tier). // The identity tier — `Staff`, `AppRole` and the auth methods — lives in `identity.osy`, beside every other demo's. enum OrderStatus { Placed, Paid, Shipped, Delivered, Cancelled, Returning } enum ReturnStatus { Initiated, Refunded } entity Customer { [Required, MaxLength(200)] string Name; [Required, MaxLength(255)] string Email; security { allow read when IsAuthenticated; allow create when IsManager; allow update when IsManager; } } entity Order { [Required, MaxLength(50)] string Reference; OrderStatus Status; [Required] Customer Customer; decimal Total; [MaxLength(100)] string? Tracking; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity ReturnShipment { [Required] Order Order; ReturnStatus Status = ReturnStatus.Initiated; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } workflow ReturnFlow { Tracks = ReturnShipment.Status; Autostart = false; // started by `Workflow.Run` from the order's Cancel route, not on create Initial = Initiated; event Refund(); state Initiated { subscribe Refund(); on Refund { goto Refunded; } } terminal success Refunded { } } workflow OrderLifecycle { Tracks = Order.Status; Autostart = true; Initial = Placed; event Pay(); event Ship(string tracking); event Deliver(); [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Manager))] event Cancel(); on Cancel { if (this.Item.Status == OrderStatus.Shipped) { var ret = Workflow.Once("make-return", () => new ReturnShipment { Order = this.Item }); await Workflow.Run("ret", ret); // hold until the return workflow refunds goto Returning; } else { goto Cancelled; } } state Placed { subscribe Pay(); on Pay { goto Paid; } } state Paid { subscribe Ship(string tracking) as Fulfil { Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Fulfillment); } on Fulfil(string tracking) { this.Item.Tracking = tracking; goto Shipped; } } state Shipped { subscribe Deliver(); on Deliver { goto Delivered; } } terminal success Delivered { } terminal cancel Cancelled { Message = "order cancelled"; } terminal cancel Returning { Message = "order cancelled after shipping — return + refund issued"; } } enum FulfillmentStatus { Building, Fulfilled, Aborted } enum StepStatus { Waiting, Done, Bad } entity Fulfillment { [Required, MaxLength(50)] string Reference; FulfillmentStatus Status; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity Reservation { [Required] Fulfillment Fulfillment; StepStatus Status = StepStatus.Waiting; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity Charge { [Required] Fulfillment Fulfillment; StepStatus Status = StepStatus.Waiting; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity Dispatch { [Required] Fulfillment Fulfillment; StepStatus Status = StepStatus.Waiting; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity Trace { [Required] Fulfillment Fulfillment; [MaxLength(20)] string Log; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } void ReleaseStock (Reservation r, Trace t) { t.Log = t.Log + "S"; Log.Information(" ↩ rollback: released reserved stock"); } void RefundCharge (Charge c, Trace t) { t.Log = t.Log + "C"; Log.Information(" ↩ rollback: refunded the charge"); } void ReleaseHold (Trace t) { t.Log = t.Log + "H"; Log.Information(" ↩ rollback: released the loyalty hold"); } workflow ReserveFlow { Tracks = Reservation.Status; Autostart = false; Initial = Waiting; event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } terminal error Bad { Message = "reservation failed"; } } workflow ChargeFlow { Tracks = Charge.Status; Autostart = false; Initial = Waiting; event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } terminal error Bad { Message = "charge failed"; } } workflow DispatchFlow { Tracks = Dispatch.Status; Autostart = false; Initial = Waiting; event Finish(); state Waiting { subscribe Finish(); on Finish { goto Bad; } } // the courier hand-off always fails terminal success Done { } terminal error Bad { Message = "dispatch failed"; } } workflow FulfillmentSaga { Tracks = Fulfillment.Status; Autostart = true; Initial = Building; state Building { enter { var trace = Workflow.Once("make-trace", () => new Trace { Fulfillment = this.Item, Log = "" }); Log.Information("▶ fulfillment {Reference}: starting saga", this.Item.Reference); await using var saga = Workflow.BeginSaga(); // dispose-without-Complete unwinds the committed steps, in reverse try { Log.Information(" ✓ step 1: reserving stock"); var reservation = Workflow.Once("make-reservation", () => new Reservation { Fulfillment = this.Item }); await saga.Run("reservation", reservation, () => ReleaseStock(reservation, trace)); // step 1 + its undo, coupled Log.Information(" ✓ step 2: charging the card"); var charge = Workflow.Once("make-charge", () => new Charge { Fulfillment = this.Item }); await saga.Run("charge", charge, () => RefundCharge(charge, trace)); // step 2 + its undo saga.OnUnwind(() => ReleaseHold(trace)); // a compensation with NO forward step (a loyalty hold booked inline) Log.Information(" ✓ step 3: dispatching the courier"); var dispatch = Workflow.Once("make-dispatch", () => new Dispatch { Fulfillment = this.Item }); await saga.Run("dispatch", dispatch); // step 3 — no undo; this child FAILS saga.Complete(); // never reached goto Fulfilled; } catch (WorkflowError e) { Log.Information("✗ fulfillment {Reference}: a step failed — rolling back committed steps in reverse", this.Item.Reference); goto Aborted; // the goto unwinds through the await-using dispose → H, C, S } } } terminal success Fulfilled { } terminal error Aborted { Message = "fulfillment aborted — reservation and charge compensated"; } }
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a // byte-identical `model/theme.osy`, and that test is what keeps them identical. // // ⚑ EVERY COLOUR HERE SHADOWS A TOKEN THE KIT ALREADY DECLARES, and that is the whole point. // The themes this replaced declared a PARALLEL palette — `Surface0/1/2`, `TextPrimary`, // `FillAccent/Success/Warning/Danger` — names `Osyrin.Ui` has never heard of. So the kit's own // `Primary`, `Success`, `Warning` and `Danger` stayed at their defaults in six demos: the moment // one of them reached for a kit control, that control painted itself indigo next to the demo's // blue. `FillAccent` was declared in all six and referenced by NONE — an accent colour that // existed only in the theme file. // // So the rule for a demo theme is: SHADOW a kit token, never invent a second name for it. A name // the kit does not have (`Radius.Card`) is a real extension and fine; a second spelling of one it // does have (`TextPrimary` for `OnBg`) is how an app ends up with two of everything. // // Excluded by design — these five own their look and must NOT adopt this file: arcade, ember, // motion, gestures (each demonstrates a visual world) and Apps/recall-osy. theme Demo { Colors { // A deep petrol blue, deliberately not the kit's indigo (#4F46E5) — a demo should look like a // considered app rather than an unstyled one, and the two should be distinguishable on sight. // It is the one saturated colour on the page; everything else is warm neutral, which is what // keeps "distinct" from turning into "loud". Primary = Modes.Of(light: Palette.From("#125E7A"), dark: Palette.From("#57B8D6")); OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#06181F"); Bg = Modes.Of(light: "#FAF9F7", dark: "#101317"); // the page — a touch warm, so cards read as lifted Surface = Modes.Of(light: "#FFFFFF", dark: "#171C22"); // a card, a row, a lane Muted = Modes.Of(light: "#EFEEEA", dark: "#1E242B"); // an inline notice, a disabled field Border = Modes.Of(light: "#E4E2DC", dark: "#28303A"); OnBg = Modes.Of(light: "#14181D", dark: "#E7EBF0"); OnSurface = Modes.Of(light: "#14181D", dark: "#E7EBF0"); TextSecondary = Modes.Of(light: "#59626D", dark: "#9BA6B3"); // labels, timestamps, captions TextMuted = Modes.Of(light: "#7C848E", dark: "#7E8894"); // the quietest text on the page Success = Modes.Of(light: Palette.From("#1D7A4C"), dark: Palette.From("#4EC98A")); Warning = Modes.Of(light: Palette.From("#B26A00"), dark: Palette.From("#E0A343")); Danger = Modes.Of(light: Palette.From("#C1352B"), dark: Palette.From("#F0736A")); } // Names the kit does not have, so these are extensions rather than second spellings. Radius { Control = "8px"; Card = "12px"; } // ⚑ THE FONTS ARE WHAT MAKE THIS READ AS A DESIGNED APP RATHER THAN A DEFAULT ONE, and `Sans` // shadows the kit's own token — so every kit control picks it up with no page edit at all. // Both faces are vendored per app under `model/fonts/` and pinned in `osyrin.lock` (`osy font // add`), because a webfont a page merely NAMES is a webfont that silently falls back. // Only the faces the app SHIPS are named here — Mono is left at the kit default because no // demo renders code, and naming an unshipped family is a silent fallback. // Geist for the UI: a neutral grotesque with real character at small sizes, which is where a // dense LOB screen lives. Fraunces for display: a warm variable serif, used ONLY on a page // title. The pairing is the whole look — one voice for reading, one for announcing. Font { Sans = "Geist, ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, system-ui, sans-serif"; Serif = "Fraunces, \"Iowan Old Style\", Palatino, Georgia, ui-serif, serif"; } FontSize { Caption = "12px"; Body = "14px"; Subhead = "17px"; Title = "26px"; } FontWeight { Regular = 400; Medium = 500; Semibold = 600; } // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. }
model/pages/board.osy131 lines
// THE BOARD — and the thing it has to make visible is that CANCELLING A SHIPPED ORDER IS NOT A FLAG. The goods are // gone, so the workflow starts a compensating RETURN child and PARKS on it; the order ends in `Returning`, not // `Cancelled`, and the difference between those two words is the entire demo. using Osyrin.Ui; [Page("/")] [Render(CSR)] [Title("Orders")] component Board() { live var orders = Order.Include(o => o.Customer).OrderByDescending(o => o.CreatedAt).ToList(); live var returns = ReturnShipment.Include(r => r.Order).ToList(); live var mine = Workflow.Inbox<Order>().Include(r => r.Item); var me = Session.CurrentUser; string reference = "ORD-1004"; decimal total = 249; string tracking = "TRK-9001"; action Pay(Order o) { PayOrder(o); } action Ship(Order o) { ShipOrder(o, tracking); } action Deliver(Order o) { DeliverOrder(o); } action Cancel(Order o) { CancelOrder(o); } action Refund(ReturnShipment r) { RefundReturn(r); } action Place() { PlaceOrder(reference, total); } action SignOut() { Session.SignOut(); } render { Stack(gap: 0, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) { Row(justify: Justify.Center, w: "100%", bg: Colors.Surface, borderW: 1, border: Colors.Border) { Row(align: Align.Center, justify: Justify.SpaceBetween, w: "100%", maxW: "980px", px: 5, h: "60px") { Text("Orders", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Row(align: Align.Center, gap: 3) { Text(me.Name, fontSize: FontSize.Caption, color: Colors.TextSecondary); if (IsManager) { Text("Manager", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); } else { Text("Fulfillment", fontSize: FontSize.Caption, color: Colors.TextSecondary); } Pressable(onClick: SignOut) { Text("Sign out", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") { Stack(gap: 5, w: "100%", maxW: "980px", p: 5) { Stack(gap: 2) { Text("Orders", fontFamily: Font.Serif, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold); Text("Place → pay → ship → deliver. After shipping, cancelling runs a return instead.", fontSize: FontSize.Caption, color: Colors.TextSecondary); foreach (var o in orders) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Row(justify: Justify.SpaceBetween, align: Align.Center) { Stack(gap: 1) { Text(o.Reference + " · " + o.Customer.Name, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold); Text(o.Total + (o.Tracking != null ? " · " + o.Tracking : ""), fontSize: FontSize.Caption, color: Colors.TextSecondary); } if (returns.Any(r => r.Order == o && r.Status == ReturnStatus.Initiated)) { Text("Return in flight — refund pending", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Warning); } else if (o.Status == OrderStatus.Returning) { Text("Returning — refund in flight", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Danger); } else { Text(o.Status, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium); } } Row(gap: 2, align: Align.Center) { if (o.Status == OrderStatus.Placed) { Button("Pay", onPress: () => Pay(o), tone: Tone.Primary); } foreach (var r in mine) { if (r.Item == o && r.SlotAlias == "Fulfil") { Button("Ship", onPress: () => Ship(o), tone: Tone.Primary); } } if (o.Status == OrderStatus.Shipped) { Button("Mark delivered", onPress: () => Deliver(o), tone: Tone.Primary); } if (IsManager && o.Status != OrderStatus.Cancelled && o.Status != OrderStatus.Returning && o.Status != OrderStatus.Delivered && !returns.Any(r => r.Order == o && r.Status == ReturnStatus.Initiated)) { Button("Cancel order", onPress: () => Cancel(o), tone: Tone.Danger); } } } } } } if (returns.Count() > 0) { Stack(gap: 2) { Text("Returns in flight", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Text("A cancelled shipped order parks here until the refund lands. THEN the order moves.", fontSize: FontSize.Caption, color: Colors.TextSecondary); foreach (var r in returns) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Row(justify: Justify.SpaceBetween, align: Align.Center) { Stack(gap: 1) { Text("Return for " + r.Order.Reference, fontSize: FontSize.Body, fontWeight: FontWeight.Medium); Text(r.Status, fontSize: FontSize.Caption, color: Colors.TextSecondary); } if (r.Status == ReturnStatus.Initiated) { Button("Refund", onPress: () => Refund(r), tone: Tone.Primary); } } } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Text("Place an order", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Row(gap: 3, align: Align.End) { Box(grow: 1) { Field("Reference", value: reference, placeholder: "ORD-1004"); } // `total` is a `decimal` and `Field` is a two-way binding of `string`, so the numeric field is // the one that binds it directly — the resolver's own `Binding<T>` check says exactly that. DecimalField("Total", value: total, min: 0m); Button("Place", onPress: Place, tone: Tone.Primary); } } } } } } } }
model/pages/login.osy55 lines
// Sign-in. Two DISJOINT tiers — one ships, one cancels — so switching between them is how you see that neither // screen offers the other's verb. using Osyrin.Ui; [Page("/login")] [AllowAnonymous] [Render(CSR)] [Title("Orders — sign in")] component LoginPage() { string email = "fael@acme.test"; string password = "demo1234"; string problem = ""; action SignIn() { var ticket = Login(email, password); if (ticket == "") { problem = "That email and password don't match anyone."; } else { Session.SignIn(ticket); } } render { Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) { Stack(gap: 4, w: "100%", maxW: "420px") { Stack(gap: 1) { Text("Orders", fontFamily: Font.Serif, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold, letterSpacing: "-0.01em"); Text("One ships, one cancels, and neither can do the other's job.", fontSize: FontSize.Body, color: Colors.TextSecondary); } Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Field("Email", value: email, placeholder: "you@acme.test", type: "email"); Field("Password", value: password, type: "password"); if (problem != "") { Box(bg: Colors.Muted, rounded: Radius.Control, px: 3, py: 2) { Text(problem, fontSize: FontSize.Caption, color: Colors.Danger); } } Button("Sign in", onPress: SignIn, tone: Tone.Primary); } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 2) { Text("Seeded people — password demo1234", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Text("fael@acme.test — the Fulfillment GRANT: ships, cannot cancel", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("mona@acme.test — the Manager GRANT: cancels, cannot ship", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("rey@acme.test — on the roster, holds neither: can do nothing", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } } }