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

Recipes

Task-first answers to "how do I do X in an app I already have?" Each recipe is the shortest path — the command, the one wiring line, and where to go deeper. The Tutorial teaches these in order on one app; this page is the lookup. Keep the Cheat sheet open alongside.

I want to… Jump to
add a CRUD feature to a database I already have ↓
relate two entities (one-to-many, many-to-many) ↓
keep deleted rows, or forbid deleting ↓
add a search box over a table ↓
know who is signed in, from anywhere ↓
keep each customer's data apart ↓
cache a query for the page, refreshed after writes ↓
require a login to reach a page ↓
run work off the request thread ↓
send a transactional email ↓
cache an expensive query ↓
keep a file a user uploads ↓
publish a domain event durably ↓
harden SQLite for production ↓
deploy, and redeploy ↓
test a feature ↓

Add a feature to an existing database

Declare the aggregate under Features/Orders/ and its pages beside it. Nothing goes on the context — no DbSet, no configuration class: RaskDbContext maps every aggregate you declare, and the build generates — from the traits on its base — its form model (OrderModel), read face (OrderRead, queried as Order.Where(…)), Order.Find(id) + order.Save(), and writes (Order.Create(model)).

public sealed class Order : Aggregate<Guid, Timestamps, Versioned, Model, Deletable>
{
    [MaxLength(40)]
    public string Reference { get; private set; } = "";
}
rask db add AddOrder && rask db update

→ Reference: Rask.Data · Learn it: Tutorial Ch 3

Another aggregate is referred to by its id, never held — a navigation from one aggregate to another is a build error (RASK087). The read face infers the join from the id's name:

public Guid CustomerId { get; private set; }            // on Order

var mine = await Order.Where(o => o.Customer.Country == "HU");   // navigation, inferred

A part of the aggregate — an order's lines — is an Entity<TId> kept in a private list and changed only through the root; loading the root by id loads it whole. Many-to-many between aggregates is a list of ids.

→ Reference: children · the read face · Learn it: Tutorial Ch 3

Keep deleted rows, or forbid deleting

Deletable on the base generates a delete that removes the row. Name another trait, or none, to say otherwise:

public sealed class Order : Aggregate<Guid, Timestamps, Versioned, Model, SoftDelete> { }  // Order.Delete stamps DeletedAt; reads hide the row
public sealed class Order : Aggregate<Guid, Timestamps, Versioned, Model> { }              // no Order.Delete at all — cancel it instead

Then rask db add OrderDeletes && rask db update. Order.IgnoreQueryFilters() brings soft-deleted rows back into a read.

→ Reference: choosing what a table carries

Declare which text is searchable in the aggregate's static Configure, add a migration, and search the read face — ranked, word-aware and accent-insensitive, on SQLite, in the browser and on PostgreSQL:

public static void Configure(EntityTypeBuilder<Product> builder) =>
    builder.HasFullTextSearch(p => new { p.Name, p.Description });   // then: rask db add ProductSearch && rask db update

var hits = await Product.Search(query).Take(20);
await Product.Search(query).Skip(20).Take(20);                   // best match first, paged in SQL

→ Reference: full-text search

Know who is signed in

Current answers from anywhere — a static factory, a command handler, an API endpoint, a job — with nothing injected:

public static Note Create(string text) => new() { Id = Guid.CreateVersion7(), Text = text, OwnerId = Current.RequiredUserId };

Current.UserId is null when nobody is signed in, and Current.RequiredUserId throws. A component that must re-render on sign-in injects IUserProvider instead.

→ Reference: Current

Keep each customer's data apart

One trait partitions a table by tenant; every read filters to the signed-in user's tenant and every create stamps it:

public sealed class Invoice : Aggregate<Guid, Timestamps, Versioned, Model, Deletable, PerTenant> { }

using (Tenant.Use(tenantId)) { /* an admin working in one tenant */ }

→ Reference: multi-tenancy

Cache a query for the page

QueryClient.Query caches a read for the session, deduplicates it and refetches it in the background. Keyed by the aggregate, it refreshes itself after any write to that aggregate — no invalidation to write:

var count = QueryClient.Query(QueryKey.For<Product>("count"), () => Product.Count());

→ Reference: Rask.Query · Learn it: Tutorial Ch 2

Require login on a page

Two gates, use both: [Authorize] at the route (redirects anonymous deep-links to /login), and the Authorize component to hide UI that anonymous users shouldn't see.

[Authorize]                                    // route-level; from Microsoft.AspNetCore.Authorization
public sealed partial class CreateProduct : Component { … }

Authorize[ NewProductButton() ]              // rendered only for signed-in users
Authorize.Role("admin")[ DeleteProductButton(product.Id) ]

