# Amber Framework 2.0 Beta documentation

Canonical documentation bundle for https://amberframework.org/docs/v2.
Prefer V2-authored pages when an inherited maintenance reference conflicts with a V2 page.


---

## Amber 2.0 Beta

Canonical page: https://amberframework.org/docs/v2

# Amber 2.0 Beta

Amber `2.0.0-beta.2` is available for evaluation. This is a prerelease: expect
breaking changes and do not treat it as a production-support promise.

The release-gated first-run path is a server-rendered ECR web application
created by the standalone Amber CLI. It includes routing, controllers, typed
configuration, sessions, static files, tests, and a development watcher without
requiring a database.

```bash
brew install amberframework/amber_cli/amber_cli
amber new my_app
cd my_app
crystal spec
amber watch
```

Web is the default application type. `amber new my_app --type web` is the
explicit equivalent. Native generation is a separate preview surface.

Start with [Installation](getting-started/installation/) for the platform
matrix, checksums, and troubleshooting, then follow the
[web-app walkthrough](getting-started/). The
[web template reference](guides/web-template/) explains every generated layer
and its release boundary.

## What is in the framework beta

- MVC controllers and ECR views
- routing, pipelines, constraints, and named routes
- typed request schemas and validation
- memory-backed jobs, sessions, and WebSocket pub/sub
- mailer APIs
- typed YAML configuration with environment-variable overrides
- standalone CLI and diagnostics LSP

## What is preview

Persistence/authentication resource generators, Grant integration, Gemma file
attachments, the separate asset pipeline, and native-app generation have useful
code and documentation, but they are not part of the beta web-app release gate.
They may require unpublished or moving dependencies. Preview pages are labeled
so new users do not mistake them for the zero-setup path.

See [Beta support](beta-support/) for the exact platform and generator matrix.

## Migrating

Amber V2 removes Kilt and Slang, stops bundling database drivers, adopts typed
configuration, and extracts the CLI from the framework repository. Existing
apps should follow the [migration guide](migration-guide/) and pin the beta
instead of a moving development branch.

## Help

