Current section
Files
Jump to
Current section
Files
CHANGELOG.md
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.3.2] - 2026-10-23
A duration's zero fields are left out again, a `key/not-allowed`
error names its key in DXN, and the README says how fast Dextrin is.
Nothing breaks.
### Fixed
- **A `key/not-allowed` error names the key in DXN**, as Node and PHP
do: `@datetime "2026-10-22T10:00:00Z" cannot be a map key`, no longer
`~U[2026-10-22 10:00:00.000000Z] cannot be a map key`.
- **A duration's zero fields are left out again** when it is written, as
DXN.md §1.5 says, and one that is zero throughout is `PT0S`:
`@duration "P1DT0H"` is written `@duration "P1D"`, `@duration "P0D"`
`@duration "PT0S"`. `.dxnb` sets no bit for a zero field either, so the
two agree, and `P0D` and `PT0S` are one value to `unique:`. Reading
still keeps what was written.
### Documentation
- **The README says how fast it is**: decoding and checking timed for 90 minutes in each implementation, on the same data sets; and the size table's one changed cell (`.dxnb`, the config file gzipped, 210 bytes).
- The README counts DXN's 31 types (it said 30), and DXN.md §4 names the old section right: "DXNS.md §2" is the old "DXN.md §4.2".
- **The docs build without a warning**: a stray code fence in
`guides/EXAMPLES.md` left the rest of the page unfenced, and three
moduledocs and two changelog entries named functions that no longer
exist (`Dextrin.Schema.Reader.read/5` builds a decoded value now).
## [0.3.1] - 2026-10-21
DXNS guides of their own, decoding about four times faster on
ichor_runtime 0.6.1, and a schema-validation benchmark. Nothing breaks.
### Documentation
- **DXNS has guides of its own** (Jan, 2026-10-21), in `guides/dxns/`: a
tutorial, the reference (`DXNS.md`, DXN.md's old §4, its sections
renumbered 1 to 8 -- "DXNS.md §2" is the old "DXN.md §4.2"), examples
and a cheatsheet. Every schema and every good and bad value in the
tutorial, the examples and the cheatsheet is checked by the test suite.
### Added
- **`bench/schema_validation.exs`**: for ten minutes, times decoding
stage by stage and the schema check, data set by data set, against a
schema set more complex than any real one, `bench/schemas/universe.dxns`
(Jan, 2026-10-21). `bench/generate_universes.exs` writes the same data
sets for the Node and PHP benchmarks.
### Documentation
- **DXN.md's reference tables are Markdown tables**, no longer TOON
(Jan, 2026-10-21); the `canonical` table said 32 rows and had 33.
### Changed
- **ichor_runtime 0.6.1** (ichor 0.7.1 for `mix ichor.gen`), the parser
regenerated with it: lexing a 5 KB document takes 2.0 ms instead of
12.4, decoding it 4.2 ms instead of 14.2 (Ichor's character classes
were a linear scan; found by the schema-validation benchmark).
## [0.3.0] - 2026-10-21
One way to write a value, nanoseconds, `@objectid` and `@ip`, the
smallest `.dxnb`, and DXNS as a superset of the schema languages DXN is
compared with. Breaking: sigils are gone, `.dxnb` gains tags a 0.2
reader does not know, and a schema that misused a refine key no longer
compiles.
### Migrating from 0.2.x
- **Text: sigils are gone.** Rewrite `~D[2026-10-20]` as
`@date "2026-10-20"`, `~T[14:30:00]` as `@time "14:30:00"`,
`~U[2026-10-20 14:30:00Z]` as `@datetime "2026-10-20T14:30:00Z"`, a
naive date-time as `@local-datetime "2026-10-20T14:30:00"`, and
`~r/^a/i` as `@regex(i) "^a"`. A sigil is now `syntax`.
- **Binary: deploy readers first.** 0.3 writes times, datetimes, local
date-times and durations in nanoseconds under tags 217-220,
`@objectid` and `@ip` under 221, 52 and 54, and a float in half or
single precision when that holds it exactly. A 0.2 reader refuses all
of these; a 0.3 reader still reads everything 0.2 wrote.
- **`Dextrin.Duration`'s seconds are `nanoseconds`**, one field for the
seconds and their fraction; a time or datetime finer than a
microsecond decodes to a `Dextrin.Nanos`.
- **`:timestamp` is a datetime in UTC**, `{:refine :datetime %{offsets:
["Z"]}}`; a schema naming it keeps working.
- **Schemas are checked harder.** An unknown refine key, a refine
argument of the wrong kind, `min-contains` without `contains`, and an
unknown `format:` are compile errors; a constraint on a value it does
not apply to refuses the value instead of raising; `{:enum 1}` no
longer takes `1.0`.
- **A trusted decode keeps six keywords** -- `:nan`,
`:positive_infinity`, `:negative_infinity`, `:nil`, `:true`, `:false`
-- as `Dextrin.Keyword`, since their atoms are already values.
- **`@uuid` reads only 8-4-4-4-12 hex digits.**
- **ichor_runtime 0.6** is the runtime dependency.
### Changed
- **ichor_runtime 0.6** (ichor 0.7 for `mix ichor.gen`), the parser
regenerated with it: a syntax error names the line and token where the
parse broke and what was expected there, not the document's start;
`mix dextrin.check` reports that line.
- **DXN.md says a pattern PCRE does not compile is `syntax`**, as every
implementation with PCRE already refused it, and names Node's limit:
without PCRE, it reads any pattern.
- **Every refine argument is checked when the schema compiles**: a
count that is no non-negative integer, a flag that is no boolean,
`multiple-of: 0`, `bits: 5`, a `pattern` that is no regex and the like
are compile errors (`schema/compile`); some raised at check time
before. A compile error writes the value it refuses in DXN, not as
Elixir's `inspect` shows it.
- **A refine constraint refuses a value it does not apply to**: a length
on an integer, a range on a string, `bits` on a negative integer.
Before, several such checks raised; `finite: false` and
`unique: false` took any value at all. A constraint key that is no
name is a compile error, no longer a crash.
- **An unknown refine key is a compile error** (`schema/compile`), as
are `min-contains:` and `max-contains:` without `contains:`. A misspelt
key (`min-lenght:`) refused every value before, silently.
- **`.dxnb` writes the smallest form of a float, a UTC datetime and a
regex without flags** (Jan, 2026-10-20; DXN.md §2.2): a float in the
shortest of CBOR's half, single and double precision that holds it
exactly (NaN and the infinities in half), a datetime in UTC as its bare
nanoseconds, a regex without flags as its bare pattern. A reader reads
every form, half-precision floats included, which it refused before.
- **One way to write a value: every sigil is gone** (Jan, 2026-10-20;
DXN.md §1.2). Breaking. Dates and times are ISO 8601 strings under a
tag -- `@date "2026-10-20"`, `@time "14:30:00.5"`, `@datetime
"2026-10-20T14:30:00Z"`, and the new `@local-datetime
"2026-10-20T14:30:00"` -- and a regex is `@regex "pattern"`, its flags
in a header, `@regex(im) "..."`, or inline, `@regex "(?im)..."`.
`~D[...]`, `~T[...]`, `~U[...]` and `~r/.../` are refused.
- **`timestamp` and `datetime` are one type**, `datetime`: an instant
with its offset, `Z` for UTC. Breaking. `:timestamp` in a schema is
sugar for `{:refine :datetime %{offsets: ["Z"]}}`, so a schema naming it
keeps its meaning.
- **Times, datetimes, local date-times and durations carry nanoseconds**
(DXN.md §2.2). A value finer than a microsecond decodes to
`Dextrin.Nanos`; `Dextrin.Duration`'s seventh field is `nanoseconds`
(was `microseconds`). In `.dxnb` the nanosecond forms have new tags
(218 time, 219 datetime, 220 duration, 217 local date-time); 0.2's
microsecond tags (1, 210, 211, 212) are still read, as what they
meant, and never written, so a tag never changes its meaning.
- **Refine keys `finite:` and `offsets:`** (DXNS.md §2), and **NaN lies
outside every range**: `min`, `max` and both exclusive bounds refuse it.
- **The README's size table is measured again**: the pretty layout and
the datetime spelling both changed it.
- **A named type sees the named types beside it** (DXNS.md §5): a
reference to a sibling in the same document resolves, in any order,
where it used to fall back to a struct reference; a named type that
reaches itself is refused (`schema/compile`).
- **HexDocs shows the README's development warning as a warning box**
(Jan, 2026-10-20): ExDoc renders no GitHub alert, so `mix docs` gives
it the README with `> [!WARNING]` turned into ExDoc's own admonition,
written to `doc_src/`; the README itself stays as GitHub shows it, and
"View Source" still points at it.
- **The package description opens with the development warning**
(Jan, 2026-10-20), "Under heavy development: not for production use
before 1.0.0.", so the registry's search results show it too.
- **An array is written `@array[1 2]`**, its bracket against the tag
(Jan, 2026-10-20): the preferred spelling, which the compact text
form now writes as the pretty layout and the canonical form already
did. `@array [1 2]` still reads, as the same array.
- **The README opens with a warning** (Jan, 2026-10-20): the library
is under heavy development and not for production use before `1.0.0`;
things can and will break, and the specification can and will change,
between any two releases until then.
- **`{:one-of ...}` means exactly one variant matches** (Jan,
2026-10-20; JSON Schema's `oneOf`), and **`{:any-of ...}`** is the new
"one or more" (`anyOf`) -- what `one-of` meant before. Breaking: a
schema whose `one-of` variants overlap now refuses a value two of them
match; write `any-of` to keep the old meaning. `{:nilable t}` is sugar
for `{:any-of :nil t}`.
- **DXN.md says `.dxnb` writes `-0.0M` as `0.0M`** (Jan, 2026-10-20):
CBOR's decimal (tag 4) has no negative zero, so it is the one value
the binary form does not keep; text keeps the sign (§1.5, §2.2).
- **The pretty layout keeps short collections on one line** (Jan,
2026-10-20): the top-level collection still breaks, one entry per
line, however short; a nested one is written on one line, its entries
single-space separated, when that line -- indent and key included --
fits the width (`width:`, 80). `encode/2` with `pretty: true`,
`Dextrin.Document.format/2` and `mix dextrin.format` all lay out this
way. A nested collection holding a comment, or a blank line between
its entries, stays over lines.
- **Decoding is untrusted by default** (Jan, 2026-10-19, ASCO's
proposal): `decode/2`, `decode_binary/2` and `Dextrin.Document` decode
a keyword as `Dextrin.Keyword.t()`, so no input can exhaust the atom
table; `trusted: true` (or `Dextrin.Registry.put_trusted/2`) is the
opt-in for a source you control, as Jason's `keys: :atoms`. Breaking
for callers that relied on atoms from the default. A `Dextrin.Document`
path's atom names a keyword key either way, and `update/3` compares the
caller's atoms with the document's keywords by name, answering edits in
the caller's atoms where they exist (`String.to_existing_atom/1` --
never a new atom).
- **A field typed with a namespaced reference takes a struct of that
name** (DXNS.md §7): a field typed `geo/Address` accepts
`%geo/Address{...}`, and refuses `%Address{...}`. It was the other way
round, because a schema `FileResolver` found as `geo/Address` kept the
name its file gave it, `Address`. Breaking for documents that wrote the
bare name; write the name the reference uses.
- **DXN.md stays format version 1.0** (Jan, 2026-10-20): the version
moves past 1.0 only once the libraries reach 1.0.0, so this release's
changes to `.dxns` documents (the body lists the fields, `%schema(N)`
carries a version) and `bits` keep `1.0`. A reader accepts any `1.x`
header; the `.dxnb` version byte is `1`.
- **DXN.md gains `bits`**, a run of bits that is not whole bytes:
`@bits {"oA==" 3}` in text, private tag 216 (`T_BITS`) around
`[bytes, count]` in `.dxnb`, `:bits` in a schema. Bits and bytes are
one value space; a whole number of bytes is bytes. The private tag
range is 200-216 (§2.5 still said 200-214).
- DXN.md §5 lists the four errors a document's edits are refused with:
`document/not-found`, `document/exists`, `document/ambiguous` and
`document/not-a-collection`, which every implementation raised and the
conformance suite expects.
- **A 0.1 schema says it is one**: `fields: @ordered %{...}`,
`closed: true` and `forbidden: [...]` are refused (`schema/compile`)
naming Dextrin 0.1's syntax and what a schema is written as now,
rather than as a field of no known type.
### Added
- **The README compares DXNS with the other schema languages**, says
why a DXNS type is never closed, and how to change a schema across
services that deploy one at a time.
- **Sugar** (Jan, 2026-10-20; DXNS.md §1; the superset's group 9):
`:int32`, `:int64`, `:uint32`, `:uint64` and `:float32`, `:number`,
`{:const v}`, and `{:if I T E}` (JSON Schema's if/then/else).
- **Annotations, `{:doc T %{...}}`** (Jan, 2026-10-20; DXNS.md §1b;
the superset's group 8): a `title`, `description`, `examples`,
`read-only`, `write-only`, `deprecated` and free `meta` on any type,
around a whole schema, named type or generic too, and `values:` for an
enum's single values. They change nothing a type takes;
`Dextrin.Schema.annotations/2` reads them and `Dextrin.Schema.Compiled`
holds a schema's. `mix dextrin.docs` shows them: a title, the
description, examples, meta and an enum's values beside the
definition, deprecated, read-only and write-only as chips; the example
tree carries a few.
- **`{:tagged name T}`** (Jan, 2026-10-20; DXNS.md §1; the superset's
group 7): a custom tag of that name whose value is a `T` -- or an
application's value its registered tag encoder writes as one.
- **Encoded content** (Jan, 2026-10-20; DXNS.md §2): `encoding:` (RFC
4648's base64, base64url, base32, base16), `media-type:` and
`content: T` on strings -- the string, decoded, is a DXN document of
type T, `.dxnb` under `application/dxnb` -- and `encodes: T` and
`encodes-seq: T` on bytes, CDDL's `.cbor` and `.cborseq`.
`Dextrin.Binary.Decoder.decode_sequence/2` reads documents back to
back.
- **`format:`** (Jan, 2026-10-20; DXNS.md §2a): a string in one of a
closed set of 22 formats -- `date`, `time`, `datetime`,
`local-datetime`, `duration`, `uuid`, `ipv4`, `ipv6`, `hostname`,
`idn-hostname`, `email`, `idn-email`, `uri`, `uri-reference`, `iri`,
`iri-reference`, `uri-template`, `json-pointer`,
`relative-json-pointer`, `semver`, `mac`, `host-port` --
`Dextrin.Schema.Format`. A format DXN has a tag for is read by that
tag's reader; every other one by a grammar the spec gives.
- **Refine keys on scalars** (Jan, 2026-10-20; DXNS.md §2; the
superset's group 5): `min-length`/`max-length` on keywords, symbols,
bytes (in bytes) and bits (in bits, under `:bits`); `min-bytes`/
`max-bytes` (UTF-8); `pattern` on keywords, symbols and bytes;
`min-precision`/`max-precision` and `min-exponent`/`max-exponent` on
decimals; `float32: true`; `bits: [0 1 4]` (RFC 8610's `.bits`);
`min`/`max`/`exclusive-*` on dates, times, datetimes, local date-times
and durations; `before-now`, `after-now`, `within: @duration "PT1H"`;
`resolution: :millisecond`.
- **A list's items in order, and lists' refine keys** (Jan, 2026-10-20;
DXNS.md §1a, §4.2; the superset's group 4). `{:list-items :string
{:repeat :integer 0 nil}}` is a list item by item: a type for one item,
`{:repeat p min max}` for several (`max` `nil` for no bound), and
`{:repeat {:group a b} min max}` for several together; a repeat takes
as many items as it can and gives them back one at a time.
`{:tuple-of-all T}` is a tuple of any length. The refine keys
`contains:`, `min-contains:`, `max-contains:`, `unique:` and
`unique-by:` count and compare a collection's elements, two values the
same when their canonical forms are; `min-count:`/`max-count:` count an
array's too.
- **`@objectid` and `@ip` are built in** (Jan, 2026-10-20; DXN.md §1.3),
like `@uuid`: readable in text, their raw bytes in `.dxnb`.
`@objectid "507f1f77bcf86cd799439011"` is BSON's 12-byte ObjectId
(`Dextrin.ObjectId`, tag 221), and may be a map key. `@ip "192.0.2.1"`,
`"10.0.0.0/8"`, `"2001:db8::1"` and `"2001:db8::/32"` are an address or
a prefix (`Dextrin.Ip`), written as RFC 9164 says, CBOR tags 52 and 54;
DXNS adds `:objectid`, `:ip` and the refine keys `version:` and
`prefix:`. **`.dxnb` uses the smallest representation that keeps a
value**, now said in DXN.md §2.2.
- **`mix dextrin.check` and `Dextrin.Schema.Tree`** (Jan, 2026-10-20): a
tree of `.dxns` files read as one -- every entry named from its path
(DXNS.md §6, `Dextrin.Schema.Layout`), the files compiled in the order
their references need -- with every problem reported as `file:line`:
syntax, compiling, a versioned file holding another version, one
version in two files, files needing each other, and a name resolving
to nothing unless its namespace is `--extern`. `Dextrin.Schema.compile/3`
takes the full names a tree gives a file's entries.
- **Generic named types** (DXNS.md §5, Jan, 2026-10-20):
`Page: {:generic [t] %shape{items: {:list-of t}}}` declares a named
type with parameters, and `{:of Page Order}` uses it. A generic may use
itself, guarded; a `%schema` cannot be generic, since a struct has one
name on the wire.
- **Recursive named types** (DXNS.md §5, Jan, 2026-10-20): a named type
or shape may refer to itself, directly or through others, when the
cycle passes a field or a collection's elements -- `Comment:
%shape{replies?: {:list-of Comment}}`, a JSON-like value type. A cycle
with no such guard, `A: {:nilable A}`, is a compile error.
- **`%shape{...}` and `{:case field %{...}}`** (DXNS.md §4a, Jan,
2026-10-20; design/shapes-and-case.md): a shape is a named, unversioned
plain-map type with a `%schema` body, which fills its missing fields'
defaults when it decodes; a case chooses a field's type by its sibling
field's value, anywhere in the field's type. **`Dextrin.Schema.check/3`**
answers whether a value matches a schema, shape or named type without
changing it, and **`conform/3`** answers it with the defaults filled.
- **The conformance adapter answers `media-type`, `identify` and
`identify-binary`** (`DXN.md` §6): the form a media type names, and
whether a body is worth decoding as text, a schema or binary, so the
suite checks `Dextrin.Format` across implementations.
- **`mix dextrin.docs` and `Dextrin.Schema.Docs`** (Jan, 2026-10-20):
HTML documentation for a directory of `.dxns` schemas: a sidebar tree
of every namespace and definition at the left of every page; the index
and every namespace's page in one design, a section per namespace
below it listing its own namespaces and definitions, then its own
definitions in full; the pages a directory tree, one `index.html` per
namespace; every reference a link, every version shown, a field's
position in the binary form; light or dark, the reader's choice kept
in localStorage. Names come from the file paths (DXNS.md §6); a
definition's documentation is the comment block directly above it and
the comment on its line, in a small Markdown subset. `example/` holds
a schema set using every kind of definition, and its generated `doc/`.
- **New type forms** (DXNS.md §1): `{:not t}`, `:never` (JSON
Schema's `false`), and element types for the collections that had
none: `{:array-of t}`, `{:ordered-map-of k v}`, `{:sorted-set-of t}`.
- **`Dextrin.Format`** (DXN.md §6, ASCO's proposal): `media_type/1`,
`extension/1`, `form/1` -- the three media types, and any `+dxn`
suffix as text -- and `identifies?/2`, a cheap prefix test of whether
a body is worth decoding as a form: binary by the envelope's magic,
text and schemas by a first significant `%` after whitespace, comments
and a header. Lists and scalars stay unrecognised on purpose: they
overlap with JSON (Jan, 2026-10-20).
- `Dextrin.Schema.compile_all/2`: several `.dxns` documents compiled into
one registry, in order, over a base registry; the first that does not
compile is the refusal, naming which (ASCO's proposal, part 3).
- **Dotted names** (DXN.md §1.1, Jan 2026-10-19): a name is segments
joined by `.`, with at most one `/` between a namespace and a name --
`%Foo.Bar/Baz{...}`, `acme.billing/invoice`, `:a.b`, `a.b: 1` -- for
structs, symbols, keywords and map keys alike. A name with two `/`, or
an empty segment, is refused. The parser is regenerated.
- DXNS.md §6 states the file convention a reader that resolves names to
files follows -- `Foo.Bar/Baz(N)` in `Foo/Bar/Baz-vN.dxns`,
`Foo/Bar/Baz/vN.dxns` or `Foo.Bar.Baz-vN.dxns` (`-v1` optional), or the
entry `Baz` in `Foo.Bar.dxns` -- with a version found twice refused.
- **`Dextrin.Schema.FileResolver` follows DXNS.md §6**: the four
layouts, the first search path that has the name deciding, a file's
version its schema's, one version found once, and every version found
loaded, so an older struct reaches the materializer. The old
`Namespace.dxns` convention is one of the layouts and keeps working.
- A resolver may answer every version it finds (`{:ok, [compiled]}`) or
why they cannot be used (`{:error, error}`), which a decode refuses
with; looking up an exact version consults it too.
- **`bits`**, as DXN.md defines it: a bitstring that is not whole
bytes -- bare, or in a `Dextrin.Bytes` -- is written `@bits {"oA==" 3}`
and `T_BITS` (216), and reads back; `Dextrin.Bits` holds the rules.
`:bits` in a schema takes bits and bytes, `:bytes` only whole bytes;
bits are no map key.
- Takes `ichor_runtime` 0.5 and `ichor` 0.6 (was 0.4 and 0.5), for the
action errors below; the DXN parser is regenerated with `ichor` 0.6.
### Fixed
- **A generic checked by its own name refuses the value**
(`schema/type-mismatch`), where `Dextrin.Schema.check/3` raised: a
generic takes no value until `{:of Name ...}` gives its arguments.
- **An enum's literal is the value itself**: `{:enum 1}` took `1.0`,
which Elixir's `==` calls equal and DXN does not (§1.5).
- **`bits` is a built-in tag** for `{:tagged}`, which took it as custom.
- **The docs show an enum value whose annotations are no map**, where
`mix dextrin.docs` raised.
- **A trusted decode keeps `:nan`, `:positive_infinity`,
`:negative_infinity`, `:nil`, `:true` and `:false` keywords**
(`Dextrin.Keyword.trusted/1`): their atoms are already NaN, the
infinities, nil and the booleans, so a trusted round trip turned the
keyword into that value.
- **NaN and the infinities are numbers in a schema**: `min: -Infinity`,
`max: Infinity`, `{:enum NaN}` compiled them as keywords.
- **A final newline is no part of a format, a date or a time**: the
grammars' `$` matched before one (PCRE's `D` flag now), and
`@time`, `@datetime`, `@local-datetime` and `@duration` read one;
`host-port` raised on `"a:80\n"`.
- **A hostname's 253 are codepoints**, not graphemes; an unknown type's
compile error writes it in DXN.
- **A string's length counts codepoints**, as DXNS.md §2 says, not
graphemes: `"e\u0301"` is two long, as in every other implementation.
- **A `.dxnb` document read inside another keeps the outer one's state**:
the shared-item and stringref tables are put back after it.
- **`@uuid` reads only 8-4-4-4-12 hex digits.** It took 32 bare hex
digits, or hyphens anywhere, which the other implementations refuse.
- **`@ip` writes every address so it reads back, and refuses a sign**:
Erlang's `:inet.ntoa` wrote a link-local address with a non-zero second
group (`fe80:1::1`) as `fe80::1%01`, which no reader takes, and its
parser took `+1.2.3.4`. Found by node-dextrin's port.
- **A field naming another file's named type resolves** (DXNS.md §6):
`Dextrin.Schema.FileResolver` compiled each file on its own, so
`discount: acme.common/Percentage` named a struct that never came and
refused every decimal. A file is now compiled with its full names, over
the named types its search path defines.
- **An error found while parsing text says where it is**: a map key
that is not allowed, a value that is not one, a schema violation now
carry the line and column of what they are about, and
`Dextrin.Error.format/1` shows them. 0.2.0 said they did; they had
neither, because Ichor placed no error an action returned.
- `Dextrin.Schema.compile/2` crashed (`Protocol.UndefinedError`) on a
document whose top level is a struct or a tuple -- maps to `is_map/1`
-- instead of refusing it; it refuses every document that is not a
plain map (`schema/compile`). Found by ASCO.
- Encoding a bitstring that is not whole bytes crashed the text and
binary writers; it is `bits` now.
- A schema a resolver found under a namespaced name (`geo/Point` in
`geo.dxns`) kept the name it had in its file, so reading an older
version of it looked for `Point` and missed.
- `Dextrin.Document.format/2` wrote the header without its space
(`@dxn"1.0"`); it keeps one space between the tag and the version,
however they were spaced.
- `Dextrin.Document.format/2` dropped a document's final newline; it now
ends with one exactly when the source did. `mix dextrin.format` still
writes a file with one final newline.
## [0.2.0] - 2026-10-19
Versioned schemas, extra fields, the conformance suite's adapter and a
document that keeps its comments. Breaking: a 0.1.x `.dxns` file has
to be rewritten, and decoding a checked struct answers a
`Dextrin.Struct`.
### Migrating from 0.1.x
- **`.dxns` files.** The body lists the fields directly, in the order
`fields: @ordered %{...}` gave them, and a schema without a version
is version 1:
```text
# 0.1.x
User: %schema{closed: true, fields: @ordered %{name: :string, email?: :string}}
# 0.2.0
User: %schema{name: :string, email?: :string}
```
An unconverted file is refused when it is compiled (`schema/compile`:
`field "fields": unrecognized type expression`), never read as
something else.
- **`closed:`, `forbidden:` and `refine-fn:` are gone.** A struct may
carry fields its schema does not declare. Where `closed: true` refused
them, decode with `strict: true` (or `Dextrin.Registry.put_strict/2`),
which refuses every undeclared field as `schema/undeclared-field`. A
`forbidden:` list or a refine predicate becomes a check in the
schema's materializer (`Dextrin.Registry.put_struct_materializer/3`),
which may refuse.
- **A checked struct decodes to a `Dextrin.Struct`**, not a string-keyed
map. A materializer that answers the map you had keeps the old shape:
`fn _name, _version, fields -> {:ok, fields} end`.
- **`.dxnb` written by 0.1.x still reads**, and so does text: its structs
carry no version, and a struct without a version is read as the latest
version loaded. Proven against bytes 0.1.3 wrote, in
`test/binary/v0_1_3_test.exs`.
- **Rewrite stored 0.1.x data before you load a version 2.** Once a
version 2 is loaded, an unversioned struct is read as version 2, and a
`.dxnb` struct with version 1's fields no longer matches it
(`schema/field-count`). Decoded and encoded again with 0.2.0, the same
value is written as `User(1)`, and from then on it is read as version 1
and handed to the materializer to upgrade.
### Added
- `Dextrin.Document` -- a `.dxn` document with everything it holds, its
comments, blank lines and commas as well as its value: `parse/2` (with
`Dextrin.decode/2`'s options and errors), `to_value/1` (what `decode/2`
gives) and `render/1` (the source, byte for byte, when nothing was
edited). Built on Ichor's lossless parse of the same grammar.
- `Dextrin.Document.replace/3`, `delete/2` and `insert/4` -- explicit
edits by path (a map's key, a struct's field name, an index). Every byte
an edit does not touch renders as it was; a replaced value keeps the
comments around it; a deleted entry takes the comment blocks above it
and its trailing block, and nobody else's; an inserted entry goes above
the comments of the entry it lands before, laid out like its
neighbours.
- `Dextrin.Document.format/2` -- a document laid out the way the pretty
printer lays out its value, with every comment kept beside the entry
it belongs to. Layout only: every token is written as it was, and a
comment-free document comes out exactly as `Dextrin.encode(value,
pretty: true)` writes its value.
- **`mix dextrin.format` keeps comments** (`--mode pretty`, the default):
it formats through `Dextrin.Document.format/2`, where it reprinted the
value and lost every comment. `--mode condense` still drops them: a
comment ends its line, and condensed output is one line.
- `Dextrin.Document.update/3` -- the document holding a new value, by the
fewest explicit edits, answered as a changeset. Equal values are left
as written; lists compare with Myers' diff. A deleted item followed by
an inserted one is ambiguous, and `on_ambiguity:` decides: `:safe`
(the default) refuses only where the deleted item owns a comment,
`:guess` reads it as a replace, `:error` refuses every one.
- `Dextrin.Registry.from_options/1` and `Dextrin.Error.from_parse/1`, what
`decode/2` and `Dextrin.Document.parse/2` share.
- `Dextrin.Canonical.print/1` -- the canonical form of a value
(`DXN.md` §1.5): one `.dxn` rendering fixed down to the byte, with
maps and sets in canonical order, floats by one exact algorithm and
one fixed set of string escapes, so values from different
implementations compare as text.
- `Dextrin.Error`'s `category` -- the error's kind as `DXN.md` §5 names
it (`"syntax"`, `"binary"`, `"encode/unsupported"`, ...), so errors
compare across implementations without comparing messages.
`Dextrin.Error.action/2` takes it; schema errors have none yet.
- `mix dextrin.conformance REQUEST ANSWER` and `Dextrin.Conformance` --
this implementation's adapter for the Dextrin Conformance Suite,
answering a JSON batch of cases with canonical forms, base64 bytes or
error categories.
It also answers `format` and `edit` cases, a `Dextrin.Document` laid
out or edited, as `text`.
- The tutorial's §8, "Evolving a schema": a worked example of versions,
reading across them, the materializer, the positional risk and
`mix dextrin.compat`. The cheatsheet gains versions and extra fields.
- `Dextrin.Schema.Compat.check/2` and `mix dextrin.compat OLD NEW` --
the version discipline check (`DXN.md` §4.8), for CI against the
previous release's schema files: a version whose content changed
without a new version, or a version that went down, fails; what the
latest version's materializer has to upgrade is listed either way.
### Changed
- Licensed under the Apache License 2.0 (`Apache-2.0`); it was MIT.
- The Node.js port is published as `@joetjen/dextrin`; the links to it
say so. The unscoped npm `dextrin` is deprecated.
- Requires `ichor_runtime ~> 0.4` (was `~> 0.2`); `ichor ~> 0.5` builds
the DXN parser, now generated with `--lossless` for the comment-keeping
document to come. Decoding runs the same code as before.
- An error of Dextrin's own found while parsing text -- a map key that is
not allowed, a schema violation -- travels through Ichor in
`Ichor.Error`'s `data` and comes back as itself, now with Ichor's
line and column; `Dextrin.Error.format/1` shows them.
- **An absent optional field is never written as `nil`** (`DXN.md`
§4.3): `.dxnb` fills every position, so `encode_binary` writes an
absent field's default where it has one and otherwise refuses
(`schema/missing-field`). It wrote `nil`, which the same schema then
refused to read back. `nil` in an optional field stays an error unless
the type is `{:nilable t}`.
- **Every schema violation carries its `DXN.md` §5 category**:
`schema/field-count`, `schema/missing-field`, `schema/type-mismatch`
and `schema/shadowed-field`, decoding text or binary and encoding
alike. They came back as `syntax` from text, `binary` from binary,
and with no category from an encode. `Dextrin.Schema.validate/3`,
`validate_encode/3` and `validate_encode_tree/2` answer a
`Dextrin.Error` instead of a string.
- `DXN.md` gains §1.5 (canonical form), §1.6 (map keys: only `string`,
`keyword`, `integer`, `uuid`, `date`, `bytes`, `symbol` and `char`)
and §5 (error categories).
- **Schemas are versioned** (`DXN.md` §4.8). A schema is written
`%schema(N){field: type, ...}`: the body lists the fields directly, in
the order `.dxnb` encodes them, and the header holds only the version
(version 1 without one). `fields:` and `@ordered` are gone. Each
`.dxns` document describes one version; the registry keeps schemas by
name *and* version (`Dextrin.Registry.fetch_struct_schema/3`, with `/2`
answering the latest). Loading a version already loaded is a
`schema/duplicate-version` error; `Dextrin.Registry.replace_struct_schema/3`
hot-swaps a loaded version and refuses one that is not
(`schema/not-loaded`).
- **A struct literal may carry its version**: `%User(3){...}`,
`%User(3)[...]`, kept in `Dextrin.Struct`'s new `version` field and
written back by the printer, the formatter and the canonical form.
- **Every struct written against a schema carries the latest loaded
version of it**, in text (`%User(3){...}`, and `%User(1){...}` when
version 1 is the only one) and in `.dxnb`, where the type name becomes
`"User(3)"`. A struct with no schema keeps its own version, or none.
`Dextrin.Registry.as_written/2` decides; `Dextrin.Struct.type_name/2`
and `parse_type_name/1` spell it. A `.dxnb` type name whose version is
not a positive integer is a `binary` error. `DXN.md` §4.8 documents
how to deploy a schema change.
- **A struct is read according to the version it was written with**
(`DXN.md` §4.8, `Dextrin.Schema.Reader`), in text and binary alike. The
latest version, or none, is read exactly. A newer version is read
against the latest: text by name, binary by position, dropping items
beyond the reader's fields and leaving missing optional fields absent.
An older version that is loaded is read with its own schema and handed
to the materializer, and refused as `schema/no-materializer` without
one; an older version that is not loaded is refused as
`schema/unknown-version`.
- **One materializer per schema, for every version** (`DXN.md` §4.8).
`Dextrin.Registry.put_struct_materializer/3` takes a function of
`(schema_name, version, fields)`, `fields` keyed by string; it is
called for every decoded struct of its schema, upgrades older
versions, and its result is checked against the latest schema — a
refusal or a rejected result is `schema/materializer`.
`Dextrin.Schema.Provider`'s optional callback becomes
`dxn_materialize/3` accordingly.
- **Without a materializer, a checked struct decodes to a
`Dextrin.Struct`** at its schema's latest version, fields in the
schema's order, instead of a plain string-keyed map — so it encodes
back to the text it came from. An optional field that was absent stays
absent instead of becoming `nil`.
- The validator's `materialize/4` is replaced by `resolve/3`;
building the value is `Dextrin.Schema.Reader`'s job.
- **A struct may carry extra fields** (`DXN.md` §4.7), kept in
`Dextrin.Struct`'s new `extra` list and written back with it: in text
as entries — after a positional struct's items,
`%User(3)["a" "a@x" nick: "b"]` (the grammar's positional rule gains
trailing entries) — and in `.dxnb` as one trailing map under the new
private tag 215. A keyed struct's undeclared names, and any entry keyed
by something other than a name, are extras. An extra never shadows a
declared field (`schema/shadowed-field`); the materializer receives
extras with the declared fields; `strict: true` refuses them
(`schema/undeclared-field`, `Dextrin.Registry.put_strict/2`). A
registered application struct's runtime keys beyond its `defstruct`
are written as its extras (`Dextrin.Struct.runtime_extra/1`).
- A keyed struct entry whose key is not a name — `%Point{1 => 2}` — is an
extra field instead of an error.
- **A struct is matched only by a struct** (`DXN.md` §4.7). A field
typed `User` accepts a struct named `User`, or the application struct
registered for `User`; a plain map, any other value, and an application
struct registered for no schema or another are type errors. Until now
every non-struct value, and every unregistered application struct, was
accepted. `DXN.md` §4.7 states the rule and its test: a host value
always means the same DXN type, schema or not.
- **Removed: `closed:`, `forbidden:` and `refine-fn:`**, and with them
`Dextrin.Schema.compile/3`'s and the file resolver's `for_paths`
`predicates` argument. A struct may carry fields its schema does not
declare; `strict: true` refuses the undeclared ones, and the
schema's materializer checks what a predicate checked.
- Schema compile errors are `Dextrin.Error`s with the `schema/compile`
category, not bare strings.
- **A map key must be one of the eight types of `DXN.md` §1.6**
(`string`, `keyword`, `integer`, `uuid`, `date`, `bytes`, `symbol`,
`char`). Any other key -- a float, `nil`, a boolean, a time, a
collection, ... -- is refused as `key/not-allowed` when decoding text
or binary and when encoding, in maps and ordered maps alike.
`Dextrin.MapKey` holds the rule.
- `DXN.md` §1.5 states that nothing is normalised: a value is written
as it was given, so `1.50M` keeps its scale and `-0.0M` its sign, in
the canonical form as in every encoder.
### Fixed
- A symbol whose name is not an identifier is refused by the text
writers (`encode/unsupported`): `encode/2`, the pretty printer and the
canonical form wrote its name verbatim, which read back as something
else, or as nothing (`""`). `.dxnb` keeps any name.
- A keyed struct written to `.dxnb` went on the wire in the order of
its keys rather than its schema's field order, so `%Point{y: 2, x: 1}`
came back with its fields swapped.
## [0.1.3] - 2026-08-18
### Fixed
- `decimal` bumped `~> 2.1` -> `~> 3.0` -- `mix hex.audit` flagged the resolved `2.4.1` against a real, published MEDIUM-severity advisory (EEF-CVE-2026-32686, "unbounded exponent in decimal enables unauthenticated DoS") while adding a downstream consumer. This package parses untrusted `.dxn`/`.dxnb` input directly into `Decimal.new/1,3` (`lib/dextrin/text/actions.ex`, `lib/dextrin/binary/decoder.ex`) -- exactly the attack surface the advisory describes, not an incidental transitive path. Every `Decimal.*` call this package makes (`new/1`, `new/3`, `to_float/1`, `to_string/2`) is unchanged, stable core API across 2.x and 3.x -- confirmed by the full existing test suite passing unmodified against `3.1.1`.
## [0.1.2] - 2026-08-14
### Added
- `Dextrin`'s own moduledoc now names its two sibling ports
(`node-dextrin` on npm, `php-dextrin` on Packagist) directly,
alongside the existing README "Other language implementations"
section and `mix.exs` package links.
### Fixed
- `Dextrin.Text.Formatter` (`encode/2`'s `pretty: true` path) rendered
a map entry's *key* incorrectly in two cases, both traced to
`render_entry/4` conflating "render this as a value" with "render
this as a keyword key":
- A string-typed key that happened to look like a bare identifier
(e.g. `"id"`) rendered as keyword-shorthand (`id: 1`) instead of
the quoted arrow form (`"id" => 1`) — silently changing the key's
type from `string` to `keyword` on the next decode. This is also
why a schema-materialized struct (whose field map is always
string-keyed) round-tripped incorrectly once pretty-printed.
- A map keyed by exactly the atom `:nan`, `:positive_infinity`, or
`:negative_infinity` — the same atoms a *float* `NaN`/`Infinity`/
`-Infinity` decodes to — rendered the key as the float sigil
(`Infinity => ...`) instead of the keyword it actually was
(`positive_infinity: ...`), because the key fell through to the
same code path that (correctly, in *value* position) special-cases
those three atoms.
`Dextrin.Text.Printer` (the compact, non-pretty path) was not
affected — only the multi-line formatter had this bug. Found via a
cross-language round-trip check against `node-dextrin`/`php-dextrin`.
## [0.1.1] - 2026-08-03
### Changed
- Bumped `ichor_runtime` to `~> 0.2` (and dev-only `ichor` to `~> 0.3`, its
matching release) for the fix to `Ichor.Actions`' `eval_all` sometimes
evaluating sibling captures out of source order. Regenerated
`lib/dextrin/text/grammar/native.ex` via `mix ichor.gen` and updated
the private `@ordered`-map re-entry helper in `Dextrin.Text.Actions`
for `ichor_runtime`'s breaking change to raw capture data: an ordered
`[{name, value}]` list instead of a plain map. No user-visible
behavior change.
## [0.1.0] - 2026-07-30
### Added
- **`.dxn` text**: full grammar support for all 30 types DXN.md §1.3
defines, via a hand-authored Aether grammar (`priv/grammar/dxn.aether`)
compiled at build time through Ichor's `Grammar.Native` backend.
- **`.dxnb` binary**: a hand-rolled CBOR codec (`Dextrin.Binary.Encoder`/
`Decoder`) covering the full type mapping, the private tag block
(200-214), the duration bitmask and regex flags byte, and both
value-sharing extensions (string-only, tag 256/25; general arbitrary-
value sharing, tags 28/29 — decode-only required, encode opt-in via
`share: true`, gated by a size-aware threshold rather than a fixed rule).
- **`.dxns` schema documents**: `Dextrin.Schema.compile/3` compiles a
decoded `.dxns` document (itself plain `.dxn` data, no new grammar)
into a `Dextrin.Registry` — struct schemas with required/optional
(`?`-suffixed keys)/closed/forbidden fields and `refine` constraints,
plus reusable named types composed from the fixed 13-form type_expr
vocabulary. Enforcement is automatic, fail-fast, and symmetric:
`Dextrin.decode/2`/`decode_binary/2` check every registered struct
name unconditionally on the way in; `Dextrin.encode/2`/
`encode_binary/2` do the same automatic whole-tree check on the way
out (`validate: false` opts out), plus an opt-in `schema:` check for
a nameless top-level value.
- **`Dextrin.Registry`**: the shared extension point for both `struct`
(schema-driven, `put_struct_materializer/3`/`put_struct_module/3`)
and `custom-tag` (`put_tag/3`/`put_tag_encoder/4`), plus a lazy
`put_resolver/2` hook for on-demand schema loading.
- **`Dextrin.Schema.Std`**: a small standard library of common named
types (`PositiveInteger`, `NonEmptyString`, `Percentage`, ...),
opt-in via `Std.registry/1`.
- **`Dextrin.Schema.FileResolver`**: one reasonable, swappable
convention resolving `Namespace/Name` references to
`<path>/Namespace.dxns` files on disk.
- **Value types**: `Dextrin.Symbol`, `Dextrin.Keyword` (never Elixir
atoms — decoding untrusted data can't exhaust the atom table),
`Dextrin.Tuple`, `Dextrin.Array`, `Dextrin.OrderedMap`,
`Dextrin.SortedSet`, `Dextrin.Struct`, `Dextrin.Duration`,
`Dextrin.Rational`, `Dextrin.Uuid`, `Dextrin.Uri`, `Dextrin.Bytes`,
`Dextrin.Char`, `Dextrin.CustomTag` — small wrapper structs only
where Elixir has nothing native that fits without losing information.
- **`Dextrin.Text.Formatter`**: multi-line, indented `.dxn` rendering
(`pretty/2`), built on top of `Dextrin.Text.Printer`'s single-line
default.
- **`mix dextrin.*` tasks**: `validate`, `encode`, `decode`, `format`,
`gen.schema` (scaffold a `.dxns` file from an existing Elixir
struct's field list), and `gen.unicode` (regenerate the grammar's
Unicode `XID_Start`/`XID_Continue` identifier ranges from the latest
Unicode Character Database — a deliberate, reviewed action, never
run at build time).
- Conformance fixtures covering `DXN.md` §3's full worked example,
per-type round-trip tests, cross-format equivalence tests, grammar
hazard regression tests, and a fuzz/malformed-input pass over the
binary decoder.
- `Dextrin.encode/2` gained `pretty:` and `indent:` opts — `pretty:
true` produces multi-line, indented output (`indent:` sets spaces
per level, default 2) instead of the single-line/compact default.
Either way, schema validation runs the same; formatting and
validation are independent concerns.
- `Dextrin.Schema.Provider`, a behaviour letting a struct's own library
ship a DXN schema for it without that library ever depending on
`dextrin` — the schema, the target struct module, and an optional
materializer are declared on a small, separately-compiled companion
module (meant to be guarded behind an `optional: true` dependency),
and `Dextrin.Schema.register_provider/2` compiles and wires it into
a `Dextrin.Registry` in one call from the consuming application.
- Property-based tests (`stream_data`/`ExUnitProperties`, already a
declared dependency but previously unused): a shared
`Dextrin.Generators` module, round-trip properties covering
`encode/2`/`decode/2`, `encode_binary/2`/`decode_binary/2`,
`share: true`, and `pretty: true` against randomly generated values,
a `Dextrin.Binary.FuzzTest` asserting `decode_binary/2` never raises
or hangs on arbitrary or bit-flipped input, and a per-type property
suite (`Dextrin.RoundTripByTypeTest`) covering every DXN type
individually rather than mixed into one nested-structure generator.
These found several of the bugs listed below on their very first run.
- `Dextrin.encode/2`/`encode_binary/2` accept a bare Elixir atom
anywhere a `keyword` value is expected — as a map key (`%{x: 1}`,
matching the shorthand's own colon syntax) or standalone
(`Dextrin.encode(:ok)`) — exactly as if it were
`Dextrin.Keyword.new(Atom.to_string(atom))`. `DXN.md` §1.3's own type
table documents `keyword`'s Elixir counterpart as "Elixir atom".
`nil`/`true`/`false` are unaffected — those stay their own literals.
- `Dextrin.decode/2`/`decode_binary/2` gained a `trusted:` opt
(default `true`) mirroring the above on the way in: `keyword` decodes
as a real Elixir atom by default, the same natural type `encode/2`
now accepts. Pass `trusted: false` — per call, or once on a reused
registry via the new `Dextrin.Registry.put_trusted/2` — for any
source you don't fully control; only then does `keyword` fall back
to `Dextrin.Keyword.t()`, so a `String.to_atom/1` call is never
reachable from attacker-controlled text. `symbol` is unaffected
either way — it stays `Dextrin.Symbol.t()` regardless of `trusted`,
mirroring `encode/2`'s own choice to treat a bare atom as a stand-in
for `keyword` only, never `symbol`.
### Changed
- **Breaking:** `Dextrin.Registry.new/0`'s `trusted` field — and so
`decode/2`/`decode_binary/2`'s effective default — is `true`, not
`false`. Any code relying on the previous behavior (`keyword`
decoding as `Dextrin.Keyword.t()` with no opt given at all) needs
`trusted: false` added explicitly. This is deliberate: `keyword`'s
wrapper only ever existed to keep untrusted input from reaching
`String.to_atom/1` unbounded; assuming a trusted source (your own
config, your own application's data) by default matches how the
type is documented (`DXN.md` §1.3) and how most callers actually use
this library, at the cost of needing an explicit opt-out for
genuinely untrusted/network input.
- **Breaking:** `Dextrin.Text.Printer`'s (and so `Dextrin.encode/2`'s
default, non-`pretty:` output) is now maximally compact: no space
after a map/struct entry's `:`, no space around `=>`, and entries/
positional-struct fields are comma-joined with no trailing space
(`%{x:1,y:2}`, `%Point{x:1,y:2}`, `%{1=>"one"}`) rather than the
previous `%{x: 1, y: 2}`-style spacing. DXN's own grammar treats
whitespace and commas as insignificant everywhere, so this loses no
information and every value still round-trips identically through
`decode/2`; only the default *rendering* got smaller. List/tuple/
set/array element separators and `@tag value`'s space are unchanged
(the former have nothing to gain — comma and space are both a
single byte — and the latter's space can't be dropped in general for
a custom tag whose value could start with an identifier character).
- **Breaking:** `Dextrin.Text.Formatter.pretty/2` now returns
`{:ok, String.t()} | {:error, Dextrin.Error.t()}`, matching
`Dextrin.Text.Printer.print/2`'s contract, instead of a bare
`String.t()` that raised `ArgumentError` on an unencodable value.
Also gained the same `indent:` opt `Dextrin.encode/2`'s new
`pretty:`/`indent:` opts use internally (default 2 spaces per
nesting level, previously a fixed, non-configurable 2).
- Adopted ichor's new `mix ichor.gen`/`ichor_runtime` split: the `.dxn`
lexer/parser is now generated ahead of time into
`lib/dextrin/text/grammar/native.ex` (checked in, regenerated via
`mix ichor.gen` whenever `priv/grammar/dxn.aether` changes) instead
of being produced by `use Ichor` at `dextrin`'s own compile time.
`Dextrin.Text.Grammar` is now a thin, hand-documented wrapper around
the generated `Grammar.Native`. This lets `mix.exs` depend on the
small `ichor_runtime` package (the only thing the generated code
actually calls) as an ordinary runtime dependency, while `ichor`
proper (the Aether front-end, format importers, `Grammar.Analysis`,
both codegen backends) moves to `only: :dev, runtime: false` — the
bulk of Ichor no longer ships in a `dextrin` release. Both are now
published to Hex separately (`ichor ~> 0.2.1`, `ichor_runtime ~>
0.1.0`) and referenced as ordinary Hex dependencies — no `git:`/
`sparse:`/`override:` needed, since `ichor`'s own `mix.exs` now
depends on `ichor_runtime` the same way. Migrating surfaced a genuine
gap in that split, fixed upstream: the raw-capture-node re-evaluation
entry point needed by `Dextrin.Text.Actions` at actual decode time
(for `@ordered %{...}`) had stayed on the top-level, dev-only `Ichor`
module instead of moving to `ichor_runtime`; it's now
`Ichor.Actions.evaluate_node/3`.
- Added `credo`, `dialyxir`, `sobelow`, and `excoveralls` as dev/test
tooling, plus a `mix precommit` alias (`format`, `compile
--warnings-as-errors`, `credo --strict`, `sobelow`, `test`,
`dialyzer`) run before every commit. Fixed everything it surfaced:
canonical module layout (`moduledoc`/`use`/`alias`/...) across the
mix tasks and text pipeline modules; `Dextrin.decode/2`'s spec
under-declared its own return type (the underlying grammar engine
can report a list of errors, not just one) — widened to match, which
also resolved three "dead code" warnings in the mix tasks' own error
handling; removed a dead `Duration.microsecond || {0, 0}` fallback
(that field is never `nil`).
- Every hand-rolled "walk a collection, thread an accumulator, halt on
the first non-`{:ok, _}` step result" reduce across
`Dextrin.Schema.Compiler`, `Dextrin.Schema.Validator`,
`Dextrin.Binary.Encoder`, `Dextrin.Text.Printer`, and
`Dextrin.Text.Actions` now uses `Ichor.Toolkit.Result.reduce_ok/3`/
`map_ok/3` instead of a hand-rolled `Enum.reduce_while/3`.
- Documentation reworked throughout: every module's docs are now
self-contained (no references to an external design document), and
the normative `DXN.md` format specification moved under
`guides/dxn/DXN.md`, alongside a full set of tutorials, examples,
and cheatsheets for both this library and the DXN format itself.
`DXN.md` itself gained a new normative §4, "Schema documents
(`.dxns`)" — the `type_expr`/`refine`-constraint vocabulary,
`%schema{}`/`%field{}` shape, named types, cross-file references, and
enforcement semantics, written implementation-independent (this
library's own choices stay documented separately, in
`Dextrin.Schema`'s own module docs) — closing a gap where `.dxns`
was documented everywhere *except* the one place meant to be
the authoritative, cross-implementation reference for it.
### Fixed
- Struct literal parsing (`%Point{x: 1, y: 2}`) and `.dxns` schema
compilation both depended, internally, on `keyword`/map-key text
always decoding as `Dextrin.Keyword`/`Dextrin.Symbol` — exposed as a
real regression the moment `trusted: true` became the default, not
merely a test-assertion mismatch: struct literals failed to parse at
all, and any schema whose own `.dxns` source was decoded trusted
failed to compile. Fixed at the boundaries that actually needed it:
`Dextrin.Text.Actions`'s struct-field-name resolution now accepts a
bare atom the same way it already accepted `Dextrin.Keyword`
/`Dextrin.Symbol`; `Dextrin.Schema.Compiler.compile/3` normalizes a
decoded `.dxns` document's atoms back to `Dextrin.Keyword` once, up
front (mirroring `Dextrin.Schema.Validated.strip/1`'s exact
recursive-walk shape), so its own `.dxns`-vocabulary pattern
matching needs no changes at every site; a schema's `enum` literals
and a `:keyword`-typed field's actual data can now be either shape
independently, compared correctly either way instead of only ever
matching the untrusted shape. `default: nil`/`true`/`false` field
values are deliberately *not* touched by that normalization (only
`.dxns` syntax positions are) — and `x: :nil` as a schema field's
*type* (the `"nil"` primitive) has its own dedicated fix, since
`String.to_atom("nil")` and the bare literal `nil` are, once
decoded, the exact same unrecoverable Elixir value.
- `Dextrin.Binary.Decoder`'s handling of the `time` tag (210) had no
fallback clause — a malformed payload that wasn't an integer crashed
with `FunctionClauseError` instead of a clean `{:error, _}`, the one
tag missed in the earlier decoder-hardening pass. Found by the
property-based fuzz test firing again after the `trusted:` default
flip added more generated shapes to exercise it with.
- `Dextrin.Text.Printer`/`Dextrin.Text.Formatter` no longer produce
unparseable text for a map entry or struct field whose
`Dextrin.Keyword`/field name isn't a bare `identifier` (empty,
containing spaces, ...) — `map_entry`'s colon-shorthand (`DXN.md`
§1.2) only exists for an `identifier`; both printers now fall back
to the arrow form (`:"name" => value`) for anything else, instead of
emitting invalid syntax like `%{"":0}` or a bare `: 0` that failed to
parse back at all. `Dextrin.Text.Printer.bare_identifier?/1` is now
public so both printers share one rule.
- `Dextrin.Binary.Decoder` no longer crashes (raises) on a malformed
`.dxnb` document that places a `tuple`/`array`/`set`/`sorted-set`
tag around a non-array payload, a bignum/`uuid` tag around a
non-bytes payload, a `uri`/`symbol`/`keyword` tag around non-text, a
`char` tag around a non-codepoint or out-of-range value, a `date`
tag around a non-integer, or a `timestamp`/`datetime` epoch outside
the representable range — every one of these now returns a clean
`{:error, %Dextrin.Error{}}` instead of an unhandled
`FunctionClauseError`/`ArgumentError`/`CaseClauseError`. Found by the
new property-based fuzz test, not a hand-picked case.
- `Dextrin.Text.Printer.print/2` no longer crashes on a value with no
DXN representation at all (a PID, a port, a reference, a function, a
raw Elixir tuple that isn't `Dextrin.Tuple`, ...) — a clean
`{:error, %Dextrin.Error{}}` now, matching `encode/2`'s own typespec,
instead of an unhandled `FunctionClauseError`.
- `@ordered %{}` (an *empty* ordered map) failed to parse at all
(`"@ordered requires a map literal argument"`), even though `%{}` on
its own decodes fine — the action handler required its raw capture
tree to have a `:map_entry` key present, which a zero-entry map
simply doesn't have (the same reason the ordinary, non-`@ordered`
`map_lit` handler already defaults it to `[]`). Found by the new
per-type property suite.
- `Dextrin.Binary.Decoder` now accepts `DXN.md` §2.4's CBOR string
-reference sharing extension (tags 256/25) — previously an
otherwise-well-formed `.dxnb` document using it (to compactly
represent repeated `symbol`/`keyword` text or repeated struct type
names) failed to decode at all (`"unrecognized CBOR tag 25"`), which
violated §2.4's "a conforming decoder MUST accept it" requirement.
`dextrin`'s own encoder still never produces tags 256/25 (that
remains out of scope, same as before) — this is decode-side
acceptance only, same stance as §2.5's tag 28/29 support.
- `Dextrin.encode/2`/`encode_binary/2` can now encode a registered
application struct (`Dextrin.Registry.put_struct_module/3`) directly
— previously, only a hand-built `Dextrin.Struct` could actually be
serialized; a real struct registered via `put_struct_module/3` was
recognized by automatic encode-time *validation* but had no path to
actually being written out, forcing callers to reconstruct a
`Dextrin.Struct` from the real struct's own fields by hand before
encoding. `Dextrin.Text.Printer`/`Dextrin.Binary.Encoder` now rebuild
the equivalent `Dextrin.Struct` automatically, in the compiled
schema's own canonical field order.