# Marreta Lang — full reference for AI agents Marreta is a DSL for REST APIs. This file concatenates the full documentation guide in reading order. It is generated; do not edit by hand. --- # Quickstart This is the five-minute tour: install the runtime, scaffold a project, and hit a live endpoint. By the end you will have a running API and know where everything lives. ## Prerequisites - A terminal on macOS, Linux, or Windows via WSL. - `curl`, to install the runtime and to call the running API. ## 1. Install ```bash curl -fsSL https://raw.githubusercontent.com/tm-dev-lab/marreta-lang/main/install.sh | sh ``` That drops a single `marreta` binary on your `PATH`. Check it: ```bash marreta --version ``` ## 2. Scaffold a project ```bash marreta init hello cd hello ``` `init` writes a tiny but complete project: an entrypoint, one schema, one task, one route, and a test. ``` hello/ app.marreta # project metadata (name, version, required runtime) schemas/greetings.marreta tasks/greetings.marreta routes/greetings.marreta tests/greetings_test.marreta marreta.env # local config (gitignored) ``` The route it generates is the whole story in four lines: ```ruby route GET "/greetings" message = greetings.build_greeting("Marreta") reply 200 as GreetingResponse, { message: message } ``` `greetings.build_greeting` is a task defined in `tasks/greetings.marreta` and called by its file's namespace, with no imports. `reply 200 as GreetingResponse` shapes and validates the response against the schema. ## 3. Serve it ```bash marreta serve ``` The server comes up on port `8080` by default. In another terminal: ```bash curl http://localhost:8080/greetings ``` ```json { "message": "Hello, Marreta!" } ``` That is a real, schema-validated HTTP response, with no framework wiring and no boilerplate. ## 4. Run the tests The scaffold ships a scenario test that exercises the endpoint end to end: ```bash marreta test ``` Scenario tests live next to your code under `tests/` and read like the behavior they check (`when GET "/greetings"` … `then response is { ... }`), so they double as executable documentation. ## 5. Check the project's health ```bash marreta doctor ``` `doctor` loads the project the same way `serve` does and reports what it found (configuration, persistence, modules, test coverage) without starting the server. It is the fastest way to catch a misconfiguration before you deploy. ## Result checkpoint You should now have Marreta installed, a scaffolded `hello` project, and a running API that answers `GET /greetings` with a schema-validated JSON response. `marreta test` passes and `marreta doctor` reports a healthy project. ## Where to go next - **[Validate a request payload](../how-to/validate-a-payload.md)**: contract the input to a route. - **[Persist data with local services](../how-to/use-local-services.md)**: add a database and read and write records. - **[Configuration](../reference/configuration.md)**: the `marreta.env` variables and what they control. You did not configure a database, a router, or a serializer to get here, and that is the point. Add capability only when you need it, and Marreta keeps the rest out of your way. --- # Save and read your first data The [Quickstart](quickstart.md) returned a greeting from memory. The natural next step is to keep data around. This tutorial builds a small notes API on the document store, which is the simplest way to persist in Marreta: you save a map and read it back, with no table to define and no migration to run. You will build two endpoints: - `POST /notes` saves a note. - `GET /notes/:id` reads one back. When you are ready for a relational database with typed columns and versioned schema changes, see [Build a relational API with migrations](relational-api-with-migrations.md). This page is the gentler starting point. ## Prerequisites - The [Quickstart](quickstart.md) finished, so `marreta init` and `marreta serve` are familiar. - Docker with Compose, to run the local document provider (Docker Desktop on macOS or Windows, or Docker Engine on Linux or Windows via WSL). ## 1. Scaffold a project with a document store ```bash marreta init notes --with doc cd notes ``` `--with doc` adds a `docker-compose.yml` with the document provider and a `marreta.env` already pointing at it. Start it and wait until it is ready: ```bash docker compose up -d --wait ``` ## 2. Validate the input The document store accepts whatever map you save, so there is no table or migration. You still want a schema for the request body, to reject malformed input. Put this in `schemas/notes.marreta`: ```ruby export schema NewNote title: string body: string ``` ## 3. Write the create route Create `routes/notes.marreta`. Validate the body, then `save` the note. `save` returns the stored record, including the `_id` the document store generates: ```ruby route POST "/notes" take payload as NewNote note = doc.notes.save({ title: payload.title, body: payload.body }) reply 201, { id: note._id, title: note.title } ``` `doc.notes` names the collection. It does not need to be declared anywhere. The first save creates it. ## 4. Write the read route Add this to the same `routes/notes.marreta`: a route to read one note back by its `_id`. If there is none, fail with a 404: ```ruby route GET "/notes/:id" note = doc.notes.find(params.id) require note else fail 404, "note not found" reply 200, note ``` ## 5. Test it A scenario test stubs the document calls with `given`, so it needs no running provider. Put it in `tests/notes_test.marreta`: `marreta test` only runs files under `tests/` whose names end in `_test.marreta`, so the suffix is required. ```ruby scenario "creates a note" given doc.notes.save(anything) returns { _id: "note-1", title: "First note" } when POST "/notes" with { title: "First note", body: "hello" } then status 201 scenario "reads a note back" given doc.notes.find("note-1") returns { _id: "note-1", title: "First note" } when GET "/notes/note-1" then status 200 ``` ```bash marreta test ``` ## 6. Run it Start the server: ```bash marreta serve ``` In another terminal, create a note and read it back using the returned `id`: ```bash curl -s -X POST http://localhost:8080/notes \ -H 'content-type: application/json' \ -d '{"title":"First","body":"hello"}' # example output: { "id": "", "title": "First" } # replace with the id returned above curl -s http://localhost:8080/notes/ # → { "title": "First", "body": "hello", "_id": "" } ``` The `_id` is generated by the document store, so the value you get will differ. ## Result checkpoint You should now have a running document provider and two endpoints: one that saves a note and returns its generated `id`, and one that reads a note by `id` or returns a 404. You did all of this with no table definition and no migration. ## Next steps - [Build a relational API with migrations](relational-api-with-migrations.md): the same idea with a typed, versioned relational schema. - [Validate a request payload](../how-to/validate-a-payload.md): go deeper on request contracts. - [`doc` namespace](../reference/namespaces/doc.md): the full set of document operations. --- # Build a relational API with migrations The [Quickstart](quickstart.md) served an in-memory greeting. This tutorial goes further: a small product catalog backed by a relational database. By the end you will have created a table from a schema, written a record through `db`, and read it back over HTTP. If you are new to persisting data, start with [Save and read your first data](save-and-read-data.md), which uses the document store and skips migrations entirely. This page is the relational version, with typed columns and versioned schema changes. You will build two endpoints: - `POST /products` creates a product. - `GET /products/:sku` reads one back. Follow the steps in order. Each block runs as shown. ## Prerequisites - The [Quickstart](quickstart.md) finished, so `marreta init` and `marreta serve` are familiar. - Docker with Compose, to run the local database provider (Docker Desktop on macOS or Windows, or Docker Engine on Linux or Windows via WSL). ## 1. Scaffold a project with a database ```bash marreta init catalog --with db cd catalog ``` `--with db` adds a `docker-compose.yml` with the database provider and a `marreta.env` already pointing at it. Start the database and wait until it is ready: ```bash docker compose up -d --wait ``` ## 2. Model the data A schema becomes a table by declaring `db: `. Create `schemas/catalog.marreta`: ```ruby export schema Product db: products id: integer sku: string name: string price: decimal ``` `schema` is the one modeling primitive in Marreta, so the same `Product` you store is also a response contract. The body a client sends is narrower, so add a payload schema for the request: ```ruby export schema NewProduct sku: string name: string price: decimal ``` ## 3. Create the table with a migration Declaring the schema models the table. It does not create it. Generate a migration from the schema and apply it: ```bash marreta migrate generate marreta migrate apply ``` `generate` writes a reviewable pair of SQL files under `migrations/` (an `up` and a `down`), and `apply` runs them against the database. Re-run both whenever you change a persistent schema. ## 4. Write the create route Create `routes/catalog.marreta`. Validate the body with the payload schema, save the row, and reply with the stored product shaped by `Product`: ```ruby route POST "/products" take payload as NewProduct product = db.products.save({ sku: payload.sku, name: payload.name, price: payload.price }) reply 201 as Product, { id: product.id, sku: product.sku, name: product.name, price: product.price } ``` `save` returns the persisted record, including the `id` the database generated. ## 5. Write the read route Add a route to fetch one product by `sku`. Open the table, narrow it with `where`, and take the first match. If there is none, fail with a 404: ```ruby route GET "/products/:sku" product = db.products >> where(sku: params.sku) >> fetch_one require product else fail 404, "product not found" reply 200 as Product, { id: product.id, sku: product.sku, name: product.name, price: product.price } ``` ## 6. Test it Before running the server, write a scenario test. Scenario tests run your route logic in memory and fast, without starting the database provider. Because of that, you declare what each external call returns with `given`, so the test stays self-contained. Put this in `tests/catalog_test.marreta`: ```ruby scenario "create a product" given db.products.save(anything) returns { id: 1, sku: "tutorial-sku", name: "Widget", price: "9.90" } when POST "/products" with { sku: "tutorial-sku", name: "Widget", price: "9.90" } then status 201 scenario "read the product back" given db.products.fetch_one() returns { id: 1, sku: "tutorial-sku", name: "Widget", price: "9.90" } when GET "/products/tutorial-sku" then status 200 ``` ```bash marreta test ``` Both scenarios pass. The route's validation, save, and shaping all ran, with the database call answered by your `given`. To learn the testing model in full, see [Test your API](../how-to/test-your-api.md). ## 7. Run it Start the server: ```bash marreta serve ``` In another terminal, create a product and read it back: ```bash curl -s -X POST http://localhost:8080/products \ -H 'content-type: application/json' \ -d '{"sku":"book-1","name":"Notebook","price":"12.50"}' # example output: { "id": , "sku": "book-1", "name": "Notebook", "price": "12.50" } curl -s http://localhost:8080/products/book-1 # the same product, read back by its sku curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/products/missing # → 404 ``` The `id` is assigned by the database, so the exact value depends on what is already stored. ## Result checkpoint You should now have a running database provider, a `products` table created by a committed migration, and two endpoints: one that persists a product and returns it with a generated `id`, and one that reads a product by `sku` or returns a 404. You modeled the data once, as a schema, and used it for both the table and the response contract. ## Next steps - [Persist data with local services](../how-to/use-local-services.md): the reusable recipe for `db`, plus query composition and troubleshooting. - [Validate a request payload](../how-to/validate-a-payload.md): go deeper on request contracts. - [`db` namespace](../reference/namespaces/db.md): the full set of query and write operations. --- # Make it event-driven [Process work asynchronously with a queue](../how-to/async-work-with-a-queue.md) handed work to a single consumer. A **topic** goes further: it broadcasts an event to any number of subscribers. This is publish and subscribe (pub/sub), and it is how you let several parts of a system react to the same thing without knowing about each other. In this tutorial an order is placed, your route publishes one `order_placed` event, and two independent subscribers react to it: one sends a confirmation, the other records analytics. ## Prerequisites - The [Quickstart](quickstart.md) finished, and ideally [Process work asynchronously with a queue](../how-to/async-work-with-a-queue.md). - Docker with Compose, to run the local messaging provider (Docker Desktop on macOS or Windows, or Docker Engine on Linux or Windows via WSL). ## 1. Scaffold with the messaging provider ```bash marreta init shop --with queue cd shop docker compose up -d --wait ``` ## 2. Model the event Put the event shape in `schemas/orders.marreta`: ```ruby export schema Order id: integer total: decimal ``` ## 3. Publish the event Create `routes/orders.marreta`. The route validates the order, publishes an event, and returns right away. It does not know or care who is listening: ```ruby route POST "/orders" take payload as Order topic.publish "order_placed" as Order, payload reply 202, { published: true } ``` Publishing `as Order` shapes the event to the schema before it goes out, so every subscriber receives the same well-formed contract. ## 4. Subscribe to the event Add two subscribers for the same topic. Each one receives every `order_placed` event, independently. This is the difference from a queue, where only one consumer would get each message: ```ruby on topic "order_placed" take event as Order log.info("EMAIL: confirmation for order #{event.id}") on topic "order_placed" take event as Order log.info("ANALYTICS: recorded order #{event.id}") ``` Each subscriber takes the event `as Order`, so it validates the incoming event against the same schema the publisher used. The contract holds on both ends, and a malformed event is rejected rather than handed to your code. A subscriber that finishes without error acknowledges the event. A runtime error rejects it without requeue, and you can reject explicitly with `nack` (or `nack requeue` to retry). See [ack and nack](../how-to/async-work-with-a-queue.md#acknowledge-and-reject) for the full semantics. The producer route and the subscribers can live in the same project, or the subscribers can live in entirely separate services. The publisher does not change either way. ## 5. Test it Write a scenario test for the publisher. Stub the publish with `given`, so it asserts the route's behavior without a running provider: ```ruby scenario "publishes an order_placed event" given topic.publish "order_placed", anything returns true when POST "/orders" with { id: 7, total: "19.90" } then status 202 scenario "rejects a malformed order" when POST "/orders" with { total: "19.90" } then status 422 ``` ```bash marreta test ``` The publisher is testable this way. The subscribers are not: they run asynchronously, triggered by messages rather than HTTP requests, so a scenario test (which drives routes with `when`) does not reach them. You verify the pub/sub delivery by running the app, next. ## 6. Run it Start the server: ```bash marreta serve ``` In another terminal, place an order: ```bash curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/orders \ -H 'content-type: application/json' \ -d '{"id":7,"total":"19.90"}' # → 202 ``` The request returns `202` immediately. In the server log, both subscribers react to the single event: ```text ... "EMAIL: confirmation for order 7" ... "ANALYTICS: recorded order 7" ``` One publish, two reactions. Add a third subscriber and it joins in without touching the publisher. ## Result checkpoint You should now have a route that publishes an `order_placed` event and returns `202`, and two subscribers that both react to each event. You have seen the pub/sub difference from a queue: a topic delivers every event to every subscriber. ## Next steps - [Process work asynchronously with a queue](../how-to/async-work-with-a-queue.md): point-to-point work, where one consumer handles each message. - [Providers](../concepts/providers.md): the messaging provider behind `topic` and `queue`. - [Configuration](../reference/configuration.md): the `MARRETA_QUEUE_*` and `MARRETA_TOPIC_EXCHANGE` variables. --- # Run Marreta in a container This tutorial packages a Marreta project as a container image and runs it three ways: with Docker, with Docker Compose, and on Kubernetes. The app is stateless on purpose, so the focus stays on containerizing and running the service. Adding a database or other provider is a later step, linked at the end. ## Prerequisites - You have finished the [Quickstart](quickstart.md), so the `marreta` CLI is installed on your machine. - Docker is installed and running, with Docker Compose available (it ships with Docker Desktop and the current Docker CLI as `docker compose`). - For the Kubernetes section, a cluster you can reach with `kubectl`. This tutorial assumes the cluster already exists and does not set one up. ## Create the project Scaffold a fresh project with the CLI you installed in the Quickstart: ```bash # Create a new project named hello-container marreta init hello-container # Move into the project directory cd hello-container ``` The scaffold already includes a working route, `GET /greetings`, that returns a JSON greeting. That is all you need to containerize. ## 1. Build and run with Docker Marreta does not publish a container image yet, so you build your own. The image downloads the Linux runtime binary from the latest GitHub release in a first stage, then copies just that binary into a clean second stage along with your project. The two stages keep the download tooling out of the final image. Create a file named `Dockerfile` in the project root: ```dockerfile # Stage 1: download the Marreta runtime binary for Linux FROM ubuntu:24.04 AS build RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* RUN curl -fsSL https://github.com/tm-dev-lab/marreta-lang/releases/latest/download/marreta-linux-x86_64 \ -o /marreta \ && chmod +x /marreta # Stage 2: the runtime image with your project FROM ubuntu:24.04 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=build /marreta /usr/local/bin/marreta WORKDIR /app COPY . /app/ EXPOSE 8080 CMD ["marreta", "serve"] ``` The asset `marreta-linux-x86_64` is the Intel and AMD build. On an arm64 host or cluster, use `marreta-linux-arm64` instead. To pin a version rather than tracking the latest release, replace `releases/latest/download` with `releases/download/v0.2.0`, using the release tag you want. Now build the image and run it: ```bash # Build the image and tag it hello-container:local docker build -t hello-container:local . # Run it, mapping the container port 8080 to the same port on your machine docker run --rm -p 8080:8080 hello-container:local ``` In another terminal, call the route: ```bash # The scaffolded route returns a JSON greeting curl localhost:8080/greetings # {"message":"Hello, Marreta!"} ``` Stop the container with `Ctrl+C`. As your project grows, add a `.dockerignore` so local files like `marreta.env` stay out of the image, since configuration belongs to each environment (see [Configure environment variables](../how-to/configure-environment.md)). ### Alternative: download the binary yourself If you would rather fetch the binary outside the build, for example to cache or scan it first, download it next to the `Dockerfile`: ```bash # Download the Linux runtime binary into the project curl -fsSL https://github.com/tm-dev-lab/marreta-lang/releases/latest/download/marreta-linux-x86_64 -o marreta # Make it executable chmod +x marreta ``` Then use a single-stage `Dockerfile` that copies the binary you already downloaded instead of fetching it: ```dockerfile FROM ubuntu:24.04 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY marreta /usr/local/bin/marreta WORKDIR /app COPY . /app/ EXPOSE 8080 CMD ["marreta", "serve"] ``` Build and run it the same way as above. ## 2. Run with Docker Compose Compose is handy once you add dependencies later. This step runs the image you built in section 1, so make sure that `docker build` finished successfully first. Create a file named `compose.yaml` in the project root, next to the `Dockerfile`: ```yaml services: api: image: hello-container:local ports: - "8080:8080" ``` Bring it up, call it, and tear it down: ```bash # Start the service in the background, using the image you built above docker compose up -d # Call the route curl localhost:8080/greetings # {"message":"Hello, Marreta!"} # Stop and remove the service docker compose down ``` ## 3. Run on Kubernetes This section assumes you already have a cluster and that `kubectl` is pointed at it. A cluster cannot see an image that exists only on your machine, so first make the image available in the way your cluster expects: - A local cluster usually has a command to load a local image into it. Check your local Kubernetes tool's documentation for how to load an image built on your machine. - A remote cluster pulls from a registry. Push the image to a registry your nodes can read, then use that full image name in the manifest below. Describe a deployment and a service in a file named `k8s.yaml`. The image is `hello-container:local`, the local image you made available above, and `imagePullPolicy: IfNotPresent` tells a node to skip pulling when it already has that image. On a single-node local cluster that is all you need. On a multi-node cluster, the image has to be present on every node that might run the pod, so load it on each one or push it to a registry and use that image name here instead: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: hello-container spec: replicas: 2 selector: matchLabels: app: hello-container template: metadata: labels: app: hello-container spec: containers: - name: api image: hello-container:local imagePullPolicy: IfNotPresent ports: - containerPort: 8080 --- apiVersion: v1 kind: Service metadata: name: hello-container spec: selector: app: hello-container ports: - port: 80 targetPort: 8080 ``` Apply it and wait for the rollout to finish: ```bash # Create the deployment and service kubectl apply -f k8s.yaml # Wait until both replicas are running kubectl rollout status deployment/hello-container # deployment "hello-container" successfully rolled out ``` Reach the service with a port-forward, then call it: ```bash # Forward local port 8081 to the service's port 80 kubectl port-forward svc/hello-container 8081:80 ``` ```bash # In another terminal, call the route through the forward curl localhost:8081/greetings # {"message":"Hello, Marreta!"} ``` Both replicas serve the same route. In a real cluster you would expose the service through an Ingress or a load balancer rather than a port-forward. ## Result checkpoint You scaffolded a project, built one image from it, and ran that same image with Docker, Docker Compose, and Kubernetes, reaching the `/greetings` route each way. ## Next steps - [Persist data with local services](../how-to/use-local-services.md): add a database or other provider, which is where Compose and Kubernetes start to earn their keep. - [Configure environment variables](../how-to/configure-environment.md): set `MARRETA_*` variables and secrets per environment, including inside a container. --- # Install the editor extension The MarretaLang extension brings syntax highlighting, completion, hover, go-to-definition, diagnostics, and formatting to `.marreta` files. It is a thin client over the `marreta` CLI: the editor features call the binary under the hood, so you install the binary first and the extension second. ## 1. Install the Marreta CLI ```bash curl -fsSL https://raw.githubusercontent.com/tm-dev-lab/marreta-lang/main/install.sh | sh ``` This installs the `marreta` binary. Confirm it is on your `PATH`: ```bash marreta --version ``` If the command is not found, add the install location to your `PATH`, or note the full path for step 3. Windows is supported through WSL: run the same command inside your WSL distribution. ## 2. Install the extension ### From a release VSIX (works today, every editor) Every extension release attaches a `.vsix` file. Download the latest one from the [GitHub releases](https://github.com/tm-dev-lab/marreta-lang/releases) (the `VS Code Extension vscode-vX.Y.Z` entries), then install it. The command palette works the same in every editor and does not need a CLI on your `PATH`: 1. Open the command palette: `Ctrl+Shift+P` (macOS: `Cmd+Shift+P`). 2. Run **Extensions: Install from VSIX**. 3. Select the `.vsix` you downloaded. This is the path for VS Code, Cursor, VSCodium, and Windsurf alike. If you prefer the command line and have the editor's CLI on your `PATH`, the equivalent is `code --install-extension marreta-extension.vsix` (Cursor: `cursor --install-extension ...`). ### From the marketplace Once MarretaLang is published to the registries, you will be able to install it by name from the Extensions view, without downloading a file: - **VS Code** pulls from the Visual Studio Marketplace. Search for **MarretaLang** and install. - **Cursor**, **VSCodium**, and **Windsurf** pull from Open VSX. Search for **MarretaLang** and install. ## 3. Point the extension at the binary (only if needed) The extension calls `marreta` on your `PATH` by default. If the binary lives somewhere else, or the extension reports that the CLI was not found, point it at the binary in your editor settings. The quickest way is the settings UI: open the command palette (`Ctrl+Shift+P`, macOS `Cmd+Shift+P`), run **Preferences: Open Settings (UI)**, search for **Marreta: Path**, and enter the full path to the binary. To edit the JSON directly instead, run **Preferences: Open User Settings (JSON)** from the same palette and add: ```json { "marreta.path": "/absolute/path/to/marreta" } ``` The same setting exists at the workspace level (a `.vscode/settings.json` in the project), if you want the path to apply to one project rather than your whole editor. Open a `.marreta` file and the editor features activate. If anything looks off, run [`marreta doctor`](../reference/cli.md) to check your setup. --- # Configure environment variables Marreta reads its runtime configuration (the server port, provider hosts, credentials, feature flags) from `MARRETA_*` environment variables. Locally you keep them in a `marreta.env` file. In production you set real environment variables. This page explains how the two relate and how to keep secrets out of git. ## Prerequisites - A scaffolded project (`marreta init hello`). - The [Quickstart](../tutorials/quickstart.md) finished. ## marreta.env and marreta.env.example `marreta init` writes two files: - **`marreta.env`** holds the real values your machine uses, including secrets. It is **gitignored**, so it never leaves your machine. - **`marreta.env.example`** is a committed template. It lists the same keys with placeholder values, so a teammate knows what to set without ever seeing a secret. The difference is only the secret values. For a database, `marreta.env` has the real password: ```bash MARRETA_DB_PROVIDER=postgres MARRETA_DB_HOST=127.0.0.1 MARRETA_DB_PORT=5432 MARRETA_DB_NAME=marreta MARRETA_DB_USER=marreta MARRETA_DB_PASSWORD=marreta ``` while `marreta.env.example` has a placeholder: ```bash MARRETA_DB_PASSWORD=change-me ``` A new contributor copies the example to `marreta.env` and fills in the real values. ## How a value is resolved When more than one source sets the same key, Marreta uses this order, highest to lowest: 1. A CLI flag, such as `--port`. 2. A process environment variable. 3. The `marreta.env` file. 4. The built-in default. A process environment variable always wins over `marreta.env`. For example, with `MARRETA_PORT=8080` in `marreta.env`: ```bash MARRETA_PORT=9091 marreta serve # the server starts on port 9091, not 8080 ``` This is exactly how production works: you do not ship a `marreta.env` file, you set the real environment variables in your platform, and they take effect because they outrank the file. ## Read a value in your code Configuration is also available to your code through the `env` namespace. `env.NAME` returns the value of an environment variable, read from `marreta.env` with process variables overriding it, as a string: ```ruby route GET "/config/region" region = env.APP_REGION or "us-east-1" reply 200, { region: region } ``` A variable that is not set reads as `null`, so `or` supplies a default and `require` can demand one before the route runs: ```ruby require env.STRIPE_KEY else fail 500, "STRIPE_KEY is not configured" ``` Values always come back as strings. Auth providers in [Secure your API](secure-your-api.md) read their secrets with the same `env.NAME` form, which is how secrets stay out of the source and in your environment. ## Local services versus external providers In a fresh project, `marreta.env` points at the local Docker providers on `127.0.0.1` (see [Persist data with local services](use-local-services.md)). To use a managed or remote provider instead, set the same keys to the real endpoint: ```bash MARRETA_DB_HOST=db.internal.example.com MARRETA_DB_PORT=5432 MARRETA_DB_NAME=catalog_prod MARRETA_DB_USER=catalog MARRETA_DB_PASSWORD=... ``` Locally you can put these in `marreta.env`. In production, set them as real environment variables through your platform's secret management, so no credentials are written to a file at all. ## Keep secrets out of git - Never commit `marreta.env`. The scaffold already gitignores it. - Commit `marreta.env.example` with placeholders, so the required keys are documented without exposing values. - In production, prefer real environment variables over a file, so secrets live in your platform's secret store. ## Use it in CI and CD A pipeline has no `marreta.env` (it is gitignored and never checked out with secrets). Set the `MARRETA_*` values as environment variables in the pipeline instead, reading secrets from the platform's secret store. Because process environment variables outrank the file, the project runs unchanged. A deploy or test step looks like this: ```bash export MARRETA_DB_HOST=db.internal.example.com export MARRETA_DB_USER=catalog export MARRETA_DB_PASSWORD="$DB_PASSWORD" # injected from the CI secret store marreta migrate apply marreta test ``` Reference secrets as pipeline variables (for example `$DB_PASSWORD` above), never as literals in the workflow file, and never echo them to the log. ## Try it ```bash # marreta.env sets MARRETA_PORT=8080, but the process env var wins: MARRETA_PORT=9091 marreta serve ``` The startup line reports `http://0.0.0.0:9091`. ## Result checkpoint You should now understand that `marreta.env` holds local values (and is gitignored), `marreta.env.example` is the committed template, and real environment variables override the file, which is how you configure a deployed app. ## Common pitfalls - **A committed secret.** If `marreta.env` is tracked, your credentials are in git history. Keep it ignored and use `marreta.env.example` for sharing keys. - **A change to `marreta.env` does not take effect.** A process environment variable with the same name overrides the file. Unset it, or change it instead. - **A provider is configured but unreachable.** Run `marreta doctor` to see whether the host and port actually resolve. ## Next steps - [Providers](../concepts/providers.md): what the `MARRETA__PROVIDER` variables select. - [Configuration reference](../reference/configuration.md): every `MARRETA_*` variable and what it controls. - [Persist data with local services](use-local-services.md): the provider variables in context. --- # Persist data with local services Most APIs need somewhere to store data. Marreta talks to a database, a cache, a document store, and a message queue through built-in namespaces, and you do not pick a client or write connection code. This guide adds a database, through its provider, to a project, runs it locally, and reads and writes records. Throughout, **local services** are the containers your project runs against, and a **provider** is the configurable backend for a namespace (see [Providers](../concepts/providers.md)). The schema and route snippets below are taken from the project's tested example suite. ## Prerequisites - Docker with Compose, to run the local database provider. - The [Quickstart](../tutorials/quickstart.md) finished, so `marreta init` and `marreta serve` are familiar. ## Scaffold with a database Pass `--with db` to `marreta init` and the scaffold includes everything the database needs: a `docker-compose.yml` with the database provider, and a `marreta.env` already pointing at it. ```bash marreta init shop --with db cd shop ``` You can ask for more than one service at once, as in `--with db,cache,queue,doc`. ## Start the local services ```bash docker compose up -d --wait ``` `--wait` blocks until the containers are healthy, so the next command finds a database ready to accept connections. The connection details Marreta uses live in `marreta.env`: ```bash MARRETA_DB_PROVIDER=postgres MARRETA_DB_HOST=127.0.0.1 MARRETA_DB_PORT=5432 MARRETA_DB_NAME=marreta MARRETA_DB_USER=marreta ``` This file is local configuration and is gitignored. The committed `marreta.env.example` documents the same keys without secrets. ## Make a schema persistent A schema becomes a table by declaring `db:
`. The fields below the declaration are the columns, and `id` is the primary key: ```ruby export schema Product db: products id: integer sku: string name: string initial_stock: integer current_stock: integer reserved_stock: integer low_stock_threshold: integer ``` `schema` is the one modeling primitive in Marreta. The same keyword that describes an API contract also describes a table, so adding `db:` makes `Product` persistent without giving up its role as a contract. That said, the body a client sends is rarely the whole row. Keep a separate schema for the request body. It gives you free input validation (see [Validate a request payload](validate-a-payload.md)) and exposes only the fields a client should set: ```ruby export schema SeedProductRequest sku: string name: string initial_stock: integer low_stock_threshold: integer ``` ## Create the table with a migration Declaring `db: products` models the table, but it does not create it. Marreta never changes your database silently at server boot. Instead, you generate a reviewable migration from the schema and apply it. `migrate generate` compares your persistent schemas against the database and writes a pair of SQL files (an `up` and a `down`) under `migrations/`: ```bash marreta migrate generate ``` Read the generated `up` file if you want to see exactly what will run, then apply it: ```bash marreta migrate apply ``` `marreta migrate status` shows whether anything is pending, and `marreta migrate rollback` reverts the last applied migration using its `down` file. The migration files are meant to be committed, so every environment evolves the same way. Run `migrate generate` again whenever you change a persistent schema, and a new migration captures the delta. ## Write a row The `db` namespace exposes each table by name. There is no query builder to import and no ORM to configure. Validate the body with the payload schema, then `save` the row. `save` returns the persisted record, including its generated `id`: ```ruby route POST "/inventory/seed" take payload as SeedProductRequest product = db.products.save({ sku: payload.sku, name: payload.name, initial_stock: payload.initial_stock, current_stock: payload.initial_stock, reserved_stock: 0, low_stock_threshold: payload.low_stock_threshold }) reply 201, { seeded: true, sku: product.sku, stock: product.current_stock, threshold: product.low_stock_threshold } ``` ## Read a row For a single record, open the table and narrow it with `>> where(...)`, then take the first match with `>> fetch_one`: ```ruby route GET "/inventory/:sku" product = db.products >> where(sku: params.sku) >> fetch_one require product else fail 404, "product not found" reply 200, product ``` ## Compose queries Steps after `>>` accumulate clauses, and nothing runs until a terminal step. `fetch` returns the full list, `fetch_one` the first row, and `count` an integer: ```ruby route GET "/db/pipeline/fetch" rows = db.items >> fetch reply 200, { items: rows } route GET "/db/pipeline/fetch_one" row = db.items >> where(active: true) >> order_by("id asc") >> fetch_one reply 200, { item: row } ``` See the [`db` namespace reference](../reference/namespaces/db.md) for the full set of steps and terminals. ## Try it ```bash docker compose up -d --wait marreta migrate generate marreta migrate apply marreta serve & curl -s -X POST http://localhost:8080/inventory/seed \ -H 'content-type: application/json' \ -d '{"sku":"abc","name":"Widget","initial_stock":100,"low_stock_threshold":10}' # → { "seeded": true, "sku": "abc", "stock": 100, "threshold": 10 } ``` Before you commit, confirm the project is wired correctly without starting the server: ```bash marreta doctor ``` `doctor` loads the project, reports the configured persistence, and tells you if a provider is unreachable. ## Result checkpoint You should now have a running database provider, a `products` table created by a committed migration, and routes that write and read rows through `db.products`. A `POST /inventory/seed` returns the persisted record, and `marreta doctor` reports the database as configured and reachable. ## Troubleshooting - **`docker compose up` exits but `serve` cannot connect.** Without `--wait`, the containers may still be starting. Re-run with `--wait`, or give them a moment. - **`Connection refused` on serve.** The host or port in `marreta.env` does not match the running container. Compare `MARRETA_DB_PORT` against `docker compose ps`. - **`relation "products" does not exist` at runtime.** You declared `db: products` but never ran the migration. Run `marreta migrate generate` and `marreta migrate apply` before serving, and re-run them after every schema change. - **The runtime refuses to load the project.** Check the `requires_marreta` line in `app.marreta` against `marreta --version`. A project can demand a newer runtime than the one installed. - **A field is missing from the saved row.** Only fields declared under the schema's `db:` line are persisted. Add the column to the schema. ## Next steps - [`db` namespace](../reference/namespaces/db.md): the full set of query and write operations. - [Configuration](../reference/configuration.md): every `MARRETA_DB_*` variable and what it controls. - [Validate a request payload](validate-a-payload.md): reject bad input before it reaches the database. --- # Evolve your database with migrations A persistent schema (one with `db:`) models a table, but Marreta never changes your database on its own. Instead, you work in two clear steps: - `marreta migrate generate` compares your schemas against the database and writes reviewable SQL. It does **not** touch the database. - `marreta migrate apply` runs that SQL. It is the **only** step that changes the database. Keeping those steps separate means every schema change lands as a reviewed, committed migration rather than a silent surprise at server boot. ## Prerequisites - A project with a persistent schema and a running database. See [Persist data with local services](use-local-services.md). - The [Quickstart](../tutorials/quickstart.md) finished. ## Generate a migration from your schema Given this persistent schema: ```ruby export schema Product db: products id: integer sku: string name: string price: decimal ``` Generate a migration: ```bash marreta migrate generate ``` This writes a pair of SQL files under `migrations/`, an `up` (apply) and a `down` (revert). For the schema above, the `up` file is plain, readable SQL: ```sql CREATE TABLE products ( id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL, sku TEXT NOT NULL, name TEXT NOT NULL, price NUMERIC NOT NULL ); ``` And the `down` file reverses it: ```sql DROP TABLE products; ``` Nothing has changed in the database yet. These files are meant to be committed, so every environment applies the same change. ## Review before you apply Because `generate` only writes files, you can read the `up` file and confirm it does what you expect before anything runs. This is the review step. Commit the migration alongside the schema change. ### Hand-written SQL A migration file is plain SQL, so you can write one by hand when the schema surface does not cover what you need (an index, a data backfill, a CHECK constraint). `apply` runs it like any other migration. When `diff` and `generate` later replay your migrations to work out the current schema, they tolerate the statement classes that cannot change the table and column model and skip them during that replay: - index statements (`CREATE INDEX`, `CREATE UNIQUE INDEX`, `DROP INDEX`), - data statements (`INSERT`, `UPDATE`, `DELETE`, and a `WITH ...` query), - anything you mark with a `-- marreta: skip-replay` line directly above the statement (the general escape valve for schema-neutral SQL such as `CREATE EXTENSION` or `GRANT`). What stays rejected is column-mutating DDL the replay cannot derive a schema from (`ALTER TABLE ... DROP COLUMN`, `ALTER COLUMN ... TYPE`, `DROP TABLE`, renames). Express those as a schema edit plus a generated migration, or, if a statement is genuinely schema-neutral, put the `-- marreta: skip-replay` marker above it. For example: ```sql -- marreta: skip-replay CREATE EXTENSION IF NOT EXISTS pgcrypto; ``` ## Apply the migration ```bash marreta migrate apply ``` This runs the pending `up` files in order. It is the single step that mutates the database: ```text Applied 20260605_183253_create_products ``` Afterward, `marreta migrate status` reports a clean state: ```text Applied: 20260605_183253_create_products Pending: none Database migration state is clean. ``` ## See what is pending `marreta migrate status` compares your migrations against the database and groups them by state: ```bash marreta migrate status ``` ```text Applied: none Pending: 20260605_183253_create_products Changed: none Missing local: none Suggested actions: - apply: marreta migrate apply - discard: marreta migrate discard 20260605_183253 ``` `marreta migrate list` shows the same migrations as a compact table: ```bash marreta migrate list ``` ```text VERSION NAME STATE 20260605_183253 create_products pending ``` Run these before `apply` to know exactly what will change. ## Preview and explain a state `marreta migrate diff` compares your current `db:` schemas against the schema your local migration files already describe, and prints the SQL a new migration would contain. It does not read or change the database. It is a dry run of `generate`. When a schema has a change no migration captures yet, `diff` shows the planned operations: ```bash marreta migrate diff ``` ```text Planned migration operations: 0 tables to create, 1 column to add, 0 foreign keys to add ALTER TABLE products ADD COLUMN description TEXT NOT NULL; ``` When the migrations already capture every schema, there is nothing to plan: ```text Database schema is up to date. ``` Where `diff` looks at your schemas versus your migration files, `status` and `list` look at your migration files versus what is applied in the database. When a migration is in a state you do not recognize, `marreta migrate explain ` describes it and tells you what to do. The states are `pending`, `changed`, `missing_local`, and `workflow`: ```bash marreta migrate explain pending ``` ```text State: pending Meaning: The migration exists locally in migrations/, but has not been applied to this database. Recommended actions: - apply it: marreta migrate apply - discard the local pending migration: marreta migrate discard ``` `marreta migrate explain workflow` prints the full state machine, from a new migration through applied, rolled back, and discarded. ## Evolve a schema When you change a persistent schema, generate again. Add a column: ```ruby export schema Product db: products id: integer sku: string name: string price: decimal in_stock: boolean ``` ```bash marreta migrate generate marreta migrate apply ``` The new migration captures only the delta (an `ALTER TABLE` adding `in_stock`), so your history is a readable trail of how the table evolved. ### Changes migrations do not generate Migrations are additive by design: `generate` writes new tables, new columns, and new foreign keys, never destructive or in-place changes. When you make a change it cannot express, such as changing a column's type or nullability, deleting a field, or removing a schema entirely, `diff` and `generate` do not silently ignore it. They report it as drift and leave it for you to handle by hand: ```text Unsupported changes detected (migrations are additive-only, handle manually): products.price: type differs (history NUMERIC, schema BIGINT) products.old_sku: present in history, no longer in any schema ``` No migration is written for these. Apply the change yourself with a hand-written migration (see "Hand-written SQL" above) once you have decided how to handle the existing data. ## Undo the last migration ```bash marreta migrate rollback ``` `rollback` runs the most recent migration's `down` file, reverting that change: ```text Rolled back 20260605_183253_create_products ``` ## Discard a pending migration If you generated a migration that is wrong and have **not** applied it yet, discard it and generate again. (Editing a generated file by hand is fine when you know the SQL you want, see "Hand-written SQL" above, but for a wrong generated migration discarding and regenerating keeps the history aligned with the schema): ```bash marreta migrate discard ``` This deletes the local `up` and `down` files for that version (for example `20260605_191139`). Use it only for a migration that is still pending. To undo a migration that is already applied, use `rollback` instead. ## Try it ```bash docker compose up -d --wait marreta migrate generate marreta migrate status marreta migrate apply ``` ## Result checkpoint You should now have a `migrations/` folder with a reviewable `up` and `down` pair, an applied table in your database, and a clear sense that `generate` only writes SQL while `apply` is the only command that changes the database. ## Troubleshooting - **`relation "products" does not exist` at runtime.** You generated a migration but never applied it. Run `marreta migrate apply`. - **You need to change an applied migration.** Do not edit it in place. Change the schema and `generate` a new migration, so every environment applies the same ordered history. - **An unapplied migration is wrong.** Discard it with `marreta migrate discard` and generate again, rather than hand-editing. - **`unsupported statement '...'` from `diff` or `generate`.** A migration file contains column-mutating DDL the replay cannot derive a schema from (a `DROP COLUMN`, an `ALTER COLUMN ... TYPE`, a `DROP TABLE`, a rename). Either express the change as a schema edit plus a generated migration, or, if the statement is genuinely schema-neutral, put a `-- marreta: skip-replay` line directly above it. A hand-written `CREATE INDEX` or backfill does not hit this, those classes are tolerated automatically (see "Hand-written SQL"). ## Next steps - [Persist data with local services](use-local-services.md): the `db` namespace you are creating tables for. - [Configure environment variables](configure-environment.md): point migrations at the right database. --- # Use transactions When a request makes more than one database write that must succeed or fail as a unit, wrap them in a `transaction` block. Every `db` operation inside the block runs on one connection, commits together when the block finishes, and rolls back as a whole if anything goes wrong. ## Write several records atomically Put the writes inside `transaction`. If the block completes, all of them commit: ```ruby route POST "/orders" take payload transaction a = db.items.save({ name: payload.first, active: true }) b = db.items.save({ name: payload.second, active: false }) reply 201, { a_id: a.id, b_id: b.id } ``` The values created inside the block (`a`, `b`) are available after it, so you can use the generated ids in the response. ## Rollback happens on any error You do not commit or roll back by hand. The block commits when it finishes normally and rolls back when anything inside it raises, including a database failure, a `fail`, or a failed `require`. So a guard inside the transaction undoes the writes that ran before it: ```ruby route POST "/orders" take payload transaction order = db.orders.save({ customer: payload.customer }) require payload.items else fail 422, "an order needs at least one item" db.line_items.save({ order_id: order.id, sku: payload.sku }) reply 201, { id: order.id } ``` If `payload.items` is empty, the `fail` rolls back the `db.orders.save` that already ran, so no orphan order is left behind. ## Broadcast is not allowed inside a transaction A transaction is sequential by definition, so `*>>` ([broadcast](../concepts/broadcast.md)) inside a `transaction` block is a runtime error. Parallel branches and a single atomic connection are mutually exclusive. Keep fan-out work outside the transaction. ## Test it A scenario test drives the route with mocked database responses, so you assert the behavior without a real database: ```ruby scenario "commits both saves in a transaction" given db.items.save({ name: "a", active: true }) returns { id: 1, name: "a", active: true } given db.items.save({ name: "b", active: false }) returns { id: 2, name: "b", active: false } when POST "/orders" with { first: "a", second: "b" } then status 201 then response is { body: { a_id: 1, b_id: 2 } } ``` This checks the route's logic and the database calls it makes, not a real rollback in the database, since the mocks always return what you tell them. The rollback itself is exercised against a live database in the project's own test suite. ## When to use it - Use a transaction when two or more writes have to agree, like an order and its line items. - Skip it for a single write, which is already atomic on its own. - Keep the block small. Long transactions hold a connection and a lock for longer, so do slow work (outbound calls, queue publishing) before or after, not inside. ## Notes - Transactions need a relational provider. See [`db`](../reference/namespaces/db.md) and [Persist data with local services](use-local-services.md). - Every operation in the block shares one connection, which is what makes the commit and rollback atomic. --- # Read request inputs A route reads its inputs with `take`. There are five input sources, each can be bound raw (a plain map or string) or, for the body / query / headers, validated and coerced against a schema. This page covers every variation and, just as important, how you *read* each result, because the access pattern differs by source. ## The five inputs | `take` | Binds | Reads as | | --- | --- | --- | | `take payload` | JSON request body | a map (`payload.field`, nestable) | | `take query` | query-string parameters | a map of strings | | `take headers` | request headers | a map of strings | | `take form` | form-encoded body (`application/x-www-form-urlencoded`) | a map of strings | | `take raw` | the unparsed request body | a single string | `take raw` exists for the cases where there is no structure to parse: a webhook whose signature you verify over the exact bytes, a plain-text or non-JSON body, or a payload you forward verbatim. It hands you the body as a string and does nothing else. Bind the headers alongside it when you need them (`take raw, headers`): ```ruby route POST "/webhooks/stripe" take raw, headers signature = headers["stripe-signature"] or fail 401, "missing signature" reply 200, { received: true, bytes: raw.length() } ``` ## Raw vs schema-validated Each binding is independent: bind it raw, or add `as ` to validate and coerce it. A schema-bound body / query / headers is checked before the route runs, an invalid value returns a `422`, and the fields appear in the generated OpenAPI. A raw bind does none of that, it just hands you the values. ```ruby # raw: a map of strings, no validation, undocumented route GET "/search" take query term = query.term or "none" # schema-bound: validated, coerced, documented schema ProductSearch term: string limit?: integer route GET "/products" take query as ProductSearch reply 200, { term: query.term, limit: query.limit or 20 } ``` `take raw` and `take form` are always raw, they do not take a schema. The body, query, and headers do. ## One take or many: inline and multi-line Write the bindings one of two ways. Do not mix them in the same route. **Inline** — a single `take` on the route line, bindings comma-separated. Good for one input or a few: ```ruby route POST "/products/search" take query as ProductSearch, payload as NewItem, headers as ApiHeaders reply 200, { ok: true } ``` **Multi-line** — one `take` per indented line, before any logic. Clearer with several: ```ruby route POST "/products/search" take query as ProductSearch take payload as NewItem take headers as ApiHeaders reply 200, { ok: true } ``` A binding can be raw or schema-bound regardless of layout, and the two can be mixed in one route (`take query as ProductSearch, payload` binds a typed query and a raw body). What you cannot do is put a `take` on the route line *and* an indented `take` below: a route is fully inline or fully multi-line, so its input contract is read in one place. ## How to read each input The declaration is half the story. How you reach a value depends on the source. ### Payload A map, accessed by field with `.`, and it nests: ```ruby route POST "/orders" take payload as NewOrder sku = payload.item.sku ``` ### Query — names match exactly Query parameter names are matched **exactly** (case-sensitive, no name rewriting), because query strings have no canonical naming convention the way headers do. - **Raw:** the key is the parameter name as sent. Use `.` for an identifier-shaped name, and the `["..."]` subscript for anything with a hyphen or other non-identifier character: ```ruby route GET "/search" take query term = query.term # ?term=... full = query["complete-name"] # ?complete-name=... (subscript: has a hyphen) ``` - **Schema-bound:** the schema field name must equal the parameter name exactly, and you read it by that field name. Because a field name is a snake_case identifier, it can only bind a parameter whose name is also a valid identifier (`limit`, `complete_name`). A parameter literally named `complete-name` or `Complete-Name` cannot be bound by a schema, use the raw `take query` and a subscript for those. ```ruby schema Search term: string complete_name?: string route GET "/search" take query as Search name = query.complete_name # binds ?complete_name=... exactly ``` > Heads up: query matching is exact. Declaring a field `complete_name` does **not** > capture `?complete-name=` or `?Complete-Name=` — those stay reachable only through the > raw `take query` subscript. (Headers are different, see below.) ### Headers — normalized name, with a convention Header names are case-insensitive by the HTTP standard, so Marreta normalizes them. - **Raw:** the key is the header name **lowercased**. A hyphenated name needs the subscript; a simple name can use `.`: ```ruby route GET "/secure" take headers token = headers["x-auth-token"] # X-Auth-Token arrives lowercased, hyphen -> subscript auth = headers.authorization # simple name -> dot ``` > Heads up: the raw key is always **lowercased**, so the subscript must be lowercase. > `headers["x-auth-token"]` works; `headers["X-Auth-Token"]` does **not** (it returns > nothing). The `.` form works only for a simple name with no hyphen (`headers.authorization`), > because the key after lowercasing has to be a valid identifier. This is the opposite of > **query**, where the raw key keeps its original case (`query["Complete-Name"]`). - **Schema-bound:** a field maps to a header by a convention, case-insensitive with `_` and `-` treated as the same. So the field `x_auth_token` captures `X-Auth-Token`, `x-auth-token`, etc., and you read it by the field name: ```ruby schema ApiHeaders x_auth_token?: string route GET "/secure" take headers as ApiHeaders token = headers.x_auth_token # captures X-Auth-Token by convention ``` This is the key difference from query: a header schema bridges `Title-Case-Hyphenated` wire names to snake_case fields (correct, because headers are case-insensitive), while a query schema matches exactly (correct, because query names are case-sensitive). ### Coercion (schema-bound query and headers) Query and header values arrive as text. A schema coerces each to its declared type, a value that cannot be coerced is a `422`, and a missing required field is a `422`: - `limit: integer` turns `"20"` into `20`; `"abc"` is a 422. - a boolean accepts only `true` or `false`. - a `list of ` field is fed by a repeated key (`?tags=a&tags=b` -> `["a", "b"]`). - an empty value (`?term=`) is treated as absent. A schema bound to query or headers must be **flat**: scalar fields and lists of scalars only, never a nested object or a list of objects (those belong to the body). See [Validate a request payload](validate-a-payload.md) and [Schemas](../concepts/schemas.md). --- # Validate a request payload You want a route that accepts JSON and rejects anything malformed, whether a missing field or a wrong type, before your logic runs. In Marreta Lang you do this by attaching a schema to the request. There is no validation library to wire up and no guard clauses to write for type checks: the schema *is* the contract. Every snippet below is taken from the project's tested example suite, so it behaves exactly as shown. ## Prerequisites - A scaffolded project (`marreta init hello`). - The [Quickstart](../tutorials/quickstart.md) finished, so routes and `reply` are familiar. ## Describe the shape A schema lists the fields you expect and their types: ```ruby export schema ItemPayload name: string active: boolean ``` Both fields are required. To make one optional, give it a trailing `?`. More on that below. ## Attach it to the route Use `take as ` on the route line. Marreta validates the incoming body against the schema *before* the route body runs: ```ruby route POST "/contracts/request-only" take payload as ItemPayload reply 200, { received: payload.name, active: payload.active } ``` If the request is missing `name`, or sends `active` as text, Marreta returns **422 Unprocessable Entity** automatically and your code never executes. Inside the route, `payload` is already typed and safe to use. This page is about the request. To contract the response as well, by shaping it and stripping extra fields, see [Shape a response](shape-a-response.md). ## The schema also documents the request Marreta generates an OpenAPI (Swagger) document from your routes. Binding the body `as ` makes the request appear there as a named, typed component with its required fields, and it documents the automatic 422. A bare `take payload` with no schema still accepts a body, but the request shows up as a free-form, untyped object. For a quick prototype, an unbound body is fine. For a product whose clients depend on a stable contract, we recommend binding the request `as `. ## Optional and nested fields Mark a field optional with `?`. A field can also be typed as another schema, which validates the nested object too: ```ruby export schema Address street: string city: string zipcode: string export schema ContactPayload name: string email: string age?: integer address: Address ``` `ContactPayload` accepts a request with or without `age`, but always requires a well-formed `address`. ## Validate business rules, not just shape A schema covers structure. For rules it cannot express, such as "billing is required" or "items must be present", use `require ... else fail`: ```ruby route POST "/doc/orders" take payload as OrderPayload require payload.billing else fail 400, "billing address is required" require payload.items else fail 400, "items are required" ``` Schema validation returns 422. Your own `fail` returns whatever status you give it. Use the schema for *what the data is*, and `require` for *what your domain allows*. ## Try it ```bash marreta serve & # A well-formed request is accepted: curl -s -X POST http://localhost:8080/contracts/request-only \ -H 'content-type: application/json' \ -d '{"name":"Alice","active":true}' # → { "received": "Alice", "active": true } # A malformed one is rejected with 422, before your code runs: curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/contracts/request-only \ -H 'content-type: application/json' \ -d '{"name":"Alice"}' ``` The second call prints `422`, because `active` is missing. ## Result checkpoint You should now have a route that accepts a typed request body, rejects malformed input with an automatic 422 before your logic runs, and exposes that contract in the generated OpenAPI document. ## Common pitfalls - **A required field is absent.** Fields without `?` reject a missing value with 422. If a field is genuinely optional, mark it with `?` and handle the absent case (for example with `match` or `payload.field or default`). - **422 vs. 400.** Type and presence failures are validation (422) and are automatic. Reserve `fail 400` for your own business rules. ## Validate query and headers too The same schema mechanism validates query-string and header inputs, with `take query as ` and `take headers as `. Those values arrive as text, so they are coerced to the declared types (a non-numeric integer, or a missing required field, is a 422), and they appear named and typed in the generated OpenAPI: ```ruby schema ProductSearch term: string limit?: integer tags?: list of string route GET "/products" take query as ProductSearch reply 200, { term: query.term, limit: query.limit or 20, tags: query.tags or [] } ``` A repeated key (`?tags=a&tags=b`) feeds a `list of ` field. A schema bound to query or headers must be **flat** (scalars and lists of scalars, no nested objects); a header field maps to its header name case-insensitively, with `_` and `-` treated as the same (`request_id` matches `Request-Id`). Without a schema, `take query` / `take headers` still give a raw map of strings. For every `take` variation (raw vs schema, inline vs multi-line) and how to read each input — including the exact-match rule for query and the lowercased keys for raw headers — see [Read request inputs](read-request-inputs.md). ## Next steps - [Read request inputs](read-request-inputs.md): every `take` variation and how to read the body, query, and headers. - [Shape a response](shape-a-response.md): contract the response, not just the request. - [Types](../reference/types.md): every field type a schema can declare. --- # Shape a response Validating input is half of a contract. The other half is the response: what your API returns, in what shape, with which status code. This page shows how to shape a response with a schema, strip fields you do not want to leak, and choose the status code at runtime. The snippets are taken from the project's tested example suite, so they behave exactly as shown. ## Prerequisites - A scaffolded project (`marreta init hello`). - The [Quickstart](../tutorials/quickstart.md) finished, so `reply` is familiar. ## Reply with a schema `reply as , ` filters the body against the schema. Any field the schema does not declare is dropped, so internal values never reach the client. The request can be unvalidated and the response is still shaped: ```ruby export schema ItemResponse id: integer name: string active: boolean route POST "/contracts/response-only" take payload reply 200 as ItemResponse, { id: 1, name: payload.name, active: true, secret: "stripped" } ``` The `secret` field is not part of `ItemResponse`, so it never appears in the response. This is the safe default for any endpoint that builds its body from internal data. ## Schemas keep your OpenAPI contract precise Marreta generates an OpenAPI (Swagger) document from your routes. When you reply `as `, the response shows up in that document as a named, typed component with its required fields, reusable across endpoints. A free-form `reply 200, { ... }` still produces a document, but the shape is inferred from the literal you return: an anonymous inline object, with no name and weaker typing. For a quick prototype, a free-form reply is fine. For a product whose clients depend on a stable contract, we recommend always replying `as `, so the documented API matches what you actually return. ## A type error in the body is caught Shaping is also validation. If the body provides a field with the wrong type for the schema, the response fails rather than sending malformed JSON. Build the body from values that match the declared types, and `reply as` guarantees the contract holds on the way out. ## Choose the status code at runtime The status does not have to be a literal. Any expression that evaluates to a status works, so you can compute it and reply once: ```ruby route GET "/response/dynamic_status" code = 202 reply code, { accepted: true } ``` This keeps a route with several outcomes to a single exit point instead of duplicating the body under each branch. ## Reply with HTML or text By default `reply` sends `application/json`. For other content types, name it after `reply`: ```ruby route GET "/page" reply html 200, "

