UXID.Registry (UXID v2.6.0)

View Source

A 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
end

Compile-time guarantees

  • every :prefix is 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
end

At 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])
  # ...
end

Because 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

default_prefix_format()

The built-in default :prefix_format (permits an internal underscore).

defid(key, opts \\ [])

(macro)

Registers an id under key. Requires :prefix; :size, :schema, :category, :validate, and :allow_uuid are optional and fall back to the registry defaults.

retired(prefix)

(macro)

Reserves a prefix so it participates in the uniqueness check but is never generated — mirroring "identifiers are never reused". Accepts a string or atom.

split_last(string, delimiter)

@spec split_last(String.t(), String.t()) :: {String.t() | nil, String.t()}

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…"}