Effects

An effect is inert data returned from update; the host interprets it and feeds the result back as a Msg through a defunctionalized callback. No closure crosses that boundary — which is what makes traces replayable.

Store

Cmd.store.insert(%App.Row{text: "x"}, fn (id : Uuid) -> App.Msg.Saved(id) end)
Cmd.store.list(fn (rows : List<App.Row>) -> App.Msg.Loaded(rows) end)
Cmd.store.get(r.id, fn (row : Option<App.Row>) -> App.Msg.Got(row) end)
Cmd.store.update(r.id, %App.Row{r | text: "y"}, fn (id : Uuid) -> App.Msg.Saved(id) end)
Cmd.store.delete(r.id, fn (old : Option<App.Row>) -> App.Msg.Removed(old) end)

Which store a call targets is derived statically: from the value's type for insert/update, and from the callback's element type for list/get/delete. So the callback annotation is load-bearing — it is not decoration.

Identity is the store's. A stored model declares id : Uuid; you omit it when constructing and the store mints it on insert. Ids you pass to get/update/delete come from rows you read back (r.id) — you never fabricate one. A Uuid is type-distinct from String, so it cannot be concatenated and a name cannot be passed where an id belongs.

Transactions

All-or-nothing. Every op is validated before any write, so a rejected op leaves the store untouched. One tx may mix models.

Cmd.store.tx(
  [Tx.insert(%App.Row{n: 1}), Tx.update(r.id, %App.Row{r | n: 2}), Tx.delete(old)],
  fn (ids : List<Uuid>) -> App.Msg.Done(ids) end)

Tx.delete takes the row, not an id — the model comes from the value's type exactly as insert does, and the id travels inside the row. One rule for all three ops, and a transaction may still mix models.

Replacing a set atomically is the common case (re-analysing input, resyncing): delete what is there and insert the new rows in ONE transaction, so a reader never sees a half-empty store.

Cmd.store.tx(
  List.concat(
    List.map(model.plan, fn (w : App.Word) -> Tx.delete(w) end),
    List.map(fresh, fn (w : App.WordInput) -> Tx.insert(%App.Word{text: w.text}) end)),
  fn (ids : List<Uuid>) -> App.Msg.Saved end)

Without this the mistake is appending: re-running an import silently doubles the rows.

AI

Cmd.ai.generate("gpt-5.6-luna", prompt, fn (reply : String) -> App.Msg.Ready(reply) end)

The model is an argument, so the app chooses per call. Two things learned the hard way:

prompt ("each object MUST have all five keys: text (String), freq (integer), ..."), and say "no prose, only JSON".

ceiling the reply comes back empty, and JSON.parse then fails with invalid JSON: Unexpected end of JSON input. If you see that, suspect the cap before the prompt.

The AI → typed data pipeline

The full shape, with the failure path handled:

# 1. a dedicated input model for what the AI supplies (the store owns id;
#    the app owns derived flags) — so the decode target is exactly the reply
defmodel App.WordInput do
  text : String
  freq : Int
  reading : String
end

# 2. fire, tracking that we are working
App.Msg.Analyze ->
  (%App.Model{model | error: "", status: "analyzing"},
   Cmd.ai.generate("gpt-5.6-luna", String.concat(instructions, model.transcript),
     fn (reply : String) -> App.Msg.Ready(reply) end))

# 3. decode, then persist — Err carries the reason, so surface it
App.Msg.Ready(reply) ->
  case JSON.parse(reply) do
    Result.Ok(inputs) ->
      (%App.Model{model | status: ""},
       Cmd.store.tx(
         List.map(inputs, fn (w : App.WordInput) ->
           Tx.insert(%App.Word{text: w.text, freq: w.freq, reading: w.reading, learned: false})
         end),
         fn (ids : List<Uuid>) -> App.Msg.Saved end))
    Result.Err(e) ->
      (%App.Model{model | error: e, status: ""}, Cmd.none())
  end

Always render model.error somewhere. A failed generation that vanishes silently is the most common defect in an AI-backed app.

JSON

JSON.parse(s) : Result<T, String> is type-directed — T comes from the call site, and the runtime accepts a value only if it truly inhabits T (rejecting a float for Int, a string for Bool, a missing field, a wrong container). It cannot coerce, so a green Ok is a real guarantee.

The target must be statically known. Bind it to a typed field, a Msg payload, or an annotation — an ambiguous target is a compile error, not a runtime surprise.

JSON.stringify(v) : String cleans as it goes: records drop their type tag, Option becomes value-or-null, tuples become arrays. Use it to send model data into a prompt.

Blobs

Env-scoped object storage for text (up to 256KB per value); an empty key is refused. Use it for content that is not a row: a pasted transcript, a rendered artifact.

Cmd.blob.put("transcripts/a", text, fn (key : String) -> App.Msg.Stored(key) end)
Cmd.blob.get("transcripts/a", fn (v : Option<String>) -> App.Msg.Got(v) end)
Cmd.blob.list("transcripts/", fn (keys : List<String>) -> App.Msg.Keys(keys) end)
Cmd.blob.delete("transcripts/a", fn (key : String) -> App.Msg.Dropped(key) end)

Time, randomness, batching

Cmd.time.now(fn (ms : Int) -> App.Msg.Stamped(ms) end)
Cmd.random.int(1, 6, fn (n : Int) -> App.Msg.Rolled(n) end)
Cmd.batch([cmdA, cmdB])
Cmd.none()

random.int is inclusive and refuses an empty range. Never reach for a host clock or RNG directly — these exist so a run is reproducible under replay.