Marreta

" route GET "/ping" reply text 200, "pong" ``` ## Try it ```bash marreta serve & curl -s -X POST http://localhost:8080/contracts/response-only \ -H 'content-type: application/json' \ -d '{"name":"Alice"}' # → { "id": 1, "name": "Alice", "active": true } ``` The `secret` field never appears, because the response is shaped by `ItemResponse`. ## Result checkpoint You should now have a route that returns a schema-shaped JSON body with extra fields stripped, a route that selects its status code at runtime, and routes that reply with HTML or plain text. ## Next steps - [Validate a request payload](validate-a-payload.md): contract the input as well as the output. - [Handle errors](handle-errors.md): return failure responses with the right status. --- # Test your API Scenario tests check your API's behavior by running a route and asserting the HTTP result. They run your real route logic (validation, control flow, shaping) in memory, without starting a server or any provider, so they are fast and deterministic. They live under `tests/` and run with `marreta test`. `marreta test` discovers scenario files by a name convention: a file must be under `tests/` and end in `_test.marreta` (for example `notes_test.marreta`). A file in `tests/` without that suffix is ignored and its scenarios never run, so name the file accordingly when you create one. The examples below use the `/greetings` route from `marreta init` and a small `/notes` endpoint backed by the document store, which needs no migration. ## Prerequisites - A scaffolded project with at least one route (`marreta init hello`). - The [Quickstart](../tutorials/quickstart.md) finished, so `marreta test` is familiar. ## Write a scenario A scenario issues a request with `when` and asserts the outcome with `then`. The scaffold ships one: ```ruby scenario "reads a greeting" when GET "/greetings" then response is { status: 200, body: { message: "Hello, Marreta!" } } ``` `when` names the verb and path (add `with { ... }` to send a JSON body). `then response is` matches the status and body. Run the file: ```bash marreta test ``` ## Given, when, then A scenario reads like a behavior, in the Given-When-Then style popularized by BDD (Behavior-Driven Development) tools such as Cucumber and its Gherkin language: - **`scenario`** names the behavior under test, such as "creates a note". - **`given`** sets up the preconditions, such as stubbing an external call (shown below). - **`when`** performs the action, an HTTP request to one of your routes. - **`then`** asserts the outcome, the response status and body. This is the same Arrange-Act-Assert structure that unit and integration tests use, written as readable steps: `given` is Arrange, `when` is Act, `then` is Assert. Marreta scenarios are deliberately API-focused. The action is always an HTTP request to a route, and the assertions are about the HTTP response, so you test your API the way a client sees it. The syntax is Marreta's own, not a port of another framework. ## A route to test The remaining examples test a small notes endpoint, backed by the document store so there is no migration to run. Add a schema and two routes: ```ruby export schema NewNote title: string body: string route POST "/notes" take payload as NewNote note = doc.notes.save({ title: payload.title, body: payload.body }) reply 201, { id: note._id, title: note.title } route GET "/notes/:id" note = doc.notes.find(params.id) require note else fail 404, "note not found" reply 200, note ``` ## Assert the failure paths Test the rejections as well as the happy path. A schema validation failure happens before your route body runs, so it needs nothing else: ```ruby scenario "rejects a malformed note" when POST "/notes" with { title: "no body" } then status 422 ``` `body` is missing, so the request never reaches your logic and the scenario sees a 422. ## Stub external calls with `given` A scenario does not connect to a document store, database, cache, queue, or HTTP service. Instead, you declare what each external call returns with `given`, which keeps the test self-contained. A route that saves and returns a note is tested like this: ```ruby scenario "creates a note" given doc.notes.save(anything) returns { _id: "note-1", title: "First note", body: "hello" } when POST "/notes" with { title: "First note", body: "hello" } then status 201 ``` The route's validation, save, and shaping all run for real. Only the document store answer comes from your `given`. `anything` matches any argument, so you do not have to restate the exact map you saved. A read stubs the same way: ```ruby scenario "reads a note back" given doc.notes.find("note-1") returns { _id: "note-1", title: "First note", body: "hello" } when GET "/notes/note-1" then status 200 ``` The `db` namespace stubs identically, with `given db.
.(...)`. The mock is strict in both directions, which keeps your tests honest: - An external call the route makes with **no matching `given`** fails the scenario as an unconfigured call. You cannot accidentally hit a real provider. - A `given` the route **never calls** fails too, as an unused given. A stub that does not match reality is a bug in the test. ## What scenario tests do not do Scenario tests verify route logic and contracts, not the store itself. They do not check that a real query returns what you expect or that a migration applied. For that end-to-end confidence, run the server against the local services and send real requests, as in [Build a relational API with migrations](../tutorials/relational-api-with-migrations.md) and [Persist data with local services](use-local-services.md). The two layers are complementary: scenarios for fast logic and contract checks, live requests for real integration. ## Try it ```bash marreta test ``` Every scenario in a `*_test.marreta` file under `tests/` runs, and the command exits non-zero if any fails. ## Result checkpoint You should now be able to write a scenario that asserts a route's status and body, test a validation failure with no setup, and test a route that persists data by stubbing the call with `given`. ## Common pitfalls - **`unconfigured call: doc..(...)`.** The route made an external call you did not stub. Add a matching `given`, using `anything` for arguments you do not need to pin down. - **`unused given`.** You declared a `given` the route never reached. Remove it, or fix the scenario so the route actually makes that call. ## Next steps - [Validate a request payload](validate-a-payload.md): the validation that produces those 422s. - [Persist data with local services](use-local-services.md): persisting for real, beyond the stubs. --- # Cache expensive work The `cache` namespace stores short-lived values in a fast key-value store through the configured cache provider, so you do not repeat expensive work on every request. Use it for computed results, lookups from a slow upstream, counters, and other short-lived state. The namespace is the same whichever provider backs it (see [Providers](../concepts/providers.md)). ## Prerequisites - A project with a running cache provider (`marreta init shop --with cache`, then `docker compose up -d --wait`). See [Persist data with local services](use-local-services.md). - The [Quickstart](../tutorials/quickstart.md) finished. ## Set and get a value `cache.set(key, value)` stores a value, and `cache.get(key)` reads it back. `cache.get` returns `null` when the key is absent: ```ruby route POST "/cache/:key" take payload cache.set(params.key, payload.value) reply 200, { ok: true } route GET "/cache/:key" value = cache.get(params.key) require value else fail 404, "not cached" reply 200, { key: params.key, value: value } ``` ## Expire with a TTL Pass `ttl:` (seconds) so a value expires on its own. This is the normal way to use a cache, since stale entries clean themselves up: ```ruby route POST "/cache/:key" take payload cache.set(params.key, payload.value, ttl: 300) reply 200, { ok: true } ``` ## Cache expensive work The common pattern is cache-aside: read from the cache, and only on a miss build the value and store it. The `if` expression returns the cached value when present, and on a miss it builds the value, caches it, and returns it: ```ruby route GET "/greeting/:name" cached = cache.get(params.name) greeting = if cached cached else fresh = "Hello, #{params.name}!" cache.set(params.name, fresh, ttl: 60) fresh reply 200, { greeting: greeting } ``` `cache.set` lives in the miss branch, so a hit serves the cached value without re-writing it or renewing its TTL. The value here is trivial to keep the example focused. In practice it is something worth caching, such as a slow query or an [upstream call](call-an-external-api.md). ## Other operations - `cache.delete(key)` removes a key. - `cache.incr(key)` increments a numeric counter atomically. - `cache.set(key, value, only_if_absent: true)` stores only if the key is not already set, and returns whether it stored. - `cache.ttl(key)` returns the remaining seconds before a key expires. ## Test it A scenario test stubs the cache call with `given`, so it needs no running cache provider: ```ruby scenario "serves a cached value" given cache.get("greeting") returns "hello" when GET "/cache/greeting" then status 200 scenario "a cache miss is a 404" given cache.get("absent") returns null when GET "/cache/absent" then status 404 ``` See [Test your API](test-your-api.md) for the testing model. ## Try it ```bash docker compose up -d --wait marreta serve & curl -s -X POST http://localhost:8080/cache/greeting \ -H 'content-type: application/json' -d '{"value":"hello"}' curl -s http://localhost:8080/cache/greeting # → { "key": "greeting", "value": "hello" } ``` ## Result checkpoint You should now be able to store and read values, set a TTL so they expire, and use cache-aside to compute a value once and reuse it. ## Troubleshooting - **`cache.get` always returns `null`.** The cache may be unreachable, or the TTL already expired. Run `marreta doctor` to check the connection. - **A value never refreshes.** A TTL that is too long serves stale data. Lower the `ttl:` or `cache.delete` the key when the source changes. ## Next steps - [Configuration](../reference/configuration.md): the `MARRETA_CACHE_*` variables. - [Call an external API](call-an-external-api.md): a common source of values worth caching. --- # Call an external API The `http_client` namespace makes HTTP requests to other services. A response is an envelope with `.status`, `.body`, and `.headers`. A 4xx or 5xx is **not** an error in Marreta, so you decide how to handle it with `require`. ## Prerequisites - A scaffolded project (`marreta init hello`). - The [Quickstart](../tutorials/quickstart.md) finished, and familiarity with [Handle errors](handle-errors.md). ## Make a GET request Call `http_client.get(url)` and read the envelope. Guard the status before using the body, so an upstream failure becomes a clear response of your choosing: ```ruby route GET "/users/:id" response = http_client.get("https://api.example.com/users/#{params.id}") require response.status == 200 else fail 502, "user service failed" reply 200, response.body ``` String interpolation (`#{params.id}`) builds the URL from request data. ## Send a body For POST, PUT, and PATCH, pipe the body into the call with `>>`. It reads as "take this payload and post it": ```ruby route POST "/orders" take payload response = payload >> http_client.post("https://api.example.com/orders") require response.status == 201 else fail 502, "order service failed" reply 201, response.body ``` The same call without the pipeline passes the body as a second argument. The two forms are equivalent, so pick whichever reads better: ```ruby route POST "/orders" take payload response = http_client.post("https://api.example.com/orders", payload) require response.status == 201 else fail 502, "order service failed" reply 201, response.body ``` `put` and `patch` work the same way. `delete` takes no body, like `get`. ## Pass query parameters There are two ways to add a query string, and which one to use depends on the verb. For a GET (or DELETE), pipe a map of parameters into the call. The map becomes the query string, so this request hits `https://api.example.com/search?q=marreta&limit=5`: ```ruby route GET "/search" response = { q: "marreta", limit: 5 } >> http_client.get("https://api.example.com/search") reply 200, response.body ``` The `query:` named argument also adds a query string, works on any verb, and is the way to add one to a request that already has a body. On a POST the piped value is the body, so the query goes in `query:`: ```ruby route POST "/orders" take payload response = payload >> http_client.post("https://api.example.com/orders", query: { trace: "abc" }) reply 201, response.body ``` In short: for GET and DELETE, the piped map is the query. For POST, PUT, and PATCH, the piped value is the body, so reach for `query:` to add query parameters. `query:` works on every verb. ## Headers and timeout Pass `headers:` and `timeout:` (milliseconds) as named arguments: ```ruby route GET "/me" response = http_client.get("https://api.example.com/me", headers: { authorization: "Bearer #{env.API_TOKEN}" }, timeout: 5000) require response.status == 200 else fail 502, "profile service failed" reply 200, response.body ``` ## Handle upstream failures Because a 4xx or 5xx is a normal response, not a thrown error, you choose what each status means for your API. Guard with `require`, and map the upstream result to your own status: ```ruby route GET "/users/:id" response = http_client.get("https://api.example.com/users/#{params.id}") require response.status != 404 else fail 404, "user not found" require response.status == 200 else fail 502, "user service failed" reply 200, response.body ``` ## Test it A scenario test stubs the call with `given`, so it runs without a live upstream. This is the right way to test a route that calls a service: you control exactly what the service returns. ```ruby scenario "returns the upstream user" given http_client.get("https://api.example.com/users/42") returns { status: 200, body: { id: 42, name: "Ada" } } when GET "/users/42" then status 200 then response is { body: { id: 42, name: "Ada" } } scenario "maps an upstream failure to 502" given http_client.get("https://api.example.com/users/99") returns { status: 500, body: { error: "boom" } } when GET "/users/99" then status 502 ``` The `given` matches on the URL, plus the body for `post`, `put`, and `patch`. Query parameters and headers are not part of the match, so you stub a GET by its URL alone. See [Test your API](test-your-api.md) for the full testing model. ## Try it ```bash marreta test ``` The scenarios pass without a live upstream. Unlike a database or cache page, there is no real provider to curl here, so this page is verified with scenario tests rather than a live call. ## Result checkpoint You should now be able to call a service with `http_client`, read the `.status`/`.body` envelope, guard the status with `require`, and test the route without a live upstream by stubbing the call. ## Next steps - [Handle errors](handle-errors.md): map upstream failures to the right status. - [Cache expensive work](use-cache.md): cache slow upstream responses. --- # Process work asynchronously with a queue Some work does not need to finish before you answer the request: sending an email, resizing an image, calling a slow downstream. A queue lets a route hand that work off and return right away, while the work happens asynchronously. One route pushes a message, and a separate consumer processes it. This is point-to-point messaging through the configured messaging provider: each message is handled by one consumer. ## Prerequisites - A project with a running messaging provider (`marreta init app --with queue`, then `docker compose up -d --wait`). See [Persist data with local services](use-local-services.md). - The [Quickstart](../tutorials/quickstart.md) finished. ## Push a message `queue.push "", ` enqueues a message. The route returns immediately, typically with `202 Accepted`, because the work has been accepted but not yet done: ```ruby route POST "/signups" take payload queue.push "welcome_emails", payload reply 202, { accepted: true } ``` The client gets its response without waiting for the email to be sent. ## Consume the message An `on queue ""` handler receives each message and processes it asynchronously, separately from the request that pushed it: ```ruby on queue "welcome_emails" take msg log.info("sending welcome email to #{msg.email}") ``` The producer route and the consumer can live in the same project. The producer already answered the client with `202`. The consumer does the real work whenever the message arrives. ## Validate the message Add a schema on both ends to enforce the message shape. Declare it once: ```ruby export schema Signup email: string ``` The producer validates and strips the message to the declared fields, and the consumer rejects anything that does not match (the message is nacked, not delivered to your handler): ```ruby route POST "/signups" take payload as Signup queue.push "welcome_emails" as Signup, payload reply 202, { accepted: true } on queue "welcome_emails" take signup as Signup log.info("sending welcome email to #{signup.email}") ``` ## Acknowledge and reject A consumer acknowledges a message automatically when its handler finishes without error. Success means the message is done and removed, so you only step in when something is wrong: - A **runtime error** in the handler (`raise`, `fail`, or anything unhandled) nacks the message **without** requeue. Retrying is opt-in: if a failure is transient and worth another attempt, reject it explicitly with `nack requeue`. - A **schema mismatch** (the message does not match `take ... as `) nacks it without requeue and never reaches your handler, because a malformed message will not become valid on retry. - Reject explicitly with `nack` to discard a message, or `nack requeue` to put it back for another attempt, usually behind a guard: ```ruby on queue "welcome_emails" take signup as Signup require signup.email else nack log.info("sending welcome email to #{signup.email}") ``` | Handler outcome | Result | |---|---| | Finishes without error | Acknowledged and removed | | `nack` | Rejected, not requeued | | `nack requeue` | Rejected, requeued for retry | | Schema mismatch on arrival | Rejected, not requeued | | Runtime error (`raise`, `fail`, unhandled) | Rejected, not requeued | The only path that retries is an explicit `nack requeue`. Every other outcome either acknowledges (a clean run) or rejects without requeue, so retries never happen by accident. The same semantics apply to `on topic` subscribers. ## Test it A scenario test stubs `queue.push` with `given`, so it asserts the producer's response without a running provider. Because the consumer runs asynchronously, a scenario verifies the producer side (the `202`), and the full push-to-consume flow is exercised against the live provider: ```ruby scenario "enqueues a welcome email" given queue.push "welcome_emails", anything returns true when POST "/signups" with { email: "ada@example.com" } then status 202 then response is { body: { accepted: true } } ``` ## Try it ```bash marreta test ``` The producer scenario passes. To watch the consumer run, start the provider and the server (`docker compose up -d --wait`, then `marreta serve`) and push a message with `curl`. The handler processes it asynchronously. ## Result checkpoint You should now have a route that enqueues work and returns `202`, and a consumer that processes each message asynchronously, optionally validated by a schema. ## Next steps - [Handle errors](handle-errors.md): `nack` and guards in a consumer. - [Configuration](../reference/configuration.md): the `MARRETA_QUEUE_*` variables. - [Providers](../concepts/providers.md): the messaging provider behind `queue`. --- # Secure your API Securing a route has two parts, and Marreta keeps them distinct: - **Authentication** is who the caller is. You declare an auth provider and gate the route with `require auth `. A failure returns **401**. - **Authorization** is what the caller may do. You add `allow `. A failure returns **403**. Both checks run before your route body, and both build the same normalized `auth` context. Auth failures are standardized and never leak token contents or internal details. ## Prerequisites - A scaffolded project (`marreta init app`). - The [Quickstart](../tutorials/quickstart.md) finished. ## Authenticate with an API key An API key is the simplest provider and needs no identity provider. It is the recommended shape for internal service-to-service access, where a caller sends a fixed key in a header. Declare it with the header to read and a stored hash. You store only the hash, never the raw key, and it comes from a secret in the environment: ```ruby auth api_key main { header: "x-api-key" secret_hash: env.API_KEY_HASH principal: "api-user" } ``` `secret_hash` must be a hash, either `sha256:<64 hex chars>` or an Argon2id string, not the raw key. At request time the runtime hashes the incoming header and compares. The `principal` is who the caller is once the key checks out. Gate a route with `require auth`. Without a valid `x-api-key`, the request gets a 401 and your code never runs: ```ruby route GET "/me" require auth main reply 200, { subject: auth.subject } ``` ## Authenticate with a JWT A JWT lets a client present a signed token instead of a fixed key. The simplest form uses an HMAC shared secret that you control, with no identity provider and no key discovery. The same secret signs and validates the token, and the issuer and audience are checked on every request: ```ruby auth jwt tokens { issuer: "https://my-service" audience: "my-api" secret: env.JWT_SECRET algorithm: "HS256" } ``` Gate a route the same way as an API key: ```ruby route GET "/profile" require auth tokens reply 200, { subject: auth.subject, issuer: auth.claims.iss } ``` For tokens from an external identity provider (Auth0, Cognito, Keycloak, Okta, Entra ID, Google), drop the secret and give the issuer and audience. The runtime discovers the signing keys from the issuer (OIDC discovery): ```ruby auth jwt tokens { issuer: "https://issuer.example.com" audience: "my-api" } ``` Some providers want the JWKS endpoint pinned directly. This is a faithful Entra ID (Azure AD) provider, with placeholder public identifiers, and role-based authorization on a real route: ```ruby auth jwt entra_id { issuer: "https://sts.windows.net//" audience: "api://" jwks_url: "https://login.microsoftonline.com/common/discovery/keys" algorithm: "RS256" } route GET "/secure" require auth entra_id allow "marreta.validation" in auth.user.roles reply 200, { ok: true } ``` The issuer, audience, and JWKS URL are public identifiers, so they read clearly inline. Only true secrets (an API key hash or an HMAC secret) belong in `env.*`. For a partner's fixed PEM public key instead of OIDC or JWKS, use `public_key_pem_file` with `algorithm`. ## Read the caller A protected route gets a normalized `auth` context automatically. A public route has no `auth`. The fields are: - `auth.provider` is the provider name, and `auth.type` is `api_key` or `jwt`. - `auth.subject` and `auth.user.id` identify the caller (the principal for an API key, the `sub` claim for a JWT). - `auth.user.roles` holds the roles from a JWT. - `auth.claims` holds the raw token claims (`auth.claims.iss`, `auth.claims.aud`). ```ruby route GET "/whoami" require auth tokens reply 200, { provider: auth.provider, type: auth.type, subject: auth.subject, roles: auth.user.roles } ``` ## Authorize with allow Authentication got the caller in. Authorization decides what they may do. `allow ` asserts any boolean condition after `require auth`. If it is false, the request gets a 403 before your body runs. Authorize an API key by its principal: ```ruby route GET "/admin" require auth main allow auth.user.id == "api-user" reply 200, { ok: true } ``` Authorize a JWT by its roles, and combine conditions freely: ```ruby route GET "/reports" require auth tokens allow "analyst" in auth.user.roles or "admin" in auth.user.roles reply 200, { ok: true } ``` Roles come from the token, so role-based `allow` belongs with a `jwt` provider. An `api_key` provider authenticates a single principal and carries no roles, so you authorize it on `auth.user.id`. ## Scope data to the caller The `auth` context is ordinary data you can use in the route. The most common pattern is to scope results to the authenticated caller, so each user only sees their own records (this assumes an `orders` table, see [Persist data with local services](use-local-services.md)): ```ruby route GET "/my/orders" require auth tokens orders = db.orders >> where(owner: auth.subject) >> fetch reply 200, { items: orders } ``` ## Test it A scenario test stubs the provider with `given auth.`, so it runs without real credentials. For an API key the principal comes from the declaration, so the stub is empty. For a JWT, the stub supplies `sub` and `roles`. Omitting the `given` exercises the unauthenticated path: ```ruby scenario "api key authenticates" given auth.main returns {} when GET "/me" then status 200 scenario "missing credentials is 401" when GET "/me" then status 401 scenario "the principal is allowed" given auth.main returns {} when GET "/admin" then status 200 scenario "a jwt without the role is forbidden" given auth.tokens returns { sub: "user-1", roles: ["viewer"] } when GET "/reports" then status 403 scenario "a jwt with the role is allowed" given auth.tokens returns { sub: "user-1", roles: ["admin"] } when GET "/reports" then status 200 ``` ```bash marreta test ``` The scenarios pass without real credentials. The `given` provides the verified identity, so you test authentication and authorization logic without minting tokens. **Important:** a scenario test assumes an already-verified identity and exercises only your route logic (the `require` and `allow` decisions). It does **not** validate token cryptography, signatures, issuer, audience, or JWKS. That validation is the runtime's responsibility, and you should cover it with runtime or live authentication tests. `marreta test` is not a substitute for real token validation. ## Result checkpoint You should now be able to authenticate a caller with an API key or a JWT, read the caller from the `auth` context, and authorize with `allow`, with 401 for missing credentials and 403 for a failed rule. ## Common pitfalls - **`secret_hash` set to the raw key.** It must be `sha256:<64 hex chars>` or an Argon2id string. The project fails to load otherwise. - **A committed secret.** The hash and JWT secret come from `env.*`, so keep them in `marreta.env` (gitignored) or real environment variables, never in source. - **Role-based `allow` on an API key.** An API key has a principal but no roles, so `"role" in auth.user.roles` is always false. Authorize an API key on `auth.user.id`, and use a `jwt` provider for role-based rules. ## Next steps - [Configure environment variables](configure-environment.md): where the auth secrets live. - [Handle errors](handle-errors.md): the 401 and 403 responses. --- # Handle errors Real routes have to say no. A record is missing, input breaks an invariant, an external call fails. Marreta gives you three tools for this: `fail` for a deliberate HTTP error, `raise` for an unexpected condition, and `rescue` for recovering from a fallible operation. This page shows when to use each. The snippets are taken from the project's tested example suite, so they behave exactly as shown. ## Prerequisites - A scaffolded project (`marreta init hello`). - Familiarity with routes and tasks from the [Quickstart](../tutorials/quickstart.md). ## Fail with a chosen status `fail , ` ends the request with that HTTP status. You can fail outright, or guard a value with `require ... else fail`: ```ruby route GET "/errors/not_found" fail 404, "resource not found" route POST "/errors/guard" take payload require payload.name else fail 400, "name is required" require payload.name.length() > 2 else fail 422, "name too short" reply 200, { ok: true, name: payload.name } ``` Use `fail` when the status is part of your API contract: 404 for a missing resource, 400 or 422 for bad input, 502 when an upstream call fails. The guard reads as a plain sentence, and the route stops the moment the condition does not hold. ## Raise on an unexpected condition `raise ` is for conditions that should not happen. An uncaught `raise` reaches the client as HTTP 500: ```ruby route GET "/errors/raise" raise "boom" ``` `raise` can carry a condition, and it propagates out of tasks, so an invariant deep in your logic still surfaces at the route: ```ruby task validate_positive(n) raise "must be positive" if n <= 0 n route GET "/errors/raise-from-task" reply 200, { result: validate_positive(-1) } ``` ## fail or raise: HTTP layer or domain layer These two are not interchangeable. The cleanest way to choose is to ask which layer the error belongs to. `fail` belongs to the **HTTP layer**. You are deciding the response the client gets, and the status is part of your API contract. A missing resource is a 404, bad input is a 400, a failed upstream call is a 502. A `fail` is an expected, designed outcome of the route. `raise` belongs to your **business and domain layer**. It signals a condition that should not happen, such as a broken invariant or an unexpected state, and it is not tied to a status. It propagates up through tasks like an exception, and only when it reaches the route uncaught does it become a 500. Closer to where it happens you can catch it with `rescue` and turn it back into a designed outcome. A rule of thumb: if you can name the HTTP status the client should see, use `fail`. If you are protecting an invariant in your logic and the status is not the point, use `raise`, then decide at the edge (with `rescue`, or by letting it become a 500) how it should surface. ## Recover with rescue `rescue` catches a runtime error from a fallible operation and lets you continue. In block form it returns a fallback shape, with `error.code` and `error.message` available inside: ```ruby route POST "/errors/rescue" take raw result = raw >> json.parse() rescue { recovered: true, code: error.code } reply 200, result ``` `rescue` also has shorter forms. Substitute a fallback value: ```ruby val = risky("x") rescue "fallback" ``` Or convert the failure into a chosen HTTP status inside a pipeline: ```ruby result = "input" >> always_fails >> rescue fail 503, "rescued" ``` Reach for `rescue` around operations that can fail for reasons outside your control (parsing untrusted input, reading a file, calling a service), so one bad input does not turn into a 500. ## Try it ```bash marreta serve & # A deliberate 404 from `fail`: curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/errors/not_found # A guard that rejects bad input with 422: curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/errors/guard \ -H 'content-type: application/json' -d '{"name":"a"}' # An uncaught `raise` becomes a 500: curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/errors/raise ``` This prints `404`, then `422`, then `500`. ## Result checkpoint You should now be able to return a deliberate HTTP error with `fail`, surface an unexpected condition as a 500 with `raise`, and recover from a fallible operation with `rescue` instead of crashing the request. ## Next steps - [Shape a response](shape-a-response.md): control the body and status of the success path. - [Validate a request payload](validate-a-payload.md): reject malformed input before your logic runs. --- # Use feature flags A feature flag turns behavior on or off through configuration, without changing code. Use one to roll a change out gradually, to keep an unfinished path off in production, or to disable something in one environment. Flags are read with the [`feature`](../reference/namespaces/feature.md) namespace. ## Check a flag `feature.enabled(name)` returns `true` or `false`. A flag name maps to an uppercased environment variable: `beta` is backed by `MARRETA_FEATURE_BETA`, and `new_pricing` by `MARRETA_FEATURE_NEW_PRICING`. Gate a whole route by guarding on it at the top: ```ruby route GET "/beta" require feature.enabled("beta") else fail 404, "not available" reply 200, { beta: true } ``` With the flag on, the route answers `200`. With it off, the guard fails and the route answers `404`: ```bash # Flag on: the route is available MARRETA_FEATURE_BETA=true marreta serve # GET /beta -> 200 {"beta":true} # Flag off (or unset): the guard rejects the request marreta serve # GET /beta -> 404 {"error":"not available"} ``` ## Branch on a flag You can also switch a branch inside a route instead of gating the whole thing: ```ruby route GET "/products/:id" product = db.products.find(params.id) require product else fail 404, "not found" if feature.enabled("new_pricing") reply 200, { id: product.id, price: product.sale_price } reply 200, { id: product.id, price: product.price } ``` ## Notes - An unset flag reads as `false`, so an unknown or unconfigured flag is off by default. - An invalid value for a `MARRETA_FEATURE_*` variable is a configuration error at load, so a typo fails the server at startup rather than silently turning the flag off. - Flags are read from `MARRETA_FEATURE_*` at startup, so changing one means restarting the server. See [Configure environment variables](configure-environment.md). - Flags are static configuration, not per-user targeting. For that, decide in your own code from the request or the authenticated user. --- # Inspect the generated OpenAPI docs Every Marreta project generates an OpenAPI document from its routes and schemas and serves it for you. There are no annotations to write and nothing to keep in sync: the spec is derived from the same code that runs. ## Open it Serve the project and the docs are already there: ```bash # Start the API marreta serve ``` Visit `http://localhost:8080/docs` in your browser for the interactive Swagger UI. For the raw document, to feed tooling or a client generator, fetch it with curl: ```bash curl http://localhost:8080/openapi.json ``` The document is OpenAPI 3.0.3. Its title and version come from `project_name` and `project_version` in `app.marreta`, and each route appears under a tag named after the file it lives in. ## Schemas sharpen the contract The more you describe with schemas, the richer the generated spec. Binding a request body with `take payload as ` makes the request a named, typed component and documents the automatic `422`, and `reply as ` does the same for the response: ```ruby route POST "/contracts/echo" take payload as ContractTypesPayload reply 200 as ContractTypesPayload, payload ``` A bare `take payload` with no schema still accepts a body, but it shows up as a free-form, untyped object. For a stable public contract, bind the request and response to schemas. Query and header schemas sharpen the contract the same way. `take query as ` and `take headers as ` emit one named, typed parameter per field (a repeated-key `list of ` becomes an array), while a bare `take query` / `take headers` reads arbitrary input and contributes no parameters to the spec. So declaring a schema is how a route opts into documented query and header parameters. ## Configure it Two variables control the docs, both covered in the [Configuration reference](../reference/configuration.md): ```bash # Change the path the docs are served at (default /docs) MARRETA_DOCS_PATH=/api-docs marreta serve # Turn the docs off, for example in a locked-down environment (default on) MARRETA_DOCS_ENABLED=false marreta serve ``` ## Notes - The spec regenerates from the code on every start, so it never drifts from the routes. - See [Validate a request payload](validate-a-payload.md) and [Shape a response](shape-a-response.md) for the schema bindings that feed the contract. --- # Observe logs and traces Marreta emits structured JSON logs. There are two kinds: a `request` record for every HTTP call the server handles, and an `app_log` record for each message your code emits. Both carry a trace id, so you can follow a single request across all of its log lines. ## Emit your own logs Use the [`log`](../reference/namespaces/log.md) namespace, which has `info`, `warn`, and `error`: ```ruby route GET "/work" log.info("starting work") result = 21 * 2 log.warn("about to finish") reply 200, { result: result } ``` Each call produces an `app_log` record with the level and your message in `data`: ```json {"timestamp":"2026-06-06T18:00:42Z","kind":"app_log","level":"info","trace_id":"0a85c287...","span_id":"4978d297...","data":"starting work"} {"timestamp":"2026-06-06T18:00:42Z","kind":"app_log","level":"warn","trace_id":"0a85c287...","span_id":"4978d297...","data":"about to finish"} ``` ## Every request is logged The server logs one `request` record per HTTP call, with the method, path, status, and duration: ```json {"timestamp":"2026-06-06T18:00:42.809Z","kind":"request","trace_id":"0a85c287...","span_id":"4978d297...","method":"GET","path":"/work","route":"/work","status":200,"duration_ms":1.04} ``` Notice the `trace_id` is the same as the two `app_log` lines above. The request and the logs your code emitted while handling it share one trace, so you can group them. Turn the per-request record off with `MARRETA_REQUEST_LOG=false` if you only want your own logs. ## Tie a request's logs together Because the `request` line and every `app_log` it produced share one `trace_id`, you can pull a single request's whole story by that id. While developing, that is one filter on the JSON lines: ```bash # every line for one request, in the order it happened marreta serve | grep '"trace_id":"0a85c287' ``` A log collector (Loki, CloudWatch, Datadog, and so on) does the same with a `trace_id` filter, which is how you read one request end to end in production. The `duration_ms` on the `request` line is that request's total server time, so sorting requests by it is the first step when a route feels slow. ## Set the level `MARRETA_LOG_LEVEL` filters by severity (`debug`, `info`, `warn`, `error`, default `info`). Raising it to `warn` drops the `info` line and keeps the `warn`: ```bash # Only warn and error app logs are emitted MARRETA_LOG_LEVEL=warn marreta serve ``` ## Follow a trace across services Each request is assigned a trace, and the `trace_id` ties its logs together. With `MARRETA_TRACE_CONTEXT` on (the default), an outbound [`http_client`](../reference/namespaces/http_client.md) request carries the W3C `traceparent` header, so a downstream service that also speaks trace context continues the same trace instead of starting a new one. That is what lets you follow one logical request across several services. ## Notes - Logs go to the process output as JSON lines, ready for any log collector to parse. - The relevant variables (`MARRETA_LOG_LEVEL`, `MARRETA_REQUEST_LOG`, `MARRETA_TRACE_CONTEXT`) are in the [Configuration reference](../reference/configuration.md). --- # Use AI assistants Most code today is written with an AI assistant in the editor. Marreta is a new, focused language, so a model that has never seen it tends to guess, falling back to Python or JavaScript shapes that do not run. Marreta closes that gap by putting correct, authoritative context where assistants already look, generated from this documentation and the language's own tested examples so it never drifts from the runtime. ## In your project: AGENTS.md `marreta init` writes an `AGENTS.md` at the project root. `AGENTS.md` is the cross-tool convention that agentic assistants load as in-context instructions. Marreta's primer leads with the corrections a model most often needs (the things it gets wrong coming from another language), then a compact syntax cheat, then a pointer to the full reference. Alongside it, `init` writes a thin pointer for GitHub Copilot, which reads its own `.github/copilot-instructions.md` rather than `AGENTS.md`. It points back to `AGENTS.md`, so there is a single source of truth. To scaffold without the agent guide, pass `--no-agents`: ```bash marreta init shop --no-agents ``` ## Refresh after upgrading The primer is stamped with the runtime version it was generated for. After upgrading the runtime, regenerate it for the current project: ```bash marreta agents ``` That rewrites `AGENTS.md` and the pointers from the installed runtime. `marreta doctor` reports when the file is behind, without ever changing it: ```text Agent guide: SKIP AGENTS.md is stamped for 0.1.0, runtime is 0.2.0. Run `marreta agents` to refresh ``` ## On the web: llms.txt Some assistants fetch documentation at query time instead of reading a project file. For them, the site publishes two files at its root: - [`marreta.dev/llms.txt`](https://marreta.dev/llms.txt) is a curated index of the documentation, one line per page with a short description. - [`marreta.dev/llms-full.txt`](https://marreta.dev/llms-full.txt) is the full reference, every guide page concatenated so a model can read it in one fetch. Both are generated from this same documentation, so they stay current as the language grows. --- # Keywords Marreta keeps a small reserved set, and the rule is one sentence: **namespaces are reserved, directives and vocabularies are contextual.** Reserved words fall into two layers. **Layer 1, reserved.** A reserved word can never be a variable. These are the infrastructure namespaces (`db`, `doc`, `feature`, `cache`, `queue`, `topic`, `fs`, `json`, `base64`, `uuid`, `log`, `time`, `math`, `http_client`), the `env` accessor, the type tokens (`string`, `integer`, `float`, `boolean`, `instant`, `date`, `duration`, `interval`), and the structural keywords grouped below. **Layer 2, contextual.** A contextual word means something in one position and is free as a name everywhere else: the `db:` schema directive, the type-names `list`, `decimal`, and `enum`, the pipeline vocabulary (`where`, `fetch`, `limit`, `order`, and the rest on the [control flow](control-flow.md) page), the scenario DSL (`scenario`, `given`, `when`, `then`), and the injected bindings (`params`, `auth`, `payload`). Even a Layer 1 word is free in a **name position**: after a dot, as a map key, as a schema field name, as a named-argument name, or as a `select` column. It reads as that name there. It is blocked only as a binder (the left side of an assignment, a parameter, a task or schema name), where it raises a dedicated error. ```ruby # Free as a name flags = { env: "prod", feature: "beta" } # map keys created = payload.date # a field after a dot columns = db.events >> select(date, status) >> fetch # a column # Blocked as a binder doc = 1 # error: 'doc' is a reserved word (the document database namespace); rename the variable. ``` A schema field named `doc`, `feature`, or `env` is allowed, because those are not directives. A field named `db` is not, because the `db:` directive already claims that line. The structural keywords below are all Layer 1, grouped by what they do. Each links to the guide that teaches it in context. ## Routes and responses | Keyword | Form | Summary | |---|---|---| | `route` | `route VERB "/path" [take payload as Schema]` | Declares an HTTP route. | | `reply` | `reply STATUS [as Schema], body` | Returns an HTTP response, optionally shaped by a schema. | | `fail` | `fail STATUS, message` | Ends the route with a chosen HTTP error. | | `require` | `require condition else fail ...` | Guards execution and fails when the condition is false. | | `allow` | `allow expression` | Authorizes the request, returning 403 when false. | ```ruby route POST "/items" take payload as NewItem require payload.name else fail 400, "name required" reply 201 as ItemView, { name: payload.name } ``` See [Validate a request payload](../how-to/validate-a-payload.md), [Shape a response](../how-to/shape-a-response.md), and [Secure your API](../how-to/secure-your-api.md). ## Schemas and tasks | Keyword | Form | Summary | |---|---|---| | `schema` | `schema Name` (add `db: table` to persist) | Declares a validation, contract, and table shape. | | `task` | `task name(args)` | Declares a reusable unit of logic. | | `take ... as` | `take payload as Schema` | Binds and validates a request or message body. | | `export` | `export schema` / `export task` | Makes a schema or task available across files. | ```ruby export schema NewItem name: string task title_case(name) name.upper() ``` ## Errors | Keyword | Form | Summary | |---|---|---| | `raise` | `raise message [if condition]` | Raises a runtime error (an uncaught one becomes a 500). | | `rescue` | `expr rescue fallback` | Recovers from a fallible operation. | | `nack` | `nack [requeue]` | Rejects a consumed message, optionally requeuing it. | ```ruby data = raw >> json.parse() rescue { ok: false } ``` See [Handle errors](../how-to/handle-errors.md). ## Messaging and persistence | Keyword | Form | Summary | |---|---|---| | `on queue` / `on topic` | `on queue "name" take msg [as Schema]` | Declares an async consumer or subscriber. | | `transaction` | `transaction` (block) | Runs the enclosed database operations atomically. | ```ruby on queue "emails" take msg log.info("sending to #{msg.to}") ``` See [Process work asynchronously with a queue](../how-to/async-work-with-a-queue.md) and [Make it event-driven](../tutorials/make-it-event-driven.md). ## Control flow and operators Conditionals (`if` / `else`, `match`, `while`), pipelines (`>>`, `*>>`), boolean operators (`and` / `or` / `not`), and collection transforms (`map` / `keep` / `skip`, `reduce`) have their own page, with snippets: [Control flow and operators](control-flow.md). --- # Control flow and operators These are the constructs you use inside a route or task to branch, loop, transform data, and compose values. The declarative keywords (`route`, `schema`, and so on) are in [Keywords](keywords.md). ## Conditionals `if` / `else` works as a statement and as an expression that produces a value: ```ruby status = if cached cached else "fresh" ``` ## Pattern matching `match` tests a value against patterns, with `fallback` as the default branch: ```ruby label = match status "active" -> "on" fallback -> "off" ``` ## Loops `while` repeats a block while its condition holds: ```ruby counter = 0 while counter < 3 counter = counter + 1 ``` ## Pipelines `>>` passes the result of each step to the next, which reads left to right instead of nesting calls. It drives database queries, HTTP calls, and collection transforms: ```ruby recent = db.orders >> where(active: true) >> order_by("id desc") >> fetch ``` ## Broadcast `*>>` sends the same value to several branches and collects their results positionally into a list. The runtime runs independent branches (outbound calls, separate queries) in parallel, so they overlap instead of waiting on each other: ```ruby sections = user_id *>> -> load_profile -> load_orders -> load_recommendations ``` See [Broadcast](../concepts/broadcast.md) for how the parallelism, result order, and the runtime's optimization of trivial branches work. ## Boolean operators `and`, `or`, and `not` combine booleans. `or` also supplies a default when the left side is null or false: ```ruby name = user.name or "guest" allowed = user.active and not user.banned ``` ## Collections Transform a list with `map`, emitting values with `keep` and dropping elements with `skip`, and fold a list with `reduce`: ```ruby names = users >> map user skip if not user.active keep user.name total = prices >> reduce(0) acc, item acc + item ``` --- # Conventions This is the house style for writing idiomatic Marreta. Following it keeps routes readable and makes `marreta fmt` and `marreta lint` predictable across a project. ## Indentation Indentation is significant. It defines blocks: route and task bodies, `match` arms, `transaction` blocks, and any nested structure. The structure of the code comes from how it is indented, so consistency is not optional. ```ruby route POST "/orders" take payload as NewOrder require payload.total else fail 400, "total is required" transaction order = db.orders.save({ total: payload.total }) db.line_items.save({ order_id: order.id }) reply 201, { id: order.id } ``` - Indent with spaces, 4 per level. The language only requires consistent widths, but every project uses 4, so do the same. - Pressing Tab is fine when your editor inserts spaces, but a literal tab character is rejected. If you hit an indentation error, set your editor to insert spaces. - A dedent must return to a level you opened before, otherwise it is an error. - Blank lines and comment-only lines do not affect indentation. ## Naming | Construct | Convention | Example | |---|---|---| | Variables | `snake_case` | `user_id`, `order_total` | | Tasks | `snake_case` | `task calculate_tax(item)` | | Task parameters | `snake_case` | `task apply_discount(base_price)` | | Schema names | `PascalCase` | `UserPayload`, `NewOrder` | | Schema fields | `snake_case` | `first_name`, `is_active` | | Optional schema fields | `snake_case?` | `email?`, `phone_number?` | | Auth providers | `snake_case` | `internal_auth`, `entra_id` | | Globals (`app.marreta`) | `snake_case` | `project_name = "payments-api"` | It is `snake_case` everywhere with one exception. Variables, tasks, parameters, and fields are `snake_case`. Schema names are the exception: they name a type, so they use `PascalCase`. ## Schemas ```ruby schema UserPayload name: string age: integer email?: string is_active: boolean ``` - Schema names use `PascalCase`, the one place that is not `snake_case`. - Fields are `snake_case`, one per line, indented 4 spaces. - Optional fields use `?` on the field name, not the type. ## Routes ```ruby route POST "/users" take payload as UserPayload require payload.name else fail 400, "name is required" reply 201, { id: 1 } ``` - Route paths use lowercase and hyphens: `"/user-profiles"`, `"/order-items/:id"`. - Bind schemas with `as SchemaName` immediately after each take binding (per binding, so `take query as SearchQuery, payload as NewItem` binds each independently). - Write the `take` bindings one of two ways, and do not mix them in the same route: inline (a single `take` on the route line, bindings comma-separated) for one or a few, or multi-line (one indented `take` per line, before any logic) when there are several: ```ruby route GET "/products/search" take query as ProductSearch take headers as ApiHeaders reply 200, { ok: true } ``` - For an optional query parameter's fallback, prefer a schema default once available; today the fallback lives in the body with `or`. Note `or` fires on any falsy value, not only on absence, so `query.limit or 20` turns a real `?limit=0` into `20`. For a value where `0` or `false` is meaningful, check for absence explicitly rather than relying on `or`. - Reading inputs differs by source: a payload field nests; a **query** name matches **exactly** (case-sensitive); a **header** name matches case-insensitively with `_`/`-` equivalent. A raw `take query` / `take headers` is a string map keyed by the exact (query) or lowercased (header) name, read with `["..."]` when the name has a hyphen. See [Read request inputs](../how-to/read-request-inputs.md). ## Tasks ```ruby task calculate_discount(price, category) rate = match category "vip" -> 0.15 "premium" -> 0.10 fallback -> 0.0 price * rate ``` - Name a task as a verb in its base form, since a task does something: `calculate_tax`, `load_profile`, not `tax`, `calculated_tax`, or `loading_profile`. - Prefer inline tasks (`=>`) for a single expression, and block tasks for multi-step logic. - There is no explicit `return`. The last expression is the implicit return. ## Guards ```ruby require payload.user_id else fail 400, "user_id is required" reject client.blocked else fail 403, "account blocked" ``` - Use `require` for "must be truthy" checks and `reject` for "must be falsy" checks. - Place all guards at the top of the route body, before business logic. ## Responses ```ruby reply 200, { users: users, total: total } reply 201, { id: new_user.id } reply html 200, "

