use DustEcto.Schema, prefix: ["links"], required: [:slug, :title]
pairs an Ecto.Schema (embedded) with a Dust prefix and the slug
field used as the per-record namespace key.
Usage
defmodule MyApp.Reading.Link do
use DustEcto.Schema,
prefix: ["links"], # required: segment list
required: [:slug, :title, :url], # used by changeset + Repo.all guard
mode: :flat # :flat (default) | :map
embedded_schema do
field :title, :string
field :url, :string
field :note, :string
end
def changeset(link, attrs) do
link
|> cast(attrs, [:slug, :title, :url, :note])
|> validate_required(__dust_required_fields__())
|> validate_dust_slug(:slug)
end
endMulti-segment prefixes are a non-empty list of segments. Earlier
versions accepted a dotted string ("reading.links"); after the
segment-first migration, this must be an explicit list
(["reading", "links"]) so dots in segments survive intact.
Storage modes
Pick :flat (the default) unless you know you want :map.
:flat (default) — one PUT per field; record lives on the wire as
N leaves at <prefix>/<slug>/<field> (canonical slash form). This
is the natural Dust shape: other writers (MCP, curl, sibling clients)
can edit a single field without knowing the rest of the record, and
per-field subscriptions are granular. Cost: writes are not atomic
across fields; a partial update is observable until the last PUT
lands.
:map — one PUT for the whole record at <prefix>/<slug>, with
the dumped struct as the value. Atomic, single revision per record.
Cost: writes from outside the schema (a curl that PUTs
links/foo/title directly) race with the next :map write that
clobbers the whole record. Use when you are the only writer and you
need whole-record atomicity.
Reads work identically in both modes — Dust stores everything as flat
leaves on disk, and Repo.get/2 GETs the slug path which the server
assembles back to a map.
What the macro provides
use Ecto.Schema+import Ecto.Changeset@primary_key {:slug, :string, autogenerate: false}__dust_prefix__/0— the prefix segment list (["reading", "links"])__dust_mode__/0—:flat(default) or:map__dust_required_fields__/0— the:requiredlist, used by both the user'svalidate_requiredandDustEcto.Repo.all/1's read-time guard so they stay in sync. Necessary because Ecto'svalidate_requiredis a runtime check with no introspectable metadata.validate_dust_slug/2— closes path-shape footguns by rejecting empty slugs, slugs containing.(would mis-shape a legacy record path; harmless under segment-first storage but still rejected for clarity), and slugs containing/(would conflict with the canonical slash separator).
Summary
Functions
Validates that the given slug field is a non-empty string.
Functions
@spec validate_dust_slug(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
Validates that the given slug field is a non-empty string.
In capver 3 (segment-first paths), the slug is one path segment.
Literal . and / inside that segment are ordinary characters:
the dot is just a dot, and the slash is rendered as ~1 per RFC
6901 before hitting any wire / SQL key. So this validator only
rejects what's structurally invalid — empty strings and non-strings.
Use inside any changeset/2:
|> validate_dust_slug(:slug)