This guide is the canonical v1.22 story for reusable search defaults, metadata-driven host rendering, and multi-search composition. The contract stays narrow: request-edge helpers normalize plain data, Scrypath.Composition lowers that data into Scrypath.search/3 or Scrypath.search_many/2, and your context remains the application boundary.
If you have not normalized browser params yet, start with Request-edge search. If you want the first-hour setup instead, start with the Golden path.
Why composition exists after request-edge normalization
Scrypath.QueryParams solves request-edge normalization. Composition solves a different problem: once several screens or callers share the same search defaults, where do those reusable rules live without turning Scrypath into a second runtime or a framework facade?
The answer is plain data:
- contexts or feature modules define presets and scopes as maps
Scrypath.Composition.compose/2resolvesdefaultsplusfixedconstraintsScrypath.Composition.to_search_args/1lowers that result into{text, keyword_opts}- your context still calls
Scrypath.search/3
Scrypath.Composition does not execute search, move search ownership onto schemas, or replace Phoenix, controllers, LiveView, or your context boundary.
Presets, scopes, defaults, and fixed
The public vocabulary is intentionally small:
defaultsfill in search-shaped values when the caller omitted themfixedlocks filter-bearing fields and fails on conflictsapplied,defaulted,fixed, andunsupportedremain inspectable in the result
That keeps the seam useful for tests, logs, and honest host rendering without exposing %Scrypath.Query{} or inventing schema-generated runtime verbs.
base_catalog =
%{
defaults: %{
sort: [desc: :published_at],
page: [size: 24],
facets: [:category, :author]
},
fixed: %{
filter: [published: true]
}
}
criteria = %{
text: "ecto",
facet_filter: [category: ["books"]]
}
{:ok, composition} = Scrypath.Composition.compose(base_catalog, criteria)
{text, search_opts} = Scrypath.Composition.to_search_args(composition)
MyApp.Catalog.search_books(text, search_opts)Metadata supports host-rendered honest controls
Composition is only half of the real-app seam. Hosts often need to know what the schema declares and what the current search input resolved to before they render controls.
Use:
Scrypath.schema_capabilities/1for declaration-backed supportScrypath.reflect_search/2for resolvedapplied,defaulted,fixed, andunsupportedstateScrypath.reflect_search_many/2for entry-scoped multi-search reflection
capabilities = Scrypath.schema_capabilities(MyApp.Catalog.Book)
reflection =
Scrypath.reflect_search(MyApp.Catalog.Book, %{
text: "ecto",
facets: [:category],
facet_filter: [category: ["books"], region: ["eu"]]
})Those helpers return plain data for host rendering. They do not generate UI, claim tenant-safe authz, or promise related-data propagation or rebuild correctness. Those remain host-owned concerns.
One runtime, two proof flows
Phase 85 freezes two flagship real-app proofs on top of the same runtime boundary:
Single-schema catalog flow
Use one schema, one context-owned Scrypath.search/3 call, and metadata-driven controls for a searchable Phoenix catalog page. Composition reduces repeated query glue, while schema_capabilities/1 and reflect_search/2 keep the control state inspectable and honest.
Read Faceted search with Phoenix LiveView for the concrete single-schema proof.
Multi-schema global-search flow
Use Scrypath.Composition.compose_many/2 when several schemas share some defaults but still need entry-scoped criteria, capabilities, and failure handling. The helper lowers into the existing tuple/shared-option contract for Scrypath.search_many/2; it does not create a merged capability graph or a fake universal ranking scale.
Read Multi-index search for the concrete multi-schema proof.
compose_many/2 lowers into search_many/2
Multi-search composition follows the same boundary discipline:
- per-entry composition is canonical
- shared composition lowers
defaultsonly - shared
fixedis intentionally unsupported - entry-scoped capability differences stay visible
- partial failures stay explicit
{:ok, many} =
Scrypath.Composition.compose_many(
[
%{
schema: MyApp.Post,
text: "release",
fragments: [%{defaults: %{filter: [published: true]}}],
criteria: %{facets: [:status]}
},
%{
schema: MyApp.User,
text: "release",
criteria: %{filter: [active: true]}
}
],
shared: %{defaults: %{page: [size: 8]}}
)
{entries, shared_opts} = Scrypath.Composition.to_search_many_args(many)
Scrypath.search_many(entries, Keyword.merge(shared_opts, repo: MyApp.Repo))The output stays inspectable plain data all the way to the runtime call.
Non-goals
This guide is also the canonical boundary page for what v1.22 does not promise:
- no public
%Scrypath.Query{} - no schema-generated runtime verbs
- no generated UI widgets, forms, or components
- no tenant/authz guarantees
- no related-data propagation or rebuild correctness claims
Scrypath helps you compose search-shaped data and reflect honest state. Your app still owns policy, rendering, and operational follow-through.