The login page itself is already there: /login, /register and /logout are built in, and you replace any of them by declaring your own page at the same route.

→ Reference: authentication · Learn it: Tutorial Ch 3

Run work off the request thread

Write a job record and handler, add one registration + its table, then enqueue. The enqueue returns as soon as the row is written, so the request finishes immediately; a background processor runs it at-least-once.

public sealed record SendOrderReceipt(Guid OrderId) : IJob;
builder.Services.AddRaskJobs<ProductsDbContext>(o => { /* … */ });   // needs AddRaskCqrs()
modelBuilder.AddRaskJobs();                                          // then: rask db add AddJobs && rask db update
await Jobs.Enqueue(new SendOrderReceipt(order.Id));                  // or: .In(24.Hours)

→ Reference: background jobs · Learn it: Tutorial Ch 4

Send a transactional email

Write an email whose body is a Rask component, add the mail queue, then send. Delivery happens off the request thread over SMTP with backoff.

builder.Services.AddRaskMail<ProductsDbContext>(o => { /* SMTP … */ });
modelBuilder.AddRaskMail();                                          // then: rask db add AddMail && rask db update

→ Reference: transactional email · Learn it: Tutorial Ch 5

Cache an expensive query

One registration + one table, then remember the read and forget it on write.

builder.Services.AddRaskCache<ProductsDbContext>();
modelBuilder.AddRaskCache();                                         // then: rask db add AddCache && rask db update

var products = await Cache.Remember("products", LoadProducts).For(10.Minutes);
await Cache.Forget("products");                                      // when the catalog changes

→ Reference: cache · Learn it: Tutorial Ch 6

Keep an uploaded file

Save the IRaskFile from the picker's handler with Files.Save, keep the returned id on your entity, and link to it. A RaskApp already has storage on; a hand-wired host adds the registration, the table and the routes.

builder.Services.AddRaskStorage<ProductsDbContext>();
modelBuilder.AddRaskStorage();                                       // then: rask db add AddStorage && rask db update
app.MapRaskStorage();                                                // after app.MapRask<App>()

var saved = await Files.Save(file.OpenReadStream, file.Name, file.Size).Public();
product.SetPhoto(saved.Id);
Img.Src(Files.Url(saved.Id)).Alt(product.Name)                       // no I/O — safe inside Render

Files on the default disk provider are archived by rask db backup beside the database, but not by Litestream or snapshots; use S3 or Azure for uploads you can't afford to lose.

→ Reference: file storage · The picker: HTTP & files

Publish a domain event through the outbox

A handler that must not be lost is durable: its outbox row is written in the same transaction as your data, and it runs after the commit (crash-safe, at-least-once). Raise the event from the aggregate, and let the handler choose. A RaskApp has the outbox on already:

[LocalOnly] public sealed record OrderCreated(Guid Id) : IEvent;   // then Raise(new OrderCreated(Id)) in Create

public sealed class NotifyWarehouse : IDurableHandler<OrderCreated> { … }   // via the outbox, retried
public sealed class RefreshTotals : IEventHandler<OrderCreated> { … }       // in memory, at once

→ Reference: outbox · Learn it: Tutorial Ch 7

Turn on production SQLite

UseRaskSqlite is a drop-in for .UseSqlite that installs the pragma interceptor (WAL, foreign_keys, busy_timeout on every open). Add Litestream for continuous off-box backup.

.UseRaskSqlite(sp)                         // was .UseSqlite("Data Source=app.db"); reads Rask:ConnectionStrings:App
builder.Services.AddRaskSqliteLitestream();   // reads Rask:Litestream — set ReplicaUrl to turn it on

→ Reference: production SQLite · Learn it: Tutorial Ch 8

Deploy and redeploy

rask deploy builds your Docker image on the server, runs it behind a shared Caddy proxy with automatic HTTPS, and does a health-gated zero-downtime swap. The first run on a bare box also sets it up (Docker, a non-root deploy user, firewall, SSH hardening).

rask deploy --host root@your-box.example.com --domain shop.example.com   # first time
rask deploy                                                             # after: host/domain remembered
rask deploy --github-actions                                            # write .github/workflows/deploy.yml

Needs a Dockerfile — rask new writes one.

→ Reference: deployment · Learn it: Tutorial Ch 11

Test a feature

Add a sibling <Project>.Tests project and test the slice directly — the domain rules on the entity, and a SQLite round-trip through the real DbContext:

[Fact]
public void Create_sets_the_fields()
{
    var product = Product.Create("Desk", 249m, inStock: true);

    Assert.Equal("Desk", product.Name);
    Assert.NotEqual(Guid.Empty, product.Id);
}

→ Reference: testing


Command reference → the rask CLI · One-page reference → Cheat sheet · Learn it in order → Tutorial

Confirm