The Markdown Store Backend
The store layer is a generation-time choice (ADR 0001): gen_store takes a
Backend discriminant and emits one CRUD module per entity wired to that
backend’s primitives. With Backend::Markdown, your records are plain
markdown files with YAML frontmatter — editable in any editor, diffable in
git, navigable in Obsidian — and the generated store reads and writes them
through the markdown-store
runtime crate.
The load-bearing invariant: everything above the store is byte-identical
between backends. gen_api, gen_servers, and gen_clients output the
same code whether the store talks to SQLite or to a folder of .md files —
enforced in CI by tests/backend_parity.rs.
When to pick which backend
Section titled “When to pick which backend”| SeaORM | Markdown | |
|---|---|---|
| Sweet spot | transactional, high-write, large-N | small-N, human-editable, read-heavy |
| Records | rows | vault/<entity>/<id>.md |
| Co-editing | via the app | any editor, Obsidian, sed, an agent |
| List cost | indexed SQL | parse-the-folder, capped (default 10k) |
| Multi-record atomicity | transactions | none — single-record only |
| Ids | your call | String, always (the id is the filename) |
Knowledge bases, planning trackers, configuration vaults: markdown. Anything that needs joins at scale or batch transactions: SeaORM.
Wiring it
Section titled “Wiring it”Pipeline::new("src/schema") .markdown_io( "src/persistence/markdown/generated", MarkdownIoOptions { vault_root: "data/vault".into(), layout: MarkdownLayout::PerEntityDir, id_strategy: IdStrategy::SlugFromField("title".into()), list_cap: 10_000, }, ) .dtos("src/schema/dto") .store("src/store/generated", Some("src/store/hooks")) .api("src/api/v1/generated", "AppState") .build()?;With exactly one persistence stage configured, the store backend is
inferred. Enable both seaorm(...) and markdown_io(...) and you must call
.store_backend(StoreBackendChoice::…) to disambiguate.
What the consumer writes by hand
Section titled “What the consumer writes by hand”The entire delta from a SeaORM consumer:
pub struct Store { vault: markdown_store::VaultHandle, // was: db: Arc<DatabaseConnection> change_tx: broadcast::Sender<EntityChange>,}impl Store { pub fn vault(&self) -> &markdown_store::VaultHandle { &self.vault } // was: db() // emit_change()/subscribe(): identical. No sync_junction/load_junction_ids — // many-to-many lives in frontmatter.}…and one error variant:
pub enum AppError { TaskNotFound(String), // per-entity NotFound: same as SeaORM Md(String), // replaces DbError}impl From<markdown_store::Error> for AppError { /* Md(e.to_string()) */ }Construct the vault at startup:
let vault = VaultHandle::new("data/vault", VaultLayout::PerEntityDir, IdStrategy::SlugFromField("title".into()));let state = AppState::new(vault);Semantics worth knowing
Section titled “Semantics worth knowing”- Relations are wikilinks. A
belongs_toisepic_id: '[[E0042]]'in frontmatter; amany_to_manyis a wikilink list on the owning record (the authoritative side — no junction tables).has_manyis never stored: it’s a derived view, answered by walking the child folder and filtering on the foreign key. Generated code strips brackets at its typed boundary; your JSON API never sees them. (That stripping is a policy, not a hardcoded behaviour — the markdown backend just defaults to it.) - Creates can derive ids. POST without an id (Create DTOs carry
#[serde(default)]onid) andSlugFromFieldslugifies the configured field, de-duplicating with-2,-3, … — atomically with the write. - Hand edits survive. The runtime’s
Documentround-trip preserves unknown keys, key order, and the body; untouched files re-render byte-for-byte, and a no-op update doesn’t even touch the file. - Stable order, loud ceiling.
listreturns lexicographic-by-id and errors pastlist_cap— the deliberate “wrong backend for this N” signal. - Single-record atomicity only (same-dir tempfile + fsync + rename). Need batch transactions? That’s the other backend.
Wikilinks on a SQL-backed store
Section titled “Wikilinks on a SQL-backed store”Wikilink stripping is a StoreConfig field, not a property of the markdown
backend. Each backend supplies a default — markdown strips, SQL passes through
— and wikilink_policy: None picks it up:
pub enum WikilinkPolicy { Strip, // [[id]] -> id on every relation field Passthrough, // relation ids pass through untouched}Almost everyone wants the default. Set it explicitly for the hybrid case: a SQL-backed store whose wire contract still accepts wikilinked ids — an API fed by markdown-authoring agents, or one migrating off a vault while keeping its callers working.
StoreConfig { // ... backend: Backend::Seaorm(Some(seaorm)), wikilink_policy: Some(WikilinkPolicy::Strip),}The generated DTO From impls will then accept "[[task-42]]" and store
"task-42".
See it running
Section titled “See it running”Three examples, smallest to richest:
examples/iron-log-md— iron-log’s exact schema on markdown;diff -rits generatedapi/v1against iron-log’s to watch the byte-identical invariant hold.examples/tasks-tracker— a planning vault (this repo’s own docs/planning shape) over HTTP and the generated MCP tool registry.examples/notes-kb— wikilinked notes rendered as a graph.
The CI-enforced reference consumer is
crates/markdown-pilot:
every workspace test run compiles and executes the generated markdown
store.