- [Discord](https://discord.gg/vwvP5zakSn)
- [Framework issues](https://github.com/amberframework/amber/issues)
- [CLI issues](https://github.com/amberframework/amber_cli/issues)
- [Homebrew issues](https://github.com/amberframework/homebrew-amber_cli/issues)


---

## Beta Support

Canonical page: https://amberframework.org/docs/v2/beta-support

# Amber V2 Beta Support

Platform support has three separate signals:

- **Web compile** means CI built the Amber CLI, generated a clean web app,
  installed its dependencies, ran its specs, and compiled the application on
  that operating system and CPU.
- **Install artifact** means the current CLI release publishes a ready-to-use
  archive or package for that target.
- **Release-gated** means the complete installation, generation, dependency,
  spec, build, launch, homepage, and static-asset sequence must pass on that
  platform before the beta is published.

One signal does not silently imply the others.

## Platform matrix

| Platform | Clean web compile | CLI 2.0.3 install artifact | Beta release gate |
|---|---|---|---|
| Apple Silicon macOS | Verified | Homebrew and `darwin-arm64` archive | Yes |
| x86_64 Linux | Verified | Homebrew or `linux-x86_64` archive | Yes |
| Linux ARM64 | Verified on GitHub-hosted ARM64 Linux | Source build; the next-release workflow now builds and smoke-tests `linux-arm64` | No |
| Intel macOS | Not currently verified | None | No |
| Windows x86_64 | Verified with the candidate render-path fix; released beta.2 still fails on controller-relative ECR path handling | None | No |

The [platform compile pull request](https://github.com/amberframework/amber_cli/pull/34)
is the evidence stream for Linux ARM64 and Windows. The Windows run found a
real framework defect instead of being treated as a green support claim. With
the [render-path fix](https://github.com/amberframework/amber/pull/1402), the
same job now generates the app, installs dependencies, passes its request spec,
and compiles the Windows executable.

Linux ARM64 is supported for the clean web application's source-build path and
compile contract. It is not yet release-gated, and CLI 2.0.3 does not contain a
Linux ARM64 archive. Windows is deliberately not a release gate for this beta.
Its candidate result proves the repair, not compatibility in the released
beta.2 dependency; describe Windows as supported only after the framework fix
ships and that released dependency passes the same job.

## Application and generator matrix

| Command or surface | Status |
|---|---|
| `amber new APP --type web` | Supported |
| ECR views, homepage, static files, specs, build, launch | Release-gated |
| controller, schema, job, mailer, channel generators | Supported core output |
| migration generator | Supported output; applying SQL needs database tooling |
| model, scaffold, API-resource, auth generators | Preview; persistence-backed |
| `amber new APP --type native` | Preview |
| Grant, Gemma, and Asset Pipeline guides | Preview ecosystem material |

“Preview” means the code can be evaluated, but it is not included in the clean
beta web application's compile guarantee. Add preview packages only from their
own compatible official release instructions.

## Versions

- Amber framework: `2.0.0-beta.2`
- Amber CLI: `2.0.3` or newer
- Crystal: `>= 1.20.0, < 2.0`

Generated applications pin the framework prerelease exactly. Do not replace it
with `v2-dev`, `master`, or a personal fork when following the supported path.


---

## Getting Started

Canonical page: https://amberframework.org/docs/v2/getting-started

# Build Your First Amber V2 Web App

Complete [Installation](installation/) first. This walkthrough stays inside the
release-gated core: no ORM, database, Node.js, or preview generator is required.

## Create the project

```bash
amber new my_app --type web
cd my_app
```

Dependencies install automatically. If you used `--no-deps`, run `shards
install` now.

The generated project uses ECR, typed environment YAML, and static routes. Its
`shard.yml` pins Amber `2.0.0-beta.2` from `amberframework/amber`.

## Prove the clean scaffold works

```bash
crystal spec
crystal build src/my_app.cr -o bin/my_app
amber watch
```

Open <http://127.0.0.1:3000>. Also load
<http://127.0.0.1:3000/css/app.css>; this catches a missing static route that a
homepage-only check would miss.

## Understand the generated files

```text
my_app/
├── .amber.yml
├── shard.yml
├── config/
│   ├── application.cr
│   ├── routes.cr
│   ├── environments/
│   └── initializers/
├── public/css/app.css
├── public/js/app.js
├── spec/controllers/home_controller_spec.cr
└── src/
    ├── my_app.cr
    ├── controllers/
    └── views/
        ├── home/index.ecr
        └── layouts/application.ecr
```

For the complete file-by-file contract, including the intentionally empty
extension directories, read the [V2 web template guide](../guides/web-template/).

`.amber.yml` records CLI metadata. `config/environments/*.yml` uses nested V2
sections such as `server`, `database`, `session`, and `logging`. Environment
variables override values, for example:

```bash
AMBER_SERVER_PORT=8080 amber watch
```

## Add a page

Generate a controller with ECR views:

```bash
amber generate controller Posts index show
```

The generator leaves request specs pending until routes exist. Add routes to
the generated `routes :web` block:

```crystal
get "/posts", PostsController, :index
get "/posts/:id", PostsController, :show
```

Then enable the matching request specs and run:

```bash
crystal tool format
crystal spec
```

## Add a typed request schema

```bash
amber generate schema Post title:string:required body:text
```

The Schema API is built into Amber core. See [Schema API](../guides/schema-api/)
for validation and controller integration.

## Know the beta boundary

Model, scaffold, API-resource, and auth generators currently emit
persistence-backed output. Native generation has a separate platform matrix.
They are preview surfaces, not part of this clean web-app guarantee. Read
[Beta support](../beta-support/) before using them.


---

## Amber CLI

Canonical page: https://amberframework.org/docs/v2/cli

# Amber CLI

Amber CLI `2.0.3` is the standalone project generator, development watcher,
generator suite, database tool, and diagnostics LSP for Amber V2.

```bash
brew install amberframework/amber_cli/amber_cli
amber --version
```

The fully qualified command follows Homebrew's tap-trust model. The formula is
`amber_cli`; the executable is `amber`.

## Supported quick start

```bash
amber new my_app --type web
cd my_app
crystal spec
amber watch
```

## Commands

| Command | Beta status | Purpose |
|---|---|---|
| [`new`](new/) | Supported for web | Create web or preview native apps |
| [`generate`](generate/) | Mixed | Generate core or preview components |
| [`watch`](watch/) | Supported | Rebuild and restart the app |
| `routes` | Supported | List configured routes |
| `pipelines` | Supported | Inspect pipelines |
| `database` | Preview for new apps | Operate an explicitly added persistence stack |
| `setup:lsp` | Available | Configure the bundled diagnostics LSP |

Use `amber --help` and `amber COMMAND --help` for the installed version's exact
syntax.


---

## Guides

Canonical page: https://amberframework.org/docs/v2/guides

# Guides

V2 keeps framework concepts that still apply—controllers, requests, responses,
sessions, routing, cookies, testing, and other stable APIs. An unchanged page
has no badge. **New** and **Updated** badges identify material written or
revised for V2.

Pages built around removed components are excluded rather than inherited.
Granite and Jennifer point to Grant replacements; legacy bundled-CLI commands
remain retired; assets and the standalone Amber CLI use their V2 guides.

## How to apply an example

Every code-bearing V2 guide now provides one of two placement contracts:

- a walkthrough labels each block with the exact **File**, **Files**, or
  **Run from** location and says whether to create, edit, replace, or inspect it;
- an API reference begins with **Where the examples go**, mapping declarations,
  usage fragments, configuration, views, and generated output to their normal
  application directories.

Paths are relative to the application root—the directory containing
`shard.yml`—unless a guide says otherwise. Treat `public/` as browser-served
application files only when the guide labels them as source; never hand-edit a
directory labeled as generated output.

## Supported beta core

- [Build a Pet Tracker](pet-tracker/) — the canonical first app, from routes to HTML, JSON, CSS, and browser-native JavaScript
- [Web template](web-template/) — exact output of Amber CLI 2.0.3
- [Schema API](schema-api/) — typed request parsing and validation
- [Adapters](adapters/) — framework adapter concepts and extension points

## Preview ecosystem material

- [Asset Pipeline](assets/) — separate native-ESM project
- [Native application template](native-preview/) — macOS, iOS, and Android preview
- [Grant](models/grant/) — separate ORM project
- [Gemma](uploads/) — separate attachment project

Preview pages describe work that can be evaluated, but they are not part of the
clean web-template compile guarantee. Start with the supported web template and
add preview projects deliberately.

## Use the docs with an assistant

[AI assistants](ai-assistants/) explains how to give ChatGPT, Claude, or Gemini
the current V2 source set. It also provides a single Markdown knowledge bundle
and a tested Custom GPT instruction contract. The assistant should cite these
pages, preserve exact file locations, and name beta boundaries rather than
silently filling gaps from older Amber versions.

## Maintaining a V1 application

Amber 1.4.1 documentation remains available from the version selector. Choose
that version when maintaining an existing V1 application. V2 uses badges only
where a page is new or materially updated and uses replacement links where the
path changed.


---

## Deployment

Canonical page: https://amberframework.org/docs/v2/deployment

# Deployment

Deploy Amber V2 as a compiled Crystal executable. The beta does not publish a
verified one-click recipe for Heroku, Dokku, DigitalOcean, or another hosting
vendor. Those V1 pages depended on old Crystal versions, Webpack or Node asset
builds, bundled database commands, Redis defaults, and retired buildpacks, so
they are not carried into V2.

The portable deployment contract is:

1. install production shard dependencies;
2. run the test suite;
3. compile the application target in release mode;
4. provide production configuration through environment variables;
5. run the binary behind a TLS-terminating reverse proxy or managed ingress.

```bash
shards install --production
crystal spec
shards build my_app --release
```

The generated target writes `bin/my_app`. Build on the same operating-system
and CPU family used by the runtime unless you have deliberately configured a
cross-compilation toolchain.

## Required runtime configuration

```bash
export AMBER_ENV=production
export AMBER_SERVER_HOST=0.0.0.0
export AMBER_SERVER_PORT=3000
export AMBER_SERVER_SECRET_KEY_BASE="replace-with-a-long-random-secret"
./bin/my_app
```

Set `AMBER_DATABASE_URL` only after adding and configuring a database adapter.
The default V2 web template intentionally contains no ORM or database driver.
Never commit the production secret or inject it into a container image.

## Platform checklist

- Route external HTTPS traffic through a reverse proxy or managed ingress.
- Forward to the port in `AMBER_SERVER_PORT`; do not run the process as root to
  bind directly to ports 80 or 443.
- Preserve termination signals so the process can shut down cleanly.
- Capture standard output and standard error with the platform log service.
- Restart failed processes with the platform supervisor.
- Add database migrations and backups only after choosing a V2-compatible
  persistence stack.

Continue with [Manual binary deployment](manual-deploy/) for a concrete Linux
service example.


---

## Examples

Canonical page: https://amberframework.org/docs/v2/examples

# Examples

{% page-ref page="amber-auth.md" %}

{% page-ref page="crystal-debug.md" %}

{% page-ref page="json-api-full-crud.md" %}

{% page-ref page="minimal-configuration.md" %}


---

## Cookbook

Canonical page: https://amberframework.org/docs/v2/cookbook

# Cookbook

This Amber Cookbook is inspired by original [Kemal Cookbook](http://kemalcr.com/cookbook/hello_world/) by [@sdogruyol](https://github.com/sdogruyol)

{% page-ref page="from-scratch.md" %}

{% page-ref page="hello-world.md" %}

{% page-ref page="cors.md" %}

{% page-ref page="file-download.md" %}

{% page-ref page="file-upload.md" %}

{% page-ref page="authenticate.md" %}

{% page-ref page="json-api.md" %}

{% page-ref page="json-mapping.md" %}

{% page-ref page="websocket-chat.md" %}


---

## Migration Guide

Canonical page: https://amberframework.org/docs/v2/migration-guide

# Migration Guide: Amber 1.x to 2.0

> Amber `2.0.0-beta.2` release-gates the framework core and ECR web template.
> Grant, Gemma, Asset Pipeline, persistence/auth generators, and native output
> are preview surfaces. Treat their migration guides as separate evaluations,
> not prerequisites for adopting the framework beta.

Migrate the framework core first and preserve working application behavior at
each step. Persistence, front-end tooling, uploads, and native output have their
own release boundaries; changing them at the same time makes a failure harder to
locate and harder to reverse.

## What changes in V2

| Application boundary | Amber 1.x starting point | Amber 2.0 path |
|---|---|---|
| Views | ECR, Slang, or Kilt | ECR for new and generated V2 views |
| JavaScript and CSS | Commonly Webpack-managed | Static files work without a bundler; Asset Pipeline is a separate preview |
| Persistence | Commonly Granite or Jennifer | No ORM or database driver is bundled; choose and verify persistence separately |
| Sessions | Redis-oriented configuration | Built-in memory adapter or an explicitly registered external adapter |
| WebSocket pub/sub | Redis-oriented configuration | Built-in in-process adapter or an explicitly registered external adapter |
| Request validation | Controller-specific parsing | Optional typed Schema API |
| File attachments | Application-specific integration | No bundled attachment library; Gemma is an ecosystem preview |

## Before changing dependencies

Create a migration branch and capture a working baseline:

1. Record the Crystal, Amber, ORM, database-driver, Redis, and asset-tool versions.
2. Run the existing specs and build the application binary.
3. Smoke-test the routes, session behavior, background work, WebSockets, and
   static assets the application actually uses.
4. Back up the database and prove the restore procedure before changing an ORM
   or running a schema migration.
5. List every Slang/Kilt template, Webpack entry point, Redis integration, and
   framework-generated file that will need an explicit decision.

Do not begin by deleting the old asset, persistence, or session configuration.
Keep the last working path available until its replacement has passed the same
checks.

## 1. Update the framework core

Pin the official Amber prerelease in `shard.yml`:

```yaml
dependencies:
  amber:
    github: amberframework/amber
    version: 2.0.0-beta.2
```

Then update dependencies and restore the baseline before adopting optional V2
features:

```bash
shards update
crystal spec
crystal build src/your_app.cr -o bin/your_app
```

Resolve compile errors against the [V2 routing](../guides/routing/),
[controllers](../guides/controllers/), [views](../guides/views/), and
[configuration](../getting-started/) guides. Keep persistence and asset-tool
changes out of this step whenever possible.

## 2. Move generated and legacy views to ECR

Amber V2 removes Kilt and Slang from the supported framework path. Convert one
view boundary at a time, preserve its rendered HTML contract, and run the
request or feature specs that exercise it. New V2 generators emit ECR.

The [Views guide](../guides/views/) documents layouts, partials, helpers, and
escaping behavior for the V2 path.

## 3. Make sessions and pub/sub explicit

Amber V2 includes in-memory session and pub/sub adapters. They keep a clean
application independent of Redis, but their state is local to one process.

Applications that require shared state across processes or hosts must register
and test an external implementation. Redis is not a built-in adapter guarantee;
it is one backend an application can integrate through the adapter interfaces.

Use the [Redis-to-adapters guide](redis-to-adapters/) to inventory the existing
behavior, then verify expiration, logout, session rotation, broadcasts, and
multi-process delivery before switching production traffic.

## 4. Keep persistence as a separate decision

The V2 web template does not install an ORM or database driver. An existing
Granite or Jennifer application may keep its current persistence layer while the
framework core is evaluated, but compatibility depends on that application's
Crystal version, shard versions, and usage. Amber does not make a blanket
compatibility promise for those combinations.

Grant is the V2 ecosystem direction for new persistence work, but its migration
material remains preview. Do not mix two ORMs in a production migration unless
the ownership of connections, transactions, migrations, and models is explicit.
Review the [model-layer boundary](../guides/models/) before using the
[Granite-to-Grant preview guide](granite-to-grant/).

## 5. Preserve working assets before replacing tooling

The supported V2 web application serves CSS, JavaScript, images, and fonts from
`public/` without requiring Node.js or a bundler. That does not require an
existing application to remove a working Webpack pipeline during the framework
upgrade.

If you evaluate native ESM or the separate Asset Pipeline project, treat it as
its own migration. Compare the generated files, import behavior, cache headers,
and production deployment before retiring the previous build. The
[Webpack-to-ESM guide](webpack-to-esm/) and [Asset Pipeline guides](../guides/assets/)
describe preview paths rather than a requirement of the web-framework beta.

## 6. Adopt optional V2 features after the baseline passes

Schema API, jobs, mailers, adapters, and expanded testing helpers can be adopted
independently. Add one application boundary, write or update its specs, and
restore the complete build before moving to the next.

## Verification gates

Use observed behavior instead of an estimated migration timeline:

| Gate | Evidence to keep |
|---|---|
| Framework | Dependency resolution, complete specs, and a compiled application binary |
| HTTP | Representative request specs for routes, pipelines, parameters, redirects, and errors |
| Sessions | Login/logout, expiration, rotation, cookie settings, and multi-process behavior where required |
| WebSockets | Subscription, broadcast, reconnect, and cross-process delivery where required |
| Assets | Production-built CSS and JavaScript, static-file responses, and browser smoke tests |
| Persistence | Database backup/restore proof, migrations, transactions, and representative reads and writes |
| Deployment | A staging build produced through the same commands and configuration used in production |

A migration boundary is complete when its previous behavior is reproduced or an
intentional change is documented and tested—not when a predetermined number of
days has elapsed.

## Getting help

When reporting a migration problem, include the smallest failing example plus
the Crystal version, Amber version, previous Amber version, relevant shard
versions, exact command, and complete output.

- Review the [Amber V2 release notes](https://github.com/amberframework/amber/releases).
- Ask in the [Amber Discord](https://discord.gg/vwvP5zakSn).
- Report framework behavior in the [Amber issue tracker](https://github.com/amberframework/amber/issues).
- Report CLI and generator behavior in the [Amber CLI issue tracker](https://github.com/amberframework/amber_cli/issues).


---

## Troubleshooting

Canonical page: https://amberframework.org/docs/v2/troubleshooting

# Troubleshooting

This is a place to share common problems and solutions to them.

## Can't build my amber project

Sometimes, you are trying to build an amber project in a new machine and crystal doesn't have all dependencies required by default. Then, ensure to install all development packages for `openssl` `git`, `yaml`, `libevent`, and `sqlite3`, `postgresql`, or `mysql`.

{% hint style="info" %}
Also see [Installation Guide](guides/installation.md)
{% endhint %}

## Can't connect to database

Sometimes, you're trying to run your amber project and you get an database connection error. Then, ensure your database is running and the `database_url` is well specified on `config/environments/{your-enviroment-file}.yml`, or on `DATABASE_URL` environment variable. Also remember to run `bin/amber db drop create migrate` , before executing your project.

{% hint style="info" %}
Also see [Creating the Database](guides/create-new-app.md#creating-the-database)
{% endhint %}

## Can't update dependencies

On new [crystal 0.25.0 release](https://crystal-lang.org/2018/06/15/crystal-0.25.0-released.html), the `shards` command has a global cache issue, already reported [here](https://github.com/crystal-lang/shards/issues/211) and fixed on next version. Until crystal 0.25.1 is released, you can fix your dependencies by removing global shards cache and shards files.

```text
rm -rf ~/.cache/shards
rm -rf shard.lock
rm -rf lib
```

## Something else doesn't work

Ask around on the [Amber Discord](https://discord.gg/vwvP5zakSn), or [create an issue](https://github.com/amberframework/amber/issues).

If you figure it out, edit this document as a courtesy to the next person having the same problem.


---

## In Production

Canonical page: https://amberframework.org/docs/v2/in-production

# In Production

Amber is still changing and growing rapidly. Here we list the brave folks who have started to use it in production nonetheless, building and strengthening our community. Are you using Amber in production at your company or project? Please add yourself in alphabetical order to the list under the corresponding industry!

This page has been inspired by [Crystal in production](https://github.com/crystal-lang/crystal/wiki/Used-in-production) and [Kemal Users](https://github.com/kemalcr/kemal/wiki/Kemal-Users).

## Embedded Programming 

* [Nikola Motor Company](https://nikolamotor.com) - Electric Semi Trucks and UTV's
* [Interface of Truck](https://www.youtube.com/watch?v=u4mnYkH8ntc) Interface in Video is written in Crystal

## SaaS products

* [Tovi](https://tovi.io/) - Data Analysis Software

## Blockchain

* [Saturn Network](https://saturn.network/) - Decentralized Cryptocurrency Exchange

## Hosting

* [Universal Layer](https://ulayer.net/) - Privacy and security conscious hosting provider

## Open Source Projects using Amber

Many developers around the world are trying Amber everyday, you can find a lot of projects on open source repositories like Github. [Almost 100 projects are using Amber right now!](http://shards.info/repos/amberframework/amber/dependents).

Also you can use [Wappalyzer](https://www.wappalyzer.com/technologies/amber) to detect webapps using Amber. Here are some of them:

* [Nocturne Project](https://nocturne.crnbrdrck.xyz/)- Village Builder web application \([source code](https://github.com/TheNocturneProject/Nocturne)\)
* [Amber Framework](https://amberframework.org/) - Main website for Amber \([source code](https://github.com/amberframework/amberframework.org)\)
* [Amber Latest ](https://amber-latest-app-example.herokuapp.com/)- App built with amber latest branch to test new features \([source code](https://github.com/faustinoaq/amber-latest-app-example)\)
* [Ambrockets](https://ambrockets.herokuapp.com/) - A simple WebSockets example \([source code](https://github.com/faustinoaq/ambrockets)\)
* [Triathlets](http://triathlets.krylov-alexey.ru/) - Web app for Vladivostok's triathlets \([source code](https://github.com/forsaken1/triathlets)\)
* [Crystal \[ANN\]](https://crystal-ann.com/) - Web site to announce new Crystal projects \([source code](https://github.com/crystal-community/crystal-ann)\)
* [Bird app](https://crystal-bird-app.herokuapp.com/) - Where birds are neat \([source code](https://github.com/bradford-hamilton/crystal-bird-app)\)


---

## Contributing

Canonical page: https://amberframework.org/docs/v2/contributing

# Contributing

First thank you for taking the time to contribute and making our community great!

Amber is an open source project and we love to receive contributions from our community — you! The following is a set of guidelines for contributing to Amber, which are hosted in the Amber Crystal on GitHub.

Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. These are just guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request.

## Table of contents

[What should I know before I get started?](contributing.md#what-should-i-know-before-i-get-started)

* [Code of Conduct](code-of-conduct.md)
* [Ground Rules](contributing.md#ground-rules)

[How Can I Contribute?](contributing.md#how-can-i-contribute)

* [Reporting Bugs](contributing.md#how-to-report-a-bug)
* [Suggesting Enhancements](contributing.md#how-to-suggest-a-feature-or-enhancement)
* [Your First Contribution](contributing.md#your-first-contribution)
* [Pull Requests](contributing.md#pull-requests)

[Style Guides](contributing.md#styles-guides)

* [Coding Style Guides](contributing.md#coding-style-guides)
* [Documenting code](contributing.md#documenting-code)
* [Spec Style Guides](contributing.md#spec-guides)

## What should I know before I get started?

### Ground Rules

Be a law abiding contributor!

This project adheres to the Contributor Covenant [CODE OF CONDUCT](code-of-conduct.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to our [Discord](https://discord.gg/vwvP5zakSn).

## How Can I Contribute

### How to report a bug

This section guides you through submitting a bug report for Amber. Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports.

**Before Submitting A Bug Report**

Before creating bug reports, please check this list as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible. Fill out the required template, the information it asks for helps us resolve issues faster.

**How Do I Submit A \(Good\) Bug Report?**

Bugs are tracked as [GitHub issues](https://github.com/amberframework/amber/issues). After you've determined the cause of the bug, create an issue and provide the following information by filling in the template.

**A good bug report**

* Contains the information needed to reproduce and fix problems
* Is an efficient form of communication for both bug reporter and bug receiver
* Is resolved as fast as possible
* Is sent to the person in charge
* Establishes a common ground of collaboration

Explain the problem and include additional details to help maintainers reproduce the problem:

* Use a clear and descriptive title for the issue to identify the problem.
* Describe the exact steps which reproduce the problem in as many details as possible. For example, start by explaining how you started Amber, e.g. which command exactly you used in the terminal, or how you started A,ber otherwise. When listing steps, don't just say what you did, but explain how you did it. For example, if you moved the cursor to the end of a line, explain if you used the mouse, or a keyboard shortcut or an Amber command, and if so which one?
* Provide specific examples to demonstrate the steps. Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets in the issue, use Markdown code blocks.
* Describe the behavior you observed after following the steps and point out what exactly is the problem with that behavior.
* Explain which behavior you expected to see instead and why.
* Include screenshots and animated GIFs which show you following the described steps and clearly demonstrate the problem. If you use the keyboard while following the steps, record the GIF with the Keybinding Resolver shown. You can use this tool to record GIFs on macOS and Windows, and this tool or this tool on Linux.
* If the problem is related to performance, include a CPU profile capture and a screenshot with your report.
* If the problem wasn't triggered by a specific action, describe what you were doing before the problem happened and share more information using the guidelines below.

### How to suggest a feature or enhancement

This section guides you through submitting an enhancement suggestion for Amber, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion and find related suggestions.

Before creating enhancement suggestions, please check this list as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please include as many details as possible. Fill in the template, including the steps that you imagine you would take if the feature you're requesting existed.

**Before Submitting An Enhancement Suggestion**

* Check if there's already a shard which provides that enhancement.
* Perform a cursory search to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.

**How Do I Submit A \(Good\) Enhancement Suggestion?**

Enhancement suggestions are tracked as GitHub issues. After you've determined which repository your enhancement suggestion is related to, create an issue on that repository and provide the following information:

* Use a clear and descriptive title for the issue to identify the suggestion.
* Provide a step-by-step description of the suggested enhancement in as many details as possible.
* Provide specific examples to demonstrate the steps. Include copy/pasteable snippets which you use in those examples, as Markdown code blocks.
* Describe the current behavior and explain which behavior you expected to see instead and why.
* Include screenshots and animated GIFs which help you demonstrate the steps or point out the part of Amber which the suggestion is related to. You can use this tool to record GIFs on macOS and Windows, and this tool or this tool on Linux.
* Explain why this enhancement would be useful to most Amber users and isn't something that can or should be implemented as a community package.
* List some other text editors or applications where this enhancement exists.
* Specify which version of Amber you're using. You can get the exact version by running Amber -v in your terminal, or by starting Amber and running the Application: About command from the Command Palette.
* Specify the name and version of the OS you're using.

### Your First Contribution

Unsure where to begin contributing to Amber? You can start by looking through these beginner and help-wanted issues:

Beginner issues - issues which should only require a few lines of code, and a test or two. Help wanted issues - issues which should be a bit more involved than beginner issues. Both issue lists are sorted by total number of comments. While not perfect, number of comments is a reasonable proxy for impact a given change will have.

### Pull Requests

* Fill in the required template
* Document new code based on the [Documenting Code](https://crystal-lang.org/docs/conventions/documenting_code.html) docs
* Include thoughtfully-worded, well-structured
* End files with a newline
* Format your code with`crystal tool format`
* Specs Styleguide

## Styles Guides

### Coding Style Guides

* [Style Guide](https://crystal-lang.org/docs/conventions/coding_style.html)

### Documenting code

* [Documenting Code](https://crystal-lang.org/docs/conventions/documenting_code.html)

### Spec Guides

* Include thoughtfully-worded, well-structured Crystal specs in the`./spec`folder.
* Treat`describe`as a noun or situation.
* Teat`it`as a statement about state or how an operation changes state.


---

## Code of Conduct

Canonical page: https://amberframework.org/docs/v2/code-of-conduct

# Code of Conduct

## Our Pledge

In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.

## Our Standards

Examples of behavior that contributes to creating a positive environment include:

* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members

Examples of unacceptable behavior by participants include:

* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting

## Our Responsibilities

Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.

Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.

## Scope

This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [Dru Jensen](mailto://drujensen@gmail.com) or [Elias Perez](mailto://eliasjpr@gmail.com) or [Isaac Sloan](mailto://isaac@isaacsloan.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.

Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org/) at [version 1.4](http://contributor-covenant.org/version/1/4)


---

## amber new

Canonical page: https://amberframework.org/docs/v2/cli/new

# `amber new`

```bash
amber new NAME [options]
```

## Options

| Option | Default | Meaning |
|---|---|---|
| `--type web|native` | `web` | Web is supported; native is preview |
| `-d`, `--database pg|mysql|sqlite` | `pg` | Records metadata and suggested URLs |
| `-t`, `--template ecr` | `ecr` | Amber V2 supports ECR only |
| `--no-deps` | off | Skip automatic `shards install` |
| `-y`, `--assume-yes` | off | Disable interactive prompts |

`NAME` may be a simple name, relative path, absolute path, or `.`. For a path,
the final component becomes the project name. Paths containing spaces are
rejected.

```bash
amber new my_app
amber new my_app --type web
amber new projects/admin --type web -d sqlite
amber new /tmp/amber_smoke --type web --no-deps
amber new . --type web
```

The first two commands generate the same web application. Omitting `--type`
is the recommended first run; the explicit form is useful in automation.

## Web template contract

The generated web app contains:

- `amberframework/amber` pinned to `2.0.0-beta.2`
- Crystal `>= 1.20.0, < 2.0`
- ECR layout and homepage
- typed development, test, and production YAML
- web, API, and static pipelines plus routes
- homepage request spec and `bin/` build directory
- no ORM, database driver, attachment shard, or personal fork

The database option does not install persistence. It preserves intent for later
tooling while keeping the first build independent of a database.

## After generation

```bash
cd my_app
# Needed only when --no-deps was used:
shards install
crystal spec
crystal build src/my_app.cr -o bin/my_app
amber watch
```

Verify both `/` and `/css/app.css`.

`--type native` remains available for contributors and early adopters, but it
is not part of the Amber V2 beta install/build guarantee. Read the [native
preview guide](../guides/native-preview/) before evaluating it.


---

## amber generate

Canonical page: https://amberframework.org/docs/v2/cli/generate

# `amber generate`

```bash
amber generate TYPE NAME [fields or actions]
```

| Type | Status | Output |
|---|---|---|
| `controller` | Supported | Controller, ECR views, pending route specs |
| `schema` | Supported | Built-in Schema API definition |
| `job` | Supported | Built-in job class |
| `mailer` | Supported | Built-in mailer class |
| `channel` | Supported | WebSocket channel |
| `migration` | Supported output | SQL migration; applying it needs DB tooling |
| `model` | Preview | Grant-backed model and migration |
| `scaffold` | Preview | Persistence-backed CRUD resource |
| `api` | Preview | Persistence-backed model and controller |
| `auth` | Preview | Requires a compatible persistence/auth stack |

Examples for the core web app:

```bash
amber generate controller Posts index show
amber generate schema Post title:string:required body:text
amber generate job PublishPost --queue=default --max-retries=3
amber generate mailer Digest --actions=weekly
amber generate channel Updates --topics=posts
amber generate migration CreatePosts
```

Controller routes are intentionally not guessed. Add them to `config/routes.cr`,
then enable the generated pending request specs. Amber V2 generator output is
always ECR, even if a migrated `.amber.yml` still contains a legacy Slang value.

Preview generators print a warning. They may write useful files, but the clean
web app does not include the dependencies needed to compile them.


---

## amber watch

Canonical page: https://amberframework.org/docs/v2/cli/watch

# `amber watch`

Run from the generated application root:

```bash
amber watch
```

The default V2 watch configuration rebuilds when Crystal source, environment
YAML, or ECR views change. It creates `bin/`, builds the app target, runs it,
and restarts after matching files change.

```yaml
watch:
  run:
    build_commands:
      - mkdir -p bin
      - crystal build ./src/my_app.cr -o bin/my_app
    run_commands:
      - bin/my_app
    include:
      - ./config/**/*.cr
      - ./config/environments/*.yml
      - ./src/**/*.cr
      - ./src/**/*.ecr
```

Use environment variables normally:

```bash
AMBER_SERVER_PORT=8080 amber watch
```

Stop the watcher with `Ctrl-C`. If a rebuild fails, run the printed build
command directly to get the complete compiler error.


---

## Hello World

Canonical page: https://amberframework.org/docs/v2/cookbook/hello-world

# Hello World

This recipe will help you to setup a `Hello World!` response in your `/hello` path.

{% hint style="warning" %}
First you need an amber project generated with [Amber CLI](../guides/create-new-app.md) or [from scratch](from-scratch.md).
{% endhint %}

First create a `src/controllers/hello_controller.cr` file and add this:

{% code-tabs %}
{% code-tabs-item title="src/controllers/hello\_controller.cr" %}
```crystal
class HelloController < ApplicationController
  def hello
    "Hello Amber!"
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

Then add a new route in your `config/routes.cr` file:

```crystal
Amber::Server.configure do |app|
  pipeline :web do
    # pipelines...
  end

  routes :web do
    # other routes,,,
    get "/hello", HelloController, :hello
  end
end
```


---

## CORS

Canonical page: https://amberframework.org/docs/v2/cookbook/cors

# CORS

This recipe will help you to setup a [Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) pipe in your application.

{% hint style="warning" %}
First you need an amber project generated with [Amber CLI](../guides/create-new-app.md) or [from scratch](from-scratch.md).
{% endhint %}

{% code-tabs %}
{% code-tabs-item title="config/application.cr" %}
```crystal
Amber::Server.configure do
  pipeline :api do
    plug Pipe::CORS.new
  end
  
  routes :api do
    # your routes here...
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

Also see [pipelines](../guides/routing/pipelines.md).


---

## File Download

Canonical page: https://amberframework.org/docs/v2/cookbook/file-download

# File Download

This recipe will help you to setup a static file handler in your application.

{% hint style="warning" %}
First you need an amber project generated with [Amber CLI](../guides/create-new-app.md) or [from scratch](from-scratch.md).
{% endhint %}

{% code-tabs %}
{% code-tabs-item title="config/application.cr" %}
```crystal
Amber::Server.configure do |app|
  # All static content will run these transformations
  pipeline :static do
    plug Amber::Pipe::PoweredByAmber.new
    plug Amber::Pipe::Error.new
    plug Amber::Pipe::Static.new("./public")
  end

  routes :static do
    get "/*", Amber::Controller::Static, :index
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

Also see [pipelines](../guides/routing/pipelines.md).

{% hint style="success" %}
This code is already included on projects generated by [Amber CLI](../cli/)
{% endhint %}


---

## File Upload

Canonical page: https://amberframework.org/docs/v2/cookbook/file-upload

# File Upload

File uploading is very simple. Files are detected and turned into `Amber::Router::File` and put into a temporary file cache.

You can access files from the `params` macro using the `files` method.

```crystal
params.files
```

This will return a `Hash(String, Amber::Router::File)` object that you can work with. The attributes you can access are:

```crystal
file : File
filename : String?
headers : HTTP::Headers
creation_time : Time?
modification_time : Time?
read_time : Time?
size : UInt64?
```

For example, let's say we have a controller with a `create` method that we want someone to `POST` a JSON body that includes a file to upload:

```crystal
# header: `accept: audio/mp3`
#
# POST body
# {
#    "audio_file": // binary data for the audio file
#    "file_name": "some_file_name.mp3"
# }
def create
  uploaded_file = params.files["audio_file"]
  uploaded_file_name = params["file_name"]
  
  # Do whatever you want with the files and your method 😄
end
```

{% hint style="warning" %}
First you need an amber project generated with [Amber CLI](../guides/create-new-app.md) or [from scratch](from-scratch.md).
{% endhint %}

You still require a form to upload your file, see [views](../guides/views/). Also see [request and response](../guides/controllers/request-and-response-objects.md).


---

## Cookies

Canonical page: https://amberframework.org/docs/v2/cookbook/cookies

# Cookies

This recipe will help you to setup a cookie in your application.

{% hint style="warning" %}
First you need an amber project generated with [Amber CLI](../guides/create-new-app.md) or [from scratch](from-scratch.md).
{% endhint %}

{% code-tabs %}
{% code-tabs-item title="src/controllers/some\_controller.cr" %}
```crystal
class SomeController < ApplicationController
  def set_cookie
    cookies[:example] = {
      value: "a yummy cookie with amber color",
      http_only: true,
      secure: true
    }
    "Your example cookie has been cooked successfully!"
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

Then in your routes file:

{% code-tabs %}
{% code-tabs-item title="config/routes.cr" %}
```crystal
Amber::Server.configure do |app|
  pipeline :web do
    # pipelines...
  end

  routes :web do
    # other routes,,,
    get "/set_cookie", SomeController, :set_cookie
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

Also see more detailed information about this in[ Cookies Guide](../guides/controllers/cookies.md).


---

## Authenticate

Canonical page: https://amberframework.org/docs/v2/cookbook/authenticate

# Authenticate

This recipe will help you to setup an authentication pipe in your application:

{% hint style="warning" %}
First you need an amber project generated with [Amber CLI](../guides/create-new-app.md) or [from scratch](from-scratch.md).
{% endhint %}

{% code-tabs %}
{% code-tabs-item title="src/pipes/authenticate.cr" %}
```crystal
class Authenticate < Amber::Pipe::Base
  PUBLIC_PATHS = ["/"]

  def call(context)
    some_id = context.session["some_id"]? # setup by some controller
    if some_id || public_path?(context.request.path)
      call_next(context)
    else
      context.flash[:warning] = "Please Sign In"
      context.response.headers.add "Location", "/"
      context.response.status_code = 302
    end
  end

  private def public_path?(path)
    PUBLIC_PATHS.includes?(path)

    # Different strategies can be used to determine if a path is public
    # Example, if /admin/* paths are the only private paths
    # return false if path.starts_with?("/admin")
    #
    # Example, if only a few private paths exist
    # return false if ["/secret", "/super/secret"].includes?(path)
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

Then in your routes file:

{% code-tabs %}
{% code-tabs-item title="config/routes.cr" %}
```crystal
Amber::Server.configure do |app|
  pipeline :web do
    # other pipes...
    plug Authenticate.new
  end

  routes :web do
    # some routes,,,
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

To have a full authentication experience some extra controllers, views and models are still required, please see [Amber Auth Example](../examples/amber-auth.md).

Also see [pipelines](../guides/routing/pipelines.md).


---

## JSON Mapping

Canonical page: https://amberframework.org/docs/v2/cookbook/json-mapping

# JSON Mapping

This recipe will help you to setup a basic JSON Mapping in your application.

{% hint style="warning" %}
First you need an amber project generated with [Amber CLI](../guides/create-new-app.md) or [from scratch](from-scratch.md).
{% endhint %}

JSON requests are automatically parsed into the `params` macro when the `accept` header is present and with `application/json` &#x20;

You can use this in combination with the [`respond_with`](../guides/controllers/respond-with.md) helper. Here you don't need to setup `content_type`, however, the requested path requires a `.json` extension, by example `/json_mapping.json`

```crystal
class SomeController < ApplicationController
  def json_mapping
    return "empty body" if params["some_json_key_from_your_request"]
    user = User.from_json request.body.to_s
    user.username += "mapped!"
    response_with do
      json user.to_json
    end
  end
end
```

Also see [Response With](../guides/controllers/respond-with.md) and [Response & Request](../guides/controllers/request-and-response-objects.md).


---

## WebSocket Chat

Canonical page: https://amberframework.org/docs/v2/cookbook/websocket-chat

# WebSocket Chat

This recipe will help you to setup a basic WebSocket chat in your application.

{% hint style="warning" %}
First you need an amber project generated with [Amber CLI](../guides/create-new-app.md) or [from scratch](from-scratch.md).
{% endhint %}

{% code-tabs %}
{% code-tabs-item title="src/channels/chat\_room\_channel.cr" %}
```crystal
class ChatRoomChannel < Amber::Websockets::Channel
  def handle_message(client_socket, msg)
    rebroadcast!(msg)
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

Then setup your socket file:

{% code-tabs %}
{% code-tabs-item title="src/sockets/chat\_socket.cr" %}
```crystal
struct ChatSocket < Amber::WebSockets::ClientSocket
  channel "chat_room:*", ChatRoomChannel

  def on_connect
    # returning true accept all connections
    # you can use authentication here
    true
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

Then add the `websocket` verb in your routes file:

{% code-tabs %}
{% code-tabs-item title="config/routes.cr" %}
```crystal
Amber::Server.configure do |app|
  pipeline :web do
    # pipelines...
  end

  routes :web do
    # other routes,,,
    websocket "/chat", ChatSocket
  end
end
```
{% endcode-tabs-item %}
{% endcode-tabs %}

Finally you will require an [Amber JavaScript Client](../guides/websockets/javascript-client.md) to connect with your server:

{% hint style="info" %}
You can get `amber.min.js` from `lib/amber/assets/js/amber.min.js`
{% endhint %}

{% code-tabs %}
{% code-tabs-item title="public/index.html" %}
```markup
<script src="/public/amber.min.js"></script>
<script>
let socket = new Amber.Socket('/chat');

socket.connect().then(function() {
    let channel = socket.channel('chat_room:hello');

    channel.join();

    channel.push('message_new', {
      message: "Hello Amber from WebSocket Client!"
    });

    channel.on('message_new', function(payload) {
      console.log(payload);
    });
});
</script>
```
{% endcode-tabs-item %}
{% endcode-tabs %}

{% hint style="warning" %}
To serve static files you must enable `:static` routes, please see [pipelines](../guides/routing/pipelines.md).
{% endhint %}

Also see more detailed information about this in[ ](../guides/controllers/cookies.md)[WebSockets](../guides/websockets/).


---

## Manual Binary Deployment

Canonical page: https://amberframework.org/docs/v2/deployment/manual-deploy

# Manual Binary Deployment

This example keeps compilation and runtime responsibilities explicit. Adjust
paths, the service user, and the target name for your application.

## Build the release artifact

```bash
shards install --production
crystal spec
shards build my_app --release
file bin/my_app
```

Copy `bin/my_app`, `config/`, and `public/` to the runtime host. If your
application reads other files at runtime, include them deliberately. Do not copy
development secrets or a local database.

## Configure the process

Store secrets in the host or deployment platform's secret manager. A minimal
environment is:

```bash
AMBER_ENV=production
AMBER_SERVER_HOST=0.0.0.0
AMBER_SERVER_PORT=3000
AMBER_SERVER_SECRET_KEY_BASE=replace-with-a-long-random-secret
```

`AMBER_SERVER_PORT` overrides `server.port` from
`config/environments/production.yml`. Add `AMBER_DATABASE_URL` only when the
application has a configured database adapter.

## Example systemd unit

```ini
[Unit]
Description=my_app Amber service
After=network.target

[Service]
Type=simple
User=my_app
Group=my_app
WorkingDirectory=/srv/my_app
EnvironmentFile=/etc/my_app.env
ExecStart=/srv/my_app/bin/my_app
Restart=on-failure
RestartSec=3
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
```

The environment file should be readable only by the service administrator and
service account. Terminate TLS in a reverse proxy or managed ingress and proxy
to `127.0.0.1:3000` when the proxy runs on the same host.

## Verify before shifting traffic

```bash
curl --fail --show-error http://127.0.0.1:3000/
```

Confirm the expected page, logs, restart behavior, and any persistence or file
storage dependencies before sending production traffic. Roll back by restoring
the previous binary and configuration together.


---

## Minimal Configuration

Canonical page: https://amberframework.org/docs/v2/examples/minimal-configuration

# Minimal Configuration

Amber can be run from a single file for minimal configuration setups if the user prefers this for smaller applications.

```crystal
require "amber"

class HelloController < Amber::Controller::Base
  def index
    "hello world"
  end
end

Amber::Server.configure do |app|
  pipeline :api do
  end

  routes :api do
    get "/", HelloController, :index
  end
end

Amber::Server.start
```


---

## Installation

Canonical page: https://amberframework.org/docs/v2/getting-started/installation

# Install Amber V2 Beta

The supported onboarding path uses the standalone Amber CLI. The framework
itself remains a shard dependency generated into each application.

## Supported systems

- Apple Silicon macOS — release-gated; Homebrew and direct archive
- x86_64 Linux — release-gated; Homebrew and direct archive
- Linux ARM64 — clean web app compile-verified; source install for CLI 2.0.3

Linux ARM64 is supported for the clean web source-build path, but it is not a
beta release gate and CLI 2.0.3 does not publish an ARM64 archive. The release
workflow now builds and smoke-tests that archive for the next CLI version.
Intel macOS and Windows are not release-gated. Amber 2.0.0-beta.2 currently has
a Windows ECR path defect; the candidate fix passes the Windows compile job but
has not shipped. Follow [Beta Support](../beta-support/) rather than treating a
successful Crystal installation as application compatibility.

## Prerequisites

Install Crystal 1.20 or newer, but earlier than 2.0, using the
[official Crystal instructions](https://crystal-lang.org/install/). You also
need Git and `shards`. Treat 1.20 as the compatibility floor; for a new Amber
application, use the latest stable Crystal release that satisfies this range.

```bash
crystal --version
shards --version
git --version
```

A database is not required for the generated core web app.

## Homebrew

The tap and formula use an underscore. Install the official formula with its
fully qualified name, then verify the `amber` executable:

```bash
brew install amberframework/amber_cli/amber_cli
amber --version
```

The fully qualified command follows Homebrew's tap-trust model and trusts only
the requested formula. The formula is `amber_cli` and the installed executable
is `amber`.

Expect Amber CLI `2.0.3` or newer. Version 2.0.3 includes the branded V2 web
starter and its browser-native import map.

## Direct archive

Choose `darwin-arm64` on Apple Silicon macOS or `linux-x86_64` on x86_64 Linux:

```bash
version=v2.0.3
platform=darwin-arm64
asset="amber_cli-${platform}.tar.gz"

curl -fLO "https://github.com/amberframework/amber_cli/releases/download/${version}/${asset}"
curl -fLO "https://github.com/amberframework/amber_cli/releases/download/${version}/${asset}.sha256"
shasum -a 256 -c "${asset}.sha256"
tar -xzf "${asset}"
install -m 0755 amber amber-lsp /usr/local/bin/
amber --version
```

On Linux, use `sha256sum -c`. Prefix only the `install` command with `sudo` if
needed.

## Linux ARM64 source install

CLI 2.0.3 has no `linux-arm64` release archive. Until the next CLI release,
build the tagged CLI source on the ARM64 machine instead of downloading the
x86_64 archive.

**Run from: a directory where the temporary `amber_cli/` checkout can be
created.**

```bash
sudo apt-get update
sudo apt-get install -y libsqlite3-dev
git clone --branch v2.0.3 --depth 1 https://github.com/amberframework/amber_cli.git
cd amber_cli
shards install --production
crystal build src/amber_cli.cr -o amber --release
crystal build src/amber_lsp.cr -o amber-lsp --release
sudo install -m 0755 amber amber-lsp /usr/local/bin/
amber --version
```

This produces native ARM64 executables because Crystal builds for the current
host. The platform CI uses a GitHub-hosted ARM64 Linux machine to generate,
spec, and compile the clean web app. Source installation is a narrower promise
than the release-gated x86_64 archive path: report the distribution, Crystal
version, and `uname -m` with any issue.

## Verify the installation

```bash
amber new amber_beta_smoke --type web
cd amber_beta_smoke
crystal spec
crystal build src/amber_beta_smoke.cr -o bin/amber_beta_smoke
amber watch
```

In another terminal:

```bash
curl --fail http://127.0.0.1:3000/
curl --fail http://127.0.0.1:3000/css/app.css
```

Both requests must succeed. The generated `shard.yml` pins
`amberframework/amber` at `2.0.0-beta.2`; it must not reference a personal fork.

## Manual framework dependency

For an existing Crystal app:

```yaml
dependencies:
  amber:
    github: amberframework/amber
    version: 2.0.0-beta.2

crystal: ">= 1.20.0, < 2.0"
```

Do not use the moving `v2-dev` branch in a reproducible beta application.

## Update or remove

```bash
brew update
brew upgrade amberframework/amber_cli/amber_cli
# or
brew uninstall amberframework/amber_cli/amber_cli
brew untap amberframework/amber_cli
```

## Troubleshooting

If the wrong executable runs, inspect every match:

```bash
type -a amber
amber --version
```

Remove or rename an old Amber V1 executable, or put Homebrew earlier in `PATH`.
On macOS, the beta binary must not require `openssl@1.1`; include
`otool -L "$(command -v amber)"` in an issue.

If there is no archive for another architecture, the platform is not
release-gated. Linux ARM64 is the documented source-build exception because its
generated web compile job is part of CI.


---

## Build a Pet Tracker

Canonical page: https://amberframework.org/docs/v2/guides/pet-tracker

# Build a Pet Tracker

This is the first complete Amber V2 application guide. It starts from the clean
web template and builds one small feature all the way through:

- typed Crystal records and an in-memory catalog;
- routes and one controller with HTML and JSON representations;
- ECR list and detail views;
- local CSS that carries Amber's warm, faceted visual language;
- local JavaScript loaded through the generated import map;
- request specs and a native application build.

The catalog is intentionally in memory. That keeps the tutorial inside the
release-gated web core while persistence integrations remain preview surfaces.

## 1. Generate the application

**Run from: the parent directory where `pet_tracker/` should be created.**

```bash
amber new pet_tracker
cd pet_tracker
```

Keep the generated layout and import map. The steps below replace the `/` route
and add application-owned files around that working baseline.

## 2. Define the record

**File: `src/models/pet.cr` — create this complete file.**

```crystal
struct Pet
  include JSON::Serializable

  getter slug : String
  getter name : String
  getter kind : String
  getter status : String
  getter note : String

  def initialize(@slug, @name, @kind, @status, @note)
  end
end
```

`JSON::Serializable` gives the same typed record an explicit JSON
representation. The ECR views will read the getters directly.

## 3. Add a small catalog

**File: `src/models/pet_catalog.cr` — create this complete file.**

```crystal
module PetCatalog
  PETS = [
    Pet.new("miso", "Miso", "Cat", "Available", "Window-seat specialist"),
    Pet.new("juniper", "Juniper", "Dog", "Fostered", "Trail-tested optimist"),
    Pet.new("pixel", "Pixel", "Rabbit", "Available", "Quiet keyboard companion"),
  ]

  def self.all : Array(Pet)
    PETS
  end

  def self.find(slug : String) : Pet?
    PETS.find { |pet| pet.slug == slug }
  end
end
```

This is the tutorial's temporary data boundary. A future database-backed app
can replace `PetCatalog` without moving rendering or route decisions into the
model.

## 4. Negotiate HTML and JSON

**File: `src/controllers/pets_controller.cr` — create this complete file.**

```crystal
class PetsController < ApplicationController
  def index
    pets = PetCatalog.all

    respond_with do
      html { render("index.ecr") }
      json { pets.to_json }
    end
  end

  def show
    pet = PetCatalog.find(params["slug"])
    unless pet
      return set_response(
        body: "Pet not found",
        status_code: 404,
        content_type: "text/plain"
      )
    end

    respond_with do
      html { render("show.ecr") }
      json { pet.to_json }
    end
  end
end
```

Each action loads its resource once. `respond_with` then states which public
representations exist. Read [Respond With](../controllers/respond-with/) for
content negotiation details.

## 5. Register the routes

**File: `config/routes.cr` — replace the generated `routes :web` block. Leave
the generated pipelines and `routes :static` block in place.**

```crystal
routes :web do
  get "/", PetsController, :index
  get "/pets", PetsController, :index
  get "/pets/:slug", PetsController, :show
end
```

The dynamic `:slug` segment is available as `params["slug"]` inside `show`.

## 6. Create the ECR views

**File: `src/views/pets/index.ecr` — create this complete list view.**

```ecr
<main class="pet-shell">
  <header class="pet-hero">
    <p class="pet-eyebrow">Pet Tracker · Amber V2</p>
    <h1>Small records.<br><em>Good homes.</em></h1>
    <p>Meet the animals currently moving through our foster network.</p>
  </header>

  <nav class="pet-filters" aria-label="Filter pets">
    <button type="button" data-pet-filter="all" aria-pressed="true">All pets</button>
    <button type="button" data-pet-filter="available" aria-pressed="false">Available</button>
    <button type="button" data-pet-filter="fostered" aria-pressed="false">Fostered</button>
  </nav>

  <section class="pet-grid" aria-label="Pets">
    <% pets.each do |pet| %>
      <article class="pet-card" data-pet-status="<%= escape_html(pet.status.downcase) %>">
        <span class="pet-kind"><%= escape_html(pet.kind) %></span>
        <h2><a href="/pets/<%= escape_html(pet.slug) %>"><%= escape_html(pet.name) %></a></h2>
        <p><%= escape_html(pet.note) %></p>
        <span class="pet-status"><%= escape_html(pet.status) %></span>
      </article>
    <% end %>
  </section>
</main>
```

The template owns HTML and escapes every value at the output boundary. The
controller only coordinates data and representations.

**File: `src/views/pets/show.ecr` — create this complete detail view.**

```ecr
<main class="pet-shell pet-detail">
  <a class="pet-back" href="/pets">← All pets</a>
  <p class="pet-eyebrow"><%= escape_html(pet.kind) %> · <%= escape_html(pet.status) %></p>
  <h1><%= escape_html(pet.name) %></h1>
  <p><%= escape_html(pet.note) %></p>
</main>
```

No layout change is required. The generated
`src/views/layouts/application.ecr` already loads `/css/app.css`, maps the
stable module name `app` to `/js/app.js`, and inserts the action template as
`content`.

## 7. Carry the visual language into the app

The website's visual language is a starting vocabulary, not a requirement to
copy its identity. This layer reuses warm paper, amber accents, compact status
labels, editorial type scale, and soft card geometry from the generated
starter.

**File: `public/css/app.css` — append this component layer after the generated
starter styles.**

```css
.pet-shell {
  width: min(1120px, calc(100% - 40px));
  margin-inline: auto;
  padding-block: clamp(72px, 10vw, 132px);
}

.pet-hero { max-width: 760px; }
.pet-eyebrow {
  color: var(--amber-accent-deep);
  font-size: .72rem;
  font-weight: 850;
  letter-spacing: .15em;
  text-transform: uppercase;
}

.pet-hero h1,
.pet-detail h1 {
  margin: 0;
  font-family: ui-serif, Georgia, serif;
  font-size: clamp(4rem, 9vw, 7.5rem);
  letter-spacing: -.055em;
  line-height: .88;
}

.pet-hero h1 em { color: var(--amber-accent); }
.pet-hero > p:last-child { max-width: 42rem; color: var(--amber-muted); font-size: 1.08rem; }
.pet-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 40px 22px; }
.pet-filters button {
  padding: 9px 13px;
  border: 1px solid var(--amber-line);
  border-radius: 999px;
  background: #fffdf9;
  color: var(--amber-muted);
  font: inherit;
  font-size: .76rem;
  font-weight: 800;
  cursor: pointer;
}

.pet-filters button[aria-pressed="true"] { border-color: var(--amber-accent); background: var(--amber-accent); color: white; }
.pet-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.pet-card {
  min-height: 270px;
  padding: 28px;
  border: 1px solid var(--amber-line);
  border-radius: 20px;
  background: rgba(255, 253, 249, .82);
  box-shadow: var(--amber-shadow);
}

.pet-card[hidden] { display: none; }
.pet-kind, .pet-status { color: var(--amber-accent-deep); font-size: .68rem; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; }
.pet-card h2 { margin: 54px 0 8px; font-family: ui-serif, Georgia, serif; font-size: 2.4rem; }
.pet-card h2 a { text-decoration: none; }
.pet-card p { min-height: 3rem; color: var(--amber-muted); }
.pet-status { display: inline-flex; margin-top: 18px; padding: 6px 9px; border: 1px solid var(--amber-line); border-radius: 999px; }
.pet-back { color: var(--amber-accent-deep); font-weight: 800; text-decoration: none; }
.pet-detail .pet-eyebrow { margin-top: 80px; }

@media (max-width: 780px) {
  .pet-grid { grid-template-columns: 1fr; }
  .pet-card { min-height: 0; }
}
```

Everything is local. There is no hosted font, CSS framework, JavaScript
package, Node.js process, or build step.

## 8. Add browser behavior

**File: `public/js/app.js` — replace the generated starter module with this
complete filter behavior.**

```javascript
document.querySelectorAll("[data-pet-filter]").forEach((button) => {
  button.addEventListener("click", () => {
    const filter = button.dataset.petFilter;

    document.querySelectorAll("[data-pet-filter]").forEach((candidate) => {
      candidate.setAttribute("aria-pressed", String(candidate === button));
    });

    document.querySelectorAll("[data-pet-status]").forEach((card) => {
      card.hidden = filter !== "all" && card.dataset.petStatus !== filter;
    });
  });
});
```

The generated import map already loads this file as the `app` module. The
filter is progressive enhancement: all pet links and content remain available
when JavaScript is disabled.

## 9. Test the public contract

**File: `spec/controllers/pets_controller_spec.cr` — create this complete
request spec.**

```crystal
require "../spec_helper"

describe PetsController do
  it "renders the pet list as HTML" do
    response = get("/pets")
    assert_response_success(response)
    response.body.should contain("Small records")
    response.body.should contain("Miso")
  end

  it "returns the same pets as JSON" do
    headers = HTTP::Headers{"Accept" => "application/json"}
    response = get("/pets", headers: headers)
    assert_response_success(response)
    response.headers["Content-Type"].should contain("application/json")
    response.body.should contain(%("slug":"miso"))
  end

  it "returns 404 for an unknown pet" do
    get("/pets/not-here").status_code.should eq(404)
  end
end
```

Keep the generated `spec/controllers/home_controller_spec.cr`. Its `/` request
now verifies the second route into `PetsController#index`.

**Run from: the `pet_tracker/` application root.**

```bash
shards install
crystal spec
crystal build src/pet_tracker.cr -o bin/pet_tracker
amber watch
```

Open `http://127.0.0.1:3000/`, filter the cards, follow a pet link, then request
the negotiated JSON representation:

**Run from: any terminal while `amber watch` is running.**

```bash
curl -H 'Accept: application/json' http://127.0.0.1:3000/pets
curl -H 'Accept: application/json' http://127.0.0.1:3000/pets/miso
```

The published guide was checked against a clean Amber CLI 2.0.3 web scaffold:
all four request examples passed and the application compiled successfully.

## Where to go next

- [Web Template](../web-template/) explains every generated baseline file.
- [Views](../views/) expands the controller, ECR, partial, and layout boundary.
- [Import Maps](../assets/import-maps/) shows how to split browser behavior into
  more local modules.
- [Beta Support](../../beta-support/) separates release-gated web core from
  preview persistence and native surfaces.


---

## Web Template

Canonical page: https://amberframework.org/docs/v2/guides/web-template

# Amber V2 Web Template

**Run from: the parent directory where `my_app/` should be created.**

```bash
amber new my_app
```

Web is the default. `amber new my_app --type web` is the explicit equivalent.

The command defaults to ECR and records `pg` as future database intent, but the
generated application has no ORM, database shard, migration requirement,
Node.js dependency, or front-end bundler. Its layout resolves the local
`public/js/app.js` ES module through a browser-native import map.

## Generated project

**Generated output: the complete top-level structure under `my_app/`.**

```text
my_app/
├── .amber.yml
├── .gitignore
├── shard.yml
├── config/
│   ├── application.cr
│   ├── routes.cr
│   ├── environments/
│   │   ├── development.yml
│   │   ├── production.yml
│   │   └── test.yml
│   └── initializers/.keep
├── db/seeds.cr
├── public/
│   ├── css/app.css
│   ├── js/app.js
│   ├── img/.keep
│   ├── favicon.ico
│   └── robots.txt
├── spec/
│   ├── spec_helper.cr
│   ├── controllers/home_controller_spec.cr
│   └── channels, jobs, mailers, models, requests, schemas/
└── src/
    ├── my_app.cr
    ├── controllers/
    │   ├── application_controller.cr
    │   └── home_controller.cr
    ├── views/
    │   ├── home/index.ecr
    │   └── layouts/application.ecr
    └── channels, jobs, mailers, models, schemas, sockets/
```

The empty extension directories are intentional. They give generators stable
destinations without forcing unused dependencies into a new application.

## First rendered page

A new Amber web application includes a small, production-shaped design system.
It uses no remote fonts, images, JavaScript packages, or build step: the crystal
mark, warm paper field, status chips, and responsive layout are authored in the
generated ECR and `public/css/app.css`. Browser behavior starts in the local
`public/js/app.js` module; the application layout maps it to the stable name
`app` without a package manager or CDN.

Its visible content begins with:

**Expected browser output: text rendered by the generated home view.**

```text
my_app                                      Amber V2 beta

Amber V2 · Web application
Your new idea starts here.

my_app is running. Your first route, view, and locally served CSS and JavaScript
are ready to shape.

Server rendered · Crystal powered · Ready to customize

First edits · Make it yours.
01 Edit the page              src/views/home/index.ecr
02 Add a route                config/routes.cr
03 Generate a controller      amber generate controller Posts
```

The starter carries over the site's warm neutrals, faceted geometry, editorial
type hierarchy, and compact status labels. It leaves out Amber's character art
and website fonts so the generated application has a coherent starting system
without inheriting the framework site's identity.

**File: `src/views/layouts/application.ecr` — the generated layout contains this
complete front-end entry point.**

```ecr
<link rel="stylesheet" href="/css/app.css">
<script type="importmap">
  {"imports":{"app":"/js/app.js"}}
</script>
<script type="module">import "app";</script>
```

See [Import maps](../assets/import-maps/) for splitting the local module into
application-owned controllers and utilities.

## Framework pin

**File: `shard.yml` — the generated dependency manifest uses the official
repository and an exact prerelease pin.**

```yaml
crystal: ">= 1.20.0, < 2.0"

dependencies:
  amber:
    github: amberframework/amber
    version: 2.0.0-beta.2
```

Use the official repository and exact prerelease pin shown above. Moving branches
and personal forks do not provide the documented beta contract.

## Template freshness

The CLI embeds its web scaffold in the executable. It does not fetch a template
manifest when `amber new` runs. That makes generation deterministic and usable
offline, but a template correction requires an Amber CLI patch release.

**Run from: any directory before generating a new application with Homebrew.**

```bash
brew update
brew upgrade amberframework/amber_cli/amber_cli
amber --version
```

Updating the CLI changes future generated projects; it does not rewrite an
existing application.

## Typed environments

Development, test, and production settings use nested typed sections:

**Files: `config/environments/development.yml`,
`config/environments/test.yml`, and `config/environments/production.yml` — the
generated files share this shape but contain environment-specific values.**

```yaml
name: my_app

server:
  host: 127.0.0.1
  port: 3000
  secret_key_base: "development-secret"

session:
  key: "my_app.session"
  store: "signed_cookie"
  adapter: "memory"
  expires: 0

logging:
  severity: "debug"
  colorize: true
```

The generated database URL is configuration metadata only. No database driver
is installed and no database is contacted by the clean scaffold.

**Run from: the application root. Environment overrides follow the nested key
path.**

```bash
AMBER_SERVER_PORT=8080 amber watch
```

## Request pipelines

`config/routes.cr` defines three explicit pipelines:

- `web` includes error handling, logging, sessions, flash, and CSRF.
- `static` serves the files under `public`.
- `api` is ready for explicit API routes and does not run by default.

The clean app registers `/` in the web pipeline and `/*` in the static
pipeline. That is why both of these checks matter:

**Run from: the application root while `amber watch` is running.**

```bash
curl --fail http://127.0.0.1:3000/
curl --fail http://127.0.0.1:3000/css/app.css
```

## Compile contract

**Run from: the generated application root.**

```bash
cd my_app
shards install
crystal spec
crystal build src/my_app.cr -o bin/my_app
amber watch
```

The clean scaffold must pass its homepage request spec, compile a native binary,
serve the homepage, and serve the static stylesheet without adding a database or
preview ecosystem dependency.

## Extend the supported core

**Run from: the application root. These generators produce core framework
output.**

```bash
amber generate controller Posts index show
amber generate schema Post title:string:required body:text
amber generate job Digest
amber generate mailer Welcome
amber generate channel Notifications
```

Controller generation does not edit routes. Add the desired routes explicitly
to `config/routes.cr`, then enable the generated request examples.

Read [Beta support](../../beta-support/) before using persistence, auth,
API-resource, scaffold, or native generation. Those are preview surfaces and
are not part of this web-template guarantee. See the [native preview
boundary](../native-preview/) before evaluating desktop or mobile output.


---

## Native Application Preview

Canonical page: https://amberframework.org/docs/v2/guides/native-preview

# Amber V2 Native Application Preview

Amber CLI can generate a cross-platform native project, but this surface is a
preview. It is **not release-gated with the V2 web beta** and is not covered by
the clean web-template compile guarantee.

**Run from: the parent directory where `field_app/` should be created.**

```bash
amber new field_app --type native
```

The generated project uses Amber V2 application patterns without starting an
HTTP server. Its interface layer is built around Asset Pipeline UI, with
platform hosts and build scripts for desktop and mobile work.

## Platform map

| Target | Generated direction | Preview boundary |
|---|---|---|
| macOS | Native AppKit host | Requires the Apple toolchain and preview dependencies |
| iOS | UIKit host and simulator/device build scripts | Cross-compilation and signing are not part of the web beta gate |
| Android | Android host and NDK build scripts | SDK, NDK, JDK, and device setup are not part of the web beta gate |

## Generated concepts

- `config/native.yml` as the native capability manifest
- Asset Pipeline UI components for platform rendering
- macOS, iOS, and Android host projects and build scripts
- FSDD process-manager structure
- crystal-audio integration points
- platform-oriented accessibility and end-to-end test locations

These are descriptions of generated output, not a promise that every platform
builds from a clean machine today. Platform-specific prerequisites, signing,
cross-compilers, and preview shard compatibility must be proven separately
before native support can graduate.

## Choose the supported first run

For the V2 beta installation and onboarding path, create the default web app:

**Run from: the parent directory where `my_app/` should be created.**

```bash
amber new my_app
cd my_app
crystal spec
amber watch
```

Use native generation when you intend to evaluate the platform work and can
report exact toolchain results. Do not interpret “generated” as “release-gated.”


---

## Schema API

Canonical page: https://amberframework.org/docs/v2/guides/schema-api

# Schema API

The Schema API is the headline feature of Amber 2.0. It provides compile-time validated request parameters with automatic type coercion, replacing the traditional params hash with a type-safe, validated approach.

## Where the examples go

- Schema definitions and their validated success/error types belong under
  `src/schemas/`, grouped by resource or request flow.
- Validation calls belong inside the controller action under `src/controllers/`
  that receives the matching request.
- Register the route for that action in `config/routes.cr`.

Blocks on this page use those destinations unless a closer label says
otherwise.

## Why Schema API?

Traditional web frameworks handle request parameters as loosely-typed hashes:

**File: a controller action under `src/controllers/` — this is the legacy
pattern to replace, not recommended V2 code.**

```crystal
# Old way - runtime errors, no type safety
def create
  email = params[:email].as(String)  # Could fail at runtime
  age = params[:age].to_i            # No validation
end
```

**File: `src/schemas/create_user_schema.cr` — define the request contract here.**

```crystal
# New way - compile-time safety, automatic validation
class CreateUserSchema < Amber::Schema::Definition
  field :email, String, required: true, format: :email
  field :age, Int32, min: 18

  validates_to UserRequest, UserValidationError
end
```

## Key Benefits

- **Type Safety**: Crystal's type system catches errors at compile time
- **Automatic Validation**: Built-in validators for common patterns
- **Content Type Aware**: Different schemas for JSON, XML, form data
- **Self-Documenting**: Schema definitions document your API
- **OpenAPI Generation**: Automatic API spec generation

## Quick Start

### 1. Define a Schema

**File: `src/schemas/create_post_schema.cr` — create this schema class.**

```crystal
class CreatePostSchema < Amber::Schema::Definition
  content_type "application/json"

  field :title, String, required: true, max_length: 200
  field :body, String, required: true
  field :published, Bool, default: false
  field :tags, Array(String), max_items: 10

  validates_to PostRequest, PostValidationError
end
```

### 2. Define Success/Error Types

**File: `src/schemas/create_post_schema.cr` — keep these result types beside the
schema, or split them under `src/schemas/posts/` when the resource grows.**

```crystal
class PostRequest < Amber::Schema::ValidatedRequest
  getter title : String
  getter body : String
  getter published : Bool
  getter tags : Array(String)
end

class PostValidationError < Amber::Schema::ValidationError
  def to_response
    {message: "Validation failed", errors: errors}
  end
end
```

### 3. Use in Controller

**File: `src/controllers/posts_controller.cr` — add this `create` action inside
`PostsController`, then register `POST /posts` in `config/routes.cr`.**

```crystal
class PostsController < ApplicationController
  def create
    case result = CreatePostSchema.validate(request)
    when Amber::Schema::Success
      post = Post.create!(result.data)
      respond_with 201, post.to_json
    when Amber::Schema::Failure
      respond_with 400, result.error.to_response.to_json
    end
  end
end
```

## Documentation Sections

- [Basics](basics/) - Schema definition, field types, and options
- [Validation](validation/) - Built-in validators and custom validation
- [Parsers](parsers/) - Content type handling (JSON, XML, Forms, etc.)
- [OpenAPI](openapi/) - Automatic API documentation generation


---

## Adapters

Canonical page: https://amberframework.org/docs/v2/guides/adapters

# Adapter System

Amber 2.0 routes session storage and pub/sub messaging through adapter
interfaces. In-memory adapters are built in; an application can register a
separate implementation when it needs an external store or message broker.

## Why Adapters?

In Amber 1.x, Redis was required for sessions and WebSocket messaging. This created issues:

- Required Redis installation for development
- External dependency even for simple apps
- No flexibility for other backends

Amber 2.0 solves this with:

- Memory-based adapters work immediately
- No external dependencies required
- Implement custom adapters for any backend
- Tests can use the in-memory implementations without an external service

## Built-in Adapters

### Memory Adapters (Default)

**File: `config/environments/development.yml` — edit the existing `session:`
and `pubsub:` keys. Apply the same shape deliberately to `test.yml` or
`production.yml`; environment files do not inherit from one another.**

```yaml
session:
  key: "amber.session"
  store: "signed_cookie"
  adapter: "memory"
  expires: 3600

pubsub:
  adapter: "memory"
```

Memory adapters are perfect for:

- Development environments
- Testing
- Single-server deployments
- Simple applications

### Cookie Sessions

For stateless session storage:

**File: one file under `config/environments/`, such as
`config/environments/production.yml` — replace that environment's existing
`session:` section.**

```yaml
session:
  key: "amber.session"
  store: "signed_cookie"
  expires: 3600
```

## Configuration

### Session Configuration

**File: `config/environments/production.yml` — replace the existing `session:`
section after registering the custom `database` adapter.**

```yaml
session:
  key: "myapp.session"
  adapter: "database"  # Your custom adapter
  expires: 86400       # 24 hours
```

### PubSub Configuration

**File: the applicable file under `config/environments/` — edit the existing
`pubsub:` section.**

```yaml
pubsub:
  adapter: "memory"  # Or custom adapter name
```

## Custom Adapters

Implement custom adapters for your specific needs:

- Database sessions (PostgreSQL, MySQL)
- Redis (via community shard)
- Cloud storage (AWS DynamoDB)
- Message queues (RabbitMQ, Kafka)

See [Session Adapters](sessions/) and [PubSub Adapters](pubsub/) for implementation guides.

## Migration from Redis

If you used Redis in Amber 1.x, see the [Migration Guide](../../migration-guide/redis-to-adapters/) for step-by-step migration instructions.


---

## Asset Pipeline

Canonical page: https://amberframework.org/docs/v2/guides/assets

# Asset Pipeline

> **Preview ecosystem guide:** Asset Pipeline is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

The separate Asset Pipeline project explores higher-level management of native
browser ESM modules and import maps. Amber's supported beta starter does not
require it. Begin with local modules in the [Import Maps](import-maps/) guide,
then evaluate this preview only when fingerprinting or generated import maps
earn the extra dependency.

## What this guide changes

Complete the steps from the root of an Amber V2 web application. Each example
names its destination and whether to create, edit, or run it. The completed
example adds these files and edits:

**Files changed by this guide:**

```text
my_app/
├── shard.yml                                      # edit
├── config/application.cr                          # edit
├── src/javascript/hello_controller.js             # create
├── src/views/home/index.ecr                       # edit
└── src/views/layouts/application.ecr              # edit
```

`public/javascript/` is generated output. Do not hand-edit files there.

## 1. Add the dependency

**File: `shard.yml` — add this entry under the existing `dependencies:` key.**

```yaml
dependencies:
  amber:
    github: amberframework/amber
    version: 2.0.0-beta.2
  asset_pipeline:
    github: amberframework/asset_pipeline
    version: ~> 0.36.0
```

Keep any other dependencies already present. YAML must contain only one
top-level `dependencies:` key.

**Run from: the application root, beside `shard.yml`.**

```bash
shards install
```

This creates or updates `shard.lock`. If dependency resolution fails, stop
here: the preview Asset Pipeline release is not compatible with the versions
selected by the application.

## 2. Configure the loader

**File: `config/application.cr` — keep the existing `require "amber"`, then
append this complete block.**

```crystal
require "asset_pipeline"

FRONT_LOADER = AssetPipeline::FrontLoader.new(
  js_source_path: Path["src/javascript"],
  js_output_path: Path["public/javascript"]
) do |import_maps|
  import_map = AssetPipeline::ImportMap.new(
    "application",
    Path["/javascript"]
  )

  import_map.add_import(
    "@hotwired/stimulus",
    "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm",
    preload: true
  )
  import_map.add_import("HelloController", "hello_controller.js")

  import_maps << import_map
end
```

The released V2 web template loads `config/application.cr` directly. Its empty
`config/initializers/` directory is reserved for future generator output and is
not the documented loading boundary for this beta. Source JavaScript lives in
`src/javascript/`; Asset Pipeline writes browser-facing files to
`public/javascript/` when the layout renders the map.

## 3. Edit the application layout

**File: `src/views/layouts/application.ecr` — add the import-map call inside
`<head>`, after the stylesheet link.**

```ecr
<link rel="stylesheet" href="/css/app.css">
<%= FRONT_LOADER.render_import_map_tag %>
```

**File: `src/views/layouts/application.ecr` — add the initialization call just
before the closing `</body>` tag.**

```ecr
<%= content %>
<%= FRONT_LOADER.render_stimulus_initialization_script %>
</body>
```

Remove the starter's hand-authored `<script type="importmap">` and
`<script type="module">import "app";</script>` tags if they are still present.
A document must not contain competing import maps for the same module graph.

## 4. Create a Stimulus controller

**File: `src/javascript/hello_controller.js` — create this complete file.**

```javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["output"]

  greet() {
    this.outputTarget.textContent = "Hello from Stimulus!"
  }
}
```

The import-map key ends in `Controller`, so the generated initialization script
registers it as `hello`.

## 5. Use the controller in a view

**File: `src/views/home/index.ecr` — add this element inside the page's existing
main content. Do not replace the application layout.**

```ecr
<section data-controller="hello">
  <button type="button" data-action="click->hello#greet">Greet</button>
  <span data-hello-target="output" aria-live="polite"></span>
</section>
```

## 6. Verify the complete path

**Run from: the application root.**

```bash
crystal spec
amber watch
```

Open `http://127.0.0.1:3000/`, click **Greet**, and confirm that “Hello from
Stimulus!” appears next to the button. In the browser's network panel, confirm
that the Stimulus module and a fingerprinted file under `/javascript/` both
load successfully.

If the page reports that `FRONT_LOADER` is undefined, confirm that the file is
`config/application.cr` and that its configuration appears after
`require "amber"`. If the controller does not connect, confirm that the import
name is exactly `HelloController` and that only one import map is rendered on
the page.

## What Asset Pipeline adds

- native ESM modules without a JavaScript bundler;
- generated import maps and module-preload links;
- fingerprinted browser-facing JavaScript files;
- Stimulus controller imports and registration;
- cache clearing when source files change.

It does not decide the structure of your ECR views or CSS. Keep application
markup in `src/views/`, styling in `public/css/`, and behavior in
`src/javascript/`.

## Next steps

- [Import Maps](import-maps/) — the supported dependency-free V2 baseline
- [Stimulus Integration](stimulus/) — add more controllers without losing the
  file boundary
- [Configuration](configuration/) — change paths and cache behavior safely
- [Webpack migration](../../migration-guide/webpack-to-esm/) — move an Amber
  1.x application in reviewable stages


---

## File Uploads (Gemma)

Canonical page: https://amberframework.org/docs/v2/guides/uploads

# File Uploads with Gemma

> **Preview ecosystem guide:** Gemma is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

Gemma is a file attachment toolkit for Crystal applications, inspired by [Shrine for Ruby](https://shrinerb.com). It connects model attachments to validation, temporary uploads, permanent storage, and delivery across configurable backends.

## Where the examples go

- Add dependencies in `shard.yml` and run commands from the application root.
- Configure Gemma in `config/application.cr`, which the released V2 template
  loads directly.
- Attachment declarations belong in Grant models under `src/models/`.
- Upload handling belongs in the receiving controller under `src/controllers/`;
  form and display markup belongs in the matching ECR file under `src/views/`.

Blocks on this page use those destinations unless a closer label says
otherwise.

## Why Gemma?

- **Storage Agnostic** - Switch between filesystem and S3 without changing application code
- **Grant Integration** - First-class support for Grant ORM with `has_one_attached` and `has_many_attached`
- **Validation Support** - Built-in validators for file size, content type, and dimensions
- **Plugin System** - Add MIME type detection and metadata extraction
- **Two-Stage Uploads** - Cache files temporarily, then promote to permanent storage

## Installation

**File: `shard.yml` — add Gemma under the existing `dependencies:` key.**

```yaml
dependencies:
  gemma:
    github: amberframework/gemma
    version: ~> 0.6.5
```

Run `shards install` from the application root.

## Quick Start

### 1. Configure Storage

**File: `config/application.cr` — append this setup after `require "amber"`.
Do not put it in the generated empty `config/initializers/` directory unless
you also add and verify an explicit require.**

```crystal
require "gemma"

Gemma.configure do |config|
  # Temporary storage for uploads in progress
  config.storages["cache"] = Gemma::Storage::FileSystem.new(
    "uploads",
    prefix: "cache"
  )

  # Permanent storage for completed uploads
  config.storages["store"] = Gemma::Storage::FileSystem.new("uploads")
end
```

### 2. Add Attachment to Model

**File: `src/models/user.cr` — keep the attachment declaration inside `User`.**

```crystal
require "gemma/grant"

class User < Grant::Base
  include Gemma::Grant::Attachable

  column id : Int64, primary: true
  column name : String
  column avatar_data : JSON::Any?

  has_one_attached :avatar
end
```

### 3. Use in Controller

**File: `src/controllers/users_controller.cr` — add this behavior inside the
action that receives the upload.**

```crystal
class UsersController < ApplicationController
  def create
    user = User.new(user_params)

    # Assign uploaded file
    if file = params.files["avatar"]?
      user.avatar = file.file
    end

    if user.save
      redirect_to "/users/#{user.id}"
    else
      render "users/new.ecr"
    end
  end
end
```

### 4. Display in View

**File: `src/views/users/show.ecr` — render the attachment inside the user page.**

```ecr
<% if user.avatar %>
  <img src="<%= user.avatar_url %>" alt="Avatar">
<% end %>
```

## How It Works

Gemma uses a two-stage upload process:

1. **Cache Stage** - Files are first uploaded to temporary "cache" storage
2. **Store Stage** - On model save, cached files are promoted to permanent "store" storage

This approach provides several benefits:

- Failed validations don't leave orphaned files
- Users can preview uploads before final submission
- Background processing can happen between stages

```crystal
# Behind the scenes
user.avatar = uploaded_file  # Uploaded to cache
user.save                     # Promoted to store
```

## Core Concepts

### UploadedFile

Represents an uploaded file with metadata:

```crystal
uploaded_file = user.avatar

uploaded_file.id              # => "abc123.jpg"
uploaded_file.url             # => "/uploads/abc123.jpg"
uploaded_file.size            # => 12345
uploaded_file.mime_type       # => "image/jpeg"
uploaded_file.original_filename # => "photo.jpg"
uploaded_file.extension       # => "jpg"
uploaded_file.exists?         # => true

# Access raw IO
uploaded_file.open do |io|
  # Process file content
end

# Download to tempfile
uploaded_file.download do |tempfile|
  # Work with local file
end
```

### Storages

Gemma supports multiple storage backends:

| Storage | Use Case |
|---------|----------|
| `FileSystem` | Local development, simple deployments |
| `S3` | Production, cloud deployments |
| `Memory` | Testing |

### Attacher

The internal mechanism that manages file attachment lifecycle:

```crystal
attacher = user._avatar_attacher

attacher.file       # Current file
attacher.cached?    # File in temporary storage?
attacher.stored?    # File in permanent storage?
attacher.changed?   # File was modified?
attacher.url        # File URL
```

## Features

### Single File Attachments

```crystal
class User < Grant::Base
  include Gemma::Grant::Attachable

  column avatar_data : JSON::Any?
  has_one_attached :avatar
end

user.avatar = File.open("photo.jpg")
user.save

user.avatar_url  # => "/uploads/abc123.jpg"
```

### Multiple File Attachments

```crystal
class Post < Grant::Base
  include Gemma::Grant::Attachable

  column images_data : JSON::Any?
  has_many_attached :images
end

post.images = [File.open("img1.jpg"), File.open("img2.jpg")]
post.save

post.images.each do |image|
  puts image.url
end

# Add single file
post.add_image(File.open("img3.jpg"))

# Remove file
post.remove_image(post.images.first)

# Clear all
post.clear_images
```

### Custom Uploaders

Create custom uploaders for specialized handling:

```crystal
class ImageUploader < Gemma
  def generate_location(io, metadata, context, **options)
    name = super(io, metadata, **options)

    # Organize by model and ID
    File.join(
      context[:model].class.name.underscore,
      context[:model].id.to_s,
      name
    )
  end
end

# Use custom uploader
has_one_attached :avatar, uploader: ImageUploader
```

## Next Steps

- [Attachments](attachments/) - Single and multiple file attachments
- [Storage Backends](storage/) - Configure FileSystem and S3
- [Validation](validation/) - Validate file uploads


---

## Models

Canonical page: https://amberframework.org/docs/v2/guides/models

# Models in Amber V2

Amber `2.0.0-beta.2` does not install an ORM or database driver in the supported
web template. This keeps the first project build independent of a database and
lets an application choose its persistence layer explicitly.

[Grant](grant/) is documented as an ecosystem preview. Its release lifecycle
is separate from the Amber framework beta, so confirm a compatible official
Grant release before adding it to an application.

If you are migrating an Amber 1 application, keep the 1.4.1 documentation open
for the existing Granite or Jennifer code and use the
[Granite-to-Grant preview guide](../../migration-guide/granite-to-grant/) only
after reviewing its compatibility notice.


---

## Controllers

Canonical page: https://amberframework.org/docs/v2/guides/controllers

# Controllers

A controller action turns an HTTP request into a response. Amber creates the
controller selected by the router, runs its filters, calls the action, and
finalizes the response through the active pipeline.

**Run from: the application root.**

```bash
amber generate controller Posts index show
```

The generator writes Crystal controller code and ECR views, but it deliberately
does not guess routes. For the command above it creates
`src/controllers/posts_controller.cr`, `src/views/posts/index.ecr`, and
`src/views/posts/show.ecr`.

**File: `config/routes.cr` — add these routes inside the existing
`Amber::Server.configure` block.**

```crystal
Amber::Server.configure do
  routes :web do
    get "/posts", PostsController, :index
    get "/posts/:id", PostsController, :show
  end
end
```

## Actions and views

**File: `src/controllers/posts_controller.cr` — replace the generated action
bodies with the application behavior. Keep the class inside this file.**

```crystal
class PostsController < ApplicationController
  def index
    title = "Recent posts"
    render("index.ecr")
  end

  def show
    post_id = params[:id]
    render("show.ecr")
  end
end
```

Local variables remain available to the ECR template rendered by the action.
Keep request parsing and authorization in explicit boundaries; use the [Schema
API](../schema-api/index.md) when input needs typed validation.

Amber's `resources` macro uses the conventional action names `index`, `new`,
`create`, `show`, `edit`, `update`, and `destroy`. Ordinary actions may use any
name when registered explicitly.

## Controller interfaces

- [Sessions and flash](sessions.md)
- [Request and response objects](request-and-response-objects.md)
- [Routing](../routing/index.md)
- [Schema API](../schema-api/index.md)

V2 web output is ECR. Examples that require `.slang` templates belong to the
V1 documentation and should not be copied into a new V2 application.


---

## SSL

Canonical page: https://amberframework.org/docs/v2/guides/ssl

# SSL

For development use self-signed keys and edit ```config/environments/development.yml```

```
ssl_key_file: example.key
ssl_cert_file: example.crt
```

For production, please use CertBot (https://certbot.eff.org) and edit ```config/environments/production.yml```

It is possible to set this in your Amber configure block as well, in ```config/application.cr```

```
Amber::Server.configure do |setting|
  # Server options
  setting.name = "Example web application."
  setting.port = 3000 # Port you wish your app to run
  setting.host = "0.0.0.0"
  setting.ssl_key_file = "example.key"
  setting.ssl_cert_file = "example.crt"
end
```


---

## Views

Canonical page: https://amberframework.org/docs/v2/guides/views

# Views

Amber V2's supported web path renders HTML with Crystal ECR templates. The
convention is deliberately small:

- controllers load resources and declare response formats;
- ECR templates own HTML;
- the application layout owns the document shell and local assets;
- `public/` owns files the browser requests directly.

## Negotiate HTML and JSON in one action

Use `respond_with` when one resource has more than one representation. The
action loads the resource once and makes each public format explicit:

**File: `src/controllers/articles_controller.cr` — add this action inside
`ArticlesController`.**

```crystal
class ArticlesController < ApplicationController
  def show
    article = ArticleCatalog.fetch(params["slug"])

    respond_with do
      html { render("show.ecr") }
      json { article.to_json }
    end
  end
end
```

A request with `Accept: text/html` renders the ECR template and layout. A
request with `Accept: application/json` runs only the JSON block. Amber also
recognizes supported path extensions when the route accepts that path. If the
request asks for no available representation, Amber returns `406 Not
Acceptable`.

Keep representation selection in the controller. Do not duplicate resource
loading in separate HTML and JSON actions unless the application behavior is
actually different. Register the matching route in `config/routes.cr`; see
[Routes](../routing/routes/) for the complete route boundary.

## Generated view structure

A clean web application starts with:

**Generated files:**

```text
src/views/
├── home/index.ecr
└── layouts/application.ecr
```

As the application grows, group templates by controller and name reusable
partials with a leading underscore:

**Reference structure:**

```text
src/views/
├── articles/
│   ├── _meta.ecr
│   ├── index.ecr
│   └── show.ecr
└── layouts/
    └── application.ecr
```

**File: `src/controllers/application_controller.cr` — keep this constant inside
the generated base controller.**

```crystal
class ApplicationController < Amber::Controller::Base
  LAYOUT = "application.ecr"
end
```

## Render ECR safely

Local variables in the controller action are available to the rendered ECR.
ECR does not automatically escape interpolation, so escape values that can
contain user or external data.

**File: `src/views/articles/show.ecr` — create this template for the controller's
`render("show.ecr")` branch.**

```ecr
<article class="article-shell">
  <p class="eyebrow">Field note</p>
  <h1><%= escape_html(article[:title]) %></h1>
  <p><%= escape_html(article[:summary]) %></p>

  <%= render(partial: "articles/_meta.ecr") %>
</article>
```

The layout receives the completed action template as `content`. That value is
framework-rendered HTML, so it is intentionally inserted without escaping.

**File: `src/views/layouts/application.ecr` — this is a complete minimal layout;
merge the asset tags into an existing branded layout instead of discarding its
navigation and metadata.**

```ecr
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/css/app.css">
  </head>
  <body>
    <%= content %>
    <script type="importmap">
      {"imports":{"app":"/js/app.js"}}
    </script>
    <script type="module">import "app";</script>
  </body>
</html>
```

The common rendering forms belong inside controller actions.

**File: a controller under `src/controllers/`, such as
`src/controllers/articles_controller.cr` — choose the form that matches the
view file you created.**

```crystal
render("show.ecr")
render(partial: "articles/_meta.ecr")
render("card.ecr", layout: false)
render("admin/show.ecr", layout: "admin.ecr")
```

## Front-end boundary

The layout above uses a browser-native import map. The `app` name resolves to a
local ES module served by Amber's static pipeline. The generated baseline needs
no Node.js dependency, package manager, bundler, UI framework, or CDN.

Read [Import maps](../assets/import-maps/) for the complete local-module pattern
and [Web template](../web-template/) for the exact generated project structure.

The V2 CLI generator emits ECR only. Slang, Kilt, Mustache, and Temel examples
on the V1 site remain maintenance references for old applications, not choices
in the supported V2 web template.


---

## Performance

Canonical page: https://amberframework.org/docs/v2/guides/performance

# Performance

Amber treats performance as an architectural property and a documentation
obligation. Every published number should travel with its workload, hardware,
protocol, duration, errors, and limits.

## Hosted Amber V2 result

On July 17, 2026, the Amber V2 performance lab measured a mature mixed
application on a DigitalOcean Basic one-vCPU, 512 MB-class target. A separate
four-vCPU host generated load over a private VPC.

| Current Amber JSON path | Result |
|---|---:|
| Median throughput | **21,795 requests/second** |
| Median p50 latency | 655 microseconds |
| Median p99 latency | 4.20 milliseconds |
| Repetitions | 7 |
| Socket errors | 0 |
| Non-2xx responses | 0 |

This result is whole HTTP traffic over real sockets. It is not an in-process
router lookup rate.

## Workload

The release-mode `x86_64-v2` binary installed 1,000 routes and replayed the same
deterministic 4,096-request table in every trial:

| Dimension | Mix |
|---|---|
| Route shapes | 45% static, 25% REST ID, 15% ID plus action, 5% nested, 5% constrained, 5% glob |
| HTTP methods | 65% GET, 20% POST, 8% PUT, 5% PATCH, 2% DELETE |
| Locality | 70% selected the first 20% in each method-and-shape cell |
| Query strings | 20% of requests |
| Connections | 16 |
| Load-generator threads | 4 |
| Warmup | 5 seconds |
| Measurement | 15 seconds |
| Repetitions | 7, with rotating variant order |

Writes carried an eight-field JSON CRUD/mobile payload. Controllers consumed
every decoded field and serialized an acknowledgement. Reads consumed captured
route parameters and serialized a response. The measured path included the
Crystal HTTP parser and serializer, Amber routing and pipeline dispatch, body
decoding, controller work, and JSON serialization.

Across the complete seven-variant body-codec/compiler matrix, the load
generator received **18,728,053 successful responses** with zero socket errors
and zero non-2xx responses.

## What the number means

The 21,795 requests/second result is more demanding and more representative
than a static-response endpoint. It supports the narrower statement that Amber
V2 can exceed 10,000 requests/second in this documented one-vCPU hosted
workload.

It does not establish:

- a cross-framework ranking;
- a universal result for every Amber application;
- a production capacity plan or service-level agreement;
- database, cache, proxy, TLS, or public-internet performance;
- the throughput of the final beta tag on different hardware.

Application behavior, infrastructure, compiler version, connection strategy,
and request shape can move the result substantially. Benchmark your deployed
path before making capacity decisions.

## Router microbenchmarks are separate

Amber also measures route matching in isolation. Those results can reach
millions of lookups per second because they intentionally exclude sockets,
HTTP parsing, middleware, controller dispatch, template rendering, and response
serialization. They are useful for choosing router implementations, but they
must never be presented as HTTP requests per second.

## Published evidence

The [round 22 summary data](/benchmarks/amber-v2-round22-summary.json) records
the result, workload, source commit, request-table hash, and hardware boundary
in a machine-readable form. The source experiment used commit
`deccb9358fd378a8d4e060cd13a19a35c609197e`; the runner used commit
`744269f4fa83ea5a5cbbbeef58d541d0981171d1`.

The result should be rerun for the GA release. If the workload, hardware, or
harness changes, publish it as a new benchmark rather than silently replacing
the historical context.


---

## Amber Docs Assistant

Canonical page: https://amberframework.org/docs/v2/guides/ai-assistants

# Amber Docs Assistant

Every V2 documentation page has a plain-Markdown source and one-click handoffs
for ChatGPT, Claude, and Gemini. For repeat use, you can also create a custom
GPT whose knowledge is the complete published Amber V2 documentation.

The assistant is a reading and teaching layer. The documentation remains the
source of truth, and platform support claims still come from the published beta
matrix and its linked CI evidence.

## Download the knowledge file

**Reference download: save this generated Markdown file before opening the GPT
builder.**

<a href="/docs/v2/knowledge.md" download>Download the Amber V2 documentation knowledge bundle</a>

The bundle combines every page currently published under `/docs/v2`, including
inherited maintenance references, and gives each section its canonical page
URL. It is text-forward so the GPT can retrieve code and prose without
interpreting a visual layout.

Download a fresh copy after a documentation release. A custom GPT does not
automatically replace a knowledge file when this website changes.

## Create the custom GPT

Custom GPT creation happens in ChatGPT's web editor and depends on your plan
and workspace permissions. Open [Explore GPTs](https://chatgpt.com/gpts), choose
**Create**, and use the configuration view. OpenAI's current
[creating and editing GPTs guide](https://help.openai.com/en/articles/8554397-creating-a-gpt)
documents access, knowledge uploads, Preview testing, sharing, and version
history.

Use these public fields:

| Field | Recommended value |
|---|---|
| Name | Amber Framework Guide |
| Description | Build and understand Amber V2 applications with answers grounded in the published documentation. |
| Knowledge | Upload the downloaded `amber-v2-docs.md` file. |

Knowledge supplies source material; instructions define behavior. Keep those
responsibilities separate.

## Where the examples go

- Paste the **GPT instructions** block into the Custom GPT editor's
  **Instructions** field. It is assistant configuration, not an Amber project
  file.
- Add each line in **Conversation starters** as its own starter in the same GPT
  configuration screen.
- Upload `amber-v2-docs.md` under **Knowledge**. Do not place it in an Amber
  application's source tree.

**GPT instructions: paste this complete Markdown into the Instructions field.**

```markdown
# Role
You are the Amber Framework Guide for Amber V2 beta users.

# Source contract
- Ground Amber answers in the uploaded Amber V2 documentation.
- Cite the canonical Amber documentation page named in the relevant bundle section.
- Distinguish release-gated web core, supported output, and preview ecosystem material.
- Prefer V2-authored guidance when an inherited Amber 1.4.1 reference conflicts with V2.
- Never invent a command, generator flag, package version, platform guarantee, benchmark, or file path.

# Teaching contract
- For every code example, name the exact file to create or edit.
- For every command, name the directory where it runs.
- Explain whether a snippet is a complete file, a replacement block, or an addition inside existing code.
- Use Crystal, ECR, YAML, CSS, JavaScript, or terminal labels accurately.
- Prefer the dependency-free web template unless the user deliberately chooses a preview integration.

# Build workflow
When a user wants to learn Amber through an app:
1. Start with the Build a Pet Tracker guide.
2. Keep HTML in ECR views, representation choice in controllers, routes in config/routes.cr, styles in public/css, and browser modules in public/js.
3. End with crystal spec, a native crystal build, and the exact URL or curl request that proves the feature.

# Uncertainty
If the uploaded documentation does not establish an answer, say what is unknown and link the closest canonical page. Do not convert an assumption into beta support language.
```

## Add useful conversation starters

**GPT configuration: add these as separate Conversation starters.**

```text
Build the Pet Tracker with me, one verified file at a time.
Show me where HTML, JSON, CSS, and JavaScript belong in an Amber V2 app.
Check whether a generator or platform is release-gated before I depend on it.
Explain this Amber error and cite the guide that supports your answer.
```

## Test before sharing

Use the GPT editor's Preview with questions that require retrieval rather than
general Crystal knowledge:

1. Ask it to start the Pet Tracker. It should name the parent directory for
   `amber new pet_tracker` and then `src/models/pet.cr`.
2. Ask for both HTML and JSON from one action. It should use `respond_with` and
   name `src/controllers/pets_controller.cr`.
3. Ask whether persistence and native generation are in the clean web compile
   guarantee. It should say they are preview surfaces.
4. Ask where CSS and JavaScript go. It should keep them local under `public/`
   and preserve the generated import map.

If an answer omits a file location, weakens the beta boundary, or cannot cite a
canonical page, tighten the instructions before adding capabilities. Web search
is optional; it is not a replacement for the uploaded release documentation.

## Use one page with any assistant

The buttons above each documentation page create a prompt containing that
page's public raw-Markdown URL. Use them when one page is enough. Copy as
Markdown remains the reliable fallback when an assistant does not accept a
prefilled prompt or the site is running only on localhost.

The page-level source contract is:

**Reference URL pattern:**

```text
https://amberframework.org/docs/raw/v2/PAGE_PATH
```

For example, the Pet Tracker source is
`https://amberframework.org/docs/raw/v2/guides/pet-tracker`.


---

## Routing

Canonical page: https://amberframework.org/docs/v2/guides/routing

# Routing

Amber compiles the routes in `config/routes.cr`. Each route selects a pipeline,
matches an HTTP method and path, then dispatches to a controller action.

The generated V2 web application starts with separate `web` and `static`
pipelines. Keep that separation: request/session behavior belongs to the web
pipeline, while files under `public/` are served through the static pipeline.

- [Pipelines](pipelines.md) — compose request handlers in execution order.
- [Routes](routes.md) — map paths, resources, namespaces, and constraints.

Run `amber routes` from the project root to inspect the routes declared in
`config/routes.cr`. The compiler and request specs remain the source of truth
for whether those routes dispatch successfully.


---

## Websockets

Canonical page: https://amberframework.org/docs/v2/guides/websockets

# Websockets

{% page-ref page="channels.md" %}

{% page-ref page="javascript-client.md" %}


---

## Mailers

Canonical page: https://amberframework.org/docs/v2/guides/mailers

# Mailers

Amber V2 includes `Amber::Mailer::Base`, MIME generation, attachments, an
in-memory delivery adapter, and SMTP delivery. Generate an ECR-backed mailer
with the standalone CLI.

**Run from: the application root.**

```bash
amber generate mailer Digest --actions=weekly
```

The generator writes `src/mailers/digest_mailer.cr`, an ECR template under
`src/views/digest_mailer/`, and a mailer spec. The generated class implements
the required HTML and text bodies.

**File: `src/mailers/digest_mailer.cr` — edit the generated class in place.**

```crystal
class DigestMailer < Amber::Mailer::Base
  def initialize(@user_name : String, @user_email : String)
  end

  def html_body : String?
    ECR.render("src/views/digest_mailer/weekly.ecr")
  end

  def text_body : String?
    "Hello, #{@user_name}!"
  end
end
```

**File: `src/views/digest_mailer/weekly.ecr` — edit the generated HTML body and
escape user-provided values.**

```crystal
<h1>Hello, <%= HTML.escape(@user_name) %>!</h1>
```

## Delivery configuration

The memory adapter is the default and is appropriate for tests. Configure SMTP
at application startup before delivering production mail.

**File: `config/application.cr` — append this configuration after
`require "amber"`.**

```crystal
Amber::Mailer::Configuration.configure do |config|
  config.adapter = :smtp
  config.smtp_host = ENV["SMTP_HOST"]
  config.smtp_port = ENV.fetch("SMTP_PORT", "587").to_i
  config.smtp_username = ENV["SMTP_USERNAME"]?
  config.smtp_password = ENV["SMTP_PASSWORD"]?
  config.use_tls = true
  config.default_from = ENV.fetch("MAIL_FROM", "noreply@example.com")
  config.helo_domain = ENV.fetch("SMTP_HELO_DOMAIN", "localhost")
end
```

Do not commit SMTP credentials.

## Build and deliver

**File: the controller action or job that owns delivery, for example
`src/jobs/digest_delivery_job.cr` — build the message before calling
`.deliver`.**

```crystal
result = DigestMailer.new("Alice", "alice@example.com")
  .to("alice@example.com")
  .subject("Your weekly digest")
  .deliver

raise result.error.to_s unless result.is_successful
```

Use `.from`, `.cc`, `.bcc`, `.reply_to`, `.header`, `.attach`, or
`.attach_file` before `.deliver` when the message needs them. The Quartz-Mailer
and Slang examples on the V1 page do not describe Amber V2's mailer API.


---

## Testing

Canonical page: https://amberframework.org/docs/v2/guides/testing

# Testing

This guide covers built-in mechanisms in Amber for testing your application.

With this guide you will learn:

* Amber testing terminology
* How to write unit, functional, integration, and system tests for your application.

## Why write tests for your Amber Application?

* Amber makes it very easy to test your application. Amber generates skeleton test code when you generate your controllers, models.
* Tests ensure your application adheres to the specifications it was built for.
* Tests help and guide you through a code refactor.
* Amber tests can also simulate browser requests and thus you can test your application's response without having to test it through your browser.

## Amber Testing

Amber was built with testing in mind. The first time you generate an Amber application using `amber new your_app_name` a `spec` directory is generated. The contents of this directory looks as follow.

* `/spec`
  * `/controllers`
  * `/system`
  * `/models`
  * `/integrations`
  * `/mailers`

The `helpers`, `mailers`, and `models` directories are meant to hold tests for view helpers, mailers, and models, respectively. The `controllers` directory is meant to hold tests for controllers, routes, and views. The integration directory is meant to hold tests for interactions between controllers.

The `system` test directory holds system tests, which are used for full browser testing of your application. System tests allow you to test your application the way your users experience it and help you test your JavaScript as well. System tests inherit from GarnetSpec and perform in browser tests for your application.

### The Test Environment

By default every Amber application generates with three environments: `development`, `test` and `production`.

Each environment's configuration can be modified similarly. In this case, we can modify our test environment by changing the options found in `config/environments/test.yml`.

{% hint style="warning" %}
Your tests are run under AMBER\_ENV=test.
{% endhint %}

{% hint style="warning" %}
Guides for other tests like Controller tests, Integration tests and Model testing are work in progress...
{% endhint %}


---

## Session Adapters

Canonical page: https://amberframework.org/docs/v2/guides/adapters/sessions

# Session Adapters

Session adapters store the key/value data associated with a session ID. Amber
V2 includes `MemorySessionAdapter`; applications can register another backend
through `AdapterFactory` when state must survive a restart or be shared across
processes.

## Complete adapter contract

A custom adapter inherits `Amber::Adapters::SessionAdapter` and implements every
abstract operation.

**Reference API: implemented by a class under `src/adapters/`, for example
`src/adapters/redis_session_adapter.cr`. Do not copy the abstract class into the
application.**

```crystal
abstract class Amber::Adapters::SessionAdapter
  abstract def get(session_id : String, key : String) : String?
  abstract def set(session_id : String, key : String, value : String) : Nil
  abstract def delete(session_id : String, key : String) : Nil
  abstract def destroy(session_id : String) : Nil
  abstract def exists?(session_id : String, key : String) : Bool
  abstract def keys(session_id : String) : Array(String)
  abstract def values(session_id : String) : Array(String)
  abstract def to_hash(session_id : String) : Hash(String, String)
  abstract def empty?(session_id : String) : Bool
  abstract def expire(session_id : String, seconds : Int32) : Nil
  abstract def batch_set(session_id : String, values : Hash(String, String)) : Nil
  abstract def batch(session_id : String, &block : Amber::Adapters::SessionBatchOperations ->) : Nil
end
```

Adapters may also override `close` to release connections and `healthy?` to
report backend availability.

`batch_set` and `batch` should be atomic when the backend supports transactions
or pipelining. The expiration operation applies to the complete session, not an
individual key.

## Built-in memory adapter

**File: the applicable file under `config/environments/`, such as
`config/environments/development.yml` — edit its existing `session:` section.**

```yaml
session:
  key: "my_app.session"
  store: "signed_cookie"
  adapter: "memory"
  expires: 3600
```

Memory state belongs to one application process and disappears when that process
stops. Use it for development, tests, or a deployment where that lifecycle is an
explicit product decision.

## Register an application adapter

Load and register the adapter before Amber builds the configured session store.
The generated application already requires `config/application.cr`, so it is a
reliable registration point.

**File: `config/application.cr` — keep `require "amber"`, require the adapter
class, then register it before routes are loaded.**

```crystal
# config/application.cr
require "amber"
require "../src/adapters/redis_session_adapter"

Amber::Adapters::AdapterFactory.register_session_adapter("redis") do
  RedisSessionAdapter.new(redis_client)
end
```

**File: `config/environments/production.yml` — edit the existing `session:`
section after the adapter is registered.**

```yaml
# config/environments/production.yml
session:
  key: "my_app.session"
  store: "signed_cookie"
  adapter: "redis"
  expires: 86400
```

The generated V2 application does not automatically require every file under
`config/initializers/`. If you choose that directory, add an explicit require
before `Amber::Server.start` and prove the load order in a clean build.

## Adapter verification

Test the implementation independently from controller behavior:

- create, read, update, and delete more than one key in a session;
- distinguish deleting one key from destroying the complete session;
- return consistent results from `keys`, `values`, `to_hash`, and `empty?`;
- expire a session and verify its keys disappear;
- prove `batch_set` and `batch` do not expose a partial update;
- exercise backend timeout, reconnect, and unavailable states;
- close connections cleanly during shutdown;
- run concurrent access tests that match the deployment process model.

For a Redis migration, also preserve or intentionally replace the previous key
namespace, serialization, expiration, and active-session policy. See
[Redis to Adapters](../../migration-guide/redis-to-adapters/).


---

## PubSub Adapters

Canonical page: https://amberframework.org/docs/v2/guides/adapters/pubsub

# PubSub Adapters

Pub/sub adapters carry WebSocket messages between publishers and subscribers.
Amber V2 includes `MemoryPubSubAdapter`; applications can register a shared
broker when broadcasts must cross process or host boundaries.

## Complete adapter contract

A custom adapter inherits `Amber::Adapters::PubSubAdapter`.

**Reference API: implemented by a class under `src/adapters/`, for example
`src/adapters/redis_pubsub_adapter.cr`. Do not copy the abstract class into the
application.**

```crystal
abstract class Amber::Adapters::PubSubAdapter
  abstract def publish(topic : String, sender_id : String, message : JSON::Any) : Nil
  abstract def subscribe(topic : String, &block : (String, JSON::Any) -> Nil) : Nil
  abstract def unsubscribe(topic : String) : Nil
  abstract def unsubscribe_all : Nil
  abstract def close : Nil
end
```

Adapters may also override `healthy?`, `subscriber_count`, and `active_topics`
when the backend can report those values accurately.

The adapter owns broker subscriptions and resource cleanup. Calling
`unsubscribe(topic)` must stop delivery for that topic; `unsubscribe_all` and
`close` must release all remaining subscriptions and connections.

## Built-in memory adapter

**File: the applicable file under `config/environments/`, such as
`config/environments/development.yml` — edit its existing `pubsub:` section.**

```yaml
pubsub:
  adapter: "memory"
```

Use it for development, tests, and intentional single-process deployments. A
browser connected to one process cannot receive a message published only inside
another process through the memory adapter.

## Register a shared adapter

**File: `config/application.cr` — keep `require "amber"`, require the adapter
class, then register it before routes are loaded.**

```crystal
# config/application.cr
require "amber"
require "../src/adapters/redis_pubsub_adapter"

Amber::Adapters::AdapterFactory.register_pubsub_adapter("redis") do
  RedisPubSubAdapter.new(redis_client)
end
```

**File: `config/environments/production.yml` — edit the existing `pubsub:`
section after the adapter is registered.**

```yaml
# config/environments/production.yml
pubsub:
  adapter: "redis"
```

Redis is an example of an application-supplied broker, not a built-in Amber V2
adapter. The adapter must match the chosen Redis shard API, connection model,
authentication, TLS, and reconnect behavior.

## Message contract

`publish` receives a topic, sender ID, and `JSON::Any` message. A shared adapter
must preserve those three values across serialization so each subscriber callback
receives the original sender ID and message.

Define a collision-safe broker namespace for the application and environment.
Do not subscribe directly to an untrusted topic name without validating or
encoding it for the broker.

## Adapter verification

- publish and receive representative JSON values without losing types;
- preserve the sender ID used to identify or filter an originating socket;
- deliver to multiple subscribers on the same topic;
- stop delivery after `unsubscribe` and `unsubscribe_all`;
- close broker connections and listener fibers cleanly;
- recover or fail visibly after a broker disconnect;
- use two application processes to prove cross-process delivery;
- verify topic isolation between environments and applications;
- load-test the subscription count and message sizes expected in production.

Presence, replay, persistence, ordering, and exactly-once delivery are not
provided merely by implementing the Amber pub/sub interface. If the application
requires one of those guarantees, specify and test it as part of the adapter.

See [Redis to Adapters](../../migration-guide/redis-to-adapters/) for a staged
cutover and rollback checklist.


---

## Import Maps

Canonical page: https://amberframework.org/docs/v2/guides/assets/import-maps

# Import Maps

Import maps let the browser resolve a stable module name such as `app` to a
JavaScript file served from your Amber application. The supported V2 baseline
uses the browser feature directly: no Node.js dependency, package manager,
bundler, UI framework, CDN, or Asset Pipeline integration is required.

## Start with one local module

**File: `public/js/app.js` — replace the generated starter module or create this
file if the application predates the V2 web template.**

```javascript
// public/js/app.js
const menuButton = document.querySelector("[data-menu-button]");
const menu = document.querySelector("[data-menu]");

menuButton?.addEventListener("click", () => {
  const open = menuButton.getAttribute("aria-expanded") !== "true";
  menuButton.setAttribute("aria-expanded", String(open));
  menu?.toggleAttribute("data-open", open);
});
```

**File: `src/views/layouts/application.ecr` — place this block immediately
before `</body>`. Replace the existing starter import-map block; do not add a
second import map.**

```ecr
<script type="importmap">
  {
    "imports": {
      "app": "/js/app.js"
    }
  }
</script>
<script type="module">import "app";</script>
```

The import map must appear before the module that uses it. Module scripts are
deferred by the browser, so the document is parsed before `app.js` executes.

## Split behavior by responsibility

Add local modules when the front end becomes large enough to benefit from
separate files.

**Reference structure — create these files under the application-owned
`public/js/` directory:**

```text
public/js/
├── app.js
├── controllers/
│   ├── menu.js
│   └── dialog.js
└── lib/
    └── format-date.js
```

**File: `src/views/layouts/application.ecr` — replace the earlier import-map
block with this expanded map.**

```ecr
<script type="importmap">
  {
    "imports": {
      "app": "/js/app.js",
      "controllers/": "/js/controllers/",
      "lib/": "/js/lib/"
    }
  }
</script>
<script type="module">import "app";</script>
```

**File: `public/js/app.js` — replace its contents with the application entry
point that composes the two controllers.**

```javascript
// public/js/app.js
import {connectMenu} from "controllers/menu.js";
import {connectDialogs} from "controllers/dialog.js";

connectMenu();
connectDialogs();
```

The trailing slash in `"controllers/"` maps every matching module prefix to
the local directory. This keeps imports readable if asset locations change
later.

## Styling stays local too

An import map solves JavaScript module names; it does not replace CSS. Keep the
front-end baseline together and visible.

**Reference structure:**

```text
public/
├── css/app.css
└── js/
    ├── app.js
    └── controllers/menu.js
```

**File: `src/views/layouts/application.ecr` — keep this stylesheet link inside
`<head>`.**

```ecr
<link rel="stylesheet" href="/css/app.css">
```

**File: `public/css/app.css` — use this as a starting layer, then extend it with
the application's components.**

```css
:root {
  --paper: #fffaf3;
  --ink: #241a15;
  --accent: #e96918;
}

.page-shell {
  display: grid;
  width: min(100% - 2rem, 72rem);
  margin-inline: auto;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}
```

This is a complete front-end path: ECR supplies semantic HTML, local CSS owns
the visual system, and local modules add behavior progressively.

## Cache versions deliberately

Static files can use a query version when you need an explicit cache boundary.

**File: `src/views/layouts/application.ecr` — update the existing asset URLs;
do not duplicate the stylesheet or import map.**

```ecr
<link rel="stylesheet" href="/css/app.css?v=2026-08-10">
<script type="importmap">
  {"imports":{"app":"/js/app.js?v=2026-08-10"}}
</script>
```

Change the version when the file changes. Keep every mapped URL local if the
application must work offline or maintain a no-third-party-runtime policy.

## Adding a dependency is a product decision

Import maps can point at remote packages, but they do not make an external
dependency free. A remote module adds availability, integrity, privacy,
compatibility, and release-policy questions. Prefer local application modules
for the supported baseline. If a third-party package earns its place, pin and
self-host the reviewed artifact when practical.

The preview Asset Pipeline ecosystem can generate import maps for larger asset
graphs, but it is not required by the Amber 2.0.0-beta.2 web-app contract. Its
package version, API, and platform support may change independently.

See [Views](../views/) for the complete controller, ECR, and layout boundary.

## Verify the file-to-browser path

**Run from: the application root.**

```bash
crystal spec
amber watch
```

Open a rendered page, use **View Source**, and confirm that it contains one
import map before the module import. Then request `/js/app.js` directly and
confirm that Amber returns the file from `public/js/app.js`. If a nested import
fails, compare its map prefix with the matching directory under `public/js/`.


---

## Stimulus Integration

Canonical page: https://amberframework.org/docs/v2/guides/assets/stimulus

# Stimulus integration

> **Preview ecosystem guide:** Asset Pipeline is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

This page extends the working example in the [Asset Pipeline guide](../). It
assumes that `config/application.cr` already defines `FRONT_LOADER` and that
`src/views/layouts/application.ecr` renders both Asset Pipeline tags.

Stimulus keeps behavior next to the feature it controls while Amber keeps HTML
in ECR. The three boundaries are:

**Reference file map:**

```text
config/application.cr                         # maps and registers controllers
src/javascript/controllers/                  # controller behavior
src/views/                                    # data-controller markup
```

## How registration works

Asset Pipeline treats an import-map key ending in `Controller` as a Stimulus
controller. It converts the class-style key to the identifier used in HTML.

| Import-map key | Registered identifier | View attribute |
|---|---|---|
| `HelloController` | `hello` | `data-controller="hello"` |
| `DropdownController` | `dropdown` | `data-controller="dropdown"` |
| `UserProfileController` | `user-profile` | `data-controller="user-profile"` |

The JavaScript filename alone does not trigger registration. The key in
`config/application.cr` must end in `Controller`.

## Add a dropdown controller

### 1. Map the source file

**File: `config/application.cr` — add this call inside the existing
`do |import_maps|` block, before `import_maps << import_map`.**

```crystal
import_map.add_import(
  "DropdownController",
  "controllers/dropdown_controller.js"
)
```

Do not create a second `FRONT_LOADER`. This line extends the `import_map`
created by the loader you already configured.

### 2. Create the controller

**File: `src/javascript/controllers/dropdown_controller.js` — create this
complete file.**

```javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["button", "panel"]

  connect() {
    this.close()
  }

  toggle() {
    const open = this.buttonTarget.getAttribute("aria-expanded") !== "true"
    this.buttonTarget.setAttribute("aria-expanded", String(open))
    this.panelTarget.hidden = !open
  }

  close() {
    this.buttonTarget.setAttribute("aria-expanded", "false")
    this.panelTarget.hidden = true
  }
}
```

### 3. Add the HTML boundary

**File: `src/views/home/index.ecr` — add this section inside the existing page
content. The application layout remains in `src/views/layouts/application.ecr`.**

```ecr
<section data-controller="dropdown">
  <button
    type="button"
    data-dropdown-target="button"
    data-action="click->dropdown#toggle"
    aria-controls="framework-details"
  >
    Framework details
  </button>

  <div id="framework-details" data-dropdown-target="panel">
    Amber renders the document; Stimulus adds this interaction.
  </div>
</section>
```

The identifier in `data-controller`, every `data-action`, and every target
prefix must all be `dropdown`. A mismatch is the most common reason the module
loads but does not connect.

## Pass values from ECR to JavaScript

Use Stimulus values for server-rendered configuration rather than generating
JavaScript source inside ECR.

### 1. Register the controller

**File: `config/application.cr` — add this call next to the other controller
imports inside the existing loader block.**

```crystal
import_map.add_import(
  "CountdownController",
  "controllers/countdown_controller.js"
)
```

### 2. Create the controller

**File: `src/javascript/controllers/countdown_controller.js` — create this
complete file.**

```javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["display"]
  static values = {
    seconds: { type: Number, default: 60 }
  }

  connect() {
    this.remaining = this.secondsValue
    this.displayTarget.textContent = String(this.remaining)
    this.timer = window.setInterval(() => this.tick(), 1000)
  }

  tick() {
    this.remaining -= 1
    this.displayTarget.textContent = String(this.remaining)

    if (this.remaining <= 0) {
      window.clearInterval(this.timer)
      this.dispatch("finished")
    }
  }

  disconnect() {
    window.clearInterval(this.timer)
  }
}
```

### 3. Supply the value from a view

**File: the ECR view that owns the countdown, for example
`src/views/events/show.ecr` — add this element where the timer should render.**

```ecr
<p
  data-controller="countdown"
  data-countdown-seconds-value="30"
>
  Time remaining:
  <span data-countdown-target="display" aria-live="polite">30</span>
</p>
```

In a real application, escape any user-controlled value before placing it in
an HTML attribute. Keep the controller generic; the ECR view owns the value for
this page.

## Add a third-party module deliberately

Remote modules add availability, privacy, integrity, and release-policy risks.
If a dependency earns its place, pin its version in the same import map as the
controller that uses it.

**File: `config/application.cr` — add both imports inside the existing loader
block. Replace the URL only after reviewing and pinning the chosen artifact.**

```crystal
import_map.add_import(
  "chart.js",
  "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/+esm"
)
import_map.add_import(
  "ChartController",
  "controllers/chart_controller.js"
)
```

**File: `src/javascript/controllers/chart_controller.js` — import the exact
name mapped above.**

```javascript
import { Controller } from "@hotwired/stimulus"
import Chart from "chart.js/auto"

export default class extends Controller {
  connect() {
    this.chart = new Chart(this.element, {
      type: "bar",
      data: {
        labels: ["HTML", "JSON"],
        datasets: [{ label: "Responses", data: [8, 5] }]
      }
    })
  }

  disconnect() {
    this.chart.destroy()
  }
}
```

**File: the ECR view that owns the chart, for example
`src/views/reports/show.ecr` — add the canvas inside the page content.**

```ecr
<canvas data-controller="chart" aria-label="Response formats"></canvas>
```

For the supported no-third-party baseline, keep modules local under
`public/js/` and follow [Import Maps](import-maps/) instead.

## Verify a controller end to end

**Run from: the application root.**

```bash
crystal spec
amber watch
```

Open the page containing the controller and verify, in order:

1. the import map contains the controller's class-style key;
2. the mapped JavaScript URL returns `200 OK`;
3. the HTML uses the converted identifier;
4. the interaction works without a browser console error;
5. navigation away from the page does not leave timers or listeners running.

When debugging, trace the same path the browser follows:
`config/application.cr` → the file under `src/javascript/` → the generated URL
under `/javascript/` → the `data-controller` element in the ECR view.


---

## Configuration

Canonical page: https://amberframework.org/docs/v2/guides/assets/configuration

# Asset Pipeline configuration

> **Preview ecosystem guide:** Asset Pipeline is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

This page extends the working setup from [Asset Pipeline](../). Complete that
guide first. Every relative path below is resolved from the application root,
the directory that contains `shard.yml`.

## Configuration boundary

Keep the loader in the file that the released V2 web template loads at boot.

**File: `config/application.cr` — place the loader after `require "amber"` and
`require "asset_pipeline"`. Replace the earlier `FRONT_LOADER` definition; do
not create a second loader.**

```crystal
FRONT_LOADER = AssetPipeline::FrontLoader.new(
  js_source_path: Path["src/javascript"],
  js_output_path: Path["public/javascript"],
  clear_cache_upon_change: true
) do |import_maps|
  import_map = AssetPipeline::ImportMap.new(
    "application",
    Path["/javascript"]
  )

  import_map.add_import(
    "@hotwired/stimulus",
    "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm",
    preload: true
  )
  import_map.add_import("HelloController", "hello_controller.js")

  import_maps << import_map
end
```

The initializer uses three distinct path concepts:

| Setting | Example | Owns |
|---|---|---|
| `js_source_path` | `src/javascript` | Files you edit |
| `js_output_path` | `public/javascript` | Generated files Amber serves |
| public asset base path | `/javascript` | URLs written into the import map |

Do not point `js_source_path` and `js_output_path` at the same directory. Cache
clearing can remove and recreate the output directory.

## Recommended file layout

**Reference structure — create source subdirectories only when the application
needs them.**

```text
my_app/
├── config/application.cr
├── src/javascript/
│   ├── controllers/
│   │   ├── dropdown_controller.js
│   │   └── modal_controller.js
│   ├── services/
│   │   └── api_service.js
│   └── application.js
└── public/javascript/                       # generated; do not edit
```

When a source file moves into a subdirectory, update its import-map destination
to match.

**File: `config/application.cr` — add these calls inside the existing
`do |import_maps|` block, before `import_maps << import_map`.**

```crystal
import_map.add_import(
  "DropdownController",
  "controllers/dropdown_controller.js"
)
import_map.add_import(
  "ModalController",
  "controllers/modal_controller.js"
)
import_map.add_import("ApiService", "services/api_service.js")
```

**Files created by those entries:**

```text
src/javascript/controllers/dropdown_controller.js
src/javascript/controllers/modal_controller.js
src/javascript/services/api_service.js
```

An import name ending in `Controller` participates in generated Stimulus
registration. `ApiService` remains an ordinary ESM import.

## Cache behavior

`clear_cache_upon_change` defaults to `true`. Keep that default in development
while source files are changing.

If you need to preserve the output directory during a focused debugging
session, change only the option on the existing loader.

**File: `config/application.cr` — edit the existing `FRONT_LOADER` arguments.**

```crystal
FRONT_LOADER = AssetPipeline::FrontLoader.new(
  js_source_path: Path["src/javascript"],
  js_output_path: Path["public/javascript"],
  clear_cache_upon_change: false
) do |import_maps|
  # Keep the existing import-map configuration here.
end
```

This fragment is not a complete replacement for the initializer: retain the
real import-map entries from your application. Re-enable cache clearing after
debugging so stale generated files do not mask source changes.

## Layout boundary

The loader is configured in Crystal, but its output belongs in the document
layout.

**File: `src/views/layouts/application.ecr` — render the map inside `<head>` and
the initialization script immediately before `</body>`.**

```ecr
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/css/app.css">
    <%= FRONT_LOADER.render_import_map_tag %>
  </head>
  <body>
    <%= content %>
    <%= FRONT_LOADER.render_stimulus_initialization_script %>
  </body>
</html>
```

This is a complete layout example. Preserve any application-specific metadata,
navigation, and accessibility landmarks when applying it to an existing file.
Remove the V2 starter's manual import-map tags so only one import map controls
the page.

## Development and production

Keep the module graph identical across environments whenever possible. If
cache behavior must differ, calculate the option once and pass it to the same
loader.

**File: `config/application.cr` — define this value above the existing loader,
then use it for `clear_cache_upon_change`.**

```crystal
clear_asset_cache = ENV["AMBER_ENV"]? != "production"

FRONT_LOADER = AssetPipeline::FrontLoader.new(
  js_source_path: Path["src/javascript"],
  js_output_path: Path["public/javascript"],
  clear_cache_upon_change: clear_asset_cache
) do |import_maps|
  # Keep the existing import-map configuration here.
end
```

Treat this as an alternative to the earlier loader definition, not an
additional constant. The fixed `true` value is still the least ambiguous
preview setup while evaluating the dependency locally.

## Deployment boundary

There is no npm or Webpack build. The Crystal application still must render the
layout so the loader can produce the browser-facing files. Do not deploy an
empty `public/javascript/` directory and assume a separate JavaScript build will
fill it.

For a container or release image, build the Crystal application as usual, start
it with a writable `public/javascript/` path, request one page that renders the
application layout, and then verify the generated asset URL. If the production
filesystem is read-only, generate the files in a build stage or choose the
supported hand-authored import-map baseline instead of this preview.

## Verify after every configuration change

**Run from: the application root.**

```bash
crystal spec
amber watch
```

Then open a page using `src/views/layouts/application.ecr` and verify all three
signals:

1. the page source contains one `<script type="importmap">`;
2. its local controller URL begins with `/javascript/`;
3. that URL returns `200 OK` and contains the controller source.

If one signal fails, inspect `config/application.cr`, the matching file under
`src/javascript/`, and the generated directory under `public/javascript/` in
that order.


---

## Sessions

Canonical page: https://amberframework.org/docs/v2/guides/controllers/sessions

# Sessions

Amber exposes `session` and `flash` directly inside a controller. Amber CLI's
V2 web template enables the session and flash pipes in this order.

**File: `config/routes.cr` — keep this order inside the generated `pipeline
:web` block.**

```crystal
pipeline :web do
  plug Amber::Pipe::Error.new
  plug Amber::Pipe::Logger.new
  plug Amber::Pipe::Session.new
  plug Amber::Pipe::Flash.new
  plug Amber::Pipe::CSRF.new
end
```

Keep `Session` before `Flash`: flash messages are serialized through the
session after the request.

## Generated configuration

The web template writes the following section to each environment YAML file.

**Files: `config/environments/development.yml`,
`config/environments/test.yml`, and `config/environments/production.yml` — edit
the existing `session:` section in each environment rather than adding a
duplicate key.**

```yaml
session:
  key: my_app.session
  store: signed_cookie
  adapter: memory
  expires: 0
```

The V2 session store uses the configured adapter for session values and an
encrypted cookie for the session identifier. The built-in `memory` adapter is
useful for local development and tests, but its data is process-local. Choose a
shared custom adapter before running multiple application processes or before
depending on sessions that must survive a restart. See [Session
Adapters](../adapters/sessions.md).

Production also requires a long `AMBER_SERVER_SECRET_KEY_BASE`; Amber uses it
to protect cookies. Do not commit a production secret to the YAML file.

## Read, write, and delete values

Session keys accept strings or symbols. Values are stored as strings.

**File: `src/controllers/logins_controller.cr` — place these actions inside
`LoginsController`, then register their routes in `config/routes.cr`.**

```crystal
class LoginsController < ApplicationController
  def create
    # Replace this lookup with your application's authentication logic.
    user_id = "42"
    session[:current_user_id] = user_id

    # Regenerate an adapter-backed session ID after authentication to prevent
    # session fixation. This is a no-op for a cookie-only store.
    context.regenerate_session!

    flash.notice = "Welcome back."
    redirect_to location: "/", status: 302
  end

  def destroy
    session.delete(:current_user_id)
    flash[:notice] = "You have signed out."
    redirect_to location: "/", status: 302
  end
end
```

**File: the controller action that needs the authenticated identity — use the
optional lookup where absence is expected.**

```crystal
if user_id = session[:current_user_id]?
  # Load the user through the persistence layer selected by the application.
end
```

Keep session payloads small and non-sensitive. Store a stable identifier, not
an entire model or authorization policy, and verify authorization again on
every protected request.

## Flash messages

Flash values are intended for the next request. Reading a value marks it for
removal; `keep` carries it forward, while `now` makes a value available only in
the current request.

**File: the controller action that sets the message.**

```crystal
flash[:error] = "Please correct the highlighted fields."
flash.keep(:error)
flash.now(:notice, "The preview was not saved.")
```

**File: `src/views/layouts/_flash.ecr` — create this reusable partial, then
render it from `src/views/layouts/application.ecr`.**

```ecr
<% flash.each do |name, message| %>
  <div class="flash flash-<%= name %>"><%= message %></div>
<% end %>
```

**File: `src/views/layouts/application.ecr` — add this call where global
messages should appear.**

```ecr
<%= render(partial: "layouts/_flash.ecr") %>
```

The V1 guide's inline Redis configuration is not a V2 configuration contract.
Implement and register a session adapter instead, then select it with the
`session.adapter` setting.


---

## Request & Response Objects

Canonical page: https://amberframework.org/docs/v2/guides/controllers/request-and-response-objects

# Request & Response Objects

Every Amber controller delegates `request` and `response` to the current
`HTTP::Server::Context`. Use Amber's controller helpers for ordinary rendering,
redirects, and negotiated responses; reach for the underlying Crystal objects
when you need a header, method, resource, or status directly.

## Request

`request` is Crystal's `HTTP::Request` with Amber routing extensions.

**File: `src/controllers/diagnostics_controller.cr` — place this action inside
`DiagnosticsController`, then register it in `config/routes.cr`.**

```crystal
class DiagnosticsController < ApplicationController
  def show
    method = request.method
    resource = request.resource
    user_agent = request.headers["User-Agent"]?
    query = request.query

    respond_with do
      json({method: method, resource: resource, user_agent: user_agent, query: query}.to_json)
    end
  end
end
```

Common controller-level helpers include:

| Helper | Result |
|---|---|
| `get?`, `post?`, `put?`, `patch?`, `delete?`, `head?` | Whether the request uses that HTTP method |
| `params` | Amber route, query, and form parameters |
| `format` | The requested response format inferred from the path or headers |
| `port` | The request port |
| `requested_url` | The parsed request URL |
| `cookies` | Amber's cookie store |
| `session`, `flash` | The current session and flash stores |

The raw request body is an `IO`. A parser or [request
schema](../schema-api/index.md) is usually a better boundary for JSON or form
input than manually reading the stream in each action.

## Response

`response` is Crystal's `HTTP::Server::Response`. Its most useful direct
properties are `status_code`, `headers`, and `content_type`.

**File: `src/controllers/health_controller.cr` — place this action inside
`HealthController`, then register it in `config/routes.cr`.**

```crystal
class HealthController < ApplicationController
  def show
    response.headers["Cache-Control"] = "no-store"
    set_response(
      body: "ok",
      status_code: 200,
      content_type: "text/plain"
    )
  end
end
```

**File: a controller action under `src/controllers/` — use `respond_with` when
that action offers these representations.**

```crystal
respond_with do
  html render("show.ecr")
  json({status: "ok"}.to_json)
  text "ok"
end
```

**File: a controller filter or action under `src/controllers/` — use `halt!`
when the pipeline must stop with a plain response.**

```crystal
halt!(403, "forbidden") unless authorized?
```

**File: a controller action under `src/controllers/` — use the redirect helper
rather than setting a `Location` header by hand.**

```crystal
redirect_to location: "/login", status: 302
```

For the upstream object APIs, see Crystal's `HTTP::Request` and
`HTTP::Server::Response` reference. Amber-specific helpers and schema
integration should remain the first choice when they express the intent.


---

## Halt!

Canonical page: https://amberframework.org/docs/v2/guides/controllers/halt

# Halt!

`halt!` sets the current context body, plain-text content type, and status code.
It is most useful in a before filter, where setting context content prevents the
controller action from running.

**File: `src/controllers/admin_controller.cr` — place the filter and action
inside `AdminController`.**

```crystal
class AdminController < ApplicationController
  before_action do
    only :index do
      halt!(403, "Forbidden") unless session[:admin_id]?
    end
  end

  def index
    render("index.ecr")
  end
end
```

`halt!` marks the request context; it does not raise an exception that escapes
ordinary Crystal control flow. Inside an action, return an explicit response
when later expressions must not run.

**File: the controller that owns `show`, for example
`src/controllers/admin_controller.cr` — replace that action body.**

```crystal
def show
  unless authorized?
    return set_response(
      body: "Forbidden",
      status_code: 403,
      content_type: "text/plain"
    )
  end

  render("show.ecr")
end
```

Amber's redirect helper sets the `Location` header and uses the same context
response mechanism.

**File: a controller action under `src/controllers/` — return this expression
at the point where request processing should redirect.**

```crystal
redirect_to location: "/login", status: 302
```

The V1 Slang example and its claim that `halt!` interrupts any action like an
exception are not copied into V2.


---

## Respond With

Canonical page: https://amberframework.org/docs/v2/guides/controllers/respond-with

# Respond With

Use `respond_with` when one controller action can return more than one content
type. Amber selects a response from the path extension or the request's
`Accept` header.

**File: `src/controllers/status_controller.cr` — add this action inside
`StatusController`.**

```crystal
class StatusController < ApplicationController
  def show
    respond_with do
      html render("show.ecr")
      json({status: "ok"}.to_json)
      text "ok"
      xml "<status>ok</status>"
    end
  end
end
```

Supported helpers and emitted media types are:

| Helper | Media type |
|---|---|
| `html` | `text/html` |
| `json` | `application/json; charset=utf-8` |
| `text` | `text/plain` |
| `xml` | `application/xml` |
| `js` | `text/javascript` |

Each helper accepts a string, a zero-argument block returning a string, or—for
HTML—rendered ECR output. If Amber cannot match any available response, it
returns `406 Response Not Acceptable`.

**File: `config/routes.cr` — register the action inside the existing `routes
:web` block.**

```crystal
get "/status", StatusController, :show
```

**File: `src/views/status/show.ecr` — create the HTML representation referenced
by `render("show.ecr")`.**

```ecr
<p>Status: ok</p>
```

**Run from: the application root while `amber watch` is running.**

```bash
curl -H 'Accept: application/json' http://127.0.0.1:3000/status
curl http://127.0.0.1:3000/status.json
```

Keep serialization explicit. For typed request parsing and structured API
errors, use the [Schema API](../schema-api/index.md).


---

## Cookies

Canonical page: https://amberframework.org/docs/v2/guides/controllers/cookies

# Cookies

Cookies are read and written through **Amber::Base::Controller\#cookies**.

The cookies being read are the ones received along with the request, the cookies being written will be sent out with the response. Reading a cookie does not get the cookie object itself back, just the value it holds.

It's advisable that you only store simple data \(strings and numbers\) in cookies. If you have to store complex objects, you would need to handle the conversion manually when reading the values on subsequent requests.

Amber also has an encrypted cookie jar for storing sensitive data. The encrypted cookie jar encrypts the values in addition to signing them, so that they cannot be read by the end user.

## Examples of writing

```crystal
class CommentsController < ApplicationController
  def new
    # Auto-fill the commenter's name if it has been stored in a cookie
    @comment = Comment.new(author: cookies[:commenter_name])
  end

  def create
    @comment = Comment.new(params[:comment])
    if @comment.save
      flash[:notice] = "Thanks for your comment!"
      if params[:remember_name]
        # Remember the commenter's name.
        cookies[:commenter_name] = @comment.author
      else
        # Delete cookie for the commenter's name cookie, if any.
        cookies.delete(:commenter_name)
      end
      redirect_to @comment.article
    else
      render action: "new"
    end
  end
end
```

## Examples of reading

```crystal
cookies[:user_name]           # => "david"
cookies.size                  # => 2
JSON.parse(cookies[:lat_lon]) # => [47.68, -122.37]
cookies.encrypted[:discount]  # => 45
```

Please note that if you specify a :domain when setting a cookie, you must also specify the domain when deleting the cookie:

```crystal
cookies.set "name", "a yummy cookie", expires: 1.year.from_now, domain:  "domain.com"
cookies.delete "name", domain: "domain.com"
```

The optional parameters for setting cookies are:

```crystal
path   # The path for which this cookie applies. Defaults to the root of the application.
domain # The domain for which this cookie applies so you can restrict to the domain level. 
        # If you use a schema like www.example.com and want to share session with user.example.com 
        # set :domain to :all. Make sure to specify the :domain option with :all or Array again 
        #  when deleting cookies.

domain: nil                           # Does not set cookie domain. (default)
domain: :all                          # Allow the cookie for the top most level domain and subdomains.
domain: %w(.example.com .example.org) # Allow the cookie for concrete domain names.

tld_length # When using :domain => :all, this option can be used to explicitly set the TLD length 
            # when using a short (<= 3 character) domain that is being interpreted as part of a TLD. 
            # For example, to share cookies between user1.lvh.me and user2.lvh.me, set :tld_length to 1.

expires    # The time at which this cookie expires, as a Time object.
secure     # Whether this cookie is only transmitted to HTTPS servers. Default is false.
httponly   # Whether this cookie is accessible via scripting or only HTTP. Defaults to false.
```


---

## Filters

Canonical page: https://amberframework.org/docs/v2/guides/controllers/filters

# Filters

Filters are methods that are run "before", "after" or "around" a controller action.

Filters are inherited, so if you set a filter on`ApplicationController`, it will be run on every controller in your application.

## Before filter

"before" filters may halt the request cycle. A common "before" filter is one which requires that a user is logged in for an action to be run. You can define the filter method this way:

```crystal
# Filters are methods that are run "before", "after" a controller action.
before_action do
  # runs for specified actions
  only [:index, :world, :show] { increment(1) }
  # runs for all actions
  all { increment(1) }
end
```

## After filter

"after" filters are executed after the request cycle. A common "after" filter is one which requires to cleanup user data after an action has been run. You can define the filter method this way:

```crystal
after_action do
  # runs for specified actions
  only [:index, :world] { increment(1) }
end
```


---

## Flash

Canonical page: https://amberframework.org/docs/v2/guides/controllers/flash

# Flash

The flash is a special part of the session which is cleared with each request. This means that values stored there will only be available on the next request, which is useful for passing error messages etc.

## Accessing the flash scope

It is accessed in much the same way as the session, as a hash.

Let's use the act of logging out as an example. The controller can send a message which will be displayed to the user on the next request:

```crystal
class LoginsController < ApplicationController
  def destroy
    session[:current_user_id] = nil
    #  Alternatively, `flash.notice=` could be use.
    flash[:notice] = "You have successfully logged out."
    redirect_to root_url
  end
end
```

## Rendering the flash message

```markup
<html>
  <!-- <head/> -->
  <body>
    <% flash.each do |name, msg| -%>
      <%= content_tag :div, msg, class: name %>
    <% end -%>

    <!-- more content -->
  </body>
</html>
```

## Flash values

You can pass anything that the session can store; you're not limited to notices and alerts:

```markup
<% if flash[:just_signed_up] %>
  <p class="welcome">Welcome to our site!</p>
<% end %>
```

## Flash.keep

If you want a flash value to be carried over to another request, use the keep method:

```crystal
class MainController < ApplicationController

  # Let's say this action corresponds to root URL, but you want
  # all requests here to be redirected to UsersController#index.
  # If an action sets the flash and redirects here, the values
  # would normally be lost when another redirect happens, but you
  # can use 'keep' to make it persist for another request.

  def index
    # Will persist all flash values.
    flash.keep

    # You can also use a key to keep only some kind of value.
    # flash.keep(:notice)
    redirect_to users_url
  end
end
```

## Flash.now

By default, adding values to the flash will make them available to the next request, but sometimes you may want to access those values in the same request. For example, if the create action fails to save a resource and you render the new template directly, that's not going to result in a new request, but you may still want to display a message using the flash. To do this, you can use flash.now in the same way you use the normal flash.

```crystal
class ClientsController < ApplicationController
  def create
    @client = Client.new(params[:client])
    if @client.save
      # ...
    else
      flash.now[:error] = "Could not save client"
      render action: "new"
    end
  end
end
```


---

## Redirection

Canonical page: https://amberframework.org/docs/v2/guides/controllers/redirection

# Redirection

Often, we need to redirect to a new url in the middle of a request. A successful `create`action, for instance, will usually redirect to the `show` action for the model we just created. Alternately, it could redirect to the `index` action to show all the things of that same type. There are plenty of other cases where redirection is useful as well.

Calling **redirect\_to** will halt the request lifecycle.

## Redirect to URL

```crystal
redirect_to(
  location: "", 
  status: 302, 
  params: { "key" => "value" }, 
  flash: { "user_id" => "1" }
)
```

## Redirect to Action

```crystal
redirect_to(
  action: :index, 
  status: 302, 
  params: { "key" => "value" }, 
  flash: { "user_id" => "1" }
)
```

## Redirect to Controller Action

```crystal
redirect_to(
  controller: :symbol, 
  action: :index, 
  status: 302, 
  params: { "key" => "value" }, 
  flash: { "user_id" => "1" })
```

## Redirect Back

```crystal
redirect_back(
  status: 302, 
  params: { "key" => "value" }, 
  flash: { "user_id" => "1" }
)
```


---

## CSRF

Canonical page: https://amberframework.org/docs/v2/guides/controllers/csrf

# CSRF

To use CSRF, enable the pipe in your `routes.cr` by adding the following pipe to a pipeline.

```
plug Amber::Pipe::CSRF.new
```

Then, insert the `csrf_tag` helper in your forms.

## How to use CSRF with AJAX

Simply call the `csrf_tag` helper inside your controller and return it as part of a JSON object:

```crystal
def my_action
    {csrf: csrf_tag}.to_json
end
```

In your Javascript, after getting the JSON object back, refresh your CSRF tag with the one from the server.

```javascript
$("input[name*=_csrf]").replaceWith(e['csrf']);
```


---

## Grant ORM

Canonical page: https://amberframework.org/docs/v2/guides/models/grant

# Grant ORM

> **Preview ecosystem guide:** Grant is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

Grant is an ActiveRecord-style ORM for Crystal that provides a familiar
interface for database operations. It is being evaluated as part of the wider
V2 ecosystem, but the core beta web template does not install it or select a
default ORM.

## Where the examples go

- Model declarations, columns, associations, validations, and callbacks belong
  in one class file under `src/models/`, such as `src/models/user.cr`.
- CRUD and query snippets run from the controller, job, service, or spec that
  owns the operation; they are expressions, not complete source files.
- Register database connections in a direct file under `config/`, such as
  `config/database.cr`, because the V2 entry point requires `config/*`.
- Run every command from the application root, beside `shard.yml`.

Blocks on this page use those destinations unless a closer label says
otherwise.

## Why Grant?

Grant aims for feature parity with Rails 8+ ActiveRecord while leveraging Crystal's compile-time type safety:

- **Familiar API**: If you know ActiveRecord, you know Grant
- **Type Safety**: Compile-time checking eliminates many runtime errors
- **Zero-cost Abstractions**: Performance comparable to hand-written SQL
- **Fiber-based Concurrency**: Native async support without callback complexity
- **Horizontal Sharding**: Built-in support for distributed databases

## Feature Overview

| Category | Features |
|----------|----------|
| **Core** | Models, columns, timestamps, CRUD operations |
| **Associations** | belongs_to, has_one, has_many, has_many :through, polymorphic |
| **Validations** | All standard validators, custom validations, conditional validation |
| **Callbacks** | Full lifecycle hooks including transaction callbacks |
| **Queries** | Fluent interface, scopes, complex conditions, eager loading |
| **Security** | Encrypted attributes, secure tokens, signed IDs |
| **Advanced** | Enums, serialization, dirty tracking, optimistic/pessimistic locking |

## Quick Start

### Define a Model

**File: `src/models/user.cr` — create this model class.**

```crystal
class User < Grant::Base
  connection pg
  table users

  column id : Int64, primary: true
  column email : String
  column name : String
  column role : String = "user"
  column active : Bool = true

  has_many :posts
  has_one :profile

  validates_presence_of :email, :name
  validates_email :email
  validate_uniqueness :email

  scope :active, -> { where(active: true) }
  scope :admins, -> { where(role: "admin") }

  timestamps
end
```

### Basic Operations

**File: the controller, job, service, or spec that owns the user operation.**

```crystal
# Create
user = User.create!(email: "alice@example.com", name: "Alice")

# Read
user = User.find(1)
users = User.where(active: true).order(:name).limit(10)

# Update
user.update!(name: "Alice Smith")

# Delete
user.destroy!
```

### Associations

**Files: declare relationships in the matching files under `src/models/`;
execute the usage examples from an application operation or spec.**

```crystal
# Define relationships
class Post < Grant::Base
  belongs_to :user
  has_many :comments, dependent: :destroy
  has_many :taggings, as: :taggable
  has_many :tags, through: :taggings
end

# Use associations
user = User.find(1)
user.posts.each do |post|
  puts post.title
  puts post.comments.count
end

# Eager loading (N+1 prevention)
posts = Post.includes(:user, :comments).where(published: true)
```

### Validations

**File: `src/models/product.cr` — keep these validations inside `Product`.**

```crystal
class Product < Grant::Base
  column price : Float64
  column stock : Int32
  column sku : String

  validates_presence_of :sku, :price
  validates_numericality_of :price, greater_than: 0
  validates_format_of :sku, with: /\A[A-Z]{2}-\d{4}\z/
  validate_uniqueness :sku

  validate "price must be reasonable" do |product|
    product.price < 1_000_000
  end
end
```

### Callbacks

**File: `src/models/order.cr` — keep these callbacks and private methods inside
`Order`.**

```crystal
class Order < Grant::Base
  before_create :generate_order_number
  before_save :calculate_total
  after_create :send_confirmation
  after_commit :update_inventory, on: :create

  private def generate_order_number
    self.order_number = "ORD-#{Time.utc.to_unix}-#{SecureRandom.hex(4)}"
  end

  private def calculate_total
    self.total = line_items.sum(&.price)
  end
end
```

## Database Support

Grant supports multiple databases:

- **PostgreSQL** (recommended): Full feature support including arrays, JSONB, UUID
- **MySQL**: JSON columns, full-text search
- **SQLite**: Great for development and testing

**File: `config/database.cr` — create this direct config file so the generated
V2 entry point loads it through `require "../config/*"`.**

```crystal
# config/database.cr
Grant::Connections << Grant::Adapter::Pg.new(
  name: "primary",
  url: ENV["DATABASE_URL"]
)
```

## Getting Started

1. [Models and Columns](basics/) - Define your data structure
2. [Associations](associations/) - Connect related models
3. [Validations](validations/) - Ensure data integrity
4. [Callbacks](callbacks/) - Hook into the lifecycle
5. [Querying](queries/) - Find and filter data
6. [Transactions](transactions/) - Maintain data consistency
7. [Security](security/) - Encryption, tokens, and secure IDs

## Migration from Granite

If you're migrating from Granite (Amber 1.x's default ORM), Grant provides a similar API with enhanced features. See the [Migration Guide](../../../migration-guide/granite-to-grant/) for details.


---

## Models and Columns

Canonical page: https://amberframework.org/docs/v2/guides/models/grant/basics

# Models and Columns

> **Preview ecosystem guide:** Grant is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

## Where the examples go

Model classes, columns, defaults, converters, and serialization declarations
belong under `src/models/`, one primary model per file. Register connections in
a direct file under `config/`, such as `config/database.cr`, so the V2 entry
point loads it through `require "../config/*"`. Usage expressions run from the
controller, job, service, or spec that owns the operation. Blocks on this page
use those destinations unless a closer comment identifies another role.

Models in Grant represent database tables and provide an object-oriented interface for data interaction.

## Basic Model Definition

```crystal
class User < Grant::Base
  connection pg        # Database connection
  table users         # Table name (optional, defaults to pluralized class name)

  column id : Int64, primary: true
  column email : String
  column name : String
  column active : Bool = true

  timestamps          # Adds created_at and updated_at
end
```

## Column Types

### Primitive Types

```crystal
class Product < Grant::Base
  connection pg

  # Integer types
  column id : Int64, primary: true      # BIGINT
  column quantity : Int32               # INTEGER
  column position : Int16               # SMALLINT

  # Floating point
  column price : Float64                # DOUBLE PRECISION
  column rating : Float32               # FLOAT

  # String types
  column name : String                  # VARCHAR/TEXT
  column description : String?          # Nullable string

  # Boolean
  column active : Bool = true           # BOOLEAN

  # Time/Date
  column published_at : Time?           # TIMESTAMP

  timestamps
end
```

### Special Types

```crystal
class AdvancedModel < Grant::Base
  connection pg

  # UUID (PostgreSQL, MySQL 8+)
  column id : UUID, primary: true

  # JSON (PostgreSQL JSONB, MySQL JSON)
  column metadata : JSON::Any?
  column settings : JSON::Any = JSON.parse("{}")

  # Arrays (PostgreSQL only)
  column tags : Array(String)?
  column scores : Array(Int32)?

  # Binary data
  column file_data : Bytes?
end
```

## Column Options

| Option | Description | Example |
|--------|-------------|---------|
| `primary: true` | Marks as primary key | `column id : Int64, primary: true` |
| `auto: false` | Disables auto-increment | `column uuid : String, primary: true, auto: false` |
| `converter:` | Custom type converter | `column data : JSON::Any, converter: Grant::Converters::Json` |
| Default value | Sets default | `column active : Bool = true` |

## Primary Keys

### Standard Auto-increment

```crystal
class User < Grant::Base
  column id : Int64, primary: true
end
```

### UUID Primary Key

```crystal
class Document < Grant::Base
  connection pg
  column id : UUID, primary: true
  column title : String
end

doc = Document.new(title: "Report")
doc.save
doc.id # => "550e8400-e29b-41d4-a716-446655440000"
```

### Natural Key

```crystal
class Country < Grant::Base
  connection pg
  column iso_code : String, primary: true, auto: false
  column name : String
end

Country.create!(iso_code: "US", name: "United States")
```

## Timestamps

```crystal
class Post < Grant::Base
  column id : Int64, primary: true
  column title : String

  timestamps  # Adds created_at and updated_at
end

post = Post.create!(title: "Hello")
post.created_at  # => 2025-01-15 12:00:00 UTC
post.updated_at  # => 2025-01-15 12:00:00 UTC

post.update!(title: "Hello World")
post.updated_at  # => 2025-01-15 12:05:00 UTC (updated)
```

## Default Values

### Static Defaults

```crystal
class Article < Grant::Base
  column status : String = "draft"
  column views : Int32 = 0
  column featured : Bool = false
  column tags : Array(String) = [] of String
end
```

### Dynamic Defaults via Callbacks

```crystal
class Token < Grant::Base
  column value : String?
  column expires_at : Time?

  before_create :set_defaults

  private def set_defaults
    self.value ||= Random::Secure.hex(32)
    self.expires_at ||= 24.hours.from_now
  end
end
```

## Multiple Database Connections

### Registering Connections

```crystal
# config/database.cr
Grant::Connections << Grant::Adapter::Pg.new(
  name: "primary",
  url: ENV["PRIMARY_DATABASE_URL"]
)

Grant::Connections << Grant::Adapter::Mysql.new(
  name: "legacy",
  url: ENV["LEGACY_DATABASE_URL"]
)

Grant::Connections << Grant::Adapter::Sqlite.new(
  name: "cache",
  url: "sqlite3://./cache.db"
)
```

### Using Different Connections

```crystal
class User < Grant::Base
  connection primary
  table users
end

class LegacyCustomer < Grant::Base
  connection legacy
  table customers
end

class CacheEntry < Grant::Base
  connection cache
  table cache_entries
end
```

## Type Converters

### Built-in Converters

```crystal
# Enum converter
enum Status
  Active
  Inactive
  Pending
end

class Account < Grant::Base
  column status : Status, converter: Grant::Converters::Enum(Status, String)
end

# JSON converter for custom types
class Settings
  include JSON::Serializable
  property theme : String = "light"
  property notifications : Bool = true
end

class User < Grant::Base
  column preferences : Settings, converter: Grant::Converters::Json(Settings, String)
end
```

### Custom Converters

```crystal
module Grant::Converters
  class EncryptedString < Grant::Converters::Base(String, String)
    def self.from_db(value : String) : String
      decrypt(value)
    end

    def self.to_db(value : String) : String
      encrypt(value)
    end
  end
end

class SecureModel < Grant::Base
  column secret : String, converter: Grant::Converters::EncryptedString
end
```

## JSON Serialization

Grant models include JSON::Serializable by default:

```crystal
user = User.find(1)
json = user.to_json
# => {"id":1,"name":"John","email":"john@example.com"}

# Custom serialization
class User < Grant::Base
  @[JSON::Field(key: "user_name")]
  column name : String

  @[JSON::Field(ignore: true)]
  column password_hash : String?
end
```

## Database-Specific Features

### PostgreSQL

```crystal
class PgModel < Grant::Base
  connection pg

  # Arrays
  column tags : Array(String)

  # JSONB
  column metadata : JSON::Any

  # Full-text search scope
  scope :search, ->(query : String) {
    where("to_tsvector('english', content) @@ plainto_tsquery('english', ?)", [query])
  }
end
```

### MySQL

```crystal
class MysqlModel < Grant::Base
  connection mysql

  # JSON column (MySQL 5.7+)
  column settings : JSON::Any

  # Full-text search
  scope :search, ->(query : String) {
    where("MATCH(title, content) AGAINST(? IN NATURAL LANGUAGE MODE)", [query])
  }
end
```

## Best Practices

### 1. Choose Appropriate Types

```crystal
# Good: Use specific types
column price_cents : Int32      # Store money as integers
column email : String           # Validated elsewhere
column published : Bool         # Clear boolean

# Avoid: Ambiguous types
column price : Float64          # Floating point money issues
column data : String            # Consider JSON::Any
```

### 2. Use Nullability Appropriately

```crystal
# Required fields (not nilable)
column email : String
column name : String

# Optional fields (nilable)
column bio : String?
column deleted_at : Time?
```

### 3. Set Sensible Defaults

```crystal
column status : String = "pending"
column retry_count : Int32 = 0
column active : Bool = true
```


---

## Associations

Canonical page: https://amberframework.org/docs/v2/guides/models/grant/associations

# Associations

> **Preview ecosystem guide:** Grant is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

## Where the examples go

Association declarations and helper methods belong inside the matching Grant
model under `src/models/`, such as `src/models/post.cr`. Usage and eager-loading
expressions run from the controller, job, service, or spec that owns the
operation. SQL index examples belong in the migration system selected by the
application, not in a model file. Blocks on this page use those destinations
unless a closer comment identifies a different role.

Grant associations declare how models find related records and where the foreign
key for that relationship lives.

## belongs_to

Creates a one-to-one connection where the declaring model holds the foreign key.

```crystal
class Post < Grant::Base
  belongs_to :user

  column id : Int64, primary: true
  column title : String
  column user_id : Int64  # Foreign key
end

# Usage
post = Post.find(1)
author = post.user  # Fetches associated user
```

### belongs_to Options

```crystal
class Post < Grant::Base
  # Custom foreign key
  belongs_to user : User, foreign_key: author_id : Int64

  # Optional association (allows NULL)
  belongs_to :category, optional: true

  # With counter cache
  belongs_to :blog, counter_cache: true

  # Touch parent on save
  belongs_to :article, touch: true

  # Custom class name
  belongs_to :author, class_name: User
end
```

## has_one

Creates a one-to-one connection where the other model holds the foreign key.

```crystal
class User < Grant::Base
  has_one :profile

  column id : Int64, primary: true
  column email : String
end

class Profile < Grant::Base
  belongs_to :user

  column id : Int64, primary: true
  column bio : String
  column user_id : Int64
end

# Usage
user = User.find(1)
profile = user.profile
user.profile = Profile.new(bio: "My bio")
```

## has_many

Creates a one-to-many connection.

```crystal
class User < Grant::Base
  has_many :posts
  has_many :comments

  # With custom foreign key
  has_many :articles, class_name: Post, foreign_key: :author_id

  column id : Int64, primary: true
end

# Usage
user = User.find(1)
user.posts.each do |post|
  puts post.title
end

# Add new post
user.posts << Post.new(title: "New Post")
```

## has_many :through

Creates a many-to-many connection through a join model.

```crystal
class User < Grant::Base
  has_many :participations
  has_many :rooms, through: :participations

  column id : Int64, primary: true
  column name : String
end

class Participation < Grant::Base
  belongs_to :user
  belongs_to :room

  column id : Int64, primary: true
  column joined_at : Time
  column role : String  # Additional attributes
end

class Room < Grant::Base
  has_many :participations
  has_many :users, through: :participations

  column id : Int64, primary: true
  column name : String
end

# Usage
user = User.find(1)
user.rooms.each { |room| puts room.name }

# Create association
Participation.create!(user: user, room: room, role: "member")
```

## Polymorphic Associations

Allow a model to belong to multiple other models through a single association.

```crystal
class Comment < Grant::Base
  belongs_to :commentable, polymorphic: true

  column id : Int64, primary: true
  column content : String
  column commentable_id : Int64?
  column commentable_type : String?
end

class Post < Grant::Base
  has_many :comments, as: :commentable
end

class Photo < Grant::Base
  has_many :comments, as: :commentable
end

# Usage
post = Post.create!(title: "My Post")
photo = Photo.create!(url: "image.jpg")

comment1 = Comment.create!(content: "Great post!", commentable: post)
comment2 = Comment.create!(content: "Nice photo!", commentable: photo)

# Retrieve polymorphic association
comment = Comment.find(1)
if comment.commentable.is_a?(Post)
  puts "Comment on post: #{comment.commentable.title}"
end
```

## Self-Referential Associations

Models that have associations to themselves.

```crystal
class Employee < Grant::Base
  belongs_to :manager, class_name: Employee, optional: true
  has_many :subordinates, class_name: Employee, foreign_key: :manager_id

  column id : Int64, primary: true
  column name : String
  column manager_id : Int64?
end

# Usage
ceo = Employee.create!(name: "CEO")
manager = Employee.create!(name: "Manager", manager: ceo)
employee = Employee.create!(name: "Employee", manager: manager)

ceo.subordinates      # => [manager]
manager.subordinates  # => [employee]
employee.manager      # => manager
```

## Association Options

### dependent

Controls what happens to associated records when parent is destroyed.

```crystal
class Author < Grant::Base
  # Destroys all posts when author is destroyed
  has_many :posts, dependent: :destroy

  # Sets category_id to NULL on products
  has_many :products, dependent: :nullify

  # Prevents deletion if players exist
  has_many :players, dependent: :restrict
end
```

### counter_cache

Maintains count of associated records on parent model.

```crystal
class Blog < Grant::Base
  column posts_count : Int32 = 0
  has_many :posts
end

class Post < Grant::Base
  belongs_to :blog, counter_cache: true
end

# Usage
blog = Blog.create!(title: "My Blog")
Post.create!(title: "First Post", blog: blog)
blog.reload.posts_count  # => 1
```

### touch

Updates parent's `updated_at` when child is saved.

```crystal
class Comment < Grant::Base
  belongs_to :post, touch: true

  # Touch specific column
  belongs_to :article, touch: :last_activity_at
end

# Updates post.updated_at whenever comment changes
comment.update!(content: "Updated")
```

### autosave

Automatically saves associated records with parent.

```crystal
class Order < Grant::Base
  has_many :line_items, autosave: true
  has_one :invoice, autosave: true
end

order = Order.new
order.line_items << LineItem.new(product: "Widget", qty: 2)
order.invoice = Invoice.new(total: 100)
order.save!  # Saves everything in transaction
```

## Nested Attributes

Accept nested attributes for associated records.

```crystal
class Order < Grant::Base
  has_many :line_items

  accepts_nested_attributes_for line_items : LineItem,
    allow_destroy: true,
    reject_if: ->(attrs : Hash) { attrs["quantity"]?.try(&.to_i) == 0 },
    limit: 50
end

# Create order with line items
order = Order.create!(
  customer_id: 1,
  line_items_attributes: [
    {product_id: 1, quantity: 2},
    {product_id: 3, quantity: 1}
  ]
)
```

## Eager Loading (N+1 Prevention)

```crystal
# Bad: N+1 queries
posts = Post.all
posts.each do |post|
  puts post.author.name  # Query for each post
end

# Good: Eager loading
posts = Post.includes(:author)
posts.each do |post|
  puts post.author.name  # No additional queries
end

# Multiple associations
posts = Post.includes(:author, :comments)

# Nested associations
users = User.includes(posts: [:comments, :tags])
```

## Validating Associations

```crystal
class Order < Grant::Base
  has_many :line_items
  belongs_to :customer

  validates_associated :line_items

  validate :must_have_items

  private def must_have_items
    if line_items.empty?
      errors.add(:line_items, "must have at least one item")
    end
  end
end
```

## Best Practices

### 1. Index Foreign Keys

```sql
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_blog_id ON posts(blog_id);
```

### 2. Use dependent Wisely

- `:destroy` - When child records should be deleted
- `:nullify` - When child records can exist independently
- `:restrict` - When deletion should be prevented

### 3. Document Complex Associations

```crystal
# Represents many-to-many between users and projects
# through team memberships with role attribute
class TeamMembership < Grant::Base
  belongs_to :user
  belongs_to :project

  column role : String  # "owner", "member", "viewer"
end
```


---

## Validations

Canonical page: https://amberframework.org/docs/v2/guides/models/grant/validations

# Validations

> **Preview ecosystem guide:** Grant is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

## Where the examples go

Validation declarations, custom validator methods, conditions, and validation
callbacks belong inside the matching Grant model under `src/models/`. Examples
that call validation methods or inspect errors run from the controller,
service, form object, or spec that owns the operation. Database constraints
belong in the migration system selected by the application. Blocks on this page
use those destinations unless a closer comment identifies a different role.

Grant runs model validations before persistence and records failures on the
model's error collection.

## Basic Validation

```crystal
class User < Grant::Base
  column email : String
  column age : Int32

  validates_email :email
  validates_numericality_of :age, greater_than: 0
end

user = User.new(email: "invalid", age: -5)
user.valid?  # => false
user.errors  # => Array of validation errors
user.save    # => false (won't save invalid records)
user.save!   # => raises Grant::RecordInvalid
```

## Built-in Validators

### Presence and Absence

```crystal
class Product < Grant::Base
  column name : String
  column internal_notes : String?

  validates_presence_of :name
  validate_not_blank :name

  validates_absence_of :internal_notes  # Must be nil/blank
end
```

### Numericality

```crystal
class Order < Grant::Base
  column total : Float64
  column quantity : Int32
  column discount : Float64

  validates_numericality_of :total, greater_than: 0
  validates_numericality_of :quantity,
    only_integer: true,
    greater_than: 0
  validates_numericality_of :discount,
    greater_than_or_equal_to: 0,
    less_than_or_equal_to: 100
end
```

**Options:**
- `greater_than`, `greater_than_or_equal_to`
- `less_than`, `less_than_or_equal_to`
- `equal_to`, `other_than`
- `odd: true`, `even: true`
- `only_integer: true`
- `in: range`
- `allow_nil: true`, `allow_blank: true`

### Format

```crystal
class User < Grant::Base
  column username : String
  column phone : String

  validates_format_of :username, with: /\A[a-zA-Z0-9_]+\z/
  validates_format_of :phone, with: /\A\d{3}-\d{3}-\d{4}\z/
  validates_format_of :username, without: /\A(admin|root)\z/,
    message: "is reserved"
end
```

### Length/Size

```crystal
class Article < Grant::Base
  column title : String
  column body : String
  column tags : Array(String)

  validates_length_of :title, minimum: 5, maximum: 100
  validates_length_of :body, minimum: 100
  validates_size_of :tags, maximum: 10
  validates_length_of :slug, is: 8  # Exactly 8
end
```

### Email and URL

```crystal
class Contact < Grant::Base
  column email : String
  column website : String?

  validates_email :email
  validates_url :website, allow_blank: true
end
```

### Confirmation

```crystal
class Account < Grant::Base
  column email : String
  column password : String

  validates_confirmation_of :email
  validates_confirmation_of :password
end

# Usage requires confirmation fields
account = Account.new(
  email: "user@example.com",
  password: "secret123"
)
account.email_confirmation = "user@example.com"
account.password_confirmation = "secret123"
account.valid?  # => true
```

### Inclusion and Exclusion

```crystal
class Subscription < Grant::Base
  column plan : String
  column username : String

  validates_inclusion_of :plan,
    in: ["free", "basic", "premium", "enterprise"]

  validates_exclusion_of :username,
    in: ["admin", "root", "system"],
    message: "is reserved"
end
```

### Uniqueness

```crystal
class User < Grant::Base
  column email : String
  column employee_id : String
  column company_id : Int64

  validate_uniqueness :email

  # Scoped uniqueness (unique within scope)
  validate_uniqueness :employee_id, scope: :company_id
end
```

## Custom Validations

### Block Syntax

```crystal
class Post < Grant::Base
  column title : String
  column content : String

  validate :title, "can't be blank" do |post|
    !post.title.to_s.blank?
  end

  validate :content, "must be at least 10 characters" do |post|
    post.content.size >= 10
  end
end
```

### Method Reference

```crystal
class Product < Grant::Base
  column price : Float64
  column sale_price : Float64?
  column on_sale : Bool

  validate :valid_sale_price

  private def valid_sale_price
    return true unless on_sale && sale_price

    if sale_price.not_nil! >= price
      errors.add(:sale_price, "must be less than regular price")
    end
  end
end
```

### Model-level Validation

```crystal
class Order < Grant::Base
  validate "total must equal sum of line items" do |order|
    calculated_total = order.line_items.sum(&.total_price)
    (order.total_amount - calculated_total).abs < 0.01
  end
end
```

## Conditional Validations

### Using Symbols

```crystal
class Post < Grant::Base
  column title : String
  column content : String
  column published : Bool

  validates_length_of :title, minimum: 10, if: :published?
  validates_presence_of :content, unless: :draft?

  def published?
    published == true
  end

  def draft?
    !published
  end
end
```

### Using Procs

```crystal
class Order < Grant::Base
  column payment_method : String
  column credit_card : String?

  validates_presence_of :credit_card,
    if: ->(order : Order) { order.payment_method == "credit" }
end
```

## Validation Contexts

```crystal
class User < Grant::Base
  column email : String
  column password : String

  # Only on create
  validates_presence_of :password, on: :create

  # Only on update
  validates_confirmation_of :password, on: :update

  # Custom context
  validate :email, "must be corporate email", on: :corporate do |user|
    user.email.ends_with?("@company.com")
  end
end

# Usage with context
user.valid?(:corporate)
user.save(context: :corporate)
```

## Working with Errors

```crystal
user = User.new(email: "invalid", age: 10)
user.valid?  # => false

# Get all errors
user.errors  # => Array(Grant::Error)

# Get errors for specific field
email_errors = user.errors.select { |e| e.field == :email }

# Get error messages
user.errors.map(&.message)
# => ["is not a valid email", "must be at least 18"]

# Full error messages
user.errors.map { |e| "#{e.field} #{e.message}" }
# => ["email is not a valid email", "age must be at least 18"]

# Add custom errors
user.errors.add(:base, "Something went wrong")
```

## Custom Error Messages

```crystal
class User < Grant::Base
  validates_numericality_of :age,
    greater_than_or_equal_to: 18,
    message: "You must be at least 18 years old"

  validates_format_of :email,
    with: /@company\.com\z/,
    message: "must be a company email address"
end
```

## Validation Callbacks

```crystal
class User < Grant::Base
  before_validation :normalize_email
  after_validation :set_defaults

  private def normalize_email
    self.email = email.downcase.strip if email
  end

  private def set_defaults
    self.role ||= "user" if errors.empty?
  end
end
```

## Skipping Validations

```crystal
# Skip validations (use carefully!)
user.save(validate: false)

# Bulk operations skip validations
User.update_all(active: false)
```

## Best Practices

### 1. Layer Validations

```crystal
class CreditCard < Grant::Base
  # Format validation
  validates_format_of :number, with: /\A\d{16}\z/

  # Business logic validation
  validate :number, "must pass Luhn check" do |card|
    LuhnValidator.valid?(card.number)
  end

  # Database constraint (in migration)
  # ADD CONSTRAINT valid_card_number CHECK (char_length(number) = 16)
end
```

### 2. Add Database Constraints

```crystal
# Model validation
validate_uniqueness :email

# Also add database constraint
# CREATE UNIQUE INDEX users_email_unique ON users(email);
```

### 3. Order Validations by Cost

```crystal
class Product < Grant::Base
  # Fast validations first
  validates_presence_of :name
  validates_length_of :name, in: 1..100

  # Database queries later
  validate_uniqueness :sku

  # Expensive operations last
  validate :image, "must be valid" do |product|
    ImageValidator.valid?(product.image_data) if product.image_data
  end
end
```


---

## Callbacks

Canonical page: https://amberframework.org/docs/v2/guides/models/grant/callbacks

# Callbacks

> **Preview ecosystem guide:** Grant is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

## Where the examples go

Callback declarations and their private methods belong inside the matching
Grant model under `src/models/`, such as `src/models/user.cr`. Examples that
invoke `save`, `destroy`, or a bulk operation run from the controller, job,
service, or spec that owns the operation. External delivery belongs in a job or
service called after commit. Blocks on this page use those destinations unless
a closer comment identifies a different role.

Callbacks are methods that get called at certain moments of an object's lifecycle. They allow you to trigger logic before or after alterations to your model's state.

## Available Callbacks

### Create Callbacks

```crystal
class User < Grant::Base
  before_validation :set_defaults           # 1. First callback
  # validations run here                    # 2. Validations
  after_validation :process_validated_data  # 3. After validation
  before_save :before_save_tasks           # 4. Before save (create or update)
  before_create :before_create_tasks       # 5. Before create specifically
  # INSERT happens here                     # 6. Database insert
  after_create :after_create_tasks         # 7. After create
  after_save :after_save_tasks            # 8. After save (create or update)
  after_commit :after_commit_tasks        # 9. After transaction commits
end
```

### Update Callbacks

```crystal
class Product < Grant::Base
  before_validation :normalize_data         # 1. First callback
  # validations run here                    # 2. Validations
  after_validation :process_changes        # 3. After validation
  before_save :before_save_tasks          # 4. Before save
  before_update :before_update_tasks      # 5. Before update specifically
  # UPDATE happens here                    # 6. Database update
  after_update :after_update_tasks        # 7. After update
  after_save :after_save_tasks           # 8. After save
  after_commit :after_commit_tasks       # 9. After transaction commits
end
```

### Destroy Callbacks

```crystal
class Comment < Grant::Base
  before_destroy :cleanup_associations     # 1. Before destroy
  # DELETE happens here                    # 2. Database delete
  after_destroy :log_deletion             # 3. After destroy
  after_commit :notify_deletion          # 4. After transaction commits
end
```

## Callback Registration

### Method Symbols

```crystal
class Article < Grant::Base
  before_save :sanitize_content
  after_create :publish_to_feed

  private def sanitize_content
    self.content = Sanitizer.clean(content)
  end

  private def publish_to_feed
    FeedService.publish(self) if published?
  end
end
```

### Blocks

```crystal
class Order < Grant::Base
  before_save do
    self.total = calculate_total
  end

  after_create do
    OrderMailer.confirmation(self).deliver_later
  end
end
```

### Conditional Callbacks

```crystal
class Post < Grant::Base
  # With symbol conditions
  before_save :update_slug, if: :title_changed?
  after_create :notify_subscribers, if: :published?

  # With proc conditions
  before_destroy :archive_content,
    if: ->(post : Post) { post.views > 1000 }

  # Multiple conditions
  after_save :clear_cache,
    if: :published?,
    unless: :draft?
end
```

## Common Callback Patterns

### Data Normalization

```crystal
class User < Grant::Base
  before_validation :normalize_fields

  column email : String
  column phone : String?
  column name : String

  private def normalize_fields
    self.email = email.downcase.strip
    self.phone = phone.try(&.gsub(/\D/, ""))
    self.name = name.split.map(&.capitalize).join(" ")
  end
end
```

### Setting Defaults

```crystal
class Document < Grant::Base
  before_create :set_defaults

  column uuid : String
  column version : Int32
  column status : String

  private def set_defaults
    self.uuid ||= UUID.random.to_s
    self.version ||= 1
    self.status ||= "draft"
  end
end
```

### Generating Tokens

```crystal
class Session < Grant::Base
  before_create :generate_token

  column token : String
  column expires_at : Time

  private def generate_token
    loop do
      self.token = Random::Secure.hex(32)
      break unless Session.exists?(token: token)
    end
    self.expires_at = 24.hours.from_now
  end
end
```

### Slug Generation

```crystal
class Article < Grant::Base
  before_save :generate_slug

  column title : String
  column slug : String

  private def generate_slug
    return unless title_changed?

    base_slug = title.downcase.gsub(/[^a-z0-9]+/, "-")
    self.slug = base_slug

    counter = 1
    while Article.exists?(slug: slug)
      self.slug = "#{base_slug}-#{counter}"
      counter += 1
    end
  end
end
```

### Audit Trails

```crystal
class AuditableModel < Grant::Base
  after_create :log_create
  after_update :log_update
  after_destroy :log_destroy

  private def log_create
    AuditLog.create!(
      model: self.class.name,
      record_id: id,
      action: "create",
      user_id: Current.user_id,
      changes: attributes.to_json
    )
  end

  private def log_update
    return unless changes.any?
    AuditLog.create!(
      model: self.class.name,
      record_id: id,
      action: "update",
      user_id: Current.user_id,
      changes: changes.to_json
    )
  end
end
```

### Cache Management

```crystal
class Product < Grant::Base
  after_save :clear_cache
  after_destroy :clear_cache

  private def clear_cache
    Cache.delete("product:#{id}")
    Cache.delete("category:#{category_id}:products")
  end
end
```

## Halting Execution

### Throwing :abort

```crystal
class Order < Grant::Base
  before_save :check_inventory

  private def check_inventory
    if total_items > available_stock
      errors.add(:items, "Insufficient inventory")
      throw :abort  # Halts execution
    end
  end
end
```

### Preventing Destruction

```crystal
class User < Grant::Base
  before_destroy :prevent_admin_deletion

  private def prevent_admin_deletion
    if admin? && User.where(admin: true).count == 1
      errors.add(:base, "Cannot delete the last admin")
      throw :abort
    end
  end
end
```

## Transaction Callbacks

### after_commit

Runs after the database transaction successfully commits:

```crystal
class Order < Grant::Base
  after_commit :send_confirmation, on: :create
  after_commit :update_inventory, on: :update

  private def send_confirmation
    # Safe to send email - transaction committed
    OrderMailer.confirmation(self).deliver_later
  end

  private def update_inventory
    # Safe to call external services
    InventoryService.sync(self)
  end
end
```

### after_rollback

Runs if the database transaction is rolled back:

```crystal
class Payment < Grant::Base
  after_rollback :log_failure

  private def log_failure
    Log.error { "Payment #{id} failed: #{errors.full_messages}" }
  end
end
```

## Performance Considerations

### Keep Callbacks Fast

```crystal
class Post < Grant::Base
  # Bad: Synchronous external call
  after_create :notify_external_service

  private def notify_external_service
    HTTPClient.post("https://api.example.com/webhook", body: to_json)
  end

  # Good: Queue for background processing
  after_create :queue_notification

  private def queue_notification
    NotificationJob.perform_later(self.id)
  end
end
```

### Use Conditional Callbacks

```crystal
class User < Grant::Base
  # Only run expensive callbacks when necessary
  after_save :sync_to_crm, if: :crm_fields_changed?

  private def crm_fields_changed?
    (changes.keys & ["email", "name", "company"]).any?
  end
end
```

## Skipping Callbacks

```crystal
# Skip callbacks when needed
user.save(skip_callbacks: true)

# Bulk operations skip callbacks
User.update_all(active: false)
user.update_columns(name: "New")  # Direct SQL, no callbacks
```

## Best Practices

### 1. Keep Callbacks Simple

```crystal
# Good: Single responsibility
before_save :normalize_email
before_save :hash_password
before_save :set_defaults

# Bad: Doing too much
before_save :do_everything
```

### 2. Use Appropriate Callback

```crystal
# Good: after_commit for external services
after_commit :send_email

# Bad: after_save might run even if rolled back
after_save :send_email
```

### 3. Consider Service Objects

```crystal
# Instead of complex callbacks
class User < Grant::Base
  after_create :setup_user_account

  private def setup_user_account
    UserAccountSetupService.new(self).perform
  end
end

class UserAccountSetupService
  def initialize(@user : User)
  end

  def perform
    create_profile
    send_welcome_email
    assign_default_role
  end
end
```


---

## Querying

Canonical page: https://amberframework.org/docs/v2/guides/models/grant/queries

# Querying

> **Preview ecosystem guide:** Grant is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

## Where the examples go

Query expressions run from the controller, job, service, or spec that owns the
read; they are not complete model files. Named and default scopes belong inside
the matching Grant model under `src/models/`. The complex-query example should
be extracted to a service or query object under `src/services/` when it is
shared or independently tested. Blocks on this page use those destinations
unless a closer comment identifies a different role.

Grant provides a fluent, chainable query API that generates efficient SQL while maintaining type safety.

## Basic Querying

```crystal
# Find all active users
users = User.where(active: true)

# Chain multiple conditions (AND)
posts = Post.where(published: true, featured: true)
            .where(author_id: current_user.id)

# Find with multiple fields
post = Post.find_by(slug: "my-post", published: true)
```

### Query Execution

Queries are lazy - they don't execute until you call a terminal method:

```crystal
# Building query (not executed)
query = User.where(active: true).order(:name)

# Execution happens here
users = query.select     # Returns array of User
first = query.first      # Returns User?
count = query.count      # Returns Int32
exists = query.exists?   # Returns Bool
```

## Where Conditions

### Basic WHERE

```crystal
# Equality
User.where(status: "active")
User.where(age: 25)

# Multiple conditions (AND)
User.where(status: "active", verified: true)
```

### Comparison Operators

```crystal
Post.where(:views, :gt, 100)        # Greater than
Post.where(:price, :lteq, 50.0)     # Less than or equal
Post.where(:created_at, :gt, 7.days.ago)

# Available operators
Post.where(:field, :eq, value)      # =
Post.where(:field, :neq, value)     # !=
Post.where(:field, :gt, value)      # >
Post.where(:field, :lt, value)      # <
Post.where(:field, :gteq, value)    # >=
Post.where(:field, :lteq, value)    # <=
Post.where(:field, :in, array)      # IN
Post.where(:field, :nin, array)     # NOT IN
Post.where(:field, :like, pattern)  # LIKE
```

### WhereChain Methods

```crystal
# Pattern matching
User.where.like(:email, "%@gmail.com")
User.where.not_like(:name, "test%")

# Comparisons
User.where.gt(:age, 18)
User.where.lt(:age, 65)
User.where.gteq(:score, 80)
User.where.lteq(:price, 100)

# NULL checks
User.where.is_null(:deleted_at)
User.where.is_not_null(:verified_at)

# Ranges
User.where.between(:age, 25..35)
Product.where.between(:price, 10.0..50.0)

# NOT IN
User.where.not_in(:id, [1, 2, 3])
```

### Raw SQL Conditions

```crystal
# With placeholders
Post.where("LOWER(title) LIKE ?", ["%crystal%"])
User.where("age * 2 > ?", [50])

# PostgreSQL specific
Post.where("tags @> ARRAY[?]::varchar[]", ["ruby"])
Post.where("metadata->>'key' = $", ["value"])
```

## OR and NOT Conditions

### OR Groups

```crystal
# Simple OR
User.where(role: "admin").or { |q| q.where(role: "moderator") }
# SQL: WHERE role = 'admin' OR role = 'moderator'

# Complex OR
User.where(verified: true)
    .or do |q|
      q.where(role: "admin")
       .where.gt(:level, 10)
    end
# SQL: WHERE verified = true OR (role = 'admin' AND level > 10)
```

### NOT Groups

```crystal
# Simple NOT
User.not { |q| q.where(status: "banned") }

# Complex NOT
User.not do |q|
  q.where(active: false)
   .where.is_null(:email_verified_at)
end
# SQL: WHERE NOT (active = false AND email_verified_at IS NULL)
```

## Ordering and Limiting

```crystal
# Single field
User.order(:name)              # ASC by default
User.order(created_at: :desc)  # Explicit direction

# Multiple fields
Post.order(featured: :desc, created_at: :desc)

# Limit and offset
Post.limit(10)
Post.offset(20).limit(10)  # Pagination

# First/Last
User.first          # Single record
User.first(5)       # First 5 records
User.last(10)       # Last 10 records

# Distinct
User.distinct.select(:country)
```

## Scopes

### Defining Scopes

```crystal
class Post < Grant::Base
  # Simple scopes
  scope :published, -> { where(published: true) }
  scope :featured, -> { where(featured: true) }
  scope :recent, -> { order(created_at: :desc) }

  # Parameterized scopes
  scope :by_author, ->(author_id : Int32) { where(author_id: author_id) }
  scope :tagged_with, ->(tag : String) { where("tags @> ARRAY[?]", [tag]) }
  scope :older_than, ->(date : Time) { where.lt(:created_at, date) }

  # Complex scopes
  scope :popular, -> {
    where.gt(:views, 1000)
         .where.gt(:likes, 100)
         .order(views: :desc)
  }
end

# Using scopes
Post.published.recent.limit(10)
Post.by_author(current_user.id).featured
```

### Default Scopes

```crystal
class Product < Grant::Base
  # Applied to all queries automatically
  default_scope { where(active: true).where.is_null(:deleted_at) }

  # Bypass default scope
  scope :all_including_deleted, -> { unscoped }
end

Product.all              # Includes default scope
Product.unscoped.all     # Bypasses default scope
```

## Joins and Eager Loading

### Joins

```crystal
# Join with association
Post.joins(:author)
    .where("users.active = ?", [true])

# Left joins (include records without association)
User.left_joins(:posts)
    .where("posts.id IS NULL")  # Users without posts
```

### Eager Loading

```crystal
# Preload associations
posts = Post.includes(:author, :comments)
posts.each do |post|
  puts post.author.name        # No additional query
  puts post.comments.size      # No additional query
end

# Nested includes
User.includes(posts: [:comments, :tags])
```

## Aggregations

```crystal
# Count
User.count
User.where(active: true).count
User.distinct.count(:country)

# Sum, Average, Min, Max
Order.sum(:total)
Product.average(:price)
Product.minimum(:price)
Product.maximum(:stock)

# With grouping
Order.group_by(:customer_id).sum(:total)
Review.group_by(:product_id).average(:rating)
```

## Batch Processing

```crystal
# Bad: Loads everything at once
User.all.each { |user| user.process! }

# Good: Process in batches
User.find_in_batches(batch_size: 1000) do |users|
  users.each(&.process!)
end
```

## Pluck for Values

```crystal
# Bad: Instantiate models
emails = User.where(active: true).map(&.email)

# Good: Direct database values
emails = User.where(active: true).pluck(:email)
```

## Complex Query Example

```crystal
def search_products(params)
  query = Product.where(active: true)

  # Text search
  if term = params["q"]?
    query = query.where.like(:name, "%#{term}%")
                 .or { |q| q.where.like(:description, "%#{term}%") }
  end

  # Price range
  if min_price = params["min_price"]?
    query = query.where.gteq(:price, min_price.to_f)
  end
  if max_price = params["max_price"]?
    query = query.where.lteq(:price, max_price.to_f)
  end

  # Categories
  if categories = params["categories"]?
    query = query.where.in(:category_id, categories.split(","))
  end

  # In stock only
  if params["in_stock"]?
    query = query.where.gt(:stock, 0)
  end

  # Sorting
  case params["sort"]?
  when "price_asc"
    query = query.order(:price)
  when "price_desc"
    query = query.order(price: :desc)
  when "newest"
    query = query.order(created_at: :desc)
  else
    query = query.order(:name)
  end

  query.limit(params.fetch("limit", "20").to_i)
end
```

## Best Practices

### 1. Use Indexes

```crystal
# Ensure indexed columns in WHERE
User.where(email: "user@example.com")  # email should be indexed
```

### 2. Select Only Needed Columns

```crystal
# Bad: Loads all columns
users = User.where(active: true)

# Good: Load only required columns
users = User.where(active: true).select(:id, :name, :email)
```

### 3. Avoid N+1 Queries

```crystal
# Bad: N+1 queries
posts = Post.all
posts.each { |post| puts post.author.name }

# Good: Eager loading
posts = Post.includes(:author)
posts.each { |post| puts post.author.name }
```


---

## Transactions

Canonical page: https://amberframework.org/docs/v2/guides/models/grant/transactions

# Transactions

> **Preview ecosystem guide:** Grant is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

## Where the examples go

Transaction and locking expressions run from the controller, job, service, or
spec that owns the multi-record operation. Optimistic-locking columns and
transaction callback declarations belong inside the matching Grant model under
`src/models/`. Shared financial or inventory workflows should live in a service
under `src/services/` with focused specs. Blocks on this page use those
destinations unless a closer comment identifies a different role.

Use a transaction when several writes must commit or roll back together. Grant
also exposes isolation and locking controls for workflows that coordinate
concurrent database changes.

## Basic Transactions

```crystal
Grant::Base.transaction do
  user = User.find!(1)
  user.balance -= 100
  user.save!

  transfer = Transfer.create!(
    user_id: user.id,
    amount: -100
  )

  # Automatic rollback on exception
  raise "Insufficient funds" if user.balance < 0
end
```

### Transaction Methods

```crystal
# Block syntax
Grant::Base.transaction do
  # All operations in one transaction
  User.create!(name: "Alice")
  User.create!(name: "Bob")
end

# With explicit rollback
Grant::Base.transaction do |tx|
  user = User.create!(name: "Alice")

  if some_condition_fails
    raise DB::Rollback.new("Condition failed")
  end
end
```

## Nested Transactions with Savepoints

```crystal
Grant::Base.transaction do
  order = Order.create!(customer_id: 1, total: 0)

  items.each do |item_data|
    Grant::Base.transaction do  # Savepoint
      item = OrderItem.create!(
        order_id: order.id,
        product_id: item_data[:product_id],
        quantity: item_data[:quantity]
      )

      product = Product.find!(item_data[:product_id])
      product.stock -= item_data[:quantity]

      # Rollback just this item if out of stock
      raise "Out of stock" if product.stock < 0

      product.save!
      order.total += item.subtotal
    end
  rescue
    # Skip item but continue with order
    Log.warn { "Skipping item #{item_data[:id]}" }
  end

  order.save!
end
```

## Isolation Levels

```crystal
# Available levels
IsolationLevel::ReadUncommitted
IsolationLevel::ReadCommitted
IsolationLevel::RepeatableRead
IsolationLevel::Serializable

# Serializable for financial operations
Grant::Base.transaction(isolation: :serializable) do
  account1 = Account.find!(1)
  account2 = Account.find!(2)

  account1.balance -= 100
  account2.balance += 100

  account1.save!
  account2.save!
end

# Read committed for reports
Grant::Base.transaction(isolation: :read_committed) do
  generate_report
end
```

## Pessimistic Locking

Lock rows to prevent concurrent modifications.

### Row-Level Locking

```crystal
Grant::Base.transaction do
  # Lock account for update
  account = Account.find!(1)
  account.lock!  # FOR UPDATE

  # No other transaction can modify this account
  account.balance -= 100
  account.save!
end

# Lock with custom mode
Grant::Base.transaction do
  account = Account.lock!(:share)  # FOR SHARE
  # Read but prevent updates
end
```

### with_lock Helper

```crystal
account = Account.find!(1)

account.with_lock do |locked_account|
  locked_account.balance -= 100
  locked_account.save!
end
```

### Lock Multiple Rows

```crystal
Grant::Base.transaction do
  accounts = Account.where(user_id: 1).lock
  accounts.each do |account|
    account.process_fees
  end
end
```

## Optimistic Locking

Use a version column to detect concurrent modifications.

```crystal
class Product < Grant::Base
  include Grant::Locking::Optimistic

  column id : Int64, primary: true
  column name : String
  column price : Float64
  column lock_version : Int32 = 0
end

# Automatic version checking
product = Product.find!(1)
product.price = 29.99
product.save!  # Increments lock_version

# Concurrent update detection
product1 = Product.find!(1)
product2 = Product.find!(1)

product1.price = 19.99
product1.save!  # Works

product2.price = 24.99
product2.save!  # Raises Grant::StaleRecordError
```

### Handling Conflicts

```crystal
def update_with_retry(product, max_retries = 3)
  retry_count = 0

  loop do
    begin
      yield product
      product.save!
      break
    rescue Grant::StaleRecordError
      retry_count += 1
      raise if retry_count >= max_retries

      product.reload
      Log.info { "Retrying update (attempt #{retry_count})" }
    end
  end
end

update_with_retry(product) do |p|
  p.stock -= 1
end
```

## Deadlock Prevention

### Ordered Locking

Always acquire locks in the same order to prevent deadlocks.

```crystal
def transfer_funds(from_id, to_id, amount)
  # Sort IDs to ensure consistent lock order
  ids = [from_id, to_id].sort

  Grant::Base.transaction do
    accounts = ids.map { |id| Account.find_and_lock!(id) }
    from = accounts.find { |a| a.id == from_id }.not_nil!
    to = accounts.find { |a| a.id == to_id }.not_nil!

    from.balance -= amount
    to.balance += amount

    from.save!
    to.save!
  end
end
```

### Lock Timeouts

```crystal
Grant::Base.transaction do
  Grant.connection.exec("SET LOCAL lock_timeout = '5s'")

  begin
    account = Account.find_and_lock!(1)
    account.process!
  rescue ex : DB::Error
    if ex.message.includes?("lock timeout")
      Log.warn { "Lock timeout, retrying..." }
    end
    raise ex
  end
end
```

## Transaction Callbacks

```crystal
class Order < Grant::Base
  after_commit :send_confirmation, on: :create
  after_commit :update_inventory, on: :update
  after_rollback :log_failure

  private def send_confirmation
    # Safe - transaction committed
    OrderMailer.confirmation(self).deliver_later
  end

  private def update_inventory
    InventoryService.sync(self)
  end

  private def log_failure
    Log.error { "Order #{id} failed to save" }
  end
end
```

## Best Practices

### 1. Keep Transactions Short

```crystal
# Good: Short transaction
Grant::Base.transaction do
  user.update!(status: "active")
end

# Bad: Long transaction
Grant::Base.transaction do
  users = User.all.to_a
  users.each do |user|
    user.process_complex_logic  # Time-consuming
    user.save!
  end
end
```

### 2. Use Appropriate Isolation

```crystal
# Serializable for critical financial operations
Grant::Base.transaction(isolation: :serializable) do
  transfer_funds(from, to, amount)
end

# Read committed for reports (better performance)
Grant::Base.transaction(isolation: :read_committed) do
  generate_report
end
```

### 3. Handle Failures Gracefully

```crystal
def process_order(order)
  Grant::Base.transaction do
    order.process!
    Payment.charge!(order)
    Inventory.decrement!(order)
  end
rescue Grant::RecordInvalid => e
  Log.error { "Validation failed: #{e.message}" }
  order.update!(status: "failed")
rescue => e
  Log.error { "Order processing failed: #{e.message}" }
  raise
end
```

### 4. Test Transaction Behavior

```crystal
describe "Transfer funds" do
  it "rolls back on failure" do
    account1 = Account.create!(balance: 100)
    account2 = Account.create!(balance: 50)

    expect_raises(Exception) do
      Grant::Base.transaction do
        account1.balance -= 200  # More than available
        account2.balance += 200
        account1.save!
        account2.save!
        raise "Insufficient funds"
      end
    end

    # Both accounts unchanged
    account1.reload.balance.should eq(100)
    account2.reload.balance.should eq(50)
  end
end
```


---

## Security Features

Canonical page: https://amberframework.org/docs/v2/guides/models/grant/security

# Security Features

> **Preview ecosystem guide:** Grant is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

## Where the examples go

Encrypted attributes, secure-token declarations, signed-ID methods,
normalization, and enums belong inside the matching Grant model under
`src/models/`. Configure encryption in `config/application.cr`, which the
released V2 template loads directly. Token generation and lookup expressions
run from the controller, job, service, or spec that owns the security flow.
Never put key values in source code or committed environment YAML.

Grant provides built-in security features for protecting sensitive data, generating secure tokens, and creating tamper-proof URLs.

## Encrypted Attributes

Store sensitive data encrypted at rest.

### Basic Encryption

```crystal
class User < Grant::Base
  column id : Int64, primary: true
  column email : String
  column ssn : String?
  column credit_card_number : String?

  # Encrypt these fields
  encrypts :ssn, :credit_card_number
end

# Usage is transparent
user = User.create!(
  email: "alice@example.com",
  ssn: "123-45-6789"
)

user.ssn  # => "123-45-6789" (decrypted)
# In database: encrypted blob
```

### Deterministic Encryption

Use deterministic encryption when you need to search encrypted fields.

```crystal
class User < Grant::Base
  # Non-deterministic (more secure, cannot search)
  encrypts :ssn

  # Deterministic (searchable)
  encrypts :phone_number, deterministic: true
end

# Can search deterministic fields
User.where(phone_number: "+1-555-1234")  # Works

# Cannot search non-deterministic fields
User.where(ssn: "123-45-6789")  # Won't work
```

### Configuration

**File: `config/application.cr` — append this configuration after the Grant
dependency is required.**

```crystal
Grant::Encryption.configure do |config|
  config.primary_key = ENV["ENCRYPTION_PRIMARY_KEY"]
  config.key_derivation_salt = ENV["ENCRYPTION_KEY_DERIVATION_SALT"]
  config.deterministic_key = ENV["ENCRYPTION_DETERMINISTIC_KEY"]
end

# Generate keys
# crystal eval 'require "random"; puts Random::Secure.hex(32)'
```

## Secure Tokens

Generate cryptographically secure tokens for authentication.

### Basic Token Generation

```crystal
class User < Grant::Base
  column id : Int64, primary: true
  column email : String
  column auth_token : String?

  has_secure_token :auth_token
end

user = User.create!(email: "alice@example.com")
user.auth_token  # => "pX27zsMN2ViQKta1bGfLmVJE"

# Regenerate token
user.regenerate_auth_token
```

### Token Options

```crystal
class ApiKey < Grant::Base
  column id : Int64, primary: true
  column user_id : Int64
  column key : String?
  column secret : String?

  # Default: 24 characters, URL-safe base64
  has_secure_token :key

  # Custom length
  has_secure_token :secret, length: 32

  # Hex format
  has_secure_token :hex_key, length: 16, alphabet: :hex
end
```

### Token Authentication

```crystal
class ApplicationController < Amber::Controller::Base
  def authenticate_api_key
    token = request.headers["Authorization"]?
      .try(&.gsub("Bearer ", ""))

    unless token && ApiKey.find_by(key: token)
      halt!(401, "Invalid API key")
    end
  end
end
```

## Signed IDs

Create tamper-proof, expiring identifiers for URLs.

### Basic Signed IDs

```crystal
class User < Grant::Base
  include Grant::SignedId

  column id : Int64, primary: true
  column email : String
end

user = User.find!(1)

# Generate signed ID
signed_id = user.signed_id
# => "eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1RPT0iL..."

# Find by signed ID
found = User.find_signed(signed_id)
# => User(id: 1, email: "alice@example.com")

# Invalid/tampered ID returns nil
User.find_signed("tampered_id")  # => nil
```

### Expiring Signed IDs

```crystal
# Expires in 15 minutes
signed_id = user.signed_id(expires_in: 15.minutes)

# Expires at specific time
signed_id = user.signed_id(expires_at: 1.hour.from_now)

# Expired ID returns nil
User.find_signed(expired_signed_id)  # => nil
```

### Scoped Signed IDs

```crystal
# Scope to specific purpose
signed_id = user.signed_id(purpose: :password_reset)

# Must use same purpose to verify
User.find_signed(signed_id, purpose: :password_reset)  # Works
User.find_signed(signed_id, purpose: :email_confirm)   # => nil
```

### Use Cases

```crystal
class PasswordResetController < ApplicationController
  def create
    user = User.find_by!(email: params["email"])
    token = user.signed_id(
      expires_in: 15.minutes,
      purpose: :password_reset
    )

    PasswordResetMailer.send(user.email, token)
    redirect_to "/login", notice: "Check your email"
  end

  def update
    user = User.find_signed!(
      params["token"],
      purpose: :password_reset
    )

    user.update!(password: params["password"])
    redirect_to "/login", notice: "Password updated"
  rescue Grant::InvalidSignedId
    redirect_to "/forgot-password", alert: "Invalid or expired link"
  end
end
```

## Token Generation (token_for)

Generate purpose-specific tokens that can include record state.

```crystal
class User < Grant::Base
  include Grant::TokenFor

  column id : Int64, primary: true
  column email : String
  column password_salt : String

  # Token invalidates when password_salt changes
  generates_token_for :password_reset, expires_in: 15.minutes do
    password_salt
  end

  generates_token_for :email_confirmation, expires_in: 24.hours do
    email
  end
end

# Generate token
user = User.find!(1)
token = user.generate_token_for(:password_reset)

# Find by token
found = User.find_by_token_for(:password_reset, token)

# Token invalidates if password changes
user.update!(password_salt: SecureRandom.hex)
User.find_by_token_for(:password_reset, token)  # => nil
```

## Data Normalization

Automatically normalize data before saving.

```crystal
class User < Grant::Base
  column email : String
  column phone : String?
  column name : String

  # Normalize email
  normalizes :email, &.downcase.strip

  # Normalize name
  normalizes :name, &.strip.titleize

  # Normalize phone (remove non-digits)
  normalizes :phone do |phone|
    phone.gsub(/\D/, "")
  end
end

user = User.new(
  email: "  ALICE@Example.COM  ",
  name: "alice smith",
  phone: "(555) 123-4567"
)

user.email  # => "alice@example.com"
user.name   # => "Alice Smith"
user.phone  # => "5551234567"
```

## Enum Attributes

Type-safe enumerated values.

```crystal
class User < Grant::Base
  column id : Int64, primary: true
  column role : String

  enum Role
    Guest
    Member
    Admin
    SuperAdmin
  end

  enum_attribute role : Role = :member
end

user = User.new
user.role        # => Role::Member
user.member?     # => true
user.admin?      # => false

user.admin!      # Sets role to Admin
user.role        # => Role::Admin

# Scopes generated automatically
User.admin       # Users with admin role
User.member      # Users with member role
```

## Best Practices

### 1. Protect Sensitive Data

```crystal
class User < Grant::Base
  # Always encrypt PII
  encrypts :ssn, :tax_id, :bank_account

  # Deterministic only when searchable needed
  encrypts :phone_number, deterministic: true

  # Never log sensitive data
  @[JSON::Field(ignore: true)]
  column ssn : String?
end
```

### 2. Use Scoped Tokens

```crystal
# Always scope tokens to purpose
signed_id = user.signed_id(purpose: :password_reset)

# Never use generic signed IDs for sensitive operations
```

### 3. Set Appropriate Expiration

```crystal
# Short expiration for sensitive operations
password_reset_token = user.signed_id(
  expires_in: 15.minutes,
  purpose: :password_reset
)

# Longer for less sensitive
email_unsubscribe = user.signed_id(
  expires_in: 30.days,
  purpose: :unsubscribe
)
```

### 4. Rotate Encryption Keys

```crystal
# Support key rotation
Grant::Encryption.configure do |config|
  config.primary_key = ENV["NEW_ENCRYPTION_KEY"]
  config.previous_keys = [ENV["OLD_ENCRYPTION_KEY"]]
end
```


---

## Pipelines

Canonical page: https://amberframework.org/docs/v2/guides/routing/pipelines

# Pipelines

A pipeline is the ordered set of `HTTP::Handler`-compatible pipes applied to a
group of routes. The V2 web template generates this configuration.

**File: `config/routes.cr` — this is the generated baseline. Edit the existing
pipelines in place; do not create a second `Amber::Server.configure` block only
to change their order.**

```crystal
Amber::Server.configure do
  pipeline :web do
    plug Amber::Pipe::Error.new
    plug Amber::Pipe::Logger.new
    plug Amber::Pipe::Session.new
    plug Amber::Pipe::Flash.new
    plug Amber::Pipe::CSRF.new
  end

  pipeline :static do
    plug Amber::Pipe::Error.new
    plug Amber::Pipe::Static.new("./public")
  end

  routes :web do
    get "/", HomeController, :index
  end

  routes :static do
    get "/*", Amber::Controller::Static, :index
  end
end
```

Order is behavior. `Session` must run before `Flash`, and error handling should
wrap work that can fail. Add authentication, rate limiting, or application
headers deliberately to only the pipelines that need them.

## A protected pipeline

Define a second pipeline when a route group needs additional handling.

**File: `config/routes.cr` — add both the `:admin` pipeline and its route group
inside the existing `Amber::Server.configure` block.**

```crystal
Amber::Server.configure do
  pipeline :admin do
    plug Amber::Pipe::Error.new
    plug Amber::Pipe::Logger.new
    plug Amber::Pipe::Session.new
    plug Amber::Pipe::Flash.new
    plug AuthenticateAdmin.new
    plug Amber::Pipe::CSRF.new
  end

  routes :admin, "/admin" do
    get "/", AdminController, :index
  end
end
```

Custom pipes implement `call(context)` and invoke the next handler when the
request should continue. Put `AuthenticateAdmin` in its own source file, for
example `src/pipes/authenticate_admin.cr`, and require that file from the
application before `config/routes.cr` is compiled. A pipe that finalizes a
response can stop the chain.


---

## Routes

Canonical page: https://amberframework.org/docs/v2/guides/routing/routes

# Routes

Define routes inside `Amber::Server.configure` and attach each group to a named
pipeline.

**File: `config/routes.cr` — add these declarations inside the generated
`Amber::Server.configure` block. Keep the existing `:static` routes.**

```crystal
Amber::Server.configure do
  routes :web do
    get "/posts", PostsController, :index
    get "/posts/:id", PostsController, :show
    post "/posts", PostsController, :create
    patch "/posts/:id", PostsController, :update
    delete "/posts/:id", PostsController, :destroy
  end
end
```

Dynamic segments such as `:id` are available through `params` in the action.
Amber also supports `put`, `options`, `head`, `trace`, and `connect` route
macros.

## Resource routes

`resources` creates conventional routes for `index`, `new`, `create`, `show`,
`edit`, `update`, and `destroy`:

**File: `config/routes.cr` — use these entries inside an existing
`Amber::Server.configure` block, as an alternative to listing every route.**

```crystal
routes :web do
  resources "/posts", PostsController
  resources "/profiles", ProfilesController, only: [:show, :edit, :update]
  resources "/events", EventsController, except: [:destroy]
end
```

Only declare actions implemented by the controller; missing resource actions
fail during compilation.

## Scopes and namespaces

A scope on `routes` prefixes the complete group. Nested `namespace` blocks add
another path segment.

**File: `config/routes.cr` — add this route group inside
`Amber::Server.configure`.**

```crystal
routes :api, "/api" do
  namespace "/v1" do
    resources "/posts", Api::PostsController, only: [:index, :show]
  end
end
```

## Segment constraints

Constrain a dynamic segment with a regular expression when a route must reject
non-matching values.

**File: `config/routes.cr` — add the constrained route inside the existing
`:web` route group.**

```crystal
routes :web do
  get "/orders/:id", OrdersController, :show, {"id" => /\d+/}
end
```

Run `amber routes` from the project root to print the declared route table. Pair
that inspection with request specs and the compiler to verify dispatch behavior.


---

## Schema Basics

Canonical page: https://amberframework.org/docs/v2/guides/schema-api/basics

# Schema Basics

## Where the examples go

Schema classes, fields, nested schemas, inheritance, coercion, transformations,
and validated success/error types belong under `src/schemas/`, grouped by
resource or request flow. Most blocks on this page are fragments to place
inside one of those schema classes, not complete files. Controller validation
calls belong under `src/controllers/`, and their routes belong in
`config/routes.cr`.

A schema declares an input contract: accepted content type, typed fields,
defaults, validation rules, and the value or error type produced after parsing.

## Schema Definition

A schema is a class that inherits from `Amber::Schema::Definition`:

```crystal
class CreateUserSchema < Amber::Schema::Definition
  content_type "application/json"

  field :email, String, required: true, format: :email
  field :name, String, required: true
  field :age, Int32, min: 18

  validates_to UserRequest, UserValidationError
end
```

## Field Types

### Basic Types

```crystal
field :name, String              # String field
field :age, Int32                # Integer field
field :price, Float64            # Float field
field :active, Bool              # Boolean field
field :id, UUID                  # UUID field
field :created_at, Time          # Time field
```

### Collections

```crystal
field :tags, Array(String)                # Array of strings
field :scores, Array(Int32)               # Array of integers
field :metadata, Hash(String, String)     # Hash/dictionary
```

### Nested Objects

```crystal
field :address, AddressSchema             # Single nested object
field :addresses, Array(AddressSchema)    # Array of nested objects
```

## Field Options

### Required Fields

```crystal
field :email, String, required: true    # Must be present
field :nickname, String?                # Optional (can be nil)
field :bio, String                      # Optional by default
```

### Default Values

```crystal
field :role, String, default: "user"
field :active, Bool, default: true
field :page, Int32, default: 1
```

### Field Aliases

Map different input names to your field:

```crystal
field :email, String, as: "user_email"       # JSON: {"user_email": "..."}
field :full_name, String, as: "fullName"     # CamelCase input
```

### Normalization

Transform values before validation:

```crystal
field :email, String,
  normalize: ->(s : String) { s.downcase.strip }

field :phone, String,
  normalize: ->(s : String) { s.gsub(/\D/, "") }

field :tags, Array(String),
  normalize: ->(tags : Array(String)) { tags.map(&.downcase).uniq }
```

## Parameter Sources

Specify where parameters come from:

```crystal
class SearchSchema < Amber::Schema::Definition
  # From URL query string: ?q=search&page=1
  from_query do
    field :q, String, as: :query
    field :page, Int32, default: 1
    field :per_page, Int32, default: 20
  end

  # From URL path: /categories/:category_id/products
  from_path do
    field :category_id, Int32
  end

  # From HTTP headers
  from_header do
    field :api_key, String, key: "X-API-Key"
    field :version, String, key: "X-API-Version", default: "v1"
  end

  # From request body
  from_body do
    field :filters, SearchFilters
  end

  validates_to SearchRequest, SearchValidationError
end
```

## Nested Schemas

Create reusable schemas for nested objects:

```crystal
class AddressSchema < Amber::Schema::Definition
  field :street, String, required: true
  field :city, String, required: true
  field :state, String, required: true, length: 2
  field :zip, String, required: true, format: /^\d{5}(-\d{4})?$/

  validates_to Address, AddressValidationError
end

class UserSchema < Amber::Schema::Definition
  field :name, String, required: true
  field :email, String, required: true, format: :email

  # Single nested object
  field :primary_address, AddressSchema

  # Array of nested objects
  field :addresses, Array(AddressSchema), max_items: 5

  validates_to User, UserValidationError
end
```

## Schema Inheritance

Share common fields across schemas:

```crystal
# Base schema with common fields
abstract class BaseUserSchema < Amber::Schema::Definition
  field :email, String, required: true, format: :email
  field :name, String, required: true
end

# Registration adds password
class RegistrationSchema < BaseUserSchema
  field :password, String, required: true, min_length: 8
  field :password_confirmation, String, required: true
  field :terms_accepted, Bool, required: true

  validate :password_matches
  validates_to NewUser, RegistrationError
end

# Update doesn't require password
class UpdateUserSchema < BaseUserSchema
  field :bio, String, max_length: 500
  field :avatar_url, String, format: :url

  validates_to UserUpdate, UpdateError
end
```

## Type Coercion

The schema system automatically converts string inputs:

```crystal
# Input: {"age": "25", "active": "true", "price": "19.99"}
class ProductSchema < Amber::Schema::Definition
  field :age, Int32        # "25" -> 25
  field :active, Bool      # "true" -> true
  field :price, Float64    # "19.99" -> 19.99
end
```

### Custom Coercion

```crystal
class DateRangeSchema < Amber::Schema::Definition
  field :start_date, Time,
    coerce: ->(s : String) { Time.parse(s, "%Y-%m-%d", Time::Location::UTC) }

  field :status, Status,
    coerce: ->(s : String) { Status.parse(s) }
end
```

## State-Based Types

Schemas validate to specific success and failure types:

```crystal
# Success type - immutable, validated data
class UserRequest < Amber::Schema::ValidatedRequest
  getter email : String
  getter name : String
  getter age : Int32

  # Computed properties
  def adult? : Bool
    age >= 18
  end
end

# Failure type - contains validation errors
class UserValidationError < Amber::Schema::ValidationError
  def to_response
    {
      message: "User validation failed",
      errors: errors,
      fields: errors.keys
    }
  end
end
```

## Transformations

Apply transformations after validation:

```crystal
class RegistrationSchema < Amber::Schema::Definition
  field :first_name, String, required: true
  field :last_name, String, required: true
  field :email, String, required: true

  # Add computed fields after validation
  transform do |data|
    data.full_name = "#{data.first_name} #{data.last_name}"
    data.username = data.email.split("@").first
  end

  validates_to Registration, RegistrationError
end
```

## Documentation Metadata

Add documentation for API generation:

```crystal
class APISchema < Amber::Schema::Definition
  description "Creates a new user account"

  field :email, String,
    required: true,
    format: :email,
    description: "User's email address",
    example: "user@example.com"

  field :role, String,
    enum: ["admin", "user", "guest"],
    default: "user",
    description: "User's role in the system"
end
```


---

## Validation

Canonical page: https://amberframework.org/docs/v2/guides/schema-api/validation

# Validation

## Where the examples go

Built-in validation options, conditions, custom validator methods, validator
classes, contexts, and messages belong under `src/schemas/`. Most blocks on
this page are fragments to place inside the schema class they describe. Calls
that validate a request and serialize errors belong in the receiving controller
under `src/controllers/`, with its route in `config/routes.cr`.

Schema fields can enforce presence, length, format, numeric bounds, and custom
rules before application code receives a typed value.

## Built-in Validators

### Required

```crystal
field :email, String, required: true
field :age, Int32, required: true
field :bio, String?  # Optional by default
```

### String Validators

#### Length

```crystal
field :username, String, min_length: 3, max_length: 20
field :password, String, min_length: 8
field :bio, String, max_length: 500
field :zip_code, String, length: 5  # Exact length
```

#### Format

```crystal
field :email, String, format: :email
field :url, String, format: :url
field :phone, String, format: :phone_number
field :ssn, String, format: /^\d{3}-\d{2}-\d{4}$/  # Custom regex
```

#### Predefined Formats

```crystal
:email          # Valid email address
:url            # Valid URL (http/https)
:uri            # Valid URI
:uuid           # Valid UUID v4
:phone_number   # International phone format
:ip_address     # IPv4 or IPv6
:ipv4           # IPv4 only
:ipv6           # IPv6 only
:credit_card    # Credit card number (Luhn check)
:slug           # URL-safe slug
:alpha          # Letters only
:numeric        # Numbers only
:alphanumeric   # Letters and numbers
```

### Numeric Validators

```crystal
field :age, Int32, min: 18, max: 120
field :price, Float64, min: 0.01, max: 999999.99
field :quantity, Int32, min: 1
field :percentage, Float64, min: 0.0, max: 100.0
```

### Enum Validators

```crystal
field :status, String, enum: ["active", "inactive", "pending"]
field :role, String, enum: UserRoles::ALL
field :priority, Int32, enum: [1, 2, 3, 4, 5]
```

### Array Validators

```crystal
field :tags, Array(String), min_items: 1, max_items: 10
field :categories, Array(Int32), unique: true
field :emails, Array(String), each: {format: :email}
```

## Conditional Validations

### When Field Has Value

```crystal
class OrderSchema < Amber::Schema::Definition
  field :payment_method, String, enum: ["card", "paypal", "bitcoin"]

  # Only validate card fields when payment is "card"
  when_field :payment_method, "card" do
    field :card_number, String, required: true, format: :credit_card
    field :cvv, String, required: true, length: 3..4
    field :expiry, String, required: true, format: /^\d{2}\/\d{2}$/
  end

  when_field :payment_method, "paypal" do
    field :paypal_email, String, required: true, format: :email
  end
end
```

### When Field Present

```crystal
when_present :coupon_code do
  validate :valid_coupon
  validate :not_expired
end
```

### Field Dependencies

```crystal
# All must be present together
requires_together :address, :city, :state, :zip

# Exactly one must be present
requires_one_of :email, :phone, :username

# At least one must be present
requires_any_of :home_phone, :work_phone, :mobile_phone
```

## Custom Validators

### Instance Method Validators

```crystal
class RegistrationSchema < Amber::Schema::Definition
  field :password, String, required: true, min_length: 8
  field :password_confirmation, String, required: true
  field :age, Int32, required: true

  validate :password_matches
  validate :age_appropriate

  private def password_matches
    if password != password_confirmation
      errors.add(:password_confirmation, "doesn't match password")
    end
  end

  private def age_appropriate
    if age < 13
      errors.add(:age, "must be 13 or older")
    elsif age < 18
      warnings.add(:age, "parental consent required")
    end
  end
end
```

### Validator Classes

Create reusable validators:

```crystal
class EmailUniquenessValidator < Amber::Schema::Validator
  def validate(value : String, field : Field, schema : Schema)
    if User.exists?(email: value)
      schema.errors.add(field.name, "is already taken")
    end
  end
end

class SignupSchema < Amber::Schema::Definition
  field :email, String, required: true, format: :email,
        validator: EmailUniquenessValidator.new
end
```

## Validation Contexts

Run different validations based on context:

```crystal
class UserSchema < Amber::Schema::Definition
  field :email, String, required: true, format: :email
  field :password, String, required: true, min_length: 8, on: :create
  field :current_password, String, required: true, on: :update

  validate :password_complexity, on: :create
  validate :current_password_correct, on: :update
  validate :email_domain_allowed  # Runs in all contexts
end

# Usage
schema = UserSchema.new(data, context: :create)
schema = UserSchema.new(data, context: :update)
```

## Custom Error Messages

```crystal
field :age, Int32,
      required: {message: "is required for registration"},
      min: {value: 18, message: "must be 18 or older to register"}

field :email, String,
      format: {value: :email, message: "doesn't look like a valid email"}
```

## Error Handling

### Error Response Formatting

```crystal
class ValidationErrorResponse < Amber::Schema::Response
  def initialize(error : Amber::Schema::ValidationError)
    @errors = error.errors
    @message = "Validation failed"
  end

  def to_json
    {
      message: @message,
      errors: @errors,
      error_code: "VALIDATION_ERROR"
    }.to_json
  end
end
```

### In Controller

```crystal
def create
  case result = CreateUserSchema.validate(request)
  when Amber::Schema::Success
    user = User.create!(result.data)
    respond_with 201, user.to_json
  when Amber::Schema::Failure
    respond_with 400, {
      message: "Validation failed",
      errors: result.error.errors
    }.to_json
  end
end
```

## Validation Flow

The validation process follows this order:

1. **Parse** - Extract data from request based on content type
2. **Coerce** - Convert string values to proper types
3. **Validate** - Run all validators in order
4. **Transform** - Apply any transformations
5. **Return** - Success with typed data or Failure with errors


---

## Parsers

Canonical page: https://amberframework.org/docs/v2/guides/schema-api/parsers

# Parsers

## Where the examples go

Parser and field declarations belong inside schema classes under
`src/schemas/`. Blocks labeled as example requests are HTTP request bodies, not
source files. Content negotiation belongs in the receiving controller under
`src/controllers/`, and the endpoint belongs in `config/routes.cr`. Multipart
file handling must also follow the application's upload-validation boundary.

Select a parser through the schema's content type. Amber provides explicit
parsers for the formats listed below.

## Supported Content Types

- `application/json` - JSON data
- `application/xml` - XML documents
- `application/x-www-form-urlencoded` - Form data
- `multipart/form-data` - File uploads and forms
- `text/csv` - CSV bulk operations
- `application/x-protobuf` - Protocol Buffers
- `application/msgpack` - MessagePack

## JSON Parser

The most common format for APIs:

```crystal
class CreateOrderSchema < Amber::Schema::Definition
  content_type "application/json"

  field :items, Array(OrderItemSchema), required: true
  field :shipping_address, AddressSchema
  field :billing_address, AddressSchema
  field :same_as_shipping, Bool, default: false

  validates_to OrderRequest, OrderValidationError
end
```

Example request:

```json
{
  "items": [
    {"product_id": 1, "quantity": 2},
    {"product_id": 3, "quantity": 1}
  ],
  "shipping_address": {
    "street": "123 Main St",
    "city": "Springfield",
    "zip": "12345"
  },
  "same_as_shipping": true
}
```

## XML Parser

For SOAP APIs or XML-based integrations:

```crystal
class CreateOrderXMLSchema < Amber::Schema::Definition
  content_type "application/xml"

  field :items, Array(OrderItemSchema), xpath: "//order/items/item"
  field :shipping_address, AddressSchema, xpath: "//order/shipping"
  field :billing_address, AddressSchema, xpath: "//order/billing"
  field :same_as_shipping, Bool, xpath: "//order/@sameAsShipping"

  validates_to OrderRequest, OrderValidationError
end
```

Example request:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<order sameAsShipping="true">
  <items>
    <item>
      <product_id>1</product_id>
      <quantity>2</quantity>
    </item>
  </items>
  <shipping>
    <street>123 Main St</street>
    <city>Springfield</city>
  </shipping>
</order>
```

## Form Parser

For traditional HTML forms:

```crystal
class CreateOrderFormSchema < Amber::Schema::Definition
  content_type "application/x-www-form-urlencoded"

  # Arrays use bracket notation: item_ids[]=1&item_ids[]=2
  field :item_ids, Array(Int32), repeated: true
  field :item_quantities, Array(Int32), repeated: true

  # Nested objects use bracket notation
  field :shipping_street, String, as: "shipping[street]"
  field :shipping_city, String, as: "shipping[city]"
  field :shipping_zip, String, as: "shipping[zip]"

  validates_to OrderRequest, OrderValidationError

  # Transform flat form data to nested structure
  def transform
    items = item_ids.zip(item_quantities).map do |id, qty|
      OrderItem.new(product_id: id, quantity: qty)
    end

    self.items = items
    self.shipping_address = Address.new(
      street: shipping_street,
      city: shipping_city,
      zip: shipping_zip
    )
  end
end
```

## Multipart Parser

For file uploads:

```crystal
class UploadSchema < Amber::Schema::Definition
  content_type "multipart/form-data"

  field :title, String, required: true
  field :description, String
  field :file, Amber::Schema::UploadedFile, required: true

  # File validation
  validates :file do
    max_size 10.megabytes
    allowed_types ["image/jpeg", "image/png", "application/pdf"]
  end

  validates_to UploadRequest, UploadValidationError
end
```

### Multiple Files

```crystal
class GalleryUploadSchema < Amber::Schema::Definition
  content_type "multipart/form-data"

  field :album_name, String, required: true
  field :images, Array(Amber::Schema::UploadedFile), max_items: 20

  validates :images do
    each do
      max_size 5.megabytes
      allowed_types ["image/jpeg", "image/png", "image/webp"]
    end
  end
end
```

## CSV Parser

For bulk operations:

```crystal
class BulkImportSchema < Amber::Schema::Definition
  content_type "text/csv"

  # Define expected columns
  csv_columns do
    column :email, String, required: true, format: :email
    column :name, String, required: true
    column :role, String, enum: ["admin", "user"]
  end

  # Row validation
  max_rows 1000
  skip_invalid_rows false

  validates_to BulkImportRequest, BulkImportError
end
```

## Multiple Content Types

Support multiple formats for the same endpoint:

```crystal
class CreateUserController < ApplicationController
  # Select schema based on content type
  SCHEMAS = {
    "application/json" => CreateUserJSONSchema,
    "application/xml" => CreateUserXMLSchema,
    "application/x-www-form-urlencoded" => CreateUserFormSchema
  }

  def create
    content_type = request.headers["Content-Type"]
    schema_class = SCHEMAS[content_type]?

    unless schema_class
      return respond_with 415, {error: "Unsupported content type"}.to_json
    end

    case result = schema_class.validate(request)
    when Amber::Schema::Success
      user = User.create!(result.data)
      respond_with 201, user.to_json
    when Amber::Schema::Failure
      respond_with 400, result.error.to_response.to_json
    end
  end
end
```

## Content Negotiation

Automatic schema selection:

```crystal
class UserSchema < Amber::Schema::Definition
  # Define multiple content types
  accepts "application/json", "application/xml", "application/x-www-form-urlencoded"

  field :email, String, required: true
  field :name, String, required: true

  validates_to UserRequest, UserValidationError
end
```

The parser will automatically handle the request based on the `Content-Type` header.


---

## OpenAPI Generation

Canonical page: https://amberframework.org/docs/v2/guides/schema-api/openapi

# OpenAPI Generation

## Where the examples go

- OpenAPI metadata belongs beside each schema under `src/schemas/`.
- Application-wide OpenAPI configuration belongs in `config/application.cr`,
  which the released V2 template loads directly.
- Endpoint declarations belong in `config/routes.cr`; response code belongs in
  the named controller under `src/controllers/`.
- Generated specifications belong under `public/` only when the application
  intentionally serves them as static files.

Blocks on this page use those destinations unless a closer label says
otherwise.

The Schema API can automatically generate OpenAPI (Swagger) specifications from your schema definitions.

## Basic OpenAPI Metadata

Add OpenAPI metadata to your schemas:

```crystal
class CreateUserSchema < Amber::Schema::Definition
  openapi do
    operation_id "createUser"
    tags ["Users", "Registration"]
    summary "Create a new user account"
    description "Creates a new user with the provided information"

    responses do
      success 201, "User created successfully"
      error 400, "Invalid request data"
      error 409, "Email already exists"
    end
  end

  field :email, String,
    required: true,
    format: :email,
    description: "User's email address",
    example: "user@example.com"

  field :name, String,
    required: true,
    description: "User's full name",
    example: "John Doe"

  field :role, String,
    enum: ["admin", "user", "guest"],
    default: "user",
    description: "User's role in the system"

  validates_to UserRequest, UserValidationError
end
```

## Field Documentation

Document each field for the API spec:

```crystal
field :email, String,
  required: true,
  format: :email,
  description: "User's email address",
  example: "user@example.com",
  deprecated: false

field :password, String,
  required: true,
  min_length: 8,
  description: "User's password (min 8 characters)",
  example: "securepassword123",
  write_only: true  # Won't appear in response schemas
```

## Generating the Spec

Generate the OpenAPI specification:

**File: `config/application.cr` — append this setup after `require "amber"`, or
call the generation portion from a dedicated build task if production has a
read-only filesystem.**

```crystal
OpenAPI.configure do |config|
  config.title = "My API"
  config.version = "2.0.0"
  config.description = "API documentation for My Application"

  config.servers = [
    {url: "https://api.example.com", description: "Production"},
    {url: "https://staging-api.example.com", description: "Staging"}
  ]

  config.contact = {
    name: "API Support",
    email: "support@example.com"
  }
end

# Generate spec
spec = OpenAPI.generate_from_schemas([
  CreateUserSchema,
  UpdateUserSchema,
  ListUsersSchema
])

File.write("public/openapi.json", spec.to_json)
```

## Route Integration

Connect schemas to routes:

```crystal
# config/routes.cr
routes :api do
  post "/users", UsersController, :create,
    schema: CreateUserSchema,
    response_schema: UserResponseSchema

  get "/users/:id", UsersController, :show,
    schema: GetUserSchema,
    response_schema: UserResponseSchema
end
```

## Response Schemas

Define response schemas:

```crystal
class UserResponseSchema < Amber::Schema::Response
  field :id, Int64
  field :email, String
  field :name, String
  field :role, String
  field :created_at, Time

  openapi do
    description "User object response"
  end
end

class ErrorResponseSchema < Amber::Schema::Response
  field :message, String
  field :errors, Hash(String, Array(String))
  field :error_code, String

  openapi do
    description "Error response with validation details"
  end
end
```

## Security Definitions

Define authentication schemes:

```crystal
OpenAPI.configure do |config|
  config.security_schemes = {
    "bearerAuth" => {
      type: "http",
      scheme: "bearer",
      bearer_format: "JWT"
    },
    "apiKey" => {
      type: "apiKey",
      in: "header",
      name: "X-API-Key"
    }
  }
end

# Apply to schema
class ProtectedSchema < Amber::Schema::Definition
  openapi do
    security ["bearerAuth"]
  end

  # ...fields
end
```

## Serving the Spec

Serve the OpenAPI spec and Swagger UI:

```crystal
# config/routes.cr
routes :api do
  # OpenAPI JSON spec
  get "/openapi.json", OpenAPIController, :spec

  # Swagger UI (if using swagger-ui assets)
  get "/docs", OpenAPIController, :swagger_ui
end
```

```crystal
# src/controllers/openapi_controller.cr
class OpenAPIController < ApplicationController
  def spec
    spec = OpenAPI.generate
    respond_with 200, spec.to_json, "application/json"
  end

  def swagger_ui
    render "openapi/swagger_ui.ecr"
  end
end
```

## Example Generated Spec

The generated OpenAPI spec looks like:

```json
{
  "openapi": "3.0.3",
  "info": {
    "title": "My API",
    "version": "2.0.0"
  },
  "paths": {
    "/users": {
      "post": {
        "operationId": "createUser",
        "tags": ["Users", "Registration"],
        "summary": "Create a new user account",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateUser"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "User created successfully"
          },
          "400": {
            "description": "Invalid request data"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "CreateUser": {
        "type": "object",
        "required": ["email", "name"],
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "description": "User's email address",
            "example": "user@example.com"
          },
          "name": {
            "type": "string",
            "description": "User's full name",
            "example": "John Doe"
          },
          "role": {
            "type": "string",
            "enum": ["admin", "user", "guest"],
            "default": "user"
          }
        }
      }
    }
  }
}
```


---

## System Tests

Canonical page: https://amberframework.org/docs/v2/guides/testing/system-tests

# System Tests

**Setting Up System Specs**

We have made it as simple as possible to have your system specs. Before running your specs ensure you have installed the `chromedriver` and that `selenium-server standalone` is in your system path.

**Mac OS**

You can install the chromedriver and selenium standalone server with `brew`

```bash
brew install selenium-server-standalone
brew install chromedriver
```

This will install the chrome driver on the system path `/usr/local/bin/chromedriver`

If you're running in a different OS such as Linux you can specify the chromedriver path as such

```crystal
module GarnetSpec
  DRIVER = :chrome
  PATH = "/usr/local/bin/chromedriver"
end
```

System tests allows test user interactions with your application, running tests in either a real chrome browser. System tests use the Selenium Standalone Server.

For creating Amber system tests, you use the spec/system directory in your application. Here's how a system test looks like:

```crystal
class SomeFeature < GarnetSpec::System::Test
  scenario "user visits amber framework and sees getting started button" do
    visit "http://www.amberframework.org"
    timeout 1000
    click_on(:css, "header a.btn.btn-primary")
    wait 2000
    element(:tag_name, "body").text.should contain "Introduction"
  end

  scenario "user visits amberframwork homepage and sees logo" do
    visit "http://www.amberframework.org"
    wait 2000
    element(:class_name, "img-amber-logo").attribute("src").should match(
      %r(https://www.amberframework.org/assets/img/amber-logo-t-bg.png)
    )
  end
end
```

Run your specs with `crystal spec`

{% hint style="warning" %}
System Test currently only work with the Chrome Browser
{% endhint %}


---

## File Attachments

Canonical page: https://amberframework.org/docs/v2/guides/uploads/attachments

# File Attachments

> **Preview ecosystem guide:** Gemma is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

## Where the examples go

Attachment declarations, lifecycle callbacks, and uploader selection belong in
the matching Grant model under `src/models/`. Upload assignment and direct
upload handling belong in the receiving controller under `src/controllers/`.
Form and display markup belongs in the matching ECR files under `src/views/`.
Reusable uploader classes belong under `src/uploaders/`; direct storage work
belongs in a job or service with focused specs.

Gemma's `Attachable` module adds single- and multiple-file attachment declarations
to Grant models.

## Setup

Include the `Attachable` module in your Grant model:

```crystal
require "gemma/grant"

class User < Grant::Base
  include Gemma::Grant::Attachable

  column id : Int64, primary: true
  column name : String

  # Column to store attachment metadata (JSON)
  column avatar_data : JSON::Any?

  # Declare the attachment
  has_one_attached :avatar
end
```

## Single File Attachments

### Declaration

Use `has_one_attached` to attach a single file:

```crystal
class User < Grant::Base
  include Gemma::Grant::Attachable

  column id : Int64, primary: true
  column profile_picture_data : JSON::Any?
  column resume_data : JSON::Any?

  has_one_attached :profile_picture
  has_one_attached :resume
end
```

The column name must be `{attachment_name}_data` with type `JSON::Any?`.

### Attaching Files

```crystal
# From IO object
user.avatar = File.open("avatar.jpg")

# From uploaded file in controller
user.avatar = params.files["avatar"].file

# Clear attachment
user.avatar = nil
```

### Accessing Attachments

```crystal
# Get the UploadedFile object
file = user.avatar

# Check if attached
if user.avatar
  puts "Avatar attached!"
end

# Get URL
url = user.avatar_url

# With URL options
url = user.avatar_url(host: "https://cdn.example.com")

# Check if changed (before save)
user.avatar_changed?  # => true/false
```

### File Metadata

```crystal
file = user.avatar

file.id                # Unique identifier
file.original_filename # Original upload name
file.extension         # File extension
file.size              # Size in bytes
file.mime_type         # MIME type
file.metadata          # All metadata hash
```

### Working with File Content

```crystal
# Open for reading
user.avatar.open do |io|
  content = io.gets_to_end
end

# Download to tempfile
user.avatar.download do |tempfile|
  # tempfile is a File object
  system("convert", tempfile.path, "thumbnail.jpg")
end

# Stream to destination
io = IO::Memory.new
user.avatar.stream(io)
```

## Multiple File Attachments

### Declaration

Use `has_many_attached` for multiple files:

```crystal
class Post < Grant::Base
  include Gemma::Grant::Attachable

  column id : Int64, primary: true
  column title : String
  column images_data : JSON::Any?
  column attachments_data : JSON::Any?

  has_many_attached :images
  has_many_attached :attachments
end
```

### Attaching Multiple Files

```crystal
# Replace all files
post.images = [
  File.open("photo1.jpg"),
  File.open("photo2.jpg"),
  File.open("photo3.jpg")
]

# From controller with multiple file upload
post.images = params.files.select { |f| f.field == "images" }.map(&.file)
```

### Managing Collections

```crystal
# Get all files (Array of UploadedFile)
files = post.images

# Iterate
post.images.each do |image|
  puts image.url
end

# Count
post.images.size

# Add single file (singular form of attachment name)
post.add_image(File.open("new_photo.jpg"))

# Remove specific file
post.remove_image(post.images.first)

# Clear all files
post.clear_images

# Check if changed
post.images_changed?
```

## Lifecycle Callbacks

Gemma automatically hooks into Grant's lifecycle:

```crystal
class Document < Grant::Base
  include Gemma::Grant::Attachable

  column file_data : JSON::Any?
  has_one_attached :file

  # Gemma registers these automatically:
  # before_save  - promotes cached files to store
  # after_save   - persists attachment data
  # after_destroy - cleans up attached files
end
```

### Custom Processing

Add your own callbacks for additional processing:

```crystal
class Photo < Grant::Base
  include Gemma::Grant::Attachable

  column image_data : JSON::Any?
  column thumbnail_data : JSON::Any?

  has_one_attached :image
  has_one_attached :thumbnail

  after_save :generate_thumbnail

  private def generate_thumbnail
    return unless image && image_changed?

    image.download do |tempfile|
      # Generate thumbnail using ImageMagick
      thumb_path = "/tmp/thumb_#{id}.jpg"
      system("convert", tempfile.path, "-thumbnail", "100x100^", thumb_path)

      self.thumbnail = File.open(thumb_path)
      save! if thumbnail_changed?

      File.delete(thumb_path)
    end
  end
end
```

## Custom Uploaders

Create custom uploaders for specialized behavior:

```crystal
class AvatarUploader < Gemma
  # Custom file location
  def generate_location(io, metadata, context, **options)
    user = context[:model]
    filename = metadata["filename"]? || "avatar"
    extension = File.extname(filename)

    "users/#{user.id}/avatar#{extension}"
  end
end

class User < Grant::Base
  include Gemma::Grant::Attachable

  column avatar_data : JSON::Any?

  # Use custom uploader
  has_one_attached :avatar, uploader: AvatarUploader
end
```

### Uploader with Plugins

```crystal
require "gemma/plugins/determine_mime_type"
require "gemma/plugins/store_dimensions"

class ImageUploader < Gemma
  load_plugin(
    Gemma::Plugins::DetermineMimeType,
    analyzer: Gemma::Plugins::DetermineMimeType::Tools::File
  )

  load_plugin(
    Gemma::Plugins::StoreDimensions,
    analyzer: Gemma::Plugins::StoreDimensions::Tools::FastImage
  )

  finalize_plugins!
end

# Now metadata includes width/height
image.metadata["width"]   # => 1920
image.metadata["height"]  # => 1080
image.metadata["mime_type"]  # => "image/jpeg"
```

## Form Integration

### ECR Template

```erb
<form action="/users" method="post" enctype="multipart/form-data">
  <div class="form-group">
    <label for="avatar">Avatar</label>
    <input type="file" name="avatar" id="avatar" accept="image/*">
  </div>

  <% if @user.avatar %>
    <div class="current-avatar">
      <img src="<%= @user.avatar_url %>" alt="Current avatar">
      <label>
        <input type="checkbox" name="remove_avatar" value="1">
        Remove avatar
      </label>
    </div>
  <% end %>

  <button type="submit">Save</button>
</form>
```

### Controller Handling

```crystal
class UsersController < ApplicationController
  def update
    user = User.find!(params["id"])

    # Handle file upload
    if file = params.files["avatar"]?
      user.avatar = file.file
    end

    # Handle removal
    if params["remove_avatar"]? == "1"
      user.avatar = nil
    end

    if user.save
      redirect_to "/users/#{user.id}"
    else
      render "users/edit.ecr"
    end
  end
end
```

## Direct Uploads

For large files, upload directly to storage:

```crystal
# Controller
def presign
  # Generate presigned URL for direct S3 upload
  storage = Gemma.find_storage("cache").as(Gemma::Storage::S3)

  # Return presigned URL to client
  json({
    url:    storage.presigned_url(key),
    fields: storage.presigned_fields(key)
  })
end

def create
  user = User.new(user_params)

  # Accept cached file data from client
  if cached_data = params["avatar_data"]?
    user.avatar = JSON.parse(cached_data).as_h
  end

  user.save
end
```

## Best Practices

### 1. Always Use `JSON::Any?` Column Type

```crystal
# Correct
column avatar_data : JSON::Any?

# Wrong - will fail
column avatar_data : String?
```

### 2. Check for Attachment Before Accessing URL

```crystal
# Safe
url = user.avatar_url if user.avatar

# Or use the helper that returns nil
url = user.avatar_url  # => nil if no attachment
```

### 3. Clean Up Orphaned Files

```crystal
# Files are automatically deleted on destroy
user.destroy  # Avatar file is deleted

# For manual cleanup
user.avatar.try(&.delete)
user.update!(avatar_data: nil)
```

### 4. Use Appropriate Storage per Environment

```crystal
Gemma.configure do |config|
  if ENV["AMBER_ENV"] == "production"
    config.storages["store"] = Gemma::Storage::S3.new(...)
  else
    config.storages["store"] = Gemma::Storage::FileSystem.new("uploads")
  end
end
```


---

## Storage Backends

Canonical page: https://amberframework.org/docs/v2/guides/uploads/storage

# Storage Backends

> **Preview ecosystem guide:** Gemma is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

Gemma supports multiple storage backends for flexibility across different environments. All storages implement the same interface, allowing you to switch backends without changing application code.

## Where the examples go

Storage construction and Gemma-wide configuration belong in
`config/application.cr`, which the released V2 template loads directly. Direct
upload, URL, and metadata operations belong in the controller, job, service, or
spec that owns the file operation. Test-only memory storage belongs in
`spec/spec_helper.cr`. Directory trees on this page describe runtime output,
not source files to create by hand.

## Configuration

**File: `config/application.cr` — append this setup after `require "amber"`.
Keep one `Gemma.configure` block and extend it as storage needs grow.**

```crystal
require "gemma"

Gemma.configure do |config|
  # Temporary storage (for uploads in progress)
  config.storages["cache"] = Gemma::Storage::FileSystem.new(
    "uploads",
    prefix: "cache"
  )

  # Permanent storage
  config.storages["store"] = Gemma::Storage::FileSystem.new("uploads")
end
```

## FileSystem Storage

Store files on the local filesystem. Best for development and simple deployments.

### Basic Configuration

```crystal
Gemma::Storage::FileSystem.new(
  "uploads"  # Base directory
)
```

### Full Configuration

```crystal
Gemma::Storage::FileSystem.new(
  "uploads",                    # Base directory
  prefix: "attachments",        # Subdirectory prefix
  permissions: 0o644,           # File permissions (default)
  directory_permissions: 0o755, # Directory permissions (default)
  clean: true                   # Auto-clean empty directories (default)
)
```

### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `directory` | String | Required | Base directory for file storage |
| `prefix` | String? | `nil` | Subdirectory within base directory |
| `permissions` | Int | `0o644` | UNIX permissions for files |
| `directory_permissions` | Int | `0o755` | UNIX permissions for directories |
| `clean` | Bool | `true` | Remove empty parent directories on delete |

### URL Generation

```crystal
storage = Gemma::Storage::FileSystem.new("public/uploads", prefix: "files")

# URLs are relative paths
storage.url("abc123.jpg")
# => "/files/abc123.jpg"

# With host
storage.url("abc123.jpg", host: "https://cdn.example.com")
# => "https://cdn.example.com/files/abc123.jpg"
```

### Directory Structure

```
uploads/
├── cache/           # Temporary files (prefix: "cache")
│   └── abc123.jpg
└── files/           # Permanent files (prefix: "files")
    └── def456.pdf
```

## S3 Storage

Store files in Amazon S3 or S3-compatible services (DigitalOcean Spaces, MinIO, etc.).

### Basic Configuration

```crystal
require "gemma"

client = Awscr::S3::Client.new(
  region: "us-east-1",
  aws_access_key: ENV["AWS_ACCESS_KEY_ID"],
  aws_secret_key: ENV["AWS_SECRET_ACCESS_KEY"]
)

Gemma::Storage::S3.new(
  bucket: "my-app-uploads",
  client: client
)
```

### Full Configuration

```crystal
storage = Gemma::Storage::S3.new(
  bucket: "my-app-uploads",
  client: client,
  prefix: "attachments",        # Key prefix in bucket
  public: false,                # Set public ACL on upload
  upload_options: {             # Default upload options
    "x-amz-acl" => "private",
    "Cache-Control" => "max-age=31536000"
  }
)
```

### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `bucket` | String | Required | S3 bucket name |
| `client` | Awscr::S3::Client | Required | S3 client instance |
| `prefix` | String? | `nil` | Key prefix for all objects |
| `public` | Bool | `false` | Make uploads publicly readable |
| `upload_options` | Hash | `{}` | Default headers for uploads |

### S3-Compatible Services

#### DigitalOcean Spaces

```crystal
client = Awscr::S3::Client.new(
  region: "nyc3",
  aws_access_key: ENV["SPACES_ACCESS_KEY"],
  aws_secret_key: ENV["SPACES_SECRET_KEY"],
  endpoint: "https://nyc3.digitaloceanspaces.com"
)

storage = Gemma::Storage::S3.new(
  bucket: "my-space",
  client: client,
  public: true  # Spaces URLs are typically public
)
```

#### MinIO

```crystal
client = Awscr::S3::Client.new(
  region: "us-east-1",
  aws_access_key: ENV["MINIO_ACCESS_KEY"],
  aws_secret_key: ENV["MINIO_SECRET_KEY"],
  endpoint: "http://localhost:9000"
)

storage = Gemma::Storage::S3.new(
  bucket: "uploads",
  client: client
)
```

### URL Generation

S3 storage generates presigned URLs:

```crystal
# Presigned URL (default, time-limited)
storage.url("abc123.jpg")
# => "https://bucket.s3.amazonaws.com/abc123.jpg?X-Amz-..."

# For public buckets, you may want direct URLs
# Configure your application to generate these
```

### Public Access

```crystal
# Make all uploads public
storage = Gemma::Storage::S3.new(
  bucket: "public-assets",
  client: client,
  public: true  # Sets x-amz-acl: public-read
)

# Or per-upload via upload_options
storage.upload(file, "key", upload_options: {"x-amz-acl" => "public-read"})
```

## Memory Storage

In-memory storage for testing. Files are not persisted.

```crystal
Gemma::Storage::Memory.new
```

### Testing Configuration

```crystal
# spec/spec_helper.cr
Gemma.configure do |config|
  config.storages["cache"] = Gemma::Storage::Memory.new
  config.storages["store"] = Gemma::Storage::Memory.new
end
```

## Environment-Based Configuration

Configure different storages per environment:

**File: `config/application.cr` — replace the earlier `Gemma.configure` block
with this environment-aware version; do not define both.**

```crystal
require "gemma"

Gemma.configure do |config|
  # Cache storage (same for all environments)
  config.storages["cache"] = Gemma::Storage::FileSystem.new(
    "uploads",
    prefix: "cache"
  )

  # Store storage (varies by environment)
  case ENV["AMBER_ENV"]?
  when "production"
    client = Awscr::S3::Client.new(
      region: ENV["AWS_REGION"],
      aws_access_key: ENV["AWS_ACCESS_KEY_ID"],
      aws_secret_key: ENV["AWS_SECRET_ACCESS_KEY"]
    )

    config.storages["store"] = Gemma::Storage::S3.new(
      bucket: ENV["S3_BUCKET"],
      client: client,
      prefix: "uploads"
    )

  when "test"
    config.storages["store"] = Gemma::Storage::Memory.new

  else # development
    config.storages["store"] = Gemma::Storage::FileSystem.new(
      "uploads",
      prefix: "store"
    )
  end
end
```

## Storage Interface

All storages implement these methods:

```crystal
# Upload a file
storage.upload(io, "path/to/file.jpg")

# Check if file exists
storage.exists?("path/to/file.jpg")  # => true/false

# Get file URL
storage.url("path/to/file.jpg")  # => "https://..."

# Open file for reading
storage.open("path/to/file.jpg")  # => IO

# Delete file
storage.delete("path/to/file.jpg")

# Get full path/key
storage.path("path/to/file.jpg")  # => "uploads/path/to/file.jpg"
```

## Direct Usage

You can use storages directly without models:

```crystal
# Upload file
storage = Gemma.find_storage("store")
storage.upload(File.open("document.pdf"), "documents/report.pdf")

# Or via Gemma class
uploaded_file = Gemma.upload(File.open("photo.jpg"), "store")

# Access the file
uploaded_file.url       # URL to file
uploaded_file.exists?   # Check existence
uploaded_file.delete    # Remove file
```

## Custom Metadata

Pass metadata during upload:

```crystal
Gemma.upload(
  file,
  "store",
  metadata: {
    "filename" => "report.pdf",
    "mime_type" => "application/pdf",
    "size" => file.size.to_s
  }
)
```

For S3, metadata is used for Content-Disposition:

```crystal
# Sets Content-Disposition: inline; filename="report.pdf"
storage.upload(
  file,
  "key",
  metadata: {"filename" => "report.pdf"}
)
```

## Best Practices

### 1. Separate Cache and Store

Always configure both storages:

```crystal
config.storages["cache"] = ...  # Temporary uploads
config.storages["store"] = ...  # Permanent storage
```

### 2. Use Environment Variables

Never hardcode credentials:

```crystal
client = Awscr::S3::Client.new(
  region: ENV["AWS_REGION"],
  aws_access_key: ENV["AWS_ACCESS_KEY_ID"],
  aws_secret_key: ENV["AWS_SECRET_ACCESS_KEY"]
)
```

### 3. Set Appropriate Permissions

For FileSystem, restrict access:

```crystal
Gemma::Storage::FileSystem.new(
  "uploads",
  permissions: 0o600,           # Owner read/write only
  directory_permissions: 0o700  # Owner full access only
)
```

### 4. Configure CDN for Production

Serve files through a CDN:

```crystal
# In production, prefix URLs with CDN
def avatar_cdn_url
  return nil unless avatar

  if ENV["AMBER_ENV"] == "production"
    "https://cdn.example.com#{avatar_url}"
  else
    avatar_url
  end
end
```

### 5. Clean Up Cache Periodically

Cached files should be temporary. Clean them periodically:

```crystal
# Cron job or scheduled task
Dir.glob("uploads/cache/**/*").each do |path|
  if File.file?(path) && File.info(path).modification_time < 1.day.ago
    File.delete(path)
  end
end
```


---

## File Validation

Canonical page: https://amberframework.org/docs/v2/guides/uploads/validation

# File Validation

> **Preview ecosystem guide:** Gemma is not part of the Amber 2.0.0-beta.2
> core web-app release gate. Its package version, API, and platform support may
> change independently. Confirm a compatible official release before adding it
> to an application.

## Where the examples go

Attachment validation declarations, conditions, and custom validator methods
belong in the matching Grant model under `src/models/`. Analyzer and plugin
configuration belongs in `config/application.cr`. Error rendering belongs in
the matching ECR file under `src/views/`. Virus scanning and expensive file
inspection belong in a dedicated job or service after inexpensive limits have
run.

Gemma provides validation helpers for Grant models to ensure uploaded files meet your requirements.

## Setup

Include the `AttachmentValidators` module alongside `Attachable`:

```crystal
require "gemma/grant"

class User < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column id : Int64, primary: true
  column avatar_data : JSON::Any?

  has_one_attached :avatar

  # Add validations
  validate_file_size_of :avatar, maximum: 5.megabytes
  validate_content_type_of :avatar, accept: ["image/jpeg", "image/png", "image/gif"]
end
```

## File Size Validation

Limit the size of uploaded files:

```crystal
# Maximum size only
validate_file_size_of :avatar, maximum: 5.megabytes

# Minimum size only
validate_file_size_of :document, minimum: 1.kilobyte

# Both minimum and maximum
validate_file_size_of :video, minimum: 100.kilobytes, maximum: 100.megabytes

# Custom error message
validate_file_size_of :avatar,
  maximum: 2.megabytes,
  message: "must be smaller than 2MB"
```

### Size Helpers

Crystal provides convenient size methods:

```crystal
1.kilobyte   # 1024 bytes
1.megabyte   # 1024 * 1024 bytes
1.gigabyte   # 1024 * 1024 * 1024 bytes

# Or use raw bytes
validate_file_size_of :avatar, maximum: 5_242_880  # 5MB in bytes
```

## Content Type Validation

Restrict allowed file types:

### Accept List

```crystal
# Single type
validate_content_type_of :avatar, accept: "image/jpeg"

# Multiple types
validate_content_type_of :avatar, accept: ["image/jpeg", "image/png", "image/gif"]

# Wildcard matching
validate_content_type_of :document, accept: ["application/pdf", "image/*"]
```

### Reject List

```crystal
# Block specific types
validate_content_type_of :upload, reject: ["application/x-executable", "application/x-msdownload"]

# Block category with wildcard
validate_content_type_of :document, reject: "video/*"
```

### Custom Message

```crystal
validate_content_type_of :avatar,
  accept: ["image/jpeg", "image/png"],
  message: "must be a JPEG or PNG image"
```

### Common Content Types

| Category | Types |
|----------|-------|
| Images | `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/svg+xml` |
| Documents | `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.*` |
| Video | `video/mp4`, `video/webm`, `video/quicktime` |
| Audio | `audio/mpeg`, `audio/wav`, `audio/ogg` |
| Archives | `application/zip`, `application/x-tar`, `application/gzip` |

## Presence Validation

Require an attachment to be present:

```crystal
class Profile < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column photo_data : JSON::Any?
  has_one_attached :photo

  # Photo is required
  validate_presence_of :photo

  # Custom message
  validate_presence_of :photo, message: "Please upload a profile photo"
end
```

## Dimension Validation

Validate image dimensions (requires StoreDimensions plugin):

```crystal
require "fastimage"
require "gemma/plugins/store_dimensions"

class ImageUploader < Gemma
  load_plugin(
    Gemma::Plugins::StoreDimensions,
    analyzer: Gemma::Plugins::StoreDimensions::Tools::FastImage
  )
  finalize_plugins!
end

class Photo < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column image_data : JSON::Any?
  has_one_attached :image, uploader: ImageUploader

  # Exact dimensions
  validate_dimensions_of :image, width: 800, height: 600

  # Range of dimensions
  validate_dimensions_of :image,
    width: 100..2000,
    height: 100..2000

  # Only width constraint
  validate_dimensions_of :image, width: 800..1920

  # Only height constraint
  validate_dimensions_of :image, height: 600..1080
end
```

## Collection Size Validation

For `has_many_attached`, validate the number of files:

```crystal
class Post < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column images_data : JSON::Any?
  has_many_attached :images

  # Require at least one image
  validate_collection_size_of :images, minimum: 1

  # Maximum 10 images
  validate_collection_size_of :images, maximum: 10

  # Between 1 and 5 images
  validate_collection_size_of :images, minimum: 1, maximum: 5

  # Custom message
  validate_collection_size_of :images,
    maximum: 5,
    message: "You can upload at most 5 images"
end
```

## Combining Validations

Apply multiple validations to the same attachment:

```crystal
class Document < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column file_data : JSON::Any?
  has_one_attached :file

  # Must be present
  validate_presence_of :file

  # Size between 1KB and 10MB
  validate_file_size_of :file,
    minimum: 1.kilobyte,
    maximum: 10.megabytes

  # Must be PDF or Word document
  validate_content_type_of :file,
    accept: [
      "application/pdf",
      "application/msword",
      "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
    ]
end
```

## Conditional Validation

Use standard Grant validation conditions:

```crystal
class User < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column avatar_data : JSON::Any?
  column is_premium : Bool = false

  has_one_attached :avatar

  # Premium users can upload larger avatars
  validate :avatar_size_for_user_type

  private def avatar_size_for_user_type
    return unless avatar

    max_size = is_premium ? 10.megabytes : 2.megabytes

    if (size = avatar.size) && size > max_size
      errors.add(:avatar, "is too large for your account type")
    end
  end
end
```

## Custom Validators

Create custom validation logic:

```crystal
class Photo < Grant::Base
  include Gemma::Grant::Attachable

  column image_data : JSON::Any?
  has_one_attached :image

  validate :image_aspect_ratio

  private def image_aspect_ratio
    return unless image

    width = image.metadata["width"]?.try(&.to_i)
    height = image.metadata["height"]?.try(&.to_i)

    return unless width && height

    ratio = width.to_f / height.to_f

    # Require 16:9 aspect ratio (with tolerance)
    unless (1.7..1.8).includes?(ratio)
      errors.add(:image, "must have a 16:9 aspect ratio")
    end
  end
end
```

### Virus Scanning

```crystal
class Upload < Grant::Base
  include Gemma::Grant::Attachable

  column file_data : JSON::Any?
  has_one_attached :file

  validate :scan_for_viruses

  private def scan_for_viruses
    return unless file && file_changed?

    file.download do |tempfile|
      result = `clamscan --no-summary #{tempfile.path}`
      status = $?.exit_code

      if status != 0
        errors.add(:file, "failed virus scan")
      end
    end
  end
end
```

## Error Messages

Access validation errors:

```crystal
user = User.new(name: "Alice")
user.avatar = large_file

unless user.valid?
  user.errors[:avatar].each do |error|
    puts error  # => "is too large (maximum is 5242880 bytes)"
  end
end
```

### Display in Views

```ecr
<% if @user.errors[:avatar].any? %>
  <div class="alert alert-danger">
    <% @user.errors[:avatar].each do |error| %>
      <p>Avatar <%= error %></p>
    <% end %>
  </div>
<% end %>
```

## MIME Type Detection

For accurate content type validation, use the DetermineMimeType plugin:

```crystal
require "gemma/plugins/determine_mime_type"

class SecureUploader < Gemma
  load_plugin(
    Gemma::Plugins::DetermineMimeType,
    analyzer: Gemma::Plugins::DetermineMimeType::Tools::File
  )
  finalize_plugins!
end

class Document < Grant::Base
  include Gemma::Grant::Attachable
  include Gemma::Grant::AttachmentValidators

  column file_data : JSON::Any?
  has_one_attached :file, uploader: SecureUploader

  # Now validates against actual file content, not just extension
  validate_content_type_of :file, accept: "application/pdf"
end
```

### Analyzer Options

| Analyzer | Description |
|----------|-------------|
| `File` | Uses system `file` command (most accurate) |
| `Mime` | Uses Crystal's `MIME.from_filename` |
| `ContentType` | Uses HTTP Content-Type header (least secure) |

## Best Practices

### 1. Always Validate Content Type

Don't trust file extensions alone:

```crystal
# Use File analyzer for security
load_plugin(
  Gemma::Plugins::DetermineMimeType,
  analyzer: Gemma::Plugins::DetermineMimeType::Tools::File
)

validate_content_type_of :upload, accept: [...]
```

### 2. Set Reasonable Size Limits

Prevent resource exhaustion:

```crystal
# Avatars: 2-5 MB
validate_file_size_of :avatar, maximum: 5.megabytes

# Documents: 10-50 MB
validate_file_size_of :document, maximum: 50.megabytes

# Videos: Set based on your infrastructure
validate_file_size_of :video, maximum: 500.megabytes
```

### 3. Validate Before Processing

Check files before expensive operations:

```crystal
class Video < Grant::Base
  validate_content_type_of :file, accept: "video/*"
  validate_file_size_of :file, maximum: 500.megabytes

  after_save :transcode_video

  private def transcode_video
    # Only runs if validations pass
    # Safe to process the file
  end
end
```

### 4. Provide Helpful Error Messages

Guide users to fix issues:

```crystal
validate_file_size_of :avatar,
  maximum: 5.megabytes,
  message: "must be smaller than 5MB. Try compressing your image."

validate_content_type_of :avatar,
  accept: ["image/jpeg", "image/png"],
  message: "must be a JPEG or PNG file. Other formats are not supported."
```


---

## Basic View Helpers

Canonical page: https://amberframework.org/docs/v2/guides/views/basic-view-helpers

# Basic View Helpers

The [Jasper::Helpers](https://github.com/amberframework/jasper-helpers) library provides a common set of helper methods that can simplify the development of the views.

## Links

A `link_to` helper is available:

```text
== link_to "Home", "/"
```

Produces the following HTML

```markup
<a href="/">Home</a>
```

## Buttons

A `button_to` helper is available:

```text
== button_to "Logout", "/logout"
```

Produces the following HTML

```markup
<form action="/logout" class="button" method="post">
  <button type="submit">Logout</button>
</form>
```

For more complex forms, see section below.

## Forms

The form helpers listed below are very basic helpers that are included by default. For a more complete form building experience we strongly recommend using [FormBuilder.cr](form-builder.md)

The following methods provide simple HTML form elements:

* `form`
* `text_field`
* `label`
* `hidden_field`
* `select_field`
* `text_area`
* `check_box`
* `submit`

Use `amber generate scaffold [Resource] [field:type] ...` to get the most up-to-date examples of using helpers for resources.

### form

```text
== form(action: "/posts", method: :post) do
  == csrf_tag
  == submit("Create Post")

/ When using `method: :patch`, it add the hidden '_method' field for you
== form(action: "/posts", method: :patch) do
  == csrf_tag
  == submit("Update Post")
```

Produces the following HTML

```markup
<form action="/posts" method="post">
  <input type="hidden" name="<csrf-name-here>" value="<csrf-token-here>" />
  <input type="submit" value="Create Post" id="create_post">
</form>

<form action="/posts" method="post">
  <input type="hidden" name="_method" id="_method" value="patch">
  <input type="hidden" name="<csrf-name-here>" value="<csrf-token-here>" />
  <input type="submit" value="Update Post" id="update_post">
</form>
```

### text\_field

```text
== text_field name: "title", value: "", placeholder: "Title"
```

Produces the following HTML

```markup
<input type="text" name="title" id="title" value="" placeholder="Title">
```

### label

```text
== label :title
```

Produces the following HTML

```markup
<label for="title" id="title_label">Title</label>
```

### text\_area

```text
== text_area name: "body", content: "", placeholder: "Body", size: "30x10"
```

Produces the following HTML

```markup
<textarea name="body" id="body" placeholder="Body" cols="30" rows="10"></textarea>
```

### hidden\_field

```text
== hidden_field name: "secret", content: "Super Secret"
```

Produces the following HTML

```markup
<input type="hidden" name="secret" id="secret" content="Super Secret">
```

### select\_field

```text
/ Array of Arrays
== select_field name: "ranking", collection: [[1, "First"], [2, "Second"]], selected: 1

/ Array of Hashes
== select_field name: "ranking", collection: [{ 1 => "First" }, { 2 => "Second" }], selected: 1

/ Hash
== select_field name: "ranking", collection: { 1 => "First", 2 => "Second" }, selected: 1
```

All the previous code samples produce the following HTML

```markup
<select name="ranking">
  <option value="1" selected="selected">First</option>
  <option value="2">Second</option>
</select>
```

### check\_box

```text
== check_box(:published, checked: false)
```

Produces the following HTML

```markup
<input type="checkbox" name="published" id="published" value="1" checked="false">
```

### All together

```text
== form(action: "/posts", method: :post) do
  == csrf_tag

  == hidden_field name: "secret", content: "Super Secret"

  == label :title
  == text_field name: "title", value: "", placeholder: "Title"

  == label :body
  == text_area name: "body", content: "", placeholder: "Body", size: "30x10"

  == label :ranking
  == select_field name: "ranking", collection: [[1, "First"], [2, "Second"]], selected: 1

  == label(:published)
  == check_box(:published, checked: false)

  == submit("Create Post")
```

Produces the following HTML

```markup
<form action="/posts" method="post">
  <input type="hidden" name="<csrf-name-here>" value="<csrf-token-here>" />

  <input type="hidden" name="secret" id="secret" content="Super Secret">

  <label for="title" id="title_label">Title</label>
  <input type="text" name="title" id="title" value="" placeholder="Title">

  <label for="body" id="body_label">Body</label>
  <textarea name="body" id="body" placeholder="Body" cols="30" rows="10"></textarea>

  <label for="ranking" id="ranking_label">Ranking</label>
  <select name="ranking">
    <option value="1" selected="selected">First</option>
    <option value="2">Second</option>
  </select>

  <label for="published" id="published_label">Published</label>
  <input type="hidden" name="published" id="published" value="0"><input type="checkbox" name="published" id="published" value="1" checked="false">
  <input type="submit" value="Create Post" id="create_post">
</form>
```


---

## Channels

Canonical page: https://amberframework.org/docs/v2/guides/websockets/channels

# Channels

## Introduction

All messages are routed through channels, and channel topics are where clients subscribe to listen for new messages. Channels define 3 public methods that can be used:

* `handle_joined` - Called when a user joins a channel.
* `handle_message` - Called when a user sends a message to a channel.  A common message handler will simply rebroadcast the message to the other subscribers with `rebroadcast!` method.
* `handle_leave` - Called when a user leaves the channel.

## Example Usage

A channel can be generated by calling `amber g channel ChatRoom`.

```crystal
class ChatRoomChannel < Amber::Websockets::Channel

  # optional
  # Authorization can happen here  
  def handle_joined(client_socket, message)
    # channel join related functionality
    # if client_socket.session[:user_id] != message["payload"]["user_id"]
    #   client_socket.disconnect!
    # end
  end

  # required
  def handle_message(client_socket, msg)
    rebroadcast!(msg)
  end

  # optional
  def handle_leave(client_socket)
    # channel leave functionality    
  end
end
```

## What happens when a user joins?

The `handle_joined` method is invoked when a user lands on a web page that has a `new Amber.Socket` established through the JavaScript on it.
This method allows you to run any logic needed to authorize who should be connected to a channel. This is also a great 
way to send out a `#{name} has joined the chat!` message to all those currently listening to the channel.

## How are messages broadcasted?

Whenever a user sends a message that is broadcasted through the JavaScript `channel.push` function, the `handle_message` method is invoked. 
Here the message is then rebroadcasted to all those who are connected to the channel. The message is then transmitted through the 
`channel.on('message_new')` listener in the JavaScript. Before the message gets broadcast, here is where you would want to insert records into your 
database, if you wanted to keep a history of messages sent or received.

## What happens when a user leaves?

When a user leaves the web page that currently has an established socket connection, the connection breaks and triggers a message to be sent 
on the servers side. The `handle_leave` method handles this in the channels class. Here is where a message such as `#{name} has left the chat!` could 
be sent out to all connected clients.


---

## Sockets

Canonical page: https://amberframework.org/docs/v2/guides/websockets/sockets

# Sockets

A client socket represents one WebSocket connection and maps topic patterns to
channel classes. Amber CLI V2 generates channels, while the socket boundary is
currently hand-authored.

**Run from: the application root.**

```bash
amber generate channel ChatRoom --topics=chat_room
```

**File: `src/sockets/chat_socket.cr` — create this socket struct, then ensure
the application requires `src/sockets/**` before routes compile.**

```crystal
struct ChatSocket < Amber::WebSockets::ClientSocket
  channel "chat_room:*", ChatRoomChannel

  def on_connect : Bool
    # `session`, `cookies`, and validated `params` are available here.
    !!session[:current_user_id]?
  end
end
```

**File: `config/routes.cr` — add the handshake route inside the existing
`routes :web` block.**

```crystal
Amber::Server.configure do
  routes :web do
    websocket "/chat", ChatSocket
  end
end
```

Return `false` from `on_connect` to reject the connection. Override
`on_disconnect`, `on_reconnect`, or `on_error` when the application needs
connection lifecycle behavior.

**File: the controller or service that owns the event, under `src/controllers/`
or `src/services/` — broadcast after the application operation succeeds.**

```crystal
ChatSocket.broadcast(
  "message",
  "chat_room:123",
  "message_new",
  {"message" => "A new visitor!"}
)
```

The V1 `amber g socket` shortcut is not a command in the standalone V2 CLI.
Create the socket struct explicitly, generate channels with `amber generate
channel`, and cover the handshake and authorization behavior with specs.


---

## JavaScript Client

Canonical page: https://amberframework.org/docs/v2/guides/websockets/javascript-client

# JavaScript Client

## Example Usage

For use with web based applications, a JavaScript library \(`amber.js`\) is included.

### Create a single connection to the server

The first step in instantiating a socket connection with the server through JavaScript, is by using the library 
provided by Amber. Place the route that was created on the server side in the `routes.cr` as the endpoint to which
the front end will connect to.

```javascript
let socket = new Amber.Socket('/chat')
socket.connect() # returns a promise
  .then(() => {})
```

### Join a channel

After the promise passes, include the following code in the function from within the `.then` that will be triggered.

```javascript
let channel = socket.channel('chat_room:123')
channel.join()
```

In the above example, `chat_room` is the channel that was created on line 2 of the `ChatSocket` struct. You can dynamically
create separate channels within the prefix of `chat_room:` by appending any character after the colon. This allows for the 
creation of chatrooms that will allow different clients to connect to it.

### Send events / messages

When you need to send a message after a user submits the form that includes their message, you'll want to push it 
out to the server for it to be rebroadcast to all connected clients.

```javascript
channel.push('message_new', { message: 'amber websockets are the bomb diggity!' })
```

### Subscribe to events / messages

After a message is sent, you'll need to have a listener that listens to a specific subject from within the socket channel.
For example, below the subject of `message_new` is being listened to from within the `chat_room:123` channel. You may also 
have a listener on the subject of `user_join` to notify active connections of a new user to the chatroom.

```javascript
channel.on('message_new', (message) => {
  // handle new message here
})

channel.on('user_join', (message) => {})
```


---

## Webpack to ESM Migration

Canonical page: https://amberframework.org/docs/v2/migration-guide/webpack-to-esm

# Migrating from Webpack to ESM

> **Two distinct paths:** Amber `2.0.0-beta.2` supports browser-native ESM,
> import maps, and locally served CSS and JavaScript without an additional
> shard. The Asset Pipeline steps later in this page are an optional ecosystem
> preview, not part of the beta web-app release gate. Confirm its current
> compatibility before adding it.

Amber 2.0 removes Webpack from the generated baseline. An application can move
its reviewed browser-ready CSS and JavaScript under `public/`, map local ES
modules in the ECR layout, and eliminate Node and npm when no remaining source
asset requires their build tools. Start with the supported [Import Maps
guide](../guides/assets/import-maps/) before deciding whether the separate
Asset Pipeline preview adds value.

## Where the examples go

Run migration commands from the application root. The supported baseline keeps
browser-ready JavaScript in `public/js/`, CSS in `public/css/`, and import-map
tags in `src/views/layouts/application.ecr`. The optional Asset Pipeline path
keeps editable JavaScript in `src/javascript/`, generated output in
`public/javascript/`, and loader configuration in `config/application.cr`,
which the released V2 template loads directly.

## Why Migrate?

| Aspect | Webpack | Optional Asset Pipeline preview |
|--------|---------|----------------|
| Build time | 10-60+ seconds | None |
| Configuration | Complex webpack.config.js | Simple Crystal code |
| Dependencies | npm, node_modules | CDN or local files |
| Debugging | Source maps required | Native browser tools |
| Hot reload | Requires HMR plugin | Built-in browser support |

## Optional Asset Pipeline preview path

The remaining steps evaluate `amberframework/asset_pipeline`. They are not
required for the supported local-module pattern above.

### 1. Add Asset Pipeline Shard

```yaml
# shard.yml
dependencies:
  asset_pipeline:
    github: amberframework/asset_pipeline
    version: ~> 0.36.0
```

```bash
shards install
```

### 2. Configure Asset Pipeline

**File: `config/application.cr` — keep `require "amber"`, then append this
configuration.**

```crystal
require "asset_pipeline"

JS_SOURCE_PATH = Path["src/javascript"]
JS_OUTPUT_PATH = Path["public/javascript"]

FRONT_LOADER = AssetPipeline::FrontLoader.new(
  js_source_path: JS_SOURCE_PATH,
  js_output_path: JS_OUTPUT_PATH
) do |import_maps|
  import_map = AssetPipeline::ImportMap.new("application", Path["/javascript"])

  # Add your dependencies here (see Step 4)

  import_maps << import_map
end
```

### 3. Update Layout

**Before (Webpack):**
```slang
doctype html
html
  head
    title My App
    / Webpack bundle
    script src="/dist/bundle.js"
  body
    == content
```

**After (Asset Pipeline):**
```ecr
<!doctype html>
<html>
  <head>
    <title>My App</title>
    <%= FRONT_LOADER.render_import_map_tag %>
  </head>
  <body>
    <%= content %>
    <%= FRONT_LOADER.render_stimulus_initialization_script %>
  </body>
</html>
```

### 4. Migrate Dependencies

Find your npm dependencies and replace with CDN imports:

**package.json (before):**
```json
{
  "dependencies": {
    "@hotwired/stimulus": "^3.2.2",
    "jquery": "^3.7.1",
    "lodash": "^4.17.21",
    "chart.js": "^4.4.0"
  }
}
```

**Asset Pipeline (after):**
```crystal
# config/application.cr
import_map.add_import(
  "@hotwired/stimulus",
  "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm",
  preload: true
)

import_map.add_import(
  "jquery",
  "https://cdn.jsdelivr.net/npm/jquery@3.7.1/+esm"
)

import_map.add_import(
  "lodash",
  "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm"
)

import_map.add_import(
  "chart.js",
  "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/+esm"
)
```

### 5. Migrate JavaScript Files

Move and update your JavaScript:

**Before (`src/assets/javascripts/application.js`):**
```javascript
// Webpack imports
import { Application } from "@hotwired/stimulus"
import HelloController from "./controllers/hello_controller"

const app = Application.start()
app.register("hello", HelloController)
```

**After (`src/javascript/controllers/hello_controller.js`):**
```javascript
// Native ESM - same syntax, no bundler
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["output"]

  greet() {
    this.outputTarget.textContent = "Hello!"
  }
}
```

The Asset Pipeline handles Stimulus initialization automatically.

### 6. Register Controllers

```crystal
# config/application.cr
import_map = AssetPipeline::ImportMap.new("application", Path["/javascript"])

import_map.add_import(
  "@hotwired/stimulus",
  "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm",
  preload: true
)

# Register your Stimulus controllers
import_map.add_import("HelloController", "controllers/hello_controller.js")
import_map.add_import("DropdownController", "controllers/dropdown_controller.js")
import_map.add_import("ModalController", "controllers/modal_controller.js")

# Or auto-discover controllers
Dir.glob("#{JS_SOURCE_PATH}/controllers/*_controller.js").each do |file|
  name = File.basename(file, ".js")
    .split("_")
    .map(&.capitalize)
    .join
    .gsub("Controller", "Controller")  # Ensure "Controller" suffix

  import_map.add_import(name, "controllers/#{File.basename(file)}")
end
```

### 7. Clean Up Webpack

```bash
# Remove Webpack files
rm webpack.config.js
rm -rf node_modules
rm package.json
rm package-lock.json
rm yarn.lock

# Remove Webpack from .gitignore entries
# Edit .gitignore to remove node_modules/, dist/, etc.
```

## Common Migration Patterns

### jQuery

**Webpack:**
```javascript
import $ from "jquery"

$(document).ready(() => {
  $(".dropdown").dropdown()
})
```

**ESM:**
```crystal
# config/application.cr
import_map.add_import("jquery", "https://cdn.jsdelivr.net/npm/jquery@3.7.1/+esm")
```

```javascript
// src/javascript/app.js
import $ from "jquery"

document.addEventListener("DOMContentLoaded", () => {
  $(".dropdown").dropdown()
})
```

### React/Vue/Angular

For complex SPA frameworks, you have options:

**Option 1: Use ESM builds from CDN**
```crystal
# React
import_map.add_import("react", "https://esm.sh/react@18")
import_map.add_import("react-dom", "https://esm.sh/react-dom@18")

# Vue
import_map.add_import("vue", "https://unpkg.com/vue@3/dist/vue.esm-browser.js")
```

**Option 2: Keep Webpack for SPA, use ESM for Amber pages**

You can run both systems:
```crystal
# For Amber-rendered pages
FRONT_LOADER.render_import_map_tag

# For SPA routes, continue serving Webpack bundle
script src="/spa/dist/bundle.js"
```

### Lodash

**Webpack:**
```javascript
import _ from "lodash"
```

**ESM (use lodash-es for tree-shaking):**
```crystal
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm")
```

```javascript
// Import specific functions for smaller bundles
import { debounce, throttle } from "lodash"
```

### TypeScript

TypeScript requires compilation. Options:

1. **Pre-compile TypeScript** to JavaScript, serve ESM
2. **Use esbuild** for fast TypeScript compilation
3. **Keep minimal Webpack** for TypeScript only

```bash
# Option 2: esbuild
npm install -g esbuild

# Compile TypeScript to ESM
esbuild src/typescript/*.ts --outdir=public/javascript --format=esm
```

## Directory Structure Migration

**Before (Webpack):**
```
my_app/
├── src/
│   └── assets/
│       └── javascripts/
│           ├── application.js
│           └── controllers/
├── node_modules/
├── webpack.config.js
├── package.json
└── public/
    └── dist/
        └── bundle.js
```

**After (Asset Pipeline):**
```
my_app/
├── src/
│   └── javascript/
│       ├── controllers/
│       │   ├── hello_controller.js
│       │   └── dropdown_controller.js
│       ├── services/
│       │   └── api_service.js
│       └── utils/
│           └── helpers.js
├── config/
│   └── initializers/
│       └── assets.cr
└── public/
    └── javascript/
        └── (served directly - no build step)
```

## Handling CSS

Asset Pipeline focuses on JavaScript. For CSS, continue using your existing approach:

**Option 1: Plain CSS**
```ecr
<link rel="stylesheet" href="/css/application.css">
```

**Option 2: Sass compilation**
```bash
# Compile Sass separately
sass src/stylesheets:public/css --watch
```

**Option 3: Tailwind CSS**
```bash
# Tailwind CLI (no npm required)
tailwindcss -i src/css/input.css -o public/css/output.css --watch
```

## Testing the Migration

### 1. Check Browser Console

Open browser DevTools and verify:
- No 404 errors for JavaScript files
- Import map loads correctly
- Stimulus controllers connect

### 2. Verify Functionality

Test interactive features:
- Form submissions
- Dropdowns/modals
- Dynamic content loading
- WebSocket connections

### 3. Performance Comparison

```bash
# Before (Webpack build time)
time npm run build

# After (no build!)
# Just save and refresh
```

## Rollback Plan

If issues arise, you can run both systems temporarily:

```crystal
# config/application.cr
FRONT_LOADER = AssetPipeline::FrontLoader.new(...)

# In layout, conditionally use one or the other
if use_new_assets?
  FRONT_LOADER.render_import_map_tag
else
  # Fall back to Webpack bundle
  raw %(<script src="/dist/bundle.js"></script>)
end
```

## Troubleshooting

### "Module not found" errors

Ensure the CDN URL uses ESM format:
```crystal
# Wrong - CommonJS format
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/lodash/lodash.js")

# Correct - ESM format
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm")
```

### Import map not loading

Import map must be in `<head>` before any module scripts:
```ecr
<head>
  <%= FRONT_LOADER.render_import_map_tag %>
  <!-- other head content -->
</head>
```

### Stimulus controllers not connecting

Verify controller naming:
```crystal
# Import name must end with "Controller"
import_map.add_import("HelloController", "controllers/hello_controller.js")  # Correct
import_map.add_import("hello", "controllers/hello_controller.js")            # Wrong
```

### CORS errors

For CDN resources, most major CDNs handle CORS. If self-hosting:
```nginx
location /javascript/ {
  add_header Access-Control-Allow-Origin *;
}
```


---

## Granite to Grant Migration

Canonical page: https://amberframework.org/docs/v2/migration-guide/granite-to-grant

# Migrating from Granite to Grant

> **Preview migration path:** Grant is not included in the Amber
> `2.0.0-beta.2` core web template. Confirm a compatible official Grant release
> and its current API before beginning this migration; do not substitute a
> personal fork as an application default.

This guide is retained as evaluation material for teams considering a future
Granite-to-Grant migration.

## Why Grant?

| Feature | Granite | Grant |
|---------|---------|-------|
| Associations | Limited (has_many only) | Full (belongs_to, has_many, has_one, polymorphic) |
| Validations | Granite validation API | Built-in and custom Grant validators |
| Callbacks | Before/after save | Full lifecycle (create, update, destroy) |
| Query Interface | Basic where/find | Chainable scopes, joins, includes |
| Transactions | Manual | Built-in with savepoints |
| Encryption | None | Attribute encryption |
| Secure Tokens | None | has_secure_token, signed_id |

## Coexistence Strategy

Grant and Granite can coexist during migration:

```yaml
# shard.yml
dependencies:
  granite:
    github: amberframework/granite
    version: ~> 0.6.0
  grant:
    github: amberframework/grant
    version: ~> 0.3.0
```

Migrate models incrementally, starting with new features.

## Basic Model Migration

### Column Definitions

**Granite:**
```crystal
class User < Granite::Base
  connection pg
  table users

  column id : Int64, primary: true
  column email : String
  column name : String?
  column admin : Bool = false
  column created_at : Time?
  column updated_at : Time?
end
```

**Grant:**
```crystal
class User < Grant::Base
  column id : Int64, primary: true
  column email : String
  column name : String?
  column admin : Bool = false

  timestamps  # Automatically handles created_at and updated_at
end
```

### Key Differences

1. **No `connection` declaration** - Grant uses a global connection pool
2. **No `table` declaration** - Inferred from class name (configurable)
3. **`timestamps` macro** - Replaces manual timestamp columns
4. **Nullable by default** - Use `Type?` for nullable columns

## Connection Configuration

**Granite:**
```crystal
Granite::Connections << Granite::Adapter::Pg.new(
  name: "pg",
  url: ENV["DATABASE_URL"]
)
```

**Grant:**
```crystal
Grant::Connections.add(
  "primary",
  ENV["DATABASE_URL"]
)

# Or configure via environment
# Grant auto-detects DATABASE_URL
```

## Associations Migration

### has_many

**Granite:**
```crystal
class User < Granite::Base
  has_many :posts

  # Manual setup often required
  def posts
    Post.all("WHERE user_id = ?", id)
  end
end
```

**Grant:**
```crystal
class User < Grant::Base
  has_many :posts  # Just works!

  # Additional options available
  has_many :published_posts, Post, -> { where(published: true) }
  has_many :comments, through: :posts
end
```

### belongs_to

**Granite:** (manual)
```crystal
class Post < Granite::Base
  column user_id : Int64?

  def user
    User.find(user_id) if user_id
  end
end
```

**Grant:**
```crystal
class Post < Grant::Base
  column user_id : Int64

  belongs_to :user
end

post = Post.find!(1)
post.user  # => User instance, lazy loaded
```

### has_one

**Granite:** (manual)
```crystal
class User < Granite::Base
  def profile
    Profile.first("WHERE user_id = ?", id)
  end
end
```

**Grant:**
```crystal
class User < Grant::Base
  has_one :profile
end

user.profile  # => Profile or nil
```

### Polymorphic Associations

**Granite:** Not supported

**Grant:**
```crystal
class Comment < Grant::Base
  column commentable_id : Int64
  column commentable_type : String

  belongs_to :commentable, polymorphic: true
end

class Post < Grant::Base
  has_many :comments, as: :commentable
end

class Photo < Grant::Base
  has_many :comments, as: :commentable
end
```

## Validations Migration

### Basic Validations

**Granite:**
```crystal
class User < Granite::Base
  validate :email, "can't be blank" do |user|
    !user.email.nil? && !user.email.not_nil!.empty?
  end
end
```

**Grant:**
```crystal
class User < Grant::Base
  validates :email, presence: true
  validates :email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  validates :email, uniqueness: true
end
```

### Available Validators

| Granite | Grant |
|---------|-------|
| Manual blocks | `presence`, `absence`, `format`, `length`, `inclusion`, `exclusion`, `uniqueness`, `numericality`, `confirmation` |

### Custom Validations

**Granite:**
```crystal
class User < Granite::Base
  validate :custom_email_check do |user|
    # validation logic
  end
end
```

**Grant:**
```crystal
class User < Grant::Base
  validate :custom_email_check

  private def custom_email_check
    if email && !email.ends_with?("@company.com")
      errors.add(:email, "must be a company email")
    end
  end
end
```

## Callbacks Migration

**Granite:**
```crystal
class Post < Granite::Base
  before_save :set_slug

  def set_slug
    self.slug ||= title.downcase.gsub(" ", "-")
  end
end
```

**Grant:**
```crystal
class Post < Grant::Base
  before_save :set_slug
  before_create :set_published_at
  after_create :notify_subscribers
  after_destroy :cleanup_assets

  private def set_slug
    self.slug ||= title.downcase.gsub(" ", "-")
  end
end
```

### Available Callbacks

| Lifecycle | Granite | Grant |
|-----------|---------|-------|
| Create | before_save, after_save | before_create, after_create, around_create |
| Update | before_save, after_save | before_update, after_update, around_update |
| Save | before_save, after_save | before_save, after_save, around_save |
| Destroy | before_destroy, after_destroy | before_destroy, after_destroy, around_destroy |
| Validation | - | before_validation, after_validation |

## Query Interface Migration

### Finding Records

**Granite:**
```crystal
User.find(1)           # May return nil
User.find!(1)          # Raises on not found
User.first             # First record
User.all               # All records
```

**Grant:**
```crystal
User.find(1)           # Returns User?
User.find!(1)          # Raises RecordNotFound
User.first             # First record
User.last              # Last record
User.all               # ActiveRecord::Relation
```

### Where Clauses

**Granite:**
```crystal
User.all("WHERE email = ? AND active = ?", ["user@example.com", true])
```

**Grant:**
```crystal
User.where(email: "user@example.com", active: true)
User.where("email = ? AND active = ?", "user@example.com", true)
User.where(email: "user@example.com").where(active: true)  # Chainable
```

### Ordering and Limiting

**Granite:**
```crystal
User.all("ORDER BY created_at DESC LIMIT 10")
```

**Grant:**
```crystal
User.order(created_at: :desc).limit(10)
User.order(:name).first(5)
User.recent.limit(10)  # Using scope
```

### Scopes

**Granite:** Not supported natively

**Grant:**
```crystal
class Post < Grant::Base
  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc) }
  scope :by_author, ->(user : User) { where(user_id: user.id) }
end

Post.published.recent.limit(10)
Post.by_author(current_user).published
```

## CRUD Operations

### Create

**Granite:**
```crystal
user = User.new
user.email = "user@example.com"
user.save

# Or
User.create!(email: "user@example.com")
```

**Grant:**
```crystal
user = User.new(email: "user@example.com")
user.save

# Or
User.create!(email: "user@example.com")

# Build without save
user = User.build(email: "user@example.com")
```

### Update

**Granite:**
```crystal
user.email = "new@example.com"
user.save
```

**Grant:**
```crystal
user.email = "new@example.com"
user.save

# Or
user.update!(email: "new@example.com")

# Update multiple
User.where(role: "guest").update_all(role: "member")
```

### Destroy

**Granite:**
```crystal
user.destroy
```

**Grant:**
```crystal
user.destroy
user.destroy!  # Raises on failure

# Destroy multiple
User.where(inactive: true).destroy_all
```

## Transactions

**Granite:** (manual)
```crystal
Granite::Connections["pg"].transaction do |tx|
  user.save
  profile.save
end
```

**Grant:**
```crystal
Grant::Base.transaction do
  user.save!
  profile.save!
  # Automatically rolls back on exception
end

# Nested transactions with savepoints
Grant::Base.transaction do
  user.save!

  Grant::Base.transaction(requires_new: true) do
    # Savepoint - can fail without rolling back outer transaction
    risky_operation.save!
  rescue
    # Inner transaction rolled back, outer continues
  end
end
```

## Error Handling

**Granite:**
```crystal
unless user.save
  user.errors.each do |error|
    puts error
  end
end
```

**Grant:**
```crystal
unless user.save
  user.errors.full_messages.each do |message|
    puts message
  end

  # Access specific field errors
  user.errors[:email].each do |error|
    puts "Email #{error}"
  end
end

# Or use bang methods
begin
  user.save!
rescue Grant::RecordInvalid => e
  puts e.record.errors.full_messages
end
```

## Migration Checklist

### Per Model

- [ ] Update class inheritance (`Granite::Base` → `Grant::Base`)
- [ ] Remove `connection` and `table` declarations
- [ ] Replace manual timestamp columns with `timestamps`
- [ ] Convert associations to Grant syntax
- [ ] Migrate validations to declarative style
- [ ] Update callbacks to new lifecycle hooks
- [ ] Convert raw SQL queries to chainable interface
- [ ] Add scopes for common queries
- [ ] Update error handling code
- [ ] Run tests

### Application-Wide

- [ ] Update connection configuration
- [ ] Review transaction usage
- [ ] Update specs to use Grant factories/fixtures
- [ ] Run full test suite
- [ ] Performance test critical queries

## Running Both ORMs

During migration, you may need models to interact:

```crystal
# Grant model referencing Granite model
class Comment < Grant::Base
  column post_id : Int64

  def post
    # Manually fetch Granite model
    Post.find(post_id)
  end
end

# Or create a thin Grant wrapper
class PostGrant < Grant::Base
  self.table_name = "posts"

  column id : Int64, primary: true
  column title : String
  # ... mirror Granite columns
end
```

## Troubleshooting

### "undefined method" errors

Grant uses different method names. Common changes:
- `all("WHERE ...")` → `where(...)`
- `first("WHERE ...")` → `find_by(...)`
- Manual association methods → `has_many`/`belongs_to`

### Validation errors

Grant validations are more strict:
```crystal
# May need to handle nil differently
validates :email, presence: true  # Fails on nil
validates :name, presence: true, allow_nil: true  # Passes on nil
```

### Association loading

Grant associations are lazy-loaded by default:
```crystal
# N+1 query issue
users.each { |u| puts u.posts.size }

# Use eager loading
users = User.includes(:posts)
users.each { |u| puts u.posts.size }  # No N+1
```


---

## Redis to Adapters Migration

Canonical page: https://amberframework.org/docs/v2/migration-guide/redis-to-adapters

# Migrating from Redis to Adapters

Amber V2 removes Redis as a mandatory framework dependency. The framework ships
in-memory session and pub/sub adapters; it does **not** ship a first-party Redis
implementation. Applications that still need Redis must implement and register
adapters against the Amber interfaces.

This migration changes how Amber reaches the storage or message broker. It does
not require you to stop using Redis.

## Choose the target behavior

| Requirement | Suitable direction |
|---|---|
| Local development and tests | Built-in memory adapters |
| One application process where losing process-local state is acceptable | Built-in memory adapters after explicit verification |
| Sessions shared across processes or hosts | Registered external session adapter |
| WebSocket broadcasts shared across processes or hosts | Registered external pub/sub adapter |
| Existing Redis-backed production behavior | Custom Redis adapters or another verified shared backend |

The memory adapters are process-local. Do not use them as a silent replacement
for shared Redis state in a horizontally scaled deployment.

## Inventory the Amber 1.x contract

Before changing configuration, record:

- the session cookie name, signing or encryption behavior, expiration, and
  rotation rules;
- the Redis key and channel namespaces;
- the serialized session and pub/sub payload formats;
- whether users or broadcasts must survive a process restart;
- every application process that reads sessions or subscribes to broadcasts;
- cleanup jobs, Redis ACLs, TLS settings, and monitoring tied to the old keys.

Keep a deployable copy of the current configuration while the replacement is
tested.

## Built-in memory configuration

The clean V2 application selects the built-in adapters by name:

```yaml
# config/environments/development.yml
session:
  key: "my_app.session"
  store: "signed_cookie"
  adapter: "memory"
  expires: 3600

pubsub:
  adapter: "memory"
```

Use this path for development, tests, or a deployment whose process-local state
is an intentional constraint. Restart the application during testing to prove
that the resulting state loss is acceptable.

## Keep Redis through a custom session adapter

A shared session backend implements `Amber::Adapters::SessionAdapter`:

```crystal
abstract class Amber::Adapters::SessionAdapter
  abstract def get(session_id : String, key : String) : String?
  abstract def set(session_id : String, key : String, value : String) : Nil
  abstract def delete(session_id : String, key : String) : Nil
  abstract def destroy(session_id : String) : Nil
  abstract def exists?(session_id : String, key : String) : Bool
  abstract def keys(session_id : String) : Array(String)
  abstract def values(session_id : String) : Array(String)
  abstract def to_hash(session_id : String) : Hash(String, String)
  abstract def empty?(session_id : String) : Bool
  abstract def expire(session_id : String, seconds : Int32) : Nil
  abstract def batch_set(session_id : String, values : Hash(String, String)) : Nil
  abstract def batch(session_id : String, &block : Amber::Adapters::SessionBatchOperations ->) : Nil
end
```

Register the application implementation before Amber builds the session store:

```crystal
# config/application.cr
require "amber"
require "../src/adapters/redis_session_adapter"

Amber::Adapters::AdapterFactory.register_session_adapter("redis") do
  RedisSessionAdapter.new(redis_client)
end
```

Then select the registered name in the environment configuration:

```yaml
session:
  key: "my_app.session"
  store: "signed_cookie"
  adapter: "redis"
  expires: 86400
```

The [Session Adapters guide](../../guides/adapters/sessions/) documents the complete
interface and registration contract. Compile and contract-test the application
adapter against the exact Redis shard version it uses.

## Keep cross-process broadcasts through a custom pub/sub adapter

A shared message backend implements `Amber::Adapters::PubSubAdapter`:

```crystal
abstract class Amber::Adapters::PubSubAdapter
  abstract def publish(topic : String, sender_id : String, message : JSON::Any) : Nil
  abstract def subscribe(topic : String, &block : (String, JSON::Any) -> Nil) : Nil
  abstract def unsubscribe(topic : String) : Nil
  abstract def unsubscribe_all : Nil
  abstract def close : Nil
end
```

Register and select the application implementation:

```crystal
# config/application.cr
require "amber"
require "../src/adapters/redis_pubsub_adapter"

Amber::Adapters::AdapterFactory.register_pubsub_adapter("redis") do
  RedisPubSubAdapter.new(redis_client)
end
```

```yaml
pubsub:
  adapter: "redis"
```

The [PubSub Adapters guide](../../guides/adapters/pubsub/) covers registration and
multi-process behavior. Test with at least two application processes; a
single-process browser test cannot prove cross-process delivery.

## Preserve or retire existing sessions deliberately

Changing a session backend can invalidate every active session. Choose one of
these policies before deployment:

- preserve the existing Redis key namespace and serialization in the new
  adapter;
- deploy a temporary dual-read migration that moves a session after a
  successful old-format read;
- schedule a coordinated logout and communicate it as an intentional product
  change.

Do not assume that forcing every user to sign in again is harmless. Account
recovery, long-running work, carts, CSRF state, and administrative sessions may
make session loss operationally significant.

## Cutover sequence

1. Add the adapter implementation and its dependency without removing the old
   Redis configuration.
2. Contract-test every adapter method, expiration behavior, malformed payload,
   connection failure, and reconnect path.
3. Exercise login, logout, session rotation, and WebSocket broadcasts in a
   staging deployment that matches the production process count.
4. Apply the chosen active-session migration policy.
5. Switch the Amber configuration to the registered adapter name.
6. Monitor adapter errors, Redis connections, session failures, and broadcast
   delivery through the rollback window.

## Removing Redis after the cutover

Remove Redis only after confirming that no application process, job worker,
cache, rate limiter, session store, or pub/sub subscriber still uses it. Inspect
the shard dependencies, environment variables, deployment manifests, secrets,
monitoring, and infrastructure configuration before retiring the service.

Keep the previous configuration and deployment artifact available until the
replacement has passed its production verification window.

## Verification checklist

- [ ] Session create, read, update, delete, destroy, and expiration behavior pass.
- [ ] Login, logout, rotation, and invalid-cookie behavior pass.
- [ ] Restart behavior matches the chosen state policy.
- [ ] Broadcasts reach subscribers in a second application process.
- [ ] Redis authentication, TLS, ACLs, timeouts, and reconnect behavior are tested when Redis remains.
- [ ] The active-session migration or coordinated logout is documented.
- [ ] The previous configuration can be restored without a code rewrite.
