Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

Cheat sheet

The one page to keep open. Every command, flag, and wiring line you reach for while building — dense and scannable. For the prose reference see the rask CLI; to learn it in order, follow the Tutorial. Looking for "how do I do X?" — that's the Recipes.

The CLI in a screenful

# scaffold & run
rask new Shop                        # new app: the whole stack, accounts included
rask dev                              # dotnet watch run — hot reload, opens https://<name>.test
rask info                             # what rask sees: project, packages, versions

# database (wraps dotnet-ef; installs it on first use)
rask db add InitialCreate             # generate a migration from the current model
rask db update                        # apply it now — the app also does this when it starts
rask db list                          # which migrations exist, and which are applied
rask db remove                        # undo the last migration
rask db drop --yes                  # delete the database, no prompt
rask db backup                        # copy it to ./<app>-<timestamp>.db
rask db restore backups/shop.db       # put a copy back (--remote for the deployed one)

# ship it
rask deploy --host root@box --domain shop.example.com   # bare box → live HTTPS
rask deploy                           # redeploy (host/domain remembered), zero-downtime
rask deploy --github-actions          # write .github/workflows/deploy.yml
rask deploy status                    # what's running, and what a rollback restores
rask deploy logs -f                  # tail the deployed app (--follow)
rask deploy rollback                  # put the previous image back

# tab completion
rask completion zsh > "${fpath[1]}/_rask"   # also: bash, fish

Every command's own option list is in rask <command> --help, and a wrong one tells you what you probably meant.

Code inside the project is code you write. rask scaffolds the project; pages, components, CRUD slices, jobs, emails and caches are shown as copyable code in the tutorial and the guides.

A CRUD slice, in one place

A vertical slice under Features/<Plural>/ is one aggregate and the components over it:

File What it is
Product.cs the aggregate — Aggregate<Guid, Timestamps, Versioned, Model, Deletable>, private set properties, validation attributes, no constructor
CreateProduct.cs [Route("/products/new")] — a form over the generated ProductModel, saved through a QueryClient.Command() that runs Product.Create(model)
UpdateProduct.cs [Route("/products/{id:guid}/edit")] — loads Product.Model(id) through QueryClient.Query, saves with Product.Update(id, model)
DeleteProduct.cs a button per row — Product.Delete(id, version) behind a command
ProductsPage.cs [Route("/products")] — a Ui.Table with a Ui.Pagination under it, one page of Product.OrderBy(…).Skip(…).Take(…) at a time

Nothing else is written: the build generates ProductModel, the queries off the type (Product.Where(…), rows of ProductRead), Product.Find(id) + product.Save(), and the writes, RaskDbContext maps the aggregate, and a write refreshes every query keyed QueryKey.For<Product>(…) by itself. Then rask db add AddProducts — the app applies it when it starts. Chapter 2 writes all of it out.

Wiring

rask new writes one line — RaskApp.Create(args).Run<App>(); — and RaskApp wires every battery below, maps them onto RaskAppDbContext and applies pending migrations when it starts. Settings live in appsettings.json under Rask; code only turns a battery off or sets what has to be code:

var app = RaskApp.Create(args);
app.Configure(c => c.Jobs.Off());                              // no background work in this app
app.Configure(c => c.Mail.Configure(o => o.From = "hi@shop.example"));   // wins over Rask:Mail
app.Run<App>();

A hand-wired host

Without RaskApp — AddRask()/MapRask<App>() on a plain WebApplication — a data-backed app needs these in Program.cs:

builder.Services.AddRaskCqrs();                        // the mediator (IDispatcher)
builder.Services.AddRaskData<ProductsDbContext>();     // interceptors + names the context models use
builder.Services.AddDbContextFactory<ProductsDbContext>((sp, o) => o
    .UseSqlite("Data Source=app.db")
    .AddInterceptors(sp.GetServices<ISaveChangesInterceptor>()));   // audit/soft-delete/events/outbox

var app = builder.Build();
Db.Configure(app.Services);                            // Product.Where(…) now knows which database

The type argument and Db.Configure are a pair: the bare AddRaskData() registers only the interceptors, and without both, the first Product.Where(…) throws The model database has not been configured. Your context should derive from RaskDbContext, which is what maps the models you declare.

The other pillars are one registration + one modelBuilder line + a migration you add by hand:

builder.Services.AddRaskJobs<ProductsDbContext>(o => { /* … */ });   modelBuilder.AddRaskJobs();
builder.Services.AddRaskMail<ProductsDbContext>(o => { /* … */ });   modelBuilder.AddRaskMail();
builder.Services.AddRaskCache<ProductsDbContext>();                  modelBuilder.AddRaskCache();
builder.Services.AddRaskStorage<ProductsDbContext>();                modelBuilder.AddRaskStorage();
builder.Services.AddRaskOutbox<ProductsDbContext>(o => { /* … */ }); modelBuilder.AddRaskOutbox();
// the outbox claims domain-event delivery on its own — AddRaskData stays bare, in any order
app.MapRaskStorage();   // file storage's public/temporary link routes — after app.MapRask<App>()

// production SQLite — a drop-in for .UseSqlite that installs the pragma interceptor:
.UseRaskSqlite(sp)                            // in AddDbContextFactory((sp, o) => o…); reads Rask:ConnectionStrings:App
builder.Services.AddRaskSqliteLitestream();   // off-box backup; reads Rask:Litestream

