Dextrin.Registry (Dextrin v0.3.0)

Copy Markdown View Source

Extension point for both struct and custom-tag decoding/encoding. No entry for a given name → decode falls back to an opaque Dextrin.Struct/Dextrin.CustomTag — never a hard failure, since neither type requires a registration to be representable at all.

Struct entries only ever come from a compiled .dxns schema (Dextrin.Schema.compile/3) plus an optional materializer layered on top (put_struct_materializer/3) — there is no put_struct/3 that hand-writes a struct's shape directly, because struct is schema-dependent by design: the schema is what supplies field names/order/types, a materializer only decides what nicer decoded shape to produce from an already-schema-validated field map.

Every put_*/fetch_* pair here is a plain, immutable map update — Registry.t() is ordinary data threaded explicitly through Dextrin.decode/2, Dextrin.Schema.compile/3, etc.; nothing about it is a process, an ETS table, or otherwise global/mutable state.

Summary

Types

A schema's materializer (DXN.md §4.8): called for every decoded struct of its schema, whatever the version, with the schema's name, the version the fields were read with and the fields keyed by string. It answers the application's latest-version value, which is checked against the latest schema, or {:error, reason}.

A lazy source of schemas, asked on a miss for a name: every version it finds -- one compiled schema, or a list of them, all loaded -- or :unknown, or why the schemas it found cannot be used (two files with one version, a file that is not the version its name says, ...).

t()

Functions

Loads compiled as a new version of the schema name.

struct as it is written here: carrying the latest loaded version of its schema (DXN.md §4.8), whatever version it says itself. A struct whose name has no schema keeps its own version, or none.

The reverse of fetch_struct_module/2: given an application struct's own module, finds the schema name it was registered under (if any) — what lets automatic encode-time validation (Dextrin.Schema.validate_encode_tree/2) recognize a plain Elixir struct as "this is a Foo" without being told so explicitly, the same way a Dextrin.Struct's own name already identifies it.

Looks up a compiled schema by struct name, consulting the lazy resolver (if any) on a miss and caching the result. Whether schemas are all compiled up front (Dextrin.Schema.compile/3 once, at startup) or resolved on demand (a resolver reading a file, calling a schema service, whatever the caller wants) is entirely the caller's choice — this function is what makes both paths look identical to the rest of Dextrin, which never needs to know which one is in play.

Looks up version version of the schema name -- the one a struct written %Name(3){...} is read with. Unlike fetch_struct_schema/2, a miss does not fall back to another version: a struct is only ever read with the schema it was written against.

The registry a decode's opts describe: :registry (a new one when absent), with :trusted and :strict applied over it when given -- what Dextrin.decode/2, Dextrin.decode_binary/2 and Dextrin.Document.parse/2 all read.

Marks this registry as decoding strictly: a schema-backed struct that carries a field its schema does not declare is refused (schema/undeclared-field, DXN.md §4.7) instead of keeping it as an extra field. A config loader decodes strictly, to catch a typo in a hand-written file; a message consumer does not, so a newer producer's fields pass through. Dextrin.decode/2/decode_binary/2 take it as strict: true.

Declares which Elixir module a schema's data is expected to be — used only for {:reference, name} checks during encode-time validation (Dextrin.Schema.validate_encode/3). Decode-time reference checks don't need this: they carry an internal provenance marker regardless of materializer shape (Dextrin.Schema.Validated). Encode-time has no wire data to carry that marker before anything's even been encoded — __struct__ is the one signal actually available pre-encode, and this is what a reference check compares it against. Independent of put_struct_materializer/3: you can declare a module here even if you use the default plain-map materialization for something else.

Sets compiled as version compiled.version of the schema name, whether or not that version was there already.

The reverse of put_tag/3: registers how to turn an application struct (keyed by its module) back into @name value on encode. put_tag/3 alone lets you decode @my-app/money 100 into %MyApp.Money{}, but never re-encode it, since dispatch on the way out has to happen by Elixir module, not by wire-format tag name — the two directions need separate lookup tables, not one shared one.

Marks (or unmarks) this registry as decoding a trusted source, vs. an untrusted one -- the default. Currently the one thing this affects is keyword: untrusted (the default) decodes it as Dextrin.Keyword.t(), so a String.to_atom/1 call is never reachable from the input (which could otherwise exhaust the atom table); trusted decodes it as a real Elixir atom (DXN.md §1.3's own type table: keyword's Elixir type is "Elixir atom"). Pass trusted: true to Dextrin.decode/2/decode_binary/2 -- or put_trusted(registry, true) on a reused registry -- only for a source you fully control.

Registers a named type_expr — a reusable name for a combination of the fixed type_expr vocabulary (Dextrin.Schema.TypeExpr), defined entirely in .dxns data, never Elixir code. Dextrin.Schema.compile/3 populates this automatically from a document's non-%schema{} entries; put_type_alias/3 itself is only needed to seed a base registry by hand (e.g. sharing one vocabulary across several compile/3 calls without redeclaring it each time).