Welcome

" reply text 200, "pong" fail 404, "user not found" ``` - Prefer `fail` for early error exits. `reply` can intentionally model any HTTP status, including 4xx and 5xx, for example when mirroring an upstream response. - `reply` and `fail` terminate execution immediately, so no code after them runs. ## Auth ```ruby auth api_key internal_auth { header: "x-api-key" secret_hash: env.INTERNAL_KEY_HASH principal: "service" } route GET "/reports" require auth internal_auth allow auth.user.id == "service" reply 200, { ok: true } ``` - Provider names are `snake_case`, like variables. The provider type (`api_key`, `jwt`) comes first, then the name. - Keep secrets out of the source, reading them from `env` in `marreta.env`. - Place `require auth` and `allow` at the top of the route, with the other guards. - Role checks like `allow "reports.read" in auth.user.roles` need a provider whose tokens carry roles, like `jwt`. An `api_key` principal carries no roles, so authorize it on `auth.user.id`. ## Comments ```ruby # Route: list all active users route GET "/users" limit = query.limit or 10 # default page size reply 200, users ``` - Use `#` for all comments, with one space after the `#` (`marreta fmt` adds it, turning `#note` into `# note`). Divider styles like `## section` and a bare `#` are left as they are. - Prefer comments that explain why, not what. ## Formatting `marreta fmt` rewrites a project to this house style so the layout is never a review topic. It formats every `.marreta` file the project loads (the same files `marreta serve` and `marreta test` read, including any in non-standard folders like `auth/`), and it enforces: - four-space indentation (see Indentation above), - one space around operators and after commas and colons (intra-line spacing), - runs of blank lines collapsed to at most one, - exactly one newline at the end of the file, and no blank lines at the file's start or end, - one space after the leading `#` of a comment (see Comments above). These are decisions, not omissions, and the formatter deliberately stops there: - **No line wrapping and no maximum line width.** In Marreta a newline and an indent are meaning-bearing (they separate statements and open blocks), so wrapping a line would change the program's structure, which is exactly what the formatter is built never to do. Line length is the author's call. - **No alignment.** Fields and assignments are not padded into columns, because editing one line would then reflow its neighbors and add noise to every diff. - **No sorting.** The order of fields, routes, and declarations is meaningful (it is the document order of the generated OpenAPI and the author's reading order), so the formatter keeps it. - **No comment reflow.** The text of a comment belongs to the author. The formatter only fixes the single space after the leading `#`, never the words. ## File structure ```text project/ ├── marreta.env # infrastructure config (never commit secrets) ├── app.marreta # entry point, global metadata only ├── routes/ │ ├── users.marreta │ └── orders.marreta ├── schemas/ │ └── payloads.marreta ├── tasks/ │ └── calculations.marreta └── tests/ └── users_test.marreta ``` - One concern per file. Route files in `routes/`, shared tasks in `tasks/`, shared schemas in `schemas/`. - Scenario tests live in `tests/` with the `_test.marreta` suffix, which is how `marreta test` and `marreta doctor` discover them. A test outside `tests/` or without the suffix is not picked up. - `marreta.env` is for config only, no logic. Keep `app.marreta` metadata-only: the language allows routes and tasks there, but prefer them in their own directories. ## Multi-file and scoping All symbols are file-private by default. Use `export` to share a task or schema across files, reaching it through its [file namespace](../concepts/namespaces.md) as `file.task`: ```ruby # tasks/calculations.marreta export task calculate_discount(price) => price * 0.9 internal_rate = 0.05 # private, not visible outside this file ``` - Never use `export` in route files, since routes are never shared. - `export` a task or schema only when it is used in more than one file. - Name conflicts on `export` are a load error, so use distinct names. - In `app.marreta` everything is implicitly global, so no `export` is needed. --- # Configuration (marreta.env) The runtime reads these `MARRETA_*` variables from the environment and from `marreta.env`. Process environment variables override the file, which overrides the built-in defaults. For how to set them locally and in production, see [Configure environment variables](../how-to/configure-environment.md). Feature flags use the dynamic pattern `MARRETA_FEATURE_` (see [`feature`](namespaces/feature.md)). Arbitrary `env.*` values your own code reads, for example auth secrets, are not listed here. ## Server | Variable | Purpose | Accepts | Required | Default | |---|---|---|---|---| | `MARRETA_HOST` | Address the server binds to. | string | no | `0.0.0.0` | | `MARRETA_PORT` | Port the server listens on. | integer | no | `8080` | | `MARRETA_CORS` | Enables CORS. | boolean | no | `true` | | `MARRETA_CORS_ORIGIN` | Allowed CORS origin. | string | no | `*` | | `MARRETA_DOCS_ENABLED` | Serves the OpenAPI docs. | boolean | no | `true` | | `MARRETA_DOCS_PATH` | Path the docs are served at. | string | no | `/docs` | ## Runtime | Variable | Purpose | Accepts | Required | Default | |---|---|---|---|---| | `MARRETA_LOG_LEVEL` | Minimum log level. | `debug`, `info`, `warn`, `error` | no | `info` | | `MARRETA_REQUEST_LOG` | Logs each HTTP request. | boolean | no | `true` | | `MARRETA_TRACE_CONTEXT` | Propagates W3C trace context to outbound calls. | boolean | no | `true` | | `MARRETA_TIMEZONE` | Timezone for local date and time. | IANA name (`America/Sao_Paulo`) | no | `UTC` | | `MARRETA_WORKER_THREADS` | Size of the runtime thread pool. | integer | no | CPU cores | | `MARRETA_MAX_RECURSION_DEPTH` | Caps task recursion depth. | integer | no | `500` | | `MARRETA_HTTP_TIMEOUT_MS` | Default timeout for `http_client` requests. | integer (ms) | no | `30000` | | `MARRETA_RUNTIME_PROFILE` | Enables hot-path profiling when set. | `hot_path` | no | | ## Database A `db` provider is opt-in: set `MARRETA_DB_PROVIDER` to enable it. The host, name, and user are then required. | Variable | Purpose | Accepts | Required | Default | |---|---|---|---|---| | `MARRETA_DB_PROVIDER` | Selects the provider and enables `db`. | `postgres` | to use `db` | | | `MARRETA_DB_HOST` | Database host. | string | yes | | | `MARRETA_DB_PORT` | Database port. | integer | no | `5432` | | `MARRETA_DB_NAME` | Database name. | string | yes | | | `MARRETA_DB_USER` | Database user. | string | yes | | | `MARRETA_DB_PASSWORD` | Database password. | string | no | | | `MARRETA_DB_SSL_MODE` | TLS mode. | `disable`, `require`, `verify-ca`, `verify-full` | no | | | `MARRETA_DB_POOL_MAX_CONNECTIONS` | Max pool connections. | integer | no | | | `MARRETA_DB_POOL_MIN_CONNECTIONS` | Min pool connections. | integer | no | | | `MARRETA_DB_POOL_ACQUIRE_TIMEOUT_SECS` | Wait for a connection. | integer (s) | no | | | `MARRETA_DB_POOL_IDLE_TIMEOUT_SECS` | Idle connection timeout. | integer (s) | no | | | `MARRETA_DB_POOL_MAX_LIFETIME_SECS` | Max connection lifetime. | integer (s) | no | | | `MARRETA_DB_POOL_TEST_BEFORE_ACQUIRE` | Validate a connection before use. | boolean | no | | ## Document store A `doc` provider is opt-in: set `MARRETA_DOC_PROVIDER` to enable it. | Variable | Purpose | Accepts | Required | Default | |---|---|---|---|---| | `MARRETA_DOC_PROVIDER` | Selects the provider and enables `doc`. | `mongodb` | to use `doc` | | | `MARRETA_DOC_HOST` | Document store host. | string | yes | | | `MARRETA_DOC_PORT` | Document store port. | integer | no | `27017` | | `MARRETA_DOC_NAME` | Database name. | string | yes | | | `MARRETA_DOC_USER` | User. | string | yes | | | `MARRETA_DOC_PASSWORD` | Password. | string | no | | | `MARRETA_DOC_AUTH_SOURCE` | Authentication database. | string | no | | | `MARRETA_DOC_POOL_MAX_CONNECTIONS` | Max pool connections. | integer | no | | | `MARRETA_DOC_POOL_MIN_CONNECTIONS` | Min pool connections. | integer | no | | | `MARRETA_DOC_POOL_CONNECT_TIMEOUT_MS` | Connect timeout. | integer (ms) | no | | | `MARRETA_DOC_POOL_SERVER_SELECTION_TIMEOUT_MS` | Server selection timeout. | integer (ms) | no | | ## Cache A `cache` provider is opt-in: set `MARRETA_CACHE_PROVIDER` to enable it. | Variable | Purpose | Accepts | Required | Default | |---|---|---|---|---| | `MARRETA_CACHE_PROVIDER` | Selects the provider and enables `cache`. | `redis` | to use `cache` | | | `MARRETA_CACHE_HOST` | Cache host. | string | yes | | | `MARRETA_CACHE_PORT` | Cache port. | integer | no | `6379` | | `MARRETA_CACHE_USER` | User. | string | no | | | `MARRETA_CACHE_PASSWORD` | Password. | string | no | | | `MARRETA_CACHE_DB` | Database index. | integer | no | | | `MARRETA_CACHE_PREFIX` | Prefix added to every key. | string | no | empty | | `MARRETA_CACHE_DEFAULT_TTL` | Default TTL when a `set` omits one. | integer (s) | no | | | `MARRETA_CACHE_POOL_SIZE` | Connection pool size. | integer | no | `10` | | `MARRETA_CACHE_CONNECT_TIMEOUT_MS` | Connect timeout. | integer (ms) | no | `2000` | | `MARRETA_CACHE_OPERATION_TIMEOUT_MS` | Per-operation timeout. | integer (ms) | no | `1000` | | `MARRETA_CACHE_RECONNECT_MAX_RETRIES` | Reconnect attempts. | integer | no | `10` | ## Messaging A messaging provider is opt-in: set `MARRETA_QUEUE_PROVIDER` to enable `queue` and `topic`. | Variable | Purpose | Accepts | Required | Default | |---|---|---|---|---| | `MARRETA_QUEUE_PROVIDER` | Selects the provider and enables messaging. | `rabbitmq` | to use messaging | | | `MARRETA_QUEUE_HOST` | Broker host. | string | yes | | | `MARRETA_QUEUE_PORT` | Broker port. | integer | no | `5672` | | `MARRETA_QUEUE_USER` | User. | string | yes | | | `MARRETA_QUEUE_PASSWORD` | Password. | string | no | | | `MARRETA_QUEUE_VHOST` | Virtual host. | string | no | `/` | | `MARRETA_QUEUE_PREFETCH` | Unacked messages a consumer may hold. | integer | no | `10` | | `MARRETA_QUEUE_RECONNECT_MAX_RETRIES` | Reconnect attempts. | integer | no | | | `MARRETA_TOPIC_EXCHANGE` | Exchange that topics publish to. | string | no | `marreta.topics` | ## Notes - `marreta init --with db,cache,doc,queue` scaffolds these with local defaults and a `docker-compose.yml`. - Run `marreta doctor` to check the configured providers are reachable. - See [Providers](../concepts/providers.md) for the abstraction behind the provider groups. --- # CLI The `marreta` command runs and inspects a project. The everyday commands are `init`, `serve`, and `test`. ## Build and run | Command | Purpose | |---|---| | `marreta init [--with db,cache,doc,queue] [--no-agents]` | Scaffolds a new project, adding the chosen providers and a `docker-compose.yml`. `--no-agents` skips the AI-agent guide. | | `marreta serve` | Serves the project over HTTP on `MARRETA_PORT` (8080 by default). | | `marreta test [path] [--filter TEXT] [--coverage]` | Runs the scenario tests, optionally filtered, with `--coverage` for a report. | | `marreta agents` | Writes or refreshes the AI-agent guide (`AGENTS.md` and its pointers) for the current project. See [Use AI assistants](../how-to/use-ai-assistants.md). | | `marreta doctor` | Loads the project and reports its configuration without serving. | ```bash marreta init shop --with db,cache cd shop marreta serve ``` ## Database `marreta migrate ` manages relational migrations: | Subcommand | Purpose | |---|---| | `generate` | Writes a migration from the current `db:` schemas. Reports unsupported changes (a type change, a removed field) as drift instead of writing them. | | `diff` | Shows the SQL a new migration would contain, without writing it. Also reports unsupported changes as drift. | | `status` / `list` | Show applied and pending migrations. | | `explain ` | Explains a migration state (`pending`, `changed`, `missing_local`, `workflow`). | | `apply` | Applies pending migrations and changes the database. | | `rollback` | Reverts the most recent applied migration. | | `discard ` | Removes an unapplied local migration. | See [Evolve your database with migrations](../how-to/migrations.md). ## Code quality | Command | Purpose | |---|---| | `marreta fmt [--check]` | Formats every `.marreta` file the project loads (the same files `serve` and `test` read, in any folder). `--check` verifies formatting without writing. | | `marreta lint [--strict]` | Lints the project for problems, with `--strict` to fail on warnings. | ## Editor tooling `marreta tooling --format json` powers editor features. It is consumed by the VS Code extension, not run by hand. ## Notes - `serve` and `doctor` load the project the same way, so `doctor` catches a misconfiguration before you deploy. - `fmt` and `lint` also read from stdin (`--stdin --file `) for editor integration. --- # Error codes When the runtime returns an error, the response body carries a `code` field with one of these values. The codes describe what went wrong. The HTTP status is usually yours to choose with `fail` and `raise`, though the runtime sets it on its own in some cases. For example schema validation returns 422, a unique-index violation returns 409 with the `unique_violation` code, and a failed `require auth` or `allow` returns 401 or 403. ## Load-time This code happens when the project is loaded (`serve`, `test`, `doctor`) and stops the server from starting. Fix it before the app can run. `marreta doctor` reports it. | Code | Meaning | |---|---| | `config_error` | A route or export conflict, a schema cycle, an incompatible runtime, or an invalid persistent schema. | ## Runtime: your code These point to a bug in the project and surface as a 500 unless you handle them. | Code | Meaning | |---|---| | `reference_error` | An undefined variable or task, a missing property, or a value that is not callable. | | `type_error` | A value had the wrong type for the operation. | | `arity_error` | A task was called with the wrong number of arguments. | | `arithmetic_error` | Division by zero or another arithmetic fault. | | `raise_error` | An explicit `raise` from project code. | ## Runtime: infrastructure These come from a provider or a fallible operation. They are the ones you can recover from with `rescue` (see [Handle errors](../how-to/handle-errors.md)). | Code | Meaning | |---|---| | `db_error` | A relational database operation failed. | | `cache_error` | A cache operation failed. | | `queue_error` | A message queue operation failed. | | `http_client_error` | An outbound `http_client` request failed. | | `io_error` | A filesystem or project-load I/O failure. | | `infrastructure_error` | An infrastructure dependency was unavailable. | | `unique_violation` | A write violated a unique index or constraint. Returned as HTTP 409. Fires for both the relational and the document provider, including an index you created by hand. | | `invalid_identifier` | A `db` column identifier (a `select` column, an `order_by` clause, or a `where`/`like`/`in` column) was not a valid identifier (a plain name or `table.column`). Returned as HTTP 400. Guards against a runtime-derived identifier becoming a SQL injection vector. | | `unknown_column` | A `db` column identifier was a valid name but is not a column of the table, when a `db:` schema declares it. Returned as HTTP 400. | | `runtime_error` | A generic runtime fault not covered by a more specific code. | ## Validation A route that binds a body with `take payload as Schema` validates it against the schema before your code runs. When the body does not match, the runtime returns HTTP 422 with a message naming the offending field, and your route body never executes. You do not write this check, and you do not choose its status. --- # Lint codes `marreta lint` reports a small, high-signal set of diagnostics. Each carries a code, shown in the CLI output and in the editor. This page documents every code, grouped by what it protects. Any warning can be silenced on a single line with an inline directive: ```ruby # marreta: allow route_without_response route GET "/health" log.info("alive") ``` A directive on its own line silences the next line. A trailing directive silences its own line: ```ruby total = subtotal # marreta: allow unused_variable ``` Run with `--strict` to make warnings fail the command, for CI. ## Load-time ### source_load_error The file failed to load (a syntax error, a schema cycle, or an invalid configuration) and the project cannot run until it is fixed. This is an error, not a warning, and `marreta doctor` reports it too. Fix the underlying source problem. ## Routes and responses ### route_without_response A route path can finish without `reply` or `fail`, and a route that returns no response sends a silent `204 No Content`. Most often this means a branch forgot its `reply`. End every path with `reply` or `fail`. The analysis is conservative and local to the route body: `if` needs both branches to respond, `match` needs every arm plus a `fallback`, and a `reply`/`fail` inside a `rescue` recovery block does not count toward the happy path. If an empty `204` is intended, suppress the line. ### duplicate_route Two routes share the same verb and path pattern, so one shadows the other at registration. Give them distinct paths or merge them. ## Correctness ### match_without_fallback A `match` whose value is used (assigned, or passed somewhere) has no `fallback` arm. When no arm matches, the runtime returns a silent `null` that surfaces as an error far from its origin. Add a `fallback ->` arm. A bare `match` used only for its side effects, where the value is discarded, is not flagged. The check works on the top-level statements of a body. ### unknown_schema_reference A validation, response, field type, or constructor references a schema name that is not declared. Declare the schema or fix the name. ### invalid_feature_flag_name A feature flag name is not a valid `MARRETA_FEATURE_*` identifier, so it can never resolve. Use a name that matches the flag convention. ### suspicious_self_recursive_task A task calls itself with no visible base case, a likely infinite recursion. Add a guard, or rewrite it. ### unreachable_statement A statement follows a terminating statement (`reply`, `fail`, an unconditional `raise`) and can never run. Remove it, or move it before the terminator. ## Security ### non_literal_sql_identifier A `db` `order_by` clause, a `select` computed alias, or a `like`/`in` field is built from a runtime value instead of a literal. Filter values are parameterized, but the identifier is written into the SQL string as-is, so a runtime value there (including an interpolated string) is an injection vector. Use a literal for the identifier. An interpolated `order_by("created_at #{params.dir}")` is flagged for the same reason. This lint warns, it does not sanitize: the runtime guard is a separate hardening follow-up. The check applies to the relational provider only, not to `doc` pipelines. ### non_flat_input_schema A schema bound to query or headers (`take query as Schema` / `take headers as Schema`) is not flat. Query and header parameters are flat on the wire, so a schema bound there may only use scalar fields and lists of scalars. A field that references another schema (a nested object), or a list of objects (`list of SomeSchema`), is flagged. Keep query and header schemas flat, or use the raw `take query` / `take headers` for arbitrary input. This is also a load-time error; the lint surfaces it at dev time. ## Unused declarations ### unused_variable A local variable is assigned but never read. Remove the assignment, or use the value. ### unused_private_task A file-private task is declared but never called. Remove it, export it, or call it. ### unused_exported_task An exported task is never called from any file (by its `file.task` namespace, or within its own file). Call it, use it in its own file, or drop the export. ### unused_schema A non-persistent schema is declared but never referenced by a validation, a response, a field type, or a constructor. Use it, reference it from another schema, or remove it. A persistent (`db:`) schema is never flagged here, because it defines a table that can be in use without an explicit reference. ### unused_auth_provider An auth provider is declared but no route requires it. Require it on a route with `require auth `, or remove it. ### shadows_injected_binding A local in a route reuses the name of a runtime-injected binding (`params`, `query`, `headers`, `auth`, or a `take` binding), hiding the injection for the rest of the scope. Rename the local. The check is scope-aware: a name is only flagged where that binding is actually live in the route. It analyzes the top-level statements of a body and its `while`/`transaction` blocks, not assignments buried inside an `if` or `match` expression branch. --- # Namespaces A namespace groups related operations under a name you call with dot syntax, like `db.users.find(id)` or `time.now()`. These are the native namespaces: they are built into the language and the runtime, with no import. The provider-backed ones (data, cache, messaging) read their connection details from `marreta.env`. For the idea behind namespaces, including the file namespaces you create yourself by exporting tasks, see [Namespaces](../concepts/namespaces.md). ## Data and persistence | Namespace | What it does | Page | |---|---|---| | `db` | Relational database access and query pipelines. | [db](namespaces/db.md) | | `doc` | Document database access and aggregation pipelines. | [doc](namespaces/doc.md) | | `cache` | Short-lived key-value storage with TTLs. | [cache](namespaces/cache.md) | ## Messaging | Namespace | What it does | Page | |---|---|---| | `queue` | Point-to-point work queues. | [queue](namespaces/queue.md) | | `topic` | Publish-subscribe topics. | [topic](namespaces/topic.md) | ## Integration | Namespace | What it does | Page | |---|---|---| | `http_client` | Outbound HTTP requests to other services. | [http_client](namespaces/http_client.md) | ## Utilities | Namespace | What it does | Page | |---|---|---| | `time` | Instants, dates, durations, and the clock. | [time](namespaces/time.md) | | `math` | Numeric helpers (rounding, clamping, min and max). | [math](namespaces/math.md) | | `json` | Parse and serialize JSON. | [json](namespaces/json.md) | | `base64` | Encode and decode base64. | [base64](namespaces/base64.md) | | `uuid` | Generate UUIDs. | [uuid](namespaces/uuid.md) | | `log` | Structured logging. | [log](namespaces/log.md) | | `fs` | Read and write files. | [fs](namespaces/fs.md) | | `feature` | Read feature flags. | [feature](namespaces/feature.md) | Request context (`params`, `query`, `payload`, `message`) and configuration (`env`) are also addressed as namespaces, and are covered where they are used, in [Routes](../tutorials/quickstart.md) and the how-to guides. --- # base64 The `base64` namespace encodes text to base64 and decodes it back. Pass `url_safe: true` for the URL and filename safe alphabet, used in tokens and query values. ## Operations ```ruby encoded = base64.encode(text) back = encoded >> base64.decode() rescue null ``` | Name | Signature | Summary | |---|---|---| | `base64.encode` | `base64.encode(text, url_safe: true)` | Encodes text as base64. | | `base64.decode` | `base64.decode(text, url_safe: true)` | Decodes base64 text. | ## Notes - `base64.decode` fails on input that is not valid base64. Wrap it in `rescue` when the input is untrusted. --- # cache The `cache` namespace stores short-lived key-value data in a fast store, through the configured cache provider. It is for data you can afford to lose and rebuild, not for your source of truth. ## When to use Reach for the cache to avoid repeating expensive work: a computed result, a slow upstream response, or a counter. Set a TTL so entries clean themselves up. Because a value can expire or be evicted at any time, never keep anything here that you cannot recompute. For durable data, use [`db`](db.md) or [`doc`](doc.md). The [Cache expensive work](../../how-to/use-cache.md) guide walks through the cache-aside pattern from end to end. ## Operations `cache.set` stores a value and `cache.get` reads it back, returning `null` on a miss: ```ruby cache.set("greeting", "hello", ttl: 60) value = cache.get("greeting") ``` | Name | Signature | Summary | |---|---|---| | `cache.get` | `cache.get(key)` | Returns the cached value, or null when missing. | | `cache.set` | `cache.set(key, value, ttl: N, only_if_absent: true)` | Stores a value, optionally with a TTL or only when absent. | | `cache.delete` | `cache.delete(key)` | Deletes a key and returns whether it existed. | | `cache.exists` | `cache.exists(key)` | Returns whether a key exists. | | `cache.ttl` | `cache.ttl(key)` | Returns the remaining TTL in seconds, or null. | | `cache.expire` | `cache.expire(key, ttl: N)` | Updates a key's TTL. | | `cache.incr` | `cache.incr(key, by: N)` | Increments an integer counter atomically. | | `cache.decr` | `cache.decr(key, by: N)` | Decrements an integer counter atomically. | | `cache.get_many` | `cache.get_many(keys)` | Reads multiple keys at once. | | `cache.set_many` | `cache.set_many(values)` | Writes multiple entries at once. | ## Notes - The cache provider must be configured and reachable before `marreta serve`. Run `marreta doctor` to check the connection. - A value set without `ttl:` does not expire on its own. Give it a TTL, or `cache.delete` it when the underlying data changes. - `incr` and `decr` are atomic, so they are safe for counters under concurrent requests. - `only_if_absent: true` stores only when the key is unset, and returns whether it stored. It is the building block for a simple lock or a write-once flag. --- # db The `db` namespace reads and writes rows in a relational table through the configured database provider. Each table is addressed by name, as `db.
`, and a schema marked `db:` defines its columns. ## When to use Use `db` for structured data with typed columns and relationships that you evolve with versioned migrations. If you want to store flexible, nested documents without a schema or a migration step, use [`doc`](doc.md) instead. See [Persist data with local services](../../how-to/use-local-services.md) for the workflow and [Evolve your database with migrations](../../how-to/migrations.md) for schema changes. ## Persistent schemas A table is defined by a schema that declares `db:
`. The fields below it are the columns, and `id` is the primary key: ```ruby export schema Product db: products id: integer sku: string name: string price: decimal ``` Declaring the schema does not create the table. You create it, and evolve it as the schema changes, with versioned migrations. See [Evolve your database with migrations](../../how-to/migrations.md). ## Operations For single rows, call a method on the table. `save` returns the stored record with its generated `id`: ```ruby product = db.products.save({ sku: "abc", name: "Widget" }) found = db.products.find(product.id) ``` | Operation | Signature | Summary | |---|---|---| | save | `db.
.save(map)` | Inserts a row and returns it, including the generated `id`. | | find | `db.
.find(id)` | Returns the row with that primary key, or null. | | find_all | `db.
.find_all()` | Returns every row in the table. | | update | `db.
.update(id, map)` | Updates the row by id and returns it. | | delete | `db.
.delete(id)` | Deletes the row by id. | For anything beyond a primary-key lookup, open a query pipeline with `>>`. Steps accumulate clauses, and a terminal step runs the query: ```ruby premium = db.products >> where(price > 100) >> order_by("price asc") >> fetch ``` | Step | Form | Summary | |---|---|---| | where | `where(col: val)` or `where(col > val)` | Equality or comparison filter. | | like | `like("col", "pattern")` | LIKE filter. | | in | `in("col", list)` | IN filter. | | order_by | `order_by("col asc")` | Sort. | | limit / offset | `limit(n)` / `offset(n)` | Page the results. | | fetch (terminal) | `>> fetch` | Returns the matching rows as a list. | | fetch_one (terminal) | `>> fetch_one` | Returns the first row, or null. | | count (terminal) | `>> count` | Returns the number of matches. | | exists (terminal) | `>> exists` | Returns whether any row matches. | | update (terminal) | `>> update(map)` | Updates every matching row and returns the count. | | delete (terminal) | `>> delete` | Deletes every matching row and returns the count. | ## Relations A schema field that references another persistent schema is a foreign-key relation, not a copy of the row. Here an order belongs to a customer, and a customer has many orders: ```ruby export schema Customer db: customers id: integer name: string orders: list of Order export schema Order db: orders id: integer total: decimal customer: Customer ``` `Order.customer` is a relation handle. After you materialize a row, navigate it with the pipeline steps, starting from the field: ```ruby order = db.orders.find(params.id) customer = order.customer >> fetch ``` `order.customer` is a singular relation, so `>> fetch` returns the single related row (not a list), and `>> exists`, `>> count`, and `>> where(...)` work as well. The inverse `list of` relation, `customer.orders`, is a collection, so its `>> fetch` returns a list. ## Raw SQL When the pipeline cannot express a query (joins, CTEs, window functions), drop to `db.native_query` with raw SQL. Values interpolated with `#{}` are evaluated and bound as prepared-statement parameters, not concatenated into the SQL string: ```ruby rows = db.native_query("SELECT * FROM items WHERE name = #{params.name}") ``` It is an escape hatch, so reach for it only when the pipeline and the per-row methods fall short. ## Column identifiers are validated Filter values are bound as parameters, but column identifiers (the `select` columns, the `order_by` clause, and the columns in `where`, `like`, and `in`) are part of the SQL, so the runtime validates and quotes them. A column identifier must be a plain name (`price`) or a `table.column`. Anything else is rejected with a `400 invalid_identifier`, which makes a runtime-derived identifier safe by construction: ```ruby route GET "/products" take query # query.sort is request input. A column name passes and is sorted; an injection attempt # ("price; DROP TABLE products") is rejected with a 400, never concatenated into SQL. products = db.products >> order_by(query.sort) >> fetch reply 200, products ``` `order_by` accepts `column` optionally followed by `asc` or `desc`, comma-separated for several columns (`order_by("status, created_at desc")`). When a `db:` schema declares the table, an unknown column is rejected with a `400 unknown_column`. The dev-time `non_literal_sql_identifier` lint (see the lint reference) flags a runtime-built identifier before you ship; this runtime guard is the defense in depth that closes it regardless. ## Notes - The database provider must be configured and reachable before `marreta serve`. Run `marreta doctor` to check the connection. - A table does not exist until you create it with a migration. Run `marreta migrate generate` and `marreta migrate apply` after declaring or changing a `db:` schema. - Operations inside a `transaction` block share one connection and commit or roll back together. - A query can fan out to concurrent branches with `*>>`, for example to fetch a count and a page of rows in one round-trip. --- # doc The `doc` namespace stores and reads documents in a collection through the configured document provider. Each collection is addressed by name, as `doc.`, and needs no declaration: the first `save` creates it. ## When to use Use `doc` for flexible, nested data that you want to persist without defining a table or running a migration. It is the simplest way to store data in Marreta. When you need typed columns, relations, and versioned schema changes, use [`db`](db.md) instead. [Save and read your first data](../../tutorials/save-and-read-data.md) is a short tutorial built on `doc`. ## Operations `save` stores a document and returns it with a generated `_id`: ```ruby note = doc.notes.save({ title: "First", body: "hello" }) found = doc.notes.find(note._id) ``` | Operation | Signature | Summary | |---|---|---| | save | `doc..save(map)` | Stores a document and returns it, including the generated `_id`. | | find | `doc..find(id)` | Returns the document with that `_id`, or null. | | find_all | `doc..find_all()` | Returns every document in the collection. | | update | `doc..update(id, map)` | Updates the document by `_id` and returns it. | | delete | `doc..delete(id)` | Deletes the document by `_id`. | For queries beyond an id lookup, open a pipeline with `>>`. A filter takes a string field name and a comparison: ```ruby recent = doc.orders >> where("status" == "open") >> order("created_at", "desc") >> fetch_all ``` | Step | Form | Summary | |---|---|---| | where | `where("field" == value)` (also `!=`, `<`, `<=`, `>`, `>=`) | Filters by a field. | | like / in | `like("field", "pattern")` / `in("field", list)` | Pattern and set filters. | | order | `order("field", "asc")` | Sorts the results. | | limit / offset | `limit(n)` / `offset(n)` | Pages the results. | | pick | `pick(["field", ...])` | Projects each document to the named fields. | | group_by + aggregate | `group_by("field") >> sum("field", as: "name")` | Groups and aggregates (`sum`, `avg`, `min`, `max`, `count`). | | fetch_all (terminal) | `>> fetch_all` | Returns the matching documents as a list. | | fetch_one (terminal) | `>> fetch_one` | Returns the first document, or null. | | count / exists (terminal) | `>> count` / `>> exists` | Number of matches, or whether any match. | | update / upsert / delete (terminal) | `>> update(map)` / `>> upsert(map)` / `>> delete` | Writes across every match. | ## Raw aggregation pipeline When the `>>` query builder cannot express what you need, drop to `doc.pipeline(collection, stages)`. It runs a raw aggregation pipeline, where each stage is a single-key map and the keys are plain identifiers, with no `$`: ```ruby result = doc.pipeline("orders", [ { match: { status: "paid" } }, { sort: { amount: -1 } }, { limit: 2 } ]) ``` Like `db.native_query`, it is an escape hatch. Reach for it only when the query pipeline and the per-document methods fall short. ## Indexes You do not declare indexes. The runtime reads the `where` and `order` shape of every query you write against a collection and ensures a matching index in the document provider, in the background, at `marreta serve` startup. A query like this one tells the runtime to index `account_id` and `_id` together: ```ruby route GET "/orders/by-account/:id" orders = doc.orders >> where("account_id" == params.id) >> order("_id", "desc") >> limit(20) >> fetch_all reply 200, { items: orders } ``` Indexes follow your code. A new query shape is ensured the next time `marreta serve` starts, with no migration step, so the index plan stays in sync with the queries that need it. `marreta doctor` reports the plan, marking each index present, absent, or orphan. What inference does not cover, so you are not surprised in production: - **It reads the query builder, not the escape hatches.** Inference covers the `>> where(...) >> order(...)` surface with literal field names. It does not read a `like(...)` filter, a raw `doc.pipeline(...)`, a field chosen through a variable, or a pipeline built on a collection held in a variable (it matches the collection feeding the `>>` directly). Moving a query from `>> where` to a raw pipeline drops inference for that shape. - **The ensure runs in the background.** `serve` binds and starts handling requests immediately, so a brand-new filter on a large collection serves unindexed until the build finishes. - **It never drops an index.** A shape you stop using leaves its index behind as an orphan. `marreta doctor` flags it for you to remove by hand. An index you created yourself is owned by you and is never touched. - **A unique index you add by hand is still enforced.** A write that violates it returns the `unique_violation` error (HTTP 409), see [Error codes](../errors.md). ## Notes - The document provider must be configured and reachable before `marreta serve`. Run `marreta doctor` to check the connection. - Documents are keyed by `_id`, generated on `save`, not by an `id` column. - Unlike `db`, there is no migration. A collection and its fields exist as soon as you write to them. - A `doc` filter names the field as a string with a comparison, as in `where("city" == value)`. A `db` filter uses `where(city: value)`. --- # feature The `feature` namespace checks static feature flags. A flag named `` is backed by the `MARRETA_FEATURE_` environment variable, so you turn behavior on or off through configuration without changing code. ## When to use Use a feature flag to gate a route or a branch behind configuration, for example to roll out a change or disable a path in one environment. ## Operations ```ruby route GET "/beta" require feature.enabled("beta") else fail 404, "not available" reply 200, { ok: true } ``` | Name | Signature | Summary | |---|---|---| | `feature.enabled` | `feature.enabled(name)` | Returns whether the flag is enabled. | ## Notes - A flag that is not set returns `false`, so an unknown or unconfigured flag is off by default. - Flags are read from `MARRETA_FEATURE_*` at startup. See [Configure environment variables](../../how-to/configure-environment.md). --- # fs The `fs` namespace reads and writes UTF-8 files on the local filesystem of the server process. For durable application data, use [`db`](db.md) or [`doc`](doc.md). Reach for `fs` only for local files such as a template or an export. ## Operations File operations are fallible, so guard them with `rescue`: ```ruby contents = fs.read(path) rescue { error: "could not read" } ``` | Name | Signature | Summary | |---|---|---| | `fs.read` | `fs.read(path)` | Reads a UTF-8 file. | | `fs.write` | `fs.write(path, content)` | Writes a UTF-8 file and returns the content. | | `fs.append` | `fs.append(path, content)` | Appends UTF-8 content to a file. | | `fs.exists` | `fs.exists(path)` | Returns whether a file exists. | | `fs.delete` | `fs.delete(path)` | Deletes a file and returns whether it existed. | ## Notes - These operations touch the server's local disk, which is not shared across instances and may be ephemeral. Do not use `fs` as your source of truth. - `read`, `write`, `append`, and `delete` can fail (missing file, permissions). Wrap them in `rescue`. --- # http_client The `http_client` namespace makes outbound HTTP requests to other services. A response is an envelope with `.status`, `.body`, and `.headers`. A 4xx or 5xx is not an error in Marreta, so you decide how to handle each status. ## When to use Use `http_client` whenever a route needs data from, or an action on, another service. Always guard `response.status` with `require` before trusting the body, so an upstream failure becomes a response of your choosing. See [Call an external API](../../how-to/call-an-external-api.md) for query parameters, headers, pipeline forms, and testing. ## Operations Call the verb and read the envelope: ```ruby response = http_client.get("https://api.example.com/users/#{params.id}") require response.status == 200 else fail 502, "user service failed" reply 200, response.body ``` | Name | Signature | Summary | |---|---|---| | `http_client.get` | `http_client.get(url, headers:, query:, timeout:)` | Sends a GET request. | | `http_client.post` | `http_client.post(url, payload, headers:, query:, timeout:)` | Sends a POST request with a body. | | `http_client.put` | `http_client.put(url, payload, headers:, query:, timeout:)` | Sends a PUT request with a body. | | `http_client.patch` | `http_client.patch(url, payload, headers:, query:, timeout:)` | Sends a PATCH request with a body. | | `http_client.delete` | `http_client.delete(url, headers:, query:, timeout:)` | Sends a DELETE request. | For POST, PUT, and PATCH, you can pipe the body in with `>>` instead of passing it as an argument: `payload >> http_client.post(url)`. For GET and DELETE, a piped map becomes the query string. ## Notes - A 4xx or 5xx response is returned normally, not raised. Guard `response.status` yourself and map it to your own status. - `query:` adds a query string on any verb, `headers:` sets request headers, and `timeout:` overrides the default timeout in milliseconds. - A scenario test stubs a call by its URL (plus the body for `post`, `put`, `patch`), so you can test a route without a live upstream. --- # json The `json` namespace converts between JSON text and Marreta values. Routes already parse and serialize JSON bodies for you, so reach for `json` when you handle JSON as raw text, such as a string field or a third-party payload. ## When to use Use `json.parse` to turn untrusted JSON text into a value you can read, and `json.stringify` to produce JSON text to store or send. For a body that arrives on a route, the request and `reply` already handle JSON, so you rarely need these there. ## Operations `parse` is fallible, so guard it with `rescue` when the text is untrusted: ```ruby data = raw >> json.parse() rescue { error: "invalid json" } ``` | Name | Signature | Summary | |---|---|---| | `json.parse` | `json.parse(text)` | Parses JSON text into a value. | | `json.stringify` | `json.stringify(value)` | Serializes a value to compact JSON. | | `json.pretty` | `json.pretty(value)` | Serializes a value to indented JSON. | ## Notes - `json.parse` fails on malformed text. Wrap it in `rescue` so one bad input does not become a 500. See [Handle errors](../../how-to/handle-errors.md). --- # log The `log` namespace emits structured log events from your routes and consumers. Each call produces a structured record with the level and message, alongside the request's trace context. ## When to use Use `log` to record what happened for observability: an info for normal flow, a warn for a recoverable problem, an error for a failure worth alerting on. ## Operations ```ruby log.info("processing order #{order.id}") ``` | Name | Signature | Summary | |---|---|---| | `log.info` | `log.info(message)` | Emits an info event. | | `log.warn` | `log.warn(message)` | Emits a warning event. | | `log.error` | `log.error(message)` | Emits an error event. | ## Notes - An uncaught `raise` or `fail` is already logged by the runtime with its trace context, so you do not need to log before raising. --- # math The `math` namespace provides numeric helpers that work across `integer`, `float`, and `decimal` values. Individual numbers also carry their own methods (see [Types](../types.md)). Use `math` for the standalone helpers like `clamp`. ## When to use Reach for `math` to constrain or round a number in a route, for example clamping a page size or rounding a computed total. ## Operations ```ruby size = math.clamp(requested, min: 1, max: 100) ``` | Name | Signature | Summary | |---|---|---| | `math.round` | `math.round(value, places: N)` | Rounds to the given places. | | `math.ceil` | `math.ceil(value)` | Rounds up to an integer. | | `math.floor` | `math.floor(value)` | Rounds down to an integer. | | `math.abs` | `math.abs(value)` | Returns the absolute value. | | `math.clamp` | `math.clamp(value, min: N, max: N)` | Constrains a number to a range. | | `math.min` | `math.min(left, right)` | Returns the smaller number. | | `math.max` | `math.max(left, right)` | Returns the larger number. | --- # queue The `queue` namespace pushes a message onto a named queue through the configured messaging provider. A queue is point-to-point: each message is handled by exactly one consumer, declared elsewhere with `on queue`. ## When to use Use a queue to hand work off so a route can return immediately, when each message should be processed once by a single consumer (sending an email, resizing an image). To broadcast an event to several independent subscribers instead, use [`topic`](topic.md). See [Process work asynchronously with a queue](../../how-to/async-work-with-a-queue.md) for the producer, the consumer, and ack/nack semantics. ## Operations `queue.push` enqueues a message. Add `as ` to validate and strip it to the declared fields before it goes out: ```ruby queue.push "welcome_emails" as Signup, payload ``` | Name | Signature | Summary | |---|---|---| | `queue.push` | `queue.push "" [as Schema], payload` | Enqueues a point-to-point message. | The consumer is a top-level handler, not a method on this namespace: ```ruby on queue "welcome_emails" take signup as Signup log.info("sending to #{signup.email}") ``` ## Notes - The messaging provider must be configured and reachable before `marreta serve`. Run `marreta doctor` to check the connection. - A consumer acknowledges on a clean run. A runtime error or a schema mismatch nacks without requeue, and only `nack requeue` retries. See [ack and nack](../../how-to/async-work-with-a-queue.md#acknowledge-and-reject). --- # time The `time` namespace constructs and manipulates the temporal types (`instant`, `date`, `time`, `duration`, `interval`). Those types are described in [Types](../types.md). This namespace is how you create and combine them. ## When to use Use `time` to get the current moment, parse a temporal string, build a duration, or compare intervals. ## Operations ```ruby now = time.now() deadline = time.now() window = time.interval(time.date("2026-01-01"), time.date("2026-01-31")) ``` | Name | Signature | Summary | |---|---|---| | `time.now` | `time.now()` | Returns the current instant. | | `time.today` | `time.today()` | Returns the current local date. | | `time.instant` | `time.instant(text)` | Parses an ISO instant. | | `time.date` | `time.date(text)` | Parses `YYYY-MM-DD` into a date. | | `time.at` | `time.at(text)` | Parses `HH:MM:SS` into a time. | | `time.parse` | `time.parse(text)` | Parses a temporal string. | | `time.from_unix` / `time.unix` | `time.from_unix(seconds)` / `time.unix(instant)` | Converts between epoch seconds and an instant. | | `time.seconds` / `minutes` / `hours` / `days` | `time.minutes(value)` | Creates a duration. | | `time.interval` | `time.interval(start, end)` | Creates an interval between two instants. | | `time.contains` | `time.contains(interval, value)` | Returns whether an interval contains a value. | | `time.overlaps` | `time.overlaps(left, right)` | Returns whether two intervals overlap. | | `time.format` | `time.format(value, mask)` | Formats a temporal value as text. | ## Notes - The timezone for `time.today` and other local operations follows `MARRETA_TIMEZONE`. See [Configuration](../configuration.md). --- # topic The `topic` namespace publishes an event to a named topic through the configured messaging provider. A topic is publish and subscribe: every subscriber declared with `on topic` receives a copy of each event. ## When to use Use a topic to broadcast an event so several independent parts of a system can react to it, without the publisher knowing who is listening. When each message should be handled once by a single consumer instead, use [`queue`](queue.md). [Make it event-driven](../../tutorials/make-it-event-driven.md) is a tutorial built on topics. ## Operations `topic.publish` broadcasts an event. Add `as ` to shape it to the event contract before it goes out: ```ruby topic.publish "order_placed" as Order, payload ``` | Name | Signature | Summary | |---|---|---| | `topic.publish` | `topic.publish "" [as Schema], payload` | Publishes an event to every subscriber. | A subscriber is a top-level handler, not a method on this namespace, and a topic can have any number of them: ```ruby on topic "order_placed" take event as Order log.info("order #{event.id}") ``` ## Notes - The messaging provider must be configured and reachable before `marreta serve`. Run `marreta doctor` to check the connection. - Subscribers share the queue ack/nack rules: a clean run acknowledges, an error or a schema mismatch nacks without requeue. See [ack and nack](../../how-to/async-work-with-a-queue.md#acknowledge-and-reject). --- # uuid The `uuid` namespace generates UUID strings for identifiers, idempotency keys, and correlation ids. ## When to use Use `uuid.v7` when you want time-ordered ids that sort and index well, and `uuid.v4` when you want a purely random id. ## Operations ```ruby id = uuid.v7() ``` | Name | Signature | Summary | |---|---|---| | `uuid.v7` | `uuid.v7()` | Generates a time-ordered UUID v7 string. | | `uuid.v4` | `uuid.v4()` | Generates a random UUID v4 string. | --- # Types Marreta uses one set of types everywhere. The same types describe schema contracts, request payloads, responses, and the values your code works with at runtime. A field declared `amount: decimal` is validated on the way in, shaped on the way out, and is a real decimal in between. ## Scalar types | Type | Meaning | Page | |---|---|---| | `string` | Text. | [string](types/string.md) | | `integer` | A whole number. | [integer](types/integer.md) | | `float` | A floating-point number. | [float](types/float.md) | | `decimal` | An exact decimal, safe for money. | [decimal](types/decimal.md) | | `boolean` | `true` or `false`. | [boolean](types/boolean.md) | ## Collection types | Type | Meaning | Page | |---|---|---| | `list` | An ordered list of values. | [list](types/list.md) | | `map` | A key-value object. | [map](types/map.md) | ## Temporal types | Type | Meaning | |---|---| | `instant` | A point in time. | | `date` | A calendar date. | | `time` | A wall-clock time. | | `duration` | A length of time. | | `interval` | A span between two instants. | These are constructed and read through the [`time`](namespaces/time.md) namespace. See [Temporal types](types/temporal.md) for construction, properties, and the `on` method. ## Schema constructs These appear in schema definitions to compose other types: | Form | Meaning | |---|---| | `enum ["a", "b"]` | One of a fixed set of strings. | | `` | A reference to another schema, a foreign-key relation when the target is persistent. | | `list of ` | A typed list of another schema. | --- # string A `string` is text. As a schema field it is written `name: string`, and a literal uses double quotes, as in `"hello"`. Interpolation embeds values with `#{}`, as in `"Hello, #{name}"`. ```ruby name = " Ada " clean = name.trim().upper() parts = "a,b,c".split(",") ``` ## Methods | Name | Signature | Summary | |---|---|---| | `string.length` | `length()` | Returns the string length. | | `string.upper` | `upper()` | Converts to uppercase. | | `string.lower` | `lower()` | Converts to lowercase. | | `string.trim` | `trim()` | Trims surrounding whitespace. | | `string.contains` | `contains(value)` | Returns whether the string contains text. | | `string.starts_with` | `starts_with(value)` | Returns whether the string starts with text. | | `string.ends_with` | `ends_with(value)` | Returns whether the string ends with text. | | `string.index_of` | `index_of(value)` | Returns the index of text, or -1 when missing. | | `string.replace` | `replace(from, to)` | Replaces text. | | `string.split` | `split(separator)` | Splits into a list. | --- # integer An `integer` is a whole number. As a schema field it is written `age: integer`, and a literal is plain digits, as in `42`. ```ruby count = 42 bounded = count.min(100).max(0) ``` ## Methods | Name | Signature | Summary | |---|---|---| | `integer.abs` | `abs()` | Returns the absolute value. | | `integer.min` | `min(other)` | Returns the smaller value. | | `integer.max` | `max(other)` | Returns the larger value. | | `integer.to_string` | `to_string()` | Converts to a string. | --- # float A `float` is a floating-point number. As a schema field it is written `rate: float`, and a literal has a decimal point, as in `3.14`. For money, use [`decimal`](decimal.md) instead, which is exact. ```ruby price = 3.14159 rounded = price.round(2) ``` ## Methods | Name | Signature | Summary | |---|---|---| | `float.abs` | `abs()` | Returns the absolute value. | | `float.round` | `round(places)` | Rounds to the given number of places. | | `float.ceil` | `ceil()` | Rounds up. | | `float.floor` | `floor()` | Rounds down. | | `float.min` | `min(other)` | Returns the smaller value. | | `float.max` | `max(other)` | Returns the larger value. | | `float.to_string` | `to_string()` | Converts to a string. | --- # decimal A `decimal` is an exact decimal number, safe for money where a `float` would lose precision. As a schema field it is written `amount: decimal`. A request can send it as a number or a string (`"19.90"`), and it is coerced without rounding error. ```ruby total = order.amount cents = total.round(2) ``` ## Methods | Name | Signature | Summary | |---|---|---| | `decimal.round` | `round(places)` | Rounds using banker's rounding. | | `decimal.ceil` | `ceil()` | Rounds up. | | `decimal.floor` | `floor()` | Rounds down. | | `decimal.trunc` | `trunc()` | Truncates toward zero. | | `decimal.abs` | `abs()` | Returns the absolute value. | | `decimal.scale` | `scale()` | Returns the number of decimal places. | | `decimal.to_integer` | `to_integer()` | Converts to an integer, truncating toward zero. | | `decimal.to_float` | `to_float()` | Converts to a float. | | `decimal.to_string` | `to_string()` | Converts to a string. | --- # boolean A `boolean` is `true` or `false`. As a schema field it is written `active: boolean`, and the literals are `true` and `false`. Comparisons and the boolean operators (`and`, `or`, `not`) also produce booleans. ```ruby active = true adult = age >= 18 route GET "/account/:id" account = db.accounts.find(params.id) require account.active else fail 403, "account is disabled" reply 200, account ``` Booleans are the clearest values to use in `require` and `allow`. Guards also use truthiness, so `null` and `false` stop the flow. ## Notes - A `boolean` has no methods. You combine booleans with `and`, `or`, and `not` (see [Keywords](../keywords.md)). - `cache.get`, `db.
.find`, and similar return `null` when absent, which is falsy, so you can guard them with `require value else ...` directly. --- # list A `list` is an ordered collection of values. As a schema field it is written `tags: list` for any values, or `tags: list of string` for a typed list. A literal uses brackets, as in `[1, 2, 3]`. ```ruby items = [3, 1, 2] sorted = items.sort() has_two = items.includes(2) ``` ## Methods | Name | Signature | Summary | |---|---|---| | `list.length` | `length()` | Returns the number of items. | | `list.empty?` | `empty?()` | Returns whether the list is empty. | | `list.first` | `first()` | Returns the first item, or null. | | `list.last` | `last()` | Returns the last item, or null. | | `list.push` | `push(value)` | Appends a value and returns the list. | | `list.includes` | `includes(value)` | Returns whether the list includes a value. | | `list.slice` | `slice(start, end)` | Returns a sub-list. | | `list.sort` | `sort()` | Returns a sorted list. | | `list.reverse` | `reverse()` | Returns a reversed list. | | `list.unique` | `unique()` | Returns the distinct values. | | `list.join` | `join(separator)` | Joins the values into a string. | | `list.flatten` | `flatten()` | Flattens one level of nesting. | | `list.zip` | `zip(other)` | Pairs two lists of the same length. | | `list.sum` | `sum()` | Sums a numeric list. | | `list.mean` | `mean()` | Returns the mean, or null when empty. | | `list.median` | `median()` | Returns the median, or null when empty. | | `list.std_dev` | `std_dev()` | Returns the population standard deviation. | --- # map A `map` is a key-value object. As a schema field it is written `meta: map` for a free-form object, or you reference another schema for a typed, nested object. A literal uses braces, as in `{ name: "Ana", age: 30 }`. ```ruby user = { name: "Ana", age: 30 } fields = user.keys() ``` ## Methods | Name | Signature | Summary | |---|---|---| | `map.has` | `has(key)` | Returns whether the map has a key. | | `map.keys` | `keys()` | Returns the keys. | | `map.values` | `values()` | Returns the values. | | `map.size` | `size()` | Returns the number of entries. | | `map.merge` | `merge(other)` | Returns a new map with another map merged in. | | `map.delete` | `delete(key)` | Removes a key and returns the map. | --- # Temporal types `instant`, `date`, `time`, `duration`, and `interval` represent points and spans of time. You construct them through the [`time`](../namespaces/time.md) namespace and read their parts with property access. ```ruby created = time.now() year = created.year hour = created.hour window = time.interval(time.date("2026-01-01"), time.date("2026-01-31")) days = window.duration.total_days ``` ## The types | Type | What it is | Construct with | |---|---|---| | `instant` | A point in time. | `time.now()`, `time.instant("...")` | | `date` | A calendar date. | `time.today()`, `time.date("YYYY-MM-DD")` | | `time` | A wall-clock time. | `time.at("HH:MM:SS")` | | `duration` | A length of time. | `time.seconds(n)`, `time.minutes(n)`, `time.hours(n)`, `time.days(n)` | | `interval` | A span between two instants. | `time.interval(start, end)` | ## Properties Read the parts of a temporal value with a property: | On | Properties | |---|---| | `instant` | `year`, `month`, `day`, `hour`, `minute`, `second`, `weekday`, `unix`, `date`, `time` | | `date` | `year`, `month`, `day` | | `time` | `hour`, `minute`, `second` | | `duration` | `total_days`, `total_hours`, `total_minutes`, `total_seconds` | | `interval` | `duration` | The `total_*` properties return a `float`. ## Placing a time on a date A `time` has an `on(date)` method that puts it on a date, producing an instant: ```ruby opening = time.at("09:30:00").on(time.date("2026-01-15")) ``` ## Notes - Local-time values follow `MARRETA_TIMEZONE`. See [Configuration](../configuration.md). - For comparing intervals (`contains`, `overlaps`) and formatting, see the [`time`](../namespaces/time.md) namespace. --- # Namespaces A namespace groups related operations under a name you reach with dot syntax. You call `db.users.find(id)`, `time.now()`, or `cache.get("key")`. The name before the dot is the namespace, and it tells you where the behavior comes from. Marreta has two kinds of namespace: the native ones it ships with, and the file namespaces you create. ## Native namespaces Native namespaces are built into the language and the runtime. They cover data, messaging, integration, and utilities, and you use them without any import or wiring. The ones backed by infrastructure (the database, cache, and messaging) read their connection details from `marreta.env`, so the namespace stays the same while the provider behind it is configuration. ```ruby route GET "/users/:id" user = db.users.find(params.id) require user else fail 404, "user not found" reply 200, user ``` There is no `import db` and no client to construct. The full list, one page each, is in the [Namespaces reference](../reference/namespaces.md). A native namespace name is reserved, so a variable cannot shadow it. This is what keeps a configured provider from silently vanishing inside a scope that happened to reuse its name. See the [reserved words](../reference/keywords.md) model for the full rule. ## File namespaces You can also create your own namespaces. When a `.marreta` file exports tasks, the file itself behaves as a namespace for any other file that uses them: the namespace is the file name, and you call its tasks as `filename.task`. This is how shared logic travels between files, with the origin visible at the call site and still no imports. Take a file `tasks/text.marreta`: ```ruby # A private helper, file-local. It is never reachable as text.decorate, but an # exported task in the same file can call it. task decorate(word) => "<" + word + ">" export task shout(word) => word.upper() + "!" export task wrap(word) => decorate(word) ``` The file name (`text`) is the namespace. From another file, its exported tasks are reached through it, including as a pipeline stage: ```ruby route GET "/text/:word" reply 200, { shout: text.shout(params.word), wrapped: text.wrap(params.word), piped: params.word >> text.shout } ``` A few rules follow from this: - Only `export` tasks are reachable from other files. A task without `export`, like `decorate` above, stays private to its file. - A cross-file call must be namespaced. A bare `shout(word)` from another file does not resolve; it has to be `text.shout(word)`. - A file namespace cannot shadow a native namespace, so a file named `db.marreta` that exports tasks is a load error. The same is true for the reserved `app` name. - Tasks and schemas defined in `app.marreta` are global, so they need no namespace. For the built-in side and the per-namespace reference, see [Namespaces](../reference/namespaces.md). The `export` keyword that makes a task shareable is covered in [Keywords](../reference/keywords.md). --- # Providers Marreta exposes each integration concern (a relational database, a document store, a cache, messaging) as a language namespace backed by a **provider**. A provider is a high-level abstraction: you select it, set its connection details in `marreta.env`, and then use the namespace directly, with no client library and no wiring. This is why the how-to pages talk about "the cache provider" or "the database provider" rather than naming a specific technology. The technology is an implementation detail of the selected provider. ## One namespace per concern Each concern has one namespace, configured by its own `MARRETA_*` variables: | Concern | Namespace | Configured with | Current provider | |---|---|---|---| | Relational database | `db.*` | `MARRETA_DB_*` | PostgreSQL | | Document store | `doc.*` | `MARRETA_DOC_*` | MongoDB | | Cache | `cache.*` | `MARRETA_CACHE_*` | Redis | | Messaging | `queue.*`, `topic.*`, `on queue`, `on topic` | `MARRETA_QUEUE_*` | RabbitMQ | Today each namespace ships with a single provider, shown in the last column. The abstraction is deliberate: a provider can have more than one implementation, so the same `db.*` code could run on PostgreSQL today and another relational engine later without changing your routes. ## Selecting a provider You choose a provider with the `MARRETA__PROVIDER` variable and give it connection details: ```bash MARRETA_DB_PROVIDER=postgres MARRETA_DB_HOST=127.0.0.1 MARRETA_DB_PORT=5432 MARRETA_DB_NAME=catalog MARRETA_DB_USER=catalog MARRETA_DB_PASSWORD=... ``` `marreta init --with db,cache,doc,queue` scaffolds these variables and a `docker-compose.yml` for the current providers, so a fresh project runs locally with no extra setup. ## What the abstraction does and does not do The goal is not to make infrastructure disappear. Connection settings, credentials, and operational behavior still matter, and you still run the provider somewhere. What the abstraction buys you is application code that stays focused on API behavior: you write against the namespace, refer to the selected provider, and the app does not change if the implementation behind it does. ## See also - [Configure environment variables](../how-to/configure-environment.md): set the provider and its connection details. - [Persist data with local services](../how-to/use-local-services.md): the database provider in practice. --- # Schemas A `schema` describes the shape of some data: a set of named fields with types. Marreta has just one schema primitive, and you use the same one everywhere data crosses a boundary. The same declaration can validate a request, shape a response, define a database table, and type a message a consumer receives. Describing your data once, in one place, is the point. ```ruby schema NewAccount owner: string email?: string balance: decimal ``` Schema names are `PascalCase`, fields are `snake_case` one per line, and a trailing `?` marks a field optional. Fields can be scalars, collections, an `enum ["a", "b"]`, another schema (`address: Address`), or a list of one (`items: list of LineItem`). ## A request contract Bind a schema to the request body with `take payload as `. The body is validated before the route runs, so invalid input never reaches your code and returns a `422` automatically: ```ruby route POST "/accounts" take payload as NewAccount account = doc.accounts.save({ owner: payload.owner, balance: 0 }) reply 201, account ``` ## Query and header inputs The same schema also declares query-string and header inputs, with `take query as ` and `take headers as `. The values arrive as text and are validated and coerced to the declared types (an integer that does not parse, or a required field that is missing, returns a `422`), and the parameters appear named and typed in the generated OpenAPI: ```ruby schema ProductSearch term: string limit?: integer tags?: list of string route GET "/products" take query as ProductSearch reply 200, { term: query.term, limit: query.limit or 20, tags: query.tags or [] } ``` A query or header value repeated in the request (`?tags=a&tags=b`) feeds a `list of ` field. Because query and header parameters are flat on the wire, a schema bound to query or headers must be **flat**: only scalar fields and lists of scalars, never a field that references another schema (a nested object) or a list of objects. Binding a non-flat schema there is a load-time error (and a [lint](../reference/lint.md) warning). A boolean accepts only `true` or `false`, and an empty value (`?term=`) is treated as absent. Without a schema, `take query` / `take headers` still give a raw map of strings, as before. Names match differently by source. A **query** field matches the parameter name **exactly** (query names are case-sensitive), so a field can only bind a parameter whose name is a valid identifier (`complete_name` binds `?complete_name=`, not `?complete-name=`). A **header** field matches by a case-insensitive convention with `_` and `-` treated as the same (`x_request_id` binds `X-Request-Id`), because headers are case-insensitive by the HTTP standard. For the full set of binding variations and how to read each one, see [Read request inputs](../how-to/read-request-inputs.md). ## A response shape The same kind of schema shapes what goes out. `reply as ` keeps only the fields the schema declares, so internal values never leak to the client: ```ruby route GET "/accounts/:id" account = doc.accounts.find(params.id) require account else fail 404, "not found" reply 200 as AccountResponse, account ``` Both bindings also feed the generated [OpenAPI document](../how-to/openapi-docs.md), so the contract documents itself. ## A database table Add `db:
` to a schema and it defines a relational table: the fields are the columns and `id` is the primary key. The same type that validated a payload can be the stored record: ```ruby export schema Product db: products id: integer sku: string name: string price: decimal ``` A field that references another persistent schema is a foreign-key relation, which is how tables connect. Declaring the schema does not create the table; you do that with [migrations](../how-to/migrations.md). A document store works differently: it is schemaless, so there the schema validates and shapes the payloads at the edges rather than defining storage. ## A typed message Consumers receive typed messages the same way routes receive typed payloads. Bind the message with `as ` on an `on queue` or `on topic` handler, and it is validated before your handler body runs: ```ruby on queue "orders.created" take order as NewOrder db.fulfillments.save({ order_id: order.id }) ``` ## Why one primitive Because validation, response shaping, persistence, and messaging all use the same schema, you describe a concept once and reuse it at every boundary it crosses. There is no separate ORM model, request DTO, and response DTO to keep in agreement. See [Validate a request payload](../how-to/validate-a-payload.md) and [Shape a response](../how-to/shape-a-response.md) for the request and response sides, and [`db`](../reference/namespaces/db.md) for persistent schemas and relations. --- # Pipelines A pipeline threads a value through a sequence of steps with the `>>` operator. Each step takes the result of the one before it, so the code reads as the flow of the data, left to right and top to bottom, instead of nested calls you have to read inside out. ```ruby recent = db.orders >> where(active: true) >> order_by("id desc") >> fetch ``` The same value enters on the left, each `>>` hands the result to the next step, and the last step produces the final value. Without the pipeline, that query would nest as `fetch(order_by(where(db.orders, ...), ...))`, which reads backwards from how it runs. ## Where pipelines show up The operator is the same everywhere; only the steps change. - **Database and document queries** build up with steps like `where`, `order_by`, `limit`, and a terminal like `fetch` or `count`. - **Collection transforms** reshape a list with `map`, `keep`, `skip`, and `reduce`. ```ruby users = db.users >> fetch names = users >> map user skip if not user.active keep user.name ``` - **Outbound calls** pipe a body into an HTTP request: ```ruby created = payload >> http_client.post("https://api.example.com/orders") ``` - **Your own tasks** can be pipeline steps too. A task shared from another file is reached through its [file namespace](namespaces.md), which is the file name. Given a file `tasks/text.marreta` that exports a task: ```ruby export task shout(word) => word.upper() + "!" ``` another file pipes a value straight into it as `text.shout`: ```ruby loud = params.word >> text.shout ``` ## Sequential by nature A pipeline is ordered: every step waits for the one before it, because each depends on the previous result. When you have independent work that does not form a chain, like several outbound calls that do not feed each other, reach for [broadcast](broadcast.md) instead, which sends one value to several branches at once. --- # Broadcast Broadcast sends the same value to several branches at once with the `*>>` operator and collects their results into a list. Where a [pipeline](pipelines.md) is a chain, where each step feeds the next, a broadcast is a fan-out: every branch receives the same input and runs on its own. ```ruby task load_profile(user_id) => http_client.get("https://users.internal/#{user_id}").body task load_orders(user_id) => http_client.get("https://orders.internal/#{user_id}").body task load_recommendations(user_id) => http_client.get("https://recs.internal/#{user_id}").body route GET "/users/:id/dashboard" sections = params.id *>> -> load_profile -> load_orders -> load_recommendations reply 200, { profile: sections[0], orders: sections[1], recommendations: sections[2] } ``` Each branch is one of the three tasks, and each receives the same input, `params.id`. They are independent: `load_orders` does not need anything from `load_profile`, so the runtime can run all three at once. The results come back as a list in the order the branches are written, so `sections[0]` is always `load_profile` regardless of which call returned first. ## Independent work runs in parallel The reason to reach for broadcast is parallelism. When the branches do independent work, like three separate outbound calls or queries, the runtime runs them at the same time instead of one after another. Three calls that each take a second finish together in about a second, not three. That is the whole point: a fan-out of independent work without the wiring to manage threads yourself. This makes broadcast the right tool when the branches do not depend on each other. If one branch needs the result of another, that is a chain, so use a [pipeline](pipelines.md). ## The runtime may optimize trivial branches Parallelism here is an optimization the runtime applies, not a timing guarantee you should build on. When every branch is a trivial, side-effect-free computation (no calls, no database or cache or queue or HTTP, no nested broadcast), the runtime runs the branches sequentially on purpose: spawning parallel work for a handful of cheap expressions would cost more than just computing them. The results and their order are identical either way, so this is invisible to your code. In other words, the runtime parallelizes when there is real, independent work to overlap, and skips the overhead when there is not. You write the same `*>>` regardless. ## Notes - Results are positional and follow declaration order, even though execution may not. - A branch can be a task, a namespaced task (`-> file.task`), or a call. - `*>>` is not allowed inside a `transaction` block, since the branches run independently. See [Control flow and operators](../reference/control-flow.md) for the operator in the wider syntax, and [Pipelines](pipelines.md) for the sequential counterpart.