diff --git a/README.md b/README.md
index 1bb14397..dbafbf8f 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,9 @@ and [imbus AG](https://www.imbus.de/) over the last five years.
## Getting Started
- See the [tutorial](https://typelevel.org/grackle) and accompanying [demo](https://github.com/typelevel/grackle/tree/main/demo/src/main).
+- New to Grackle's mapping model? Start with [Mapping Concepts](https://typelevel.org/grackle/mappings.html) for a guided tour of
+ `ValueMapping`, `GenericMapping`, `CirceMapping`, `SqlMapping`, `ComposedMapping` and friends,
+ and how to pick the right one for your data source.
- Online Scaladoc is available [here](https://javadoc.io/doc/org.typelevel/grackle-core_2.13).
- Ask us anything the in **#grackle** channel on the Typelevel [discord server][grackle-dev].
@@ -43,25 +46,31 @@ To add Grackle to your project you should add the following to your `build.sbt`,
```scala
// Required: Scala 2.13/3.3+
-libraryDependencies += "org.typelevel" %% "grackle-core" % "0.26.0"
+libraryDependencies += "org.typelevel" %% "grackle-core" % "0.30.0"
// Optional: support for in-memory Json backend using circe
-libraryDependencies += "org.typelevel" %% "grackle-circe" % "0.26.0"
+libraryDependencies += "org.typelevel" %% "grackle-circe" % "0.30.0"
// Optional: support for in-memory generic Scala backend using shapeless
-libraryDependencies += "org.typelevel" %% "grackle-generic" % "0.26.0"
+libraryDependencies += "org.typelevel" %% "grackle-generic" % "0.30.0"
// Optional: support for Postgres backend via Doobie (JVM only)
-libraryDependencies += "org.typelevel" %% "grackle-doobie-pg" % "0.26.0"
+libraryDependencies += "org.typelevel" %% "grackle-doobie-pg" % "0.30.0"
// Optional: support for Postgres backend via Skunk
-libraryDependencies += "org.typelevel" %% "grackle-skunk" % "0.26.0"
+libraryDependencies += "org.typelevel" %% "grackle-skunk" % "0.30.0"
// Optional: support for Oracle backend via Doobie (JVM only)
-libraryDependencies += "org.typelevel" %% "grackle-doobie-oracle" % "0.26.0"
+libraryDependencies += "org.typelevel" %% "grackle-doobie-oracle" % "0.30.0"
// Optional: support for SQL Server backend via Doobie (JVM only)
-libraryDependencies += "org.typelevel" %% "grackle-doobie-mssql" % "0.26.0"
+libraryDependencies += "org.typelevel" %% "grackle-doobie-mssql" % "0.30.0"
+
+// Optional: support for H2 backend via Doobie (JVM only)
+libraryDependencies += "org.typelevel" %% "grackle-doobie-h2" % "0.30.0"
+
+// Optional: support for SQLite backend via Doobie (JVM only)
+libraryDependencies += "org.typelevel" %% "grackle-doobie-sqlite" % "0.30.0"
```
## Running tests for database backed mappings
diff --git a/docs/directory.conf b/docs/directory.conf
index afd633c9..bf10030d 100644
--- a/docs/directory.conf
+++ b/docs/directory.conf
@@ -2,5 +2,7 @@ laika.navigationOrder = [
index.md
tutorial
howto
+ mappings.md
+ pipeline.md
CONTRIBUTING.md
]
diff --git a/docs/tutorial/compiler-phases.md b/docs/howto/compiler-phases.md
similarity index 84%
rename from docs/tutorial/compiler-phases.md
rename to docs/howto/compiler-phases.md
index 2a08041a..664e87aa 100644
--- a/docs/tutorial/compiler-phases.md
+++ b/docs/howto/compiler-phases.md
@@ -26,6 +26,10 @@ in the `Elab` monad. `Elab` gives phases access to the schema, the current conte
definitions, and allows compilation to be aborted with one or more GraphQL errors via `Elab.failure`. A phase which
fails prevents the query from executing at all — the client receives an error response with no data.
+`transform` is not abstract: the inherited implementation walks the whole query algebra, maintaining the `Elab`
+context as it descends, so a phase overrides it for the node types it cares about and delegates the rest to
+`super.transform`.
+
This makes phases a natural place to enforce global policies on incoming queries. Grackle provides one such policy
phase out of the box: `QuerySizeValidator`.
@@ -45,7 +49,7 @@ _Depth_ is the number of nested selection levels in the query, and _width_ is th
selected. Both are computed after fragment spreads have been resolved, so a query cannot evade the limits by
factoring its selections into fragments.
-For example, with the Star Wars model from the previous chapter and the limits above, the query,
+For example, with the [Star Wars model](../tutorial/in-memory-model.md) and the limits above, the query,
```yaml
query {
@@ -84,8 +88,11 @@ exceeding both limits at once is reported as `"Query is too complex"`.
## Limitations
-Depth and width are syntactic measures: they are computed from the query text alone and know nothing about the size
+Depth and width are structural measures: they are computed from the query's shape and know nothing about the size
of the underlying data. In particular, width does not account for list sizes — a field yielding a thousand elements
contributes to the width just once. `QuerySizeValidator` is therefore a coarse first line of defence rather than a
complete cost model. Guarding against expensive list expansions requires taking field cardinalities and arguments
into account, which can be implemented as a custom phase following the same pattern.
+
+Introspection is not measured at all: an `Introspect` node contributes nothing to either figure, so a deeply
+nested `__schema` query passes whichever limits are configured.
diff --git a/docs/howto/directory.conf b/docs/howto/directory.conf
index 32f3f697..dbbd3805 100644
--- a/docs/howto/directory.conf
+++ b/docs/howto/directory.conf
@@ -1,4 +1,5 @@
laika.title = How-to Guides
laika.navigationOrder = [
interfaces-across-tables.md
+ compiler-phases.md
]
diff --git a/docs/index.md b/docs/index.md
index 9b7cd8e8..153d119e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -30,6 +30,9 @@ and [imbus AG](https://www.imbus.de/) over the last five years.
## Getting Started
- See the [tutorial](https://typelevel.org/grackle) and accompanying [demo](https://github.com/typelevel/grackle/tree/main/demo/src/main).
+- New to Grackle's mapping model? Start with [Mapping Concepts](https://typelevel.org/grackle/mappings.html) for a guided tour of
+ `ValueMapping`, `GenericMapping`, `CirceMapping`, `SqlMapping`, `ComposedMapping` and friends,
+ and how to pick the right one for your data source.
- Online Scaladoc is available [here](https://javadoc.io/doc/org.typelevel/grackle-core_2.13).
- Ask us anything the in **#grackle** channel on the Typelevel [discord server][grackle-dev].
@@ -56,6 +59,12 @@ libraryDependencies += "org.typelevel" %% "grackle-doobie-oracle" % "@VERSION@"
// Optional: support for SQL Server backend via Doobie (JVM only)
libraryDependencies += "org.typelevel" %% "grackle-doobie-mssql" % "@VERSION@"
+
+// Optional: support for H2 backend via Doobie (JVM only)
+libraryDependencies += "org.typelevel" %% "grackle-doobie-h2" % "@VERSION@"
+
+// Optional: support for SQLite backend via Doobie (JVM only)
+libraryDependencies += "org.typelevel" %% "grackle-doobie-sqlite" % "@VERSION@"
```
## Running tests for database backed mappings
diff --git a/docs/mappings.md b/docs/mappings.md
new file mode 100644
index 00000000..75753da5
--- /dev/null
+++ b/docs/mappings.md
@@ -0,0 +1,561 @@
+# Mapping Concepts
+
+A `Mapping` is how Grackle connects a GraphQL schema to whatever actually holds our data: a
+Scala collection, a JSON document, a SQL database, a remote API, or some combination of these.
+Grackle ships with several base classes to choose from, and a family of smaller building blocks
+that plug into any of them. This page is a guided tour of both, with an eye toward answering the
+question every new user has: *given my data source, which pieces do I actually need?*
+
+It is worth reading through the [tutorial](tutorial/intro.md) first: it walks through two
+complete, running examples (an in-memory model and a database-backed model). This page fills in
+the conceptual gaps between them and covers the pieces the tutorial does not reach.
+
+## How a query flows through Grackle
+
+Before getting into the different kinds of `Mapping`, it helps to see the pipeline they all plug
+into: Grackle is a compiler/interpreter, and every `Mapping`, however it is built, is just a set
+of extension points feeding the same parse/compile/interpret/complete pipeline. See
+[How a query flows through Grackle](pipeline.md) for a diagram of the stages, including exactly
+when and where `Env` gets created and merged in, and
+[Compiler Phases](howto/compiler-phases.md) for how to add a phase of our own.
+
+## The shape of every Mapping
+
+Whichever base class we pick, every `Mapping` has the same two moving parts:
+
+```scala
+abstract class Mapping[F[_]] {
+ val schema: Schema
+ val typeMappings: TypeMappings
+}
+```
+
+`typeMappings` is a list of `TypeMapping`s, one per GraphQL named type. Most of them are
+`ObjectMapping`s, and each `ObjectMapping` carries a list of `FieldMapping`s, one per field of
+that type. Scalars and enums get a `LeafMapping` instead, which just needs a `circe.Encoder`
+(builtins like `Int`, `String`, `Boolean` are supplied for us; we only add a `LeafMapping` for
+our *own* custom scalars).
+
+```scala
+TypeMappings(
+ ObjectMapping(QueryType)(
+ /* one FieldMapping per top level query field */
+ ),
+ ObjectMapping(SomeType)(
+ /* one FieldMapping per field of SomeType */
+ )
+)
+```
+
+So choosing how to map our API really comes down to two independent questions:
+
+1. **Which base `Mapping` class matches the shape of my data source?** This determines how
+ *object types* turn into cursors.
+2. **Which kind of `FieldMapping` does each individual field need?** This is largely independent
+ of (1): `CursorField`, `EffectField`, `RootEffect`, `RootStream` and `Delegate` are available
+ no matter which base class we are using.
+
+## Picking a base Mapping
+
+| The data looks like... | Use | Module |
+|-------------------------------------------------------------|-------------------|--------------------|
+| A handful of values, wired up by hand | `ValueMapping` | `grackle-core` |
+| Plain Scala case classes/ADTs that mirror the schema | `GenericMapping` | `grackle-generic` |
+| Data already held as `io.circe.Json` | `CirceMapping` | `grackle-circe` |
+| Rows in a relational database | `SqlMapping` | `grackle-doobie-*` / `grackle-skunk` |
+| Several of the above, combined under one schema | `ComposedMapping` | `grackle-core` |
+
+### `ValueMapping` - plain Scala values, wired up explicitly
+
+`ValueMapping` is the simplest possible backend: the "cursor" is just whatever Scala value is
+currently in focus, and we tell Grackle exactly how to get from a parent value to each child
+value with `ValueField`.
+
+```scala
+class Countries[F[_]: MonadThrow] extends ValueMapping[F] {
+ val schema = schema"""
+ type Query { countries: [Country!]! }
+ type Country { name: String! population: Int! }
+ """
+
+ val QueryType = schema.ref("Query")
+ val CountryType = schema.ref("Country")
+
+ case class Country(name: String, population: Int)
+ val allCountries = List(Country("Wales", 3_100_000), Country("Chad", 17_000_000))
+
+ val typeMappings = TypeMappings(
+ ObjectMapping(QueryType)(
+ ValueField("countries", _ => allCountries)
+ ),
+ ObjectMapping(CountryType)(
+ ValueField[Country]("name", _.name),
+ ValueField[Country]("population", _.population)
+ )
+ )
+}
+```
+
+There is no derivation magic here: `ValueField[Country]("name", _.name)` is just a function from
+parent to child value. This makes `ValueMapping` a good fit for small, one-off models, tests, and
+places where we want full control field-by-field. It does not scale gracefully to large models
+because every field needs its own line.
+
+### `GenericMapping` - automatic cursors for Scala case classes/ADTs
+
+`GenericMapping` (in `grackle-generic`) solves the "every field needs a line" problem for the
+common case where our Scala model already mirrors our GraphQL schema reasonably closely. Instead
+of a `ValueField` per field, we derive a `CursorBuilder[T]` once per type, and Grackle walks the
+case class/sealed trait structure for us (via reflection over the type's fields).
+
+```scala
+trait StarWarsMapping[F[_]] extends GenericMapping[F] {
+ import semiauto._
+
+ case class Human(id: String, name: Option[String], friends: Option[List[String]])
+ object Human {
+ implicit val cursorBuilder: CursorBuilder[Human] =
+ deriveObjectCursorBuilder[Human](HumanType)
+ .transformField("friends")(resolveFriends) // override just this one field
+ }
+
+ val typeMappings = TypeMappings(
+ ObjectMapping(QueryType)(
+ GenericField("human", allHumans.find(_.id == "1000").get)
+ )
+ )
+}
+```
+
+`transformField` lets us patch individual fields (e.g. to resolve friend ids into actual `Human`
+values) without abandoning derivation for the rest of the type. This is the mapping style used by
+the [Star Wars in-memory tutorial](tutorial/in-memory-model.md). Reach for it whenever our model
+is "plain Scala data that happens to look like the schema."
+
+### `CirceMapping` - the data is already JSON
+
+If our source of truth is `io.circe.Json` (a JSON API we are proxying, a JSON column, a cached
+document), `CirceMapping` treats JSON values as the cursor focus directly, so no case classes are
+needed at all. It is most often combined with `RootEffect`/`RootStream` (see below), which get
+`computeJson`/`computeEncodable` variants for exactly this case:
+
+```scala
+class WeatherMapping[F[_]: Sync](client: WeatherClient[F]) extends CirceMapping[F] {
+ val typeMappings = TypeMappings(
+ ObjectMapping(QueryType)(
+ RootEffect.computeJson("forecast")((path, env) =>
+ env.getR[String]("city").flatTraverse(client.forecastJson))
+ )
+ )
+}
+```
+
+### `SqlMapping` - relational databases
+
+For data that lives in a relational database, `SqlMapping` (via `DoobiePgMapping`,
+`DoobieOracleMapping`, `DoobieMSSqlMapping`, `DoobieH2Mapping`, `DoobieSqliteMapping` or Skunk's
+`SkunkMapping`) is by far the most
+sophisticated of the base classes: it compiles an entire nested GraphQL query into a *single* SQL
+query with joins, rather than issuing one query per field (avoiding the classic GraphQL N+1
+problem). Instead of `ValueField`, we get `SqlField` (a column), `SqlObject` (a related object,
+optionally with a `Join`), and `SqlJson` (a JSON column):
+
+```scala
+trait WorldMapping[F[_]] extends DoobiePgMapping[F] {
+ object country extends TableDef("country") {
+ val code = col("code", Meta[String])
+ val name = col("name", Meta[String])
+ }
+ object city extends TableDef("city") {
+ val countrycode = col("countrycode", Meta[String])
+ val name = col("name", Meta[String])
+ }
+
+ val typeMappings = TypeMappings(
+ ObjectMapping(QueryType)(
+ SqlObject("country")
+ ),
+ ObjectMapping(CountryType)(
+ SqlField("code", country.code, key = true),
+ SqlField("name", country.name),
+ SqlObject("cities", Join(country.code, city.countrycode))
+ ),
+ ObjectMapping(CityType)(
+ SqlField("name", city.name),
+ SqlObject("country", Join(city.countrycode, country.code))
+ )
+ )
+}
+```
+
+`key = true` marks the column(s) that uniquely identify a row (needed so Grackle can deduplicate
+and join correctly); `Join(parentColumn, childColumn)` tells Grackle how two tables relate so it
+can generate the right SQL join for a nested selection. See the
+[DB-backed tutorial](tutorial/db-backed-model.md) for the full walkthrough, including filtering,
+sorting and pagination via the query elaborator.
+
+### `ComposedMapping` - stitching multiple mappings together
+
+Sometimes no single backend covers our whole schema: say, most types come from a database but
+one type's data lives in memory or behind an API. `ComposedMapping` does not hold any data of its
+own; every field is handed off wholesale to some other `Mapping` via `Delegate`:
+
+```scala
+class Api[F[_]: Sync](world: Mapping[F], currency: Mapping[F]) extends ComposedMapping[F] {
+ val typeMappings = TypeMappings(
+ ObjectMapping(QueryType)(
+ Delegate("country", world), // -> SqlMapping
+ Delegate("currencies", currency) // -> ValueMapping
+ )
+ )
+}
+```
+
+The GraphQL schema for `Api` describes both `country` and `currencies`, but each field's subtree
+is compiled and executed entirely by the delegate `Mapping` named. This is also how we would
+combine, say, a `SqlMapping` for our core domain with a `CirceMapping` wrapping a third-party
+JSON API, all under one unified schema.
+
+## FieldMapping building blocks
+
+These work the same way regardless of which base class we picked above (with the obvious
+exception of backend-specific ones like `SqlField`/`ValueField`).
+
+### `CursorField` - a computed leaf value
+
+`CursorField` computes a scalar/leaf field's value from the `Cursor` currently in focus, rather
+than from a column or a case class field. It is useful for derived values, and especially for
+reading arguments or values stashed in `Env` (more on that below):
+
+```scala
+CursorField[Int]("sum", c =>
+ (for {
+ x <- c.env[Int]("x")
+ y <- c.env[Int]("y")
+ } yield x + y).toResult("Missing argument")
+)
+```
+
+### `EffectField` - an effectful, batchable field anywhere in the tree
+
+`EffectField` runs an `F[_]` effect to resolve a field that is not a root field, e.g. a per-row
+call out to another service. Crucially, Grackle collects *all* the sibling cursors that need the
+same effect and hands them to our `EffectHandler` together, so we can batch the underlying call
+instead of doing it once per row (solving our own N+1 problem, the same way `SqlMapping` solves
+GraphQL's):
+
+```scala
+ObjectMapping(CountryType)(
+ // ... SqlField("code2", ...),
+ EffectField("currencies", CurrencyQueryHandler, required = List("code2"))
+)
+
+object CurrencyQueryHandler extends EffectHandler[F] {
+ def runEffects(queries: List[(Query, Cursor)]): F[Result[List[Cursor]]] =
+ // queries is every sibling row that requested `currencies` in this batch;
+ // fetch them all in one call and return one Cursor per input query.
+ ???
+}
+```
+
+`required = List("code2")` tells Grackle that the `code2` column/field must already be present on
+the parent cursor before the effect runs (e.g. because our handler needs it to make the call).
+
+### `RootEffect` - an effect at the top of a query or mutation
+
+`RootEffect` runs once, before the rest of the query is even compiled against a cursor. This is
+the tool for mutations (insert/update/delete), auth checks, or any "do a thing, then answer with
+the result" root field. There are four constructors depending on how much we need to override:
+
+| Constructor | Use when... |
+|----------------------|------------------------------------------------------------------------------|
+| `computeUnit` | We just need to perform an effect (e.g. a DB write) and return the default shape - the classic mutation that returns `Boolean`/no meaningful payload. |
+| `computeChild` | The effect produces something we need to fold back into the *query* (e.g. "insert a row, then filter the following selection down to just that new id"), letting the rest of the tree be answered normally. |
+| `computeCursor` | The effect produces the *entire* result cursor directly, bypassing the mapping's normal field resolution. |
+| `apply` | We need full control over both the query and the cursor. |
+
+```scala
+ObjectMapping(MutationType)(
+ RootEffect.computeUnit("updatePopulation")(env =>
+ env.getR[UpdatePopulation]("updatePopulation").traverse {
+ case UpdatePopulation(id, pop) => updatePopulation(id, pop)
+ }),
+ RootEffect.computeChild("createCity")((child, _, env) =>
+ env.getR[CreateCity]("createCity").flatTraverse {
+ case CreateCity(name, cc, pop) =>
+ createCity(name, cc, pop).map { id =>
+ Unique(Filter(Eql(CityType / "id", Const(id)), child)).success
+ }
+ })
+)
+```
+
+Here `env` holds the parsed mutation input; see the next section for how it gets there.
+
+### `RootStream` - the subscription version of `RootEffect`
+
+`RootStream` is `RootEffect`'s counterpart for GraphQL subscriptions: instead of returning a single
+`F[Result[...]]`, it returns an `fs2.Stream[F, Result[...]]`, one element per event we want
+pushed to the client. It has the same `computeCursor`/`computeChild` convenience constructors as
+`RootEffect`.
+
+### `LeafMapping` - custom scalars
+
+If our schema declares a custom scalar (e.g. `scalar UUID`), give it a `LeafMapping` with a
+circe `Encoder` so Grackle knows how to serialize values of that type. Built-in scalars
+(`Int`, `Float`, `String`, `Boolean`, `ID`) already have one.
+
+## A pitfall: using `ValueField` to call out to I/O
+
+It is tempting (and it does compile, and does appear to work) to reach for `ValueField` when a
+field's value comes from a REST call, a gRPC service, or an external process, skipping
+`EffectField`/`RootEffect` altogether:
+
+```scala
+// Don't do this.
+ValueField[Country]("exchangeRate", country =>
+ Await.result(httpClient.get(s"/rates/${country.code}"), 5.seconds)
+)
+```
+
+This "works" for a toy case: a handful of rows, no concurrent load, run it once and look at the
+JSON. Past that, it stops being merely inelegant and becomes actually incorrect, for reasons
+specific to how Grackle and cats-effect fit together, not just a matter of taste:
+
+1. **`f: T => Any` is a plain, synchronous Scala function; there is no `F[_]` in sight.**
+ `ValueMapping` requires a `MonadThrow[F]` on the *surrounding* mapping, but `ValueField.f`
+ itself never touches `F`. There is no way to hand our effectful call to `F` and let it be
+ sequenced, retried, run concurrently, or cancelled the way the rest of a Grackle service (and
+ the rest of a typical cats-effect application) is built to work - the only option left is to
+ force or block on it right there in the function body (`Await.result`, `.unsafeRunSync()`, a
+ blocking HTTP client, ...).
+
+2. **Blocking there defeats cats-effect's scheduling and breaks cancellation.** A thread blocked
+ inside `ValueField.f` is invisible to the fiber runtime: it is not a suspended `F[_]` that
+ `Fiber.cancel` or `IO.timeout` can interrupt, it is a JVM thread parked doing nothing useful.
+ Do this on a bounded compute pool (the default for CPU-bound work in most cats-effect setups)
+ and, under enough concurrent load, we can exhaust the pool and deadlock the server, a well
+ known cats-effect trap, not something specific to Grackle but very easy to hit here.
+
+3. **There is no batching, so it reintroduces the exact N+1 problem `EffectField` exists to solve.**
+ `ValueField.f` is invoked once per row, in isolation - nothing collects the sibling rows of a
+ list field into one request the way `EffectField`'s `EffectHandler.runEffects` does (see
+ `EffectField` above). Put a REST call in a `ValueField` under a list field and we get one HTTP
+ round trip per element, serially, on the request thread: the very thing `SqlMapping` and
+ `EffectField` both exist to avoid.
+
+4. **Failures bypass Grackle's error model entirely.** `CursorField`, `EffectField` and
+ `RootEffect` all return a `Result[T]` (or `F[Result[T]]`), which is how Grackle attaches a
+ well-formed, per-field GraphQL error (a `Problem` with a path) instead of failing the whole
+ response. `ValueField.f` returns a bare `Any`, so the only way to signal failure is to *throw*.
+ Cursor construction runs eagerly and is only lifted into `F` afterwards
+ (`ResultT(runValue(...).pure[F])` in `QueryInterpreter.runRootValue`), so an exception thrown from
+ `f` is a raw synchronous throw that happens *before* `F`'s own error channel
+ (`MonadThrow[F]`/`attempt`/`handleErrorWith`) gets a chance to turn it into a `Result`. What
+ should have been "this one field is null with an error attached" can instead take down far more
+ than the field that failed, depending on what (if anything) wraps our top-level
+ `compileAndRun` call.
+
+**So when can we get away with it?** When the value handed to `ValueField` is already resident
+and pure by the time `ValueField` runs, i.e. the actual I/O happened earlier, as an effect, and
+`ValueField` is just projecting a plain field out of the value that effect produced. That is not
+"`ValueField` doing I/O," it is the idiomatic combination of the two: fetch once via `RootEffect`,
+then map purely over the result using `ValueMapping`'s own `valueCursor` helper:
+
+```scala
+ObjectMapping(QueryType)(
+ RootEffect.computeCursor("countries")((path, env) =>
+ restClient.fetchAllCountries.map(cs => valueCursor(path, env, cs).success))
+),
+ObjectMapping(CountryType)(
+ ValueField[Country]("name", _.name),
+ ValueField[Country]("exchangeRate", _.exchangeRate) // pure projection - already fetched above
+)
+```
+
+One HTTP call, up front, for the whole list; everything under it is genuinely pure field
+projection, which is exactly what `ValueField` is for.
+
+Outside of that pattern, treat "I need to call out to get this field's value" as a hard signal for
+`EffectField` (nested fields) or `RootEffect`/`RootStream` (root fields), not `ValueField` - even
+in a prototype, since prototypes have a way of becoming production code. The one place blocking
+inside `ValueField` is close to harmless is a genuinely single-threaded, throwaway script or test
+fixture with no concurrency or cancellation contract to violate in the first place, and even
+there, `EffectField`/`RootEffect` cost nothing extra to use correctly, so there is rarely a good
+reason not to.
+
+## `Env` - passing context down through a query
+
+`Env` is an immutable, string-keyed heterogeneous map attached to every `Cursor`. It is how
+information gets from "outside" a field (arguments, an effect's result, request-scoped context)
+down to wherever it is actually needed, without threading extra parameters through every mapping
+signature.
+
+There are two ways values end up in `Env`:
+
+**From the query elaborator**, using `Elab.env` to stash parsed arguments where a `CursorField`
+further down the tree can read them:
+
+```scala
+override val selectElaborator = SelectElaborator {
+ case (NestedType, "sum", List(Binding("x", IntValue(x)), Binding("y", IntValue(y)))) =>
+ Elab.env("x" -> x, "y" -> y)
+}
+```
+
+Combined with the `CursorField("sum", ...)` example above, this is the standard pattern for
+"the value of this field depends on its own GraphQL arguments."
+
+**From a `RootEffect`/`RootStream`**, whose `effect` function receives an `Env` (typically holding
+parsed input parsed earlier by the elaborator) and whose resulting `Cursor` can call `withEnv` to
+pass values *forward* to its descendants, e.g. so a nested `CursorField` can see the outcome of
+the root effect.
+
+Cursors read values back out with `env[T](name): Option[T]` or `envR[T](name): Result[T]`
+(the latter failing with a useful error if the key is missing or of the wrong type):
+
+```scala
+def sum(c: Cursor): Result[Int] =
+ (for {
+ x <- c.env[Int]("x")
+ y <- c.env[Int]("y")
+ } yield x + y).toResult("Missing argument")
+```
+
+`Env` is deliberately untyped (`Map[String, Any]` under the hood, guarded by a `ClassTag` check on
+read). Treat it as a way to pass a handful of well-known values down a subtree, not as a general
+dependency-injection mechanism for our whole application.
+
+## Cheat sheet
+
+- **"I just want to stand something up quickly with a few Scala values."** → `ValueMapping`.
+- **"My Scala model already looks like my schema."** → `GenericMapping` with derived
+ `CursorBuilder`s, `transformField` for the exceptions.
+- **"My data is already JSON."** → `CirceMapping`.
+- **"My data is in Postgres/Oracle/SQL Server/H2/SQLite."** → `SqlMapping` via Doobie or Skunk,
+ `SqlField` + `SqlObject` + `Join`.
+- **"A field's value depends on its own arguments or something computed higher up."** →
+ `CursorField` reading from `Env`.
+- **"A field needs an effect (I/O), and other sibling rows will need the same kind of effect."** →
+ `EffectField` with a batching `EffectHandler`.
+- **"A whole query or mutation needs to run an effect before anything else can happen."** →
+ `RootEffect` (or `RootStream` for subscriptions).
+- **"This field's data actually belongs to a completely different mapping/backend."** →
+ `Delegate`, inside a `ComposedMapping`.
+- **"I'm tempted to make a blocking/effectful call inside a `ValueField`."** → Don't - see the
+ pitfall above. Fetch once via `RootEffect`/`EffectField`, then project purely with `ValueField`
+ over the result.
+
+## Beyond the built-in backends
+
+`ValueMapping`, `GenericMapping`, `CirceMapping` and `SqlMapping` cover the common cases, but
+they are not the only data sources people put behind Grackle. Here is how the same building blocks
+apply to three sources that do not have a dedicated `Mapping` subclass.
+
+### Client-side storage (e.g. IndexedDB)
+
+There is no `IndexedDbMapping`, and there should not be one - IndexedDB is not relational, so
+none of `SqlMapping`'s table/column/`Join` machinery applies. What it *is*, an asynchronous,
+key/object store with per-store lookups by key or index, makes it exactly the shape of data source
+`RootEffect`/`EffectField` were built for:
+
+- Every read is asynchronous (`IDBRequest.onsuccess`/`onerror`), so it must be wrapped as an
+ effect in `F` - typically `cats.effect.IO` on Scala.js, bridged from IndexedDB's callback API via
+ `IO.async_` (or a small `scala-js-dom` interop helper), never called synchronously from a
+ `ValueField`/`GenericField` (see the pitfall above - it applies just as much to a browser event
+ loop as it does to a JVM thread pool: blocking there freezes the tab).
+- Pick `ValueMapping`/`GenericMapping` if we will decode each record into a Scala case class, or
+ `CirceMapping` if we are happy keeping records as `Json` (IndexedDB stores structured-clone JS
+ values, which map onto JSON very naturally, and `CirceCursor.field` will pick GraphQL fields
+ straight off matching object keys with no per-field mapping at all).
+- IndexedDB has no server-side join. Resolving a relationship (e.g. "orders for this customer")
+ means an `EffectField` (or nested `RootEffect`) that does a batched `getAll`/key-range lookup
+ across every sibling row requesting that relation in one go - the same batching shape as the
+ `CurrencyQueryHandler` example under `EffectField` above, just keyed by an IndexedDB index
+ instead of a SQL `WHERE ... IN (...)`.
+
+### Graph databases
+
+Same reasoning as IndexedDB: no built-in `GraphMapping`, and `SqlMapping`'s DSL does not apply
+unless our graph database happens to expose a genuine JDBC/SQL bridge (a few do - check before
+building anything custom, since if it is there we can reuse `SqlMapping` as-is). Otherwise, treat
+the driver the same way we would treat any other effectful client: `RootEffect`/`EffectField` issue
+the traversal query (Cypher, Gremlin, ...) in `F`, and the result (nodes, edges, or rows) lands in
+either a case class (`GenericMapping`/`ValueMapping`) or `Json` (`CirceMapping`), depending on what
+the driver hands back.
+
+The one real design decision is *how much of the nested GraphQL selection we push down into a
+single graph query*. Graph databases are good at multi-hop traversal, so unlike IndexedDB there is
+a genuine payoff to doing so - but Grackle does not give us this for free the way it does for SQL:
+`SqlMapping` is thousands of lines of purpose-built compiler translating the elaborated `Query`
+algebra (the `Select`/`Filter`/`Group`/... tree described in the
+[in-memory tutorial](tutorial/in-memory-model.md#the-query-compiler-and-elaborator)) into one SQL
+statement with joins. Writing an equivalent for Cypher/Gremlin is a serious undertaking, not
+something to reach for by default. Start with the simple thing - one `EffectField` per hop,
+batched across siblings exactly like the SQL/IndexedDB examples above - and only invest in
+compiling whole nested selections into a single traversal if profiling says the extra round trips
+actually matter.
+
+### An escape hatch: arbitrary/opaque JSON
+
+Given something like `exportGdpr(user: ID!): JSON` where the result is a JSON document whose shape
+is not (and should not need to be) described by the GraphQL schema, `CirceMapping` is the right
+tool, via `RootEffect.computeJson`/`computeEncodable`, but there are two different situations to
+tell apart, because they lead to different mapping code:
+
+**The result has a roughly known top-level shape we are happy exposing as real types.** Then do not
+model it as a scalar at all - give it proper GraphQL object types (`type GdprExport { profile:
+Profile! orders: [Order!]! ... }`) and return the fetched `Json` via `computeJson`. We need *no*
+per-field mapping for these types: `CirceCursor.field` already looks up each requested GraphQL
+field as a same-named key on the JSON object automatically. This is the better default whenever
+we can manage it: it is introspectable, and clients get real typed errors instead of "some key was
+missing inside an opaque blob."
+
+**The result is genuinely free-form** (shape varies per user, per plan, whatever - the literal
+"just give me a JSON blob" case). Now we want a real custom scalar:
+
+```scala
+val schema = schema"""
+ scalar JSON
+ type Query { exportGdpr(user: ID!): JSON! }
+"""
+
+val typeMappings = TypeMappings(
+ LeafMapping[Json](schema.ref("JSON")), // circe already has an identity Encoder[Json]
+ ObjectMapping(QueryType)(
+ RootEffect.computeJson("exportGdpr")((_, env) =>
+ env.getR[String]("user").flatTraverse(gdprService.export))
+ )
+)
+```
+
+Watch out for one sharp edge here: `CirceMapping`'s own leaf encoding
+(`CirceCursor.asLeaf`, in `circemapping.scala`) accepts a custom scalar's focus value only when
+it is *not* a JSON object: objects are assumed to be GraphQL object types, not scalar payloads, so
+an object-shaped export routed through `computeJson`/`computeEncodable` into a `scalar JSON` field
+fails with "Expected Scalar type, found ...". If our free-form document is (as GDPR exports
+usually are) a JSON *object*, do not route it through `computeJson`; build the leaf cursor directly
+against the base `Mapping.LeafCursor`, whose `asLeaf` just runs our `LeafMapping`'s `Encoder`
+against the focus value with no such restriction:
+
+```scala
+RootEffect.computeCursor("exportGdpr")((path, env) =>
+ env.getR[String]("user").flatTraverse { user =>
+ gdprService.export(user).map(_.map(json => LeafCursor(Context(path.rootTpe), json, None, env)))
+ })
+```
+
+Either way, note that `user: ID!` (or `String!`) is doing the work here, not `Long`. Grackle's
+built-in scalars are exactly the five from the GraphQL spec (`Int`, `Float`, `String`, `Boolean`,
+`ID`); a genuine `Long` argument needs its own custom scalar (and elaborator-side parsing of the
+incoming argument value) the same way `JSON` does as a result type.
+
+## Where to go next
+
+- [In-memory tutorial](tutorial/in-memory-model.md) - `GenericMapping` end to end.
+- [DB-backed tutorial](tutorial/db-backed-model.md) - `SqlMapping` end to end, including the query
+ elaborator for filtering/sorting/pagination.
+- [Compiler Phases](howto/compiler-phases.md) - installing our own compiler phase, and the
+ built-in `QuerySizeValidator`.
+- [Scaladoc](https://javadoc.io/doc/org.typelevel/grackle-core_2.13) for the full API of every
+ type mentioned here.
diff --git a/docs/pipeline.md b/docs/pipeline.md
new file mode 100644
index 00000000..a088ffe6
--- /dev/null
+++ b/docs/pipeline.md
@@ -0,0 +1,171 @@
+# How a query flows through Grackle
+
+Grackle is a compiler/interpreter: a request is parsed once, elaborated into an executable query
+algebra, then interpreted against a `Mapping` to produce `Json`. The pipeline itself is fixed - no
+backend replaces the compiler or the interpreter - and a `Mapping` plugs into it at the handful of
+points highlighted below. That is why we can mix `ValueMapping`, `SqlMapping`, effects and
+`ComposedMapping` freely: they differ mainly in how they build cursors, and everything either side
+of that is shared. `ValueMapping`, `GenericMapping`, `CirceMapping` and `ComposedMapping` each
+override `mkCursorForMappedField`, falling through to `super` for the cases they do not handle;
+`SqlMapping` instead overrides `defaultRootCursor`, because it materialises a whole subtree from a
+single query rather than a cursor per field. `SelectElaborator`, `RootEffect`/`RootStream` and
+`EffectHandler` are orthogonal to that choice: they work under any of them.
+
+```mermaid
+flowchart TD
+ Req["GraphQL request
query text + variables + request Env"]
+
+ subgraph S1["① Parse — GraphQLParser / QueryParser"]
+ direction TB
+ P1["GraphQLParser.parseText
text → Ast.Document"]
+ P2["QueryParser.parseDocument
Ast.Document → untyped query algebra
(UntypedOperation + UntypedFragment)"]
+ P1 --> P2
+ end
+
+ subgraph S2["② Compile — QueryCompiler.compile"]
+ direction TB
+ C1["compileVarDefs / compileVars
bind request variables against the schema"]
+ C2["Phase chain, threaded through the Elab state monad
ElabState carries Context + Vars + Fragments + Env"]
+ C3["IntrospectionElaborator"]
+ C4["VariablesSkipAndFragmentElaborator
substitute vars, resolve @skip/@include, expand fragments"]
+ C5["MergeFields"]
+ C6["mapping.selectElaborator
per field: arguments → Filter / Unique / Elab.env(...)"]
+ C7["ComponentElaborator
wraps Delegate fields in Component nodes"]
+ C8["EffectElaborator
wraps EffectField selections in Effect nodes"]
+ C9["any custom phases the mapping appends
by overriding compilerPhases"]
+ C1 --> C2 --> C3 --> C4 --> C5 --> C6 --> C7 --> C8 --> C9
+ end
+
+ subgraph S3["③ Interpret — QueryInterpreter.run"]
+ direction TB
+ I1["RootCursor built from rootTpe + request Env"]
+ I2{"root selects"}
+ I3["RootEffect.effect(query, path, env)
← our I/O happens here, inside F"]
+ I4["runValue / runFields / runList
walk the Query tree against the Cursor tree"]
+ I5["Environment node
merges an elaborator-bound Env into the cursor"]
+ I6["Component node
hands the subtree to another Mapping"]
+ I7["Effect node
EffectHandler resolves a field, still batched"]
+ I8["scalar/enum leaf
cursor.asLeaf"]
+ I9["ProtoJson
partial result, may hold deferred subtrees"]
+ I1 --> I2
+ I2 -->|pure field| I4
+ I2 -->|RootEffect field| I3 --> I4
+ I4 --> I5 --> I9
+ I4 --> I6 --> I9
+ I4 --> I7 --> I9
+ I4 --> I8 --> I9
+ end
+
+ subgraph S4["④ Complete — QueryInterpreter.completeAll"]
+ direction TB
+ D1["gather every deferred subtree
(Component / Effect results) across the whole response"]
+ D2["group by (Mapping, EffectHandler)"]
+ D3["run each group once
handler.runEffects / mapping.combineAndRun"]
+ D4["recursively complete
a batch can itself defer further work"]
+ D5["scatter results back into place"]
+ D1 --> D2 --> D3 --> D4 --> D5
+ end
+
+ Resp["Json response
data + errors"]
+
+ Req --> S1 --> S2 --> S3 --> S4 --> Resp
+
+ classDef pluggable fill:#e8f0fe,stroke:#4a7fd6,stroke-width:1px,color:#1a1a1a;
+ class C6,C7,C8,C9,I3,I6,I7 pluggable;
+```
+
+*(Highlighted boxes are the extension points a backend module actually supplies; everything else
+is fixed machinery shared by every `Mapping`.)*
+
+Notes:
+
+- **`Env` is created in two different places, at two different times.** The `env` we pass to
+ `compileAndRun`/`compileAndRunSubscription` is a *request-scoped* value (current user, tenant,
+ a `DataSource`, ...) that we build once, outside Grackle, before compilation even starts. It
+ seeds both the elaborator state and the `RootCursor`. Separately, `mapping.selectElaborator` can
+ call `Elab.env(...)` *during compilation*, binding a field's arguments (or anything else) into
+ the query tree as an `Environment` node - but that binding is not attached to any `Cursor` yet.
+ It is only merged in *during interpretation*, when `runValue`/`runFields` reaches that
+ `Environment` node and calls `cursor.withEnv`. This is why `CursorField`/`EffectField` functions
+ see argument values via `cursor.env[T](...)`: by the time our function runs, both Envs have
+ already been merged into the cursor it is handed.
+- **Validation brackets the phase chain; the diagram does not show it.** Before any phase runs,
+ `QueryCompiler.compile` rejects duplicate and self-referential fragment definitions and
+ unresolvable spreads (`validateVariablesAndFragments`) and checks the GraphQL field-mergeability
+ rules across the operation and everything its fragments reach (`validateFieldMergeability`).
+ `compileOperation` then binds variable definitions, checks the query's directives against the
+ schema (`Directive.validateDirectivesForQuery`) and validates every variable usage reachable from
+ the operation (`VariableUsage.validateVariableUsages`), all before the fold over `allPhases`
+ begins. A query which fails any of them never reaches a `Phase` at all, so a custom phase can
+ assume the document is already well-formed in those respects. Note also that
+ `IntrospectionElaborator`, drawn as an unconditional box above, is skipped entirely when
+ introspection is `Disabled`.
+- **Elaboration is where "arguments become behavior".** A raw `UntypedSelect` does not know what
+ `character(id: "1000")` *means* - `mapping.selectElaborator` is what turns that into a `Filter`
+ or `Unique` node (or an `Elab.env` binding) parameterized by the argument value. This is the
+ piece that is most specific to the data source, and it is exactly what `SelectElaborator.apply`
+ (see the [tutorial](tutorial/intro.md)) lets us supply.
+- **Batching happens after the fact, not during the walk.** `runValue`/`runFields` build the
+ result tree eagerly, but `Component` and `Effect` nodes do not run immediately - they produce a
+ deferred `ProtoJson` marker. `completeAll` is what gathers *all* the deferred markers across the
+ whole response, groups them by which `Mapping`/`EffectHandler` is responsible, and runs each
+ group exactly once. That is the mechanism behind `SqlMapping`'s single-query-per-level joins and
+ `EffectField`'s N+1-avoidance. Both are consumers of the same batching step.
+- **Stage ② is open-ended.** `IntrospectionElaborator`, `VariablesSkipAndFragmentElaborator` and
+ `MergeFields` always run first, in that order, and everything after them comes from the mapping's
+ `compilerPhases`: by default `selectElaborator`, `componentElaborator`, `effectElaborator`, but a
+ mapping can append phases of its own to enforce global policy on incoming queries. Grackle ships
+ one such phase, `QuerySizeValidator`; see
+ [Compiler Phases](howto/compiler-phases.md) for how to write and install our own.
+- **`ComposedMapping` is this whole diagram, nested.** A `Component` node's "hand the subtree to
+ another Mapping" step (`I6` above) means that sub-mapping runs its *own* copy of stages ②-④ on
+ that subtree, with its own schema and its own `Env`. Stitching mappings together is possible
+ precisely because every `Mapping` implements the same pipeline independently.
+
+## What a phase is, and what it sees
+
+Stage ② is a fold over a list of `QueryCompiler.Phase`s, run once per operation:
+
+```scala
+allPhases.foldLeftM(op.query) { (acc, phase) =>
+ phase.transformFragments *> phase.transform(acc)
+}
+```
+
+`Phase` has two entry points, and neither is abstract:
+
+- **`transform(query: Query): Elab[Query]`** already does a complete recursive walk of the query
+ algebra, pushing and popping the `Elab` context as it descends through `Narrow`, `Group`,
+ `Filter`, `Unique`, `Count`, inline fragments and the rest, so that `Elab.context` is always the
+ context of the node being visited. Override it for the node types we care about and delegate
+ everything else to `super.transform(query)` - that is what every built-in phase does. A phase
+ which handles a node itself and does not fall through stops the descent there.
+- **`transformFragments: Elab[Unit]`** defaults to a no-op. Fragment *definitions* live in
+ `ElabState`, not in the query tree, so `transform` never reaches inside them; a phase which has
+ to rewrite fragment bodies overrides `transformFragments` and rewrites the map through
+ `Elab.transformFragments`. Only phases running before fragments are inlined need this -
+ `IntrospectionElaborator` is the one built-in that does, because `__schema` can be selected
+ inside a fragment.
+
+`transformSelect` and `validateSubselection` are the seams inside the default `transform`, for
+hooking field descent rather than replacing the walk.
+
+Where a phase sits in the list decides which nodes it can match on at all:
+
+| Position | Selections are | Spreads | Component/Effect |
+|---|---|---|---|
+| before `VariablesSkipAndFragmentElaborator` | `UntypedSelect`, variables unsubstituted | `UntypedFragmentSpread` | absent |
+| after it, before `selectElaborator` | `UntypedSelect`, arguments bound | inlined | absent |
+| after `selectElaborator` | `Select`, wrapped in `Environment` where one was bound | inlined | absent |
+| last, where `:+` puts ours | `Select` | inlined | present |
+
+So `super.compilerPhases :+ new MyPhase`, the idiom in
+[Compiler Phases](howto/compiler-phases.md), installs our phase at the end, where it sees a
+fully elaborated tree: `Select` rather than `UntypedSelect`, no spreads, and whatever `Filter`,
+`Limit`, `Count` or `Environment` nodes the mapping's own elaborator injected. Prepending with
+`new MyPhase +: super.compilerPhases` gets us closer to what the client actually wrote, at the
+cost of handling spreads and unsubstituted variables ourselves. A phase which matches on the wrong
+one of `UntypedSelect`/`Select` for its position matches nothing, and silently does nothing.
+
+A phase aborts compilation with `Elab.failure(msg)`, which fails the whole operation: the client
+gets errors and no data.
diff --git a/docs/tutorial/directory.conf b/docs/tutorial/directory.conf
index ab798442..0fdc7d61 100644
--- a/docs/tutorial/directory.conf
+++ b/docs/tutorial/directory.conf
@@ -3,5 +3,4 @@ laika.navigationOrder = [
intro.md
in-memory-model.md
db-backed-model.md
- compiler-phases.md
]