Replaces version compiled.version of the schema name -- the hot swap of a running system.

Types

struct_materializer()

@type struct_materializer() :: (String.t(),
                          pos_integer(),
                          %{required(String.t()) => term()} ->
                            {:ok, term()} | {:error, term()})

A schema's materializer (DXN.md §4.8): called for every decoded struct of its schema, whatever the version, with the schema's name, the version the fields were read with and the fields keyed by string. It answers the application's latest-version value, which is checked against the latest schema, or {:error, reason}.

struct_resolver()

@type struct_resolver() :: (String.t() ->
                        {:ok,
                         Dextrin.Schema.Compiled.t()
                         | [Dextrin.Schema.Compiled.t()]}
                        | :unknown
                        | {:error, Dextrin.Error.t()})

A lazy source of schemas, asked on a miss for a name: every version it finds -- one compiled schema, or a list of them, all loaded -- or :unknown, or why the schemas it found cannot be used (two files with one version, a file that is not the version its name says, ...).

t()

@type t() :: %Dextrin.Registry{
  materializers: %{optional(String.t()) => struct_materializer()},
  resolver: struct_resolver() | nil,
  strict: boolean(),
  struct_modules: %{optional(String.t()) => module()},
  structs: %{
    optional(String.t()) => %{
      optional(pos_integer()) => Dextrin.Schema.Compiled.t()
    }
  },
  tag_encoders: %{optional(module()) => {String.t(), tag_encoder()}},
  tags: %{optional(String.t()) => tag_decoder()},
  trusted: boolean(),
  type_aliases: %{optional(String.t()) => Dextrin.Schema.TypeExpr.t()}
}

tag_decoder()

@type tag_decoder() :: (Dextrin.Value.t() -> {:ok, term()} | {:error, term()})

tag_encoder()

@type tag_encoder() :: (struct() -> {:ok, Dextrin.Value.t()} | {:error, term()})

Functions

add_struct_schema(registry, name, compiled)

@spec add_struct_schema(t(), String.t(), Dextrin.Schema.Compiled.t()) ::
  {:ok, t()} | {:error, Dextrin.Error.t()}

Loads compiled as a new version of the schema name.

Loading a version that is already loaded is a schema/duplicate-version error (DXN.md §4.8): two documents both claiming to be User v3 is a mistake, not an update. Replacing a version on purpose -- a hot swap in a running system -- is replace_struct_schema/3.

as_written(registry, struct)

@spec as_written(t() | nil, Dextrin.Struct.t()) :: Dextrin.Struct.t()

struct as it is written here: carrying the latest loaded version of its schema (DXN.md §4.8), whatever version it says itself. A struct whose name has no schema keeps its own version, or none.

Every writer goes through this -- text, the formatter and binary -- so encoding never writes a version other than the latest, and never writes one no schema stands behind.

fetch_materializer(registry, name)

@spec fetch_materializer(t(), String.t()) :: {:ok, struct_materializer()} | :error

fetch_schema_name_for_module(registry, module)

@spec fetch_schema_name_for_module(t(), module()) :: {:ok, String.t()} | :error

The reverse of fetch_struct_module/2: given an application struct's own module, finds the schema name it was registered under (if any) — what lets automatic encode-time validation (Dextrin.Schema.validate_encode_tree/2) recognize a plain Elixir struct as "this is a Foo" without being told so explicitly, the same way a Dextrin.Struct's own name already identifies it.

fetch_struct_module(registry, name)

@spec fetch_struct_module(t(), String.t()) :: {:ok, module()} | :error

fetch_struct_schema(registry, name)

@spec fetch_struct_schema(t(), String.t()) ::
  {:ok, Dextrin.Schema.Compiled.t(), t()}
  | {:unknown, t()}
  | {:error, Dextrin.Error.t(), t()}

Looks up a compiled schema by struct name, consulting the lazy resolver (if any) on a miss and caching the result. Whether schemas are all compiled up front (Dextrin.Schema.compile/3 once, at startup) or resolved on demand (a resolver reading a file, calling a schema service, whatever the caller wants) is entirely the caller's choice — this function is what makes both paths look identical to the rest of Dextrin, which never needs to know which one is in play.

fetch_struct_schema(registry, name, version)

@spec fetch_struct_schema(t(), String.t(), pos_integer()) ::
  {:ok, Dextrin.Schema.Compiled.t(), t()}
  | {:unknown, t()}
  | {:error, Dextrin.Error.t(), t()}

Looks up version version of the schema name -- the one a struct written %Name(3){...} is read with. Unlike fetch_struct_schema/2, a miss does not fall back to another version: a struct is only ever read with the schema it was written against.

fetch_tag(registry, name)

@spec fetch_tag(t(), String.t()) :: {:ok, tag_decoder()} | :error

