UXID.Registry (UXID v2.6.0)
View SourceA compile-time registry of an application's UXID prefixes.
Prefixes only deliver their value — "the ID names its resource on sight" — if
they are globally unique and well-formed across an app. use UXID.Registry
moves that governance out of hand-rolled CI tests and into the compiler, and
turns the same declarations into a runtime routing table (prefix → schema)
that powers authorization checks, admin auto-linking, and Relay-style global
object identification.
Declaring a registry
defmodule MyApp.IDs do
use UXID.Registry,
prefix_format: ~r/^[a-z][a-z0-9_]{1,7}$/,
delimiter: "_",
default_size: :medium,
default_validate: true
defid :org, prefix: "org", schema: MyApp.Org, category: :account
defid :contact, prefix: "contact", size: :large, schema: MyApp.CRM.Contact, allow_uuid: true
defid :lead, prefix: "lead", schema: MyApp.CRM.Lead
retired "usr" # reserve a prefix so it counts for uniqueness, never reused
endCompile-time guarantees
- every
:prefixis checked against:prefix_format— a malformed prefix is a compile error; - all prefixes (active and
retired) are checked for uniqueness — a duplicate is a compile error. This replaces the per-app runtime uniqueness test.
Generated API
By key (minting and schema configuration):
MyApp.IDs.generate!(:org) # => "org_01h…"
MyApp.IDs.prefix(:org) # => "org"
MyApp.IDs.size(:org) # => :medium
MyApp.IDs.schema(:org) # => MyApp.Org
MyApp.IDs.field_opts(:org) # => [prefix: "org", size: :medium, validate: true, allow_uuid: true, delimiter: "_"]
MyApp.IDs.all() # => [%{key: :org, prefix: "org", ...}, ...]Cross-source single source of truth — emit a JSON manifest so a database function or a mobile/JS generator mints prefixes the same way the app does:
MyApp.IDs.manifest() # => [%{"key" => "org", "prefix" => "org", "size" => "medium", ...}]
MyApp.IDs.manifest_json() # => ~s([{"key":"org","prefix":"org","size":"medium","category":"account"},...])field_opts/1 is the single-source-of-truth hook — a schema spreads it instead
of restating anything:
@primary_key {:id, UXID, [autogenerate: true] ++ MyApp.IDs.field_opts(:org)}By ID string (runtime routing — see "Parsing" below):
MyApp.IDs.known?("org_01h…") # => true (cheap prefix-only membership)
MyApp.IDs.key_for("org_01h…") # => :org
MyApp.IDs.schema_for("org_01h…") # => MyApp.Org
MyApp.IDs.resolve("org_01h…") # => %{key: :org, schema: MyApp.Org, ...}Parsing
Lookups split an ID into {prefix, body} on the last delimiter. This is
unambiguous without consulting the registry, because a UXID body is Crockford
Base32 and therefore never contains the delimiter: in in_ref_01h… every _
belongs to the prefix except the final joining one. The registry is consulted
only after the split, to answer membership/type — an unregistered but
well-formed string like nope_01h… parses fine yet resolves to nil.
Because of that, the :delimiter must be a character that cannot appear in a
Base32 body ("_" or "-"); a letter or digit is rejected at compile time.
An underscore is preferred for compound prefixes since it does not break
double-click-to-select-the-whole-id.
Routing in a layered app (self-registration)
A schema: literal points the registry up at a schema module. In a flat app
that is fine, and schema_for/1 resolves it with no further setup. In a
layered app the registry usually lives at the base layer (so every layer
can depend down on it to mint IDs and read field_opts/1), while the schemas
it routes to live above it — so naming them inverts the dependency direction.
To keep the direction correct, omit schema: and let each schema register
itself under its key with UXID.Registered. The reference then points down
(schema → registry key):
defmodule MyApp.CRM.Contact do
use Ecto.Schema
use UXID.Registered, key: :contact
@primary_key {:id, UXID, [autogenerate: true] ++ MyApp.IDs.field_opts(:contact)}
end
defmodule MyApp.IDs do
use UXID.Registry
defid :contact, prefix: "contact", route: true # filled at boot, no schema literal
endAt boot, verify!/1 scans the given OTP apps for the marker, assembles the
prefix → schema routing table into :persistent_term, and validates it —
raising if a marker names an unregistered key, two modules claim one key, or a
route: true key resolves to no schema. Wire it into your top app's start/2
so every boot (prod, dev, and CI's mix test) re-verifies:
def start(_type, _args) do
MyApp.IDs.verify!(otp_apps: [:my_app])
# ...
endBecause the registry discovers schemas by runtime reflection rather than a
compile-visible module reference, no base-layer code ever names an upper-layer
module, so Boundary/xref see nothing pointing the wrong way. known?/1 and
key_for/1 need no schema at all and work regardless.
Summary
Functions
The built-in default :prefix_format (permits an internal underscore).
Registers an id under key. Requires :prefix; :size, :schema,
:category, :validate, and :allow_uuid are optional and fall back to the
registry defaults.
Reserves a prefix so it participates in the uniqueness check but is never generated — mirroring "identifiers are never reused". Accepts a string or atom.
Splits a UXID string into {prefix, body} on the last occurrence of
delimiter. Returns {nil, string} when the delimiter is absent.
Functions
The built-in default :prefix_format (permits an internal underscore).
Registers an id under key. Requires :prefix; :size, :schema,
:category, :validate, and :allow_uuid are optional and fall back to the
registry defaults.
Reserves a prefix so it participates in the uniqueness check but is never generated — mirroring "identifiers are never reused". Accepts a string or atom.
Splits a UXID string into {prefix, body} on the last occurrence of
delimiter. Returns {nil, string} when the delimiter is absent.
iex> UXID.Registry.split_last("in_ref_01h2x…", "_")
{"in_ref", "01h2x…"}
iex> UXID.Registry.split_last("01h2x…", "_")
{nil, "01h2x…"}