After any modelBuilder.AddRask… line: rask db add <Name>.

Code idioms

// Dispatch a query or command — one method, result type inferred from the message:
var view = await dispatcher.Query(new GetProducts(), CancellationToken);   // IDispatcher, ctor-injected

// Type-safe URL for a routed page — never a string path:
NavLink.Href(Routes.ProductsPage())["Catalog"];       // list page → <Plural>Page
Routes.UpdateProduct(Id: id).Go();                     // edit page → Update<Entity>; from an event handler

// Gate on auth — route-level attribute, or a component that renders only when signed in:
[Authorize]                                            // redirects anonymous deep-links to /login
Authorize[ NewProductButton ]                        // shown only to signed-in users
Authorize.Role("admin")[ DeleteProductButton.Id(id) ]

// Declare an aggregate: private setters, no constructor, a static factory; value objects need no marker (Rask.Data):
public sealed class Product : Aggregate<Guid, Timestamps, Versioned, Model, Deletable> { public string Name { get; private set; } = ""; }

// What a table carries — traits on the base, in any order, each opt-in (Rask.Data):
public sealed class Product : Aggregate<Guid, Timestamps, Versioned, Model, Deletable> { }  // what rask new scaffolds
public sealed class Order : Aggregate<Guid, Timestamps, Versioned, Model, SoftDelete> { }   // keep the row as DeletedAt
public sealed class Invoice : Aggregate<Guid, Timestamps, Versioned> { }   // no Model, no Deletable: methods only, never deleted
public sealed class Ticket : Aggregate<Guid, PerTenant> { }                // a TenantId on the root, a filter — docs/multi-tenancy.md
public sealed class OrderLine : Entity<Guid, Timestamps> { }               // a child takes only a timestamp trait

// Who is signed in, from a static factory or anything with no constructor to inject into:
OwnerId = Current.RequiredUserId,                   // Current.UserId is null when nobody is
using (Tenant.Use(tenantId)) { /* … */ }           // work as one tenant; Tenant.Across() spans them
using (Tenant.Use(42)) { /* … */ }                 // a numbered tenant: `PerTenant<int>` on the base
builder.Services.AddRaskTenant(sp => …);            // the tenant from the request instead of the claim (Guid?, int?, long?)
b.HasIndex(d => d.Name).IsUnique("Already taken.");   // a collision fails as a RaskValidationException, not a DbUpdateException
b.HasMany<Note>().WithOne().HasForeignKey(n => n.TrailerId)
 .OnDelete(DeleteBehavior.Restrict, "Still on a note.");   // so does deleting a row that relationship references
b.WhenReferenced("Still in use.");                    // …and one any other table references (unmapped ones too)

// One row by id to SHOW it (a ProductRead), or the aggregate to CHANGE it — both skip soft-deleted rows:
var one = await Product.Where(p => p.Id == id).First(CancellationToken);
var agg = await Product.Find(id);   // Product? — whole, untracked
agg!.Rename("Anvil");
await agg.Save();                     // only what changed; stale Version throws

// Ranked full-text search — builder.HasFullTextSearch(p => new { p.Name, p.Description }) in static Configure, then:
var hits = await Product.Search(query).Take(20);

// Read and write it — no context injected; or send a command whose handler does the save:
var products = await Product.Where(p => p.Price > 0).OrderBy(p => p.Name);
var product  = await Product.Create(model);               // ProductModel from a form
var edit     = product.ToModel();                                                               // fills an edit form
await Product.Update(id, edit, p => p.Touch(now));        // + values not from the form
await Product.Delete(id, db: db);                        // join a context you hold
await dispatcher.Send(new EditProduct { Id = id, Name = name, Version = version }, CancellationToken);
var page     = await Product.OrderBy(p => p.Name).Skip(20).Take(20);  // a table's page, in SQL

// Cache a query for the session, from Render — refetched by itself after any Product write (Rask.Query):
var count = QueryClient.Query(QueryKey.For<Product>("count"), () => Product.Count());
count.IsLoading ? Ui.Text.Role("status")["Loading…"] : Text($"{count.Data} products")
var save = QueryClient.Command();                    // pending/error state for a write; save.IsPending disables Save

// Cache an expensive read; invalidate on write:
var products = await Cache.Remember("products", Load).For(10.Minutes);
await Cache.Forget("products");

// Keep an upload, then link to it — Url does no I/O, so it is safe inside Render:
var saved = await Files.Save(file.OpenReadStream, file.Name, file.Size).Public();
Img.Src(Files.Url(saved.Id)).Alt(product.Name)

// Enqueue work off the request thread — returns as soon as the row is written:
await Jobs.Enqueue(new SendOrderReceipt(order.Id));

// Send mail — queued, retried, and the same line inside a job or a durable handler:
await Mail.Send(Email.To(order.Email).Subject("Your receipt").Body(Receipt.OrderId(order.Id)));

// Say something happened — from a page, a handler, a job or a hosted service:
await Dispatcher.Publish(new OrderShipped(order.Id));

// In a test, each battery has a fake that records, and runs what it recorded when asked:
using var jobs = Jobs.Fake();   using var mail = Mail.Fake();
await jobs.Run();               mail.Sent().To("ann@example.com").Once();

Full reference → the rask CLI · Learn it in order → Tutorial · How do I…? → Recipes

Confirm