fetch_tag_encoder(registry, module)

@spec fetch_tag_encoder(t(), module()) :: {:ok, {String.t(), tag_encoder()}} | :error

fetch_type_alias(registry, name)

@spec fetch_type_alias(t(), String.t()) :: {:ok, Dextrin.Schema.TypeExpr.t()} | :error

from_options(opts)

@spec from_options(keyword()) :: t()

The registry a decode's opts describe: :registry (a new one when absent), with :trusted and :strict applied over it when given -- what Dextrin.decode/2, Dextrin.decode_binary/2 and Dextrin.Document.parse/2 all read.

new()

@spec new() :: t()

put_resolver(registry, resolver)

@spec put_resolver(t(), struct_resolver()) :: t()

put_strict(registry, strict?)

@spec put_strict(t(), boolean()) :: t()

Marks this registry as decoding strictly: a schema-backed struct that carries a field its schema does not declare is refused (schema/undeclared-field, DXN.md §4.7) instead of keeping it as an extra field. A config loader decodes strictly, to catch a typo in a hand-written file; a message consumer does not, so a newer producer's fields pass through. Dextrin.decode/2/decode_binary/2 take it as strict: true.

put_struct_materializer(registry, name, materializer)

@spec put_struct_materializer(t(), String.t(), struct_materializer()) :: t()

put_struct_module(registry, name, module)

@spec put_struct_module(t(), String.t(), module()) :: t()

Declares which Elixir module a schema's data is expected to be — used only for {:reference, name} checks during encode-time validation (Dextrin.Schema.validate_encode/3). Decode-time reference checks don't need this: they carry an internal provenance marker regardless of materializer shape (Dextrin.Schema.Validated). Encode-time has no wire data to carry that marker before anything's even been encoded — __struct__ is the one signal actually available pre-encode, and this is what a reference check compares it against. Independent of put_struct_materializer/3: you can declare a module here even if you use the default plain-map materialization for something else.

put_struct_schema(registry, name, compiled)

@spec put_struct_schema(t(), String.t(), Dextrin.Schema.Compiled.t()) :: t()

Sets compiled as version compiled.version of the schema name, whether or not that version was there already.

Not how schemas are loaded. Dextrin.Schema.compile/2 uses add_struct_schema/3, which refuses a version already loaded, and a deliberate swap is replace_struct_schema/3. This is the unchecked setter underneath both, also used to cache what a resolver answers.

put_tag(registry, name, decoder)

@spec put_tag(t(), String.t(), tag_decoder()) :: t()

put_tag_encoder(registry, module, name, encoder)

@spec put_tag_encoder(t(), module(), String.t(), tag_encoder()) :: t()

The reverse of put_tag/3: registers how to turn an application struct (keyed by its module) back into @name value on encode. put_tag/3 alone lets you decode @my-app/money 100 into %MyApp.Money{}, but never re-encode it, since dispatch on the way out has to happen by Elixir module, not by wire-format tag name — the two directions need separate lookup tables, not one shared one.

put_trusted(registry, trusted?)

@spec put_trusted(t(), boolean()) :: t()

Marks (or unmarks) this registry as decoding a trusted source, vs. an untrusted one -- the default. Currently the one thing this affects is keyword: untrusted (the default) decodes it as Dextrin.Keyword.t(), so a String.to_atom/1 call is never reachable from the input (which could otherwise exhaust the atom table); trusted decodes it as a real Elixir atom (DXN.md §1.3's own type table: keyword's Elixir type is "Elixir atom"). Pass trusted: true to Dextrin.decode/2/decode_binary/2 -- or put_trusted(registry, true) on a reused registry -- only for a source you fully control.

symbol is deliberately unaffected either way — it stays Dextrin.Symbol.t() regardless of trusted, mirroring encode/2's own choice to accept a bare atom as a stand-in for keyword, never for symbol.

put_type_alias(registry, name, type_expr)

@spec put_type_alias(t(), String.t(), Dextrin.Schema.TypeExpr.t()) :: t()

Registers a named type_expr — a reusable name for a combination of the fixed type_expr vocabulary (Dextrin.Schema.TypeExpr), defined entirely in .dxns data, never Elixir code. Dextrin.Schema.compile/3 populates this automatically from a document's non-%schema{} entries; put_type_alias/3 itself is only needed to seed a base registry by hand (e.g. sharing one vocabulary across several compile/3 calls without redeclaring it each time).

replace_struct_schema(registry, name, compiled)

@spec replace_struct_schema(t(), String.t(), Dextrin.Schema.Compiled.t()) ::
  {:ok, t()} | {:error, Dextrin.Error.t()}

Replaces version compiled.version of the schema name -- the hot swap of a running system.

Replace only replaces. Naming a version that is not loaded is a schema/not-loaded error, so a typo in a hot swap never quietly adds a version instead.