Macro for defining versioned checkpoint schemas with migration support.
Checkpoint schemas declare the current version and provide migrate/2
callbacks that transform state from older versions to the current one.
Usage
defmodule MyApp.Checkpoints.POInvestigation do
use Cyclium.CheckpointSchema, version: 2
def migrate(1, state) do
contacts = (state["vendor_contacts"] || [])
|> Enum.group_by(& &1["vendor_id"])
{:ok, Map.put(state, "vendor_contacts", contacts)}
end
def migrate(2, state), do: {:ok, state}
def migrate(_v, _state), do: {:error, :unsupported_version}
endRegistration
Declare the schema on the expectation (preferred — keeps it next to the strategy whose state it versions):
expectation(:investigate_po,
strategy: MyApp.Strategies.POInvestigation,
checkpoint_schema: MyApp.Checkpoints.POInvestigation,
...
)Or register in app config, which takes precedence over the expectation declaration (useful as a deploy-time override):
config :cyclium, :checkpoint_schemas, %{
{"my_actor", "investigate_po"} => MyApp.Checkpoints.POInvestigation
}Guidelines
- Store IDs and refs, not full payloads
- Keep state flat — avoid deeply nested structures
- No raw tool responses in checkpoints
- Normalize early before checkpointing
Summary
Functions
Raises ArgumentError unless state survives a JSON round-trip unchanged.
The bang companion to json_plain?/1, for use as a test assertion.
True when state survives a JSON round-trip unchanged — the constraint every
checkpoint state must satisfy.
Resolve the checkpoint schema module for an actor/expectation pair.
Functions
@spec assert_json_plain!(term()) :: :ok
Raises ArgumentError unless state survives a JSON round-trip unchanged.
The bang companion to json_plain?/1, for use as a test assertion.
True when state survives a JSON round-trip unchanged — the constraint every
checkpoint state must satisfy.
save_checkpoint persists state into a :map (nvarchar(max)) column, so the
Ecto/TDS adapter Jason.encode!s it on write and the restore path
Jason.decode!s it on read. Values that encode but don't round-trip are the
quiet trap: atom keys and atom values become strings, tuples fail to encode at
all, and structs either crash or lose their identity. Because init/2 is not
re-run on resume, a state that silently changed shape comes back subtly wrong.
Use this (or assert_json_plain!/1) in a strategy's checkpoint test to guard
the constraint at the source rather than discovering it after a resume.
test "checkpoint state stays JSON-plain" do
assert Cyclium.CheckpointSchema.json_plain?(MyStrategy.initial_state())
end
Resolve the checkpoint schema module for an actor/expectation pair.
Precedence:
- App-config override —
config :cyclium, :checkpoint_schemas, keyed by{actor_id, expectation_id}oractor_id(matched against the raw episode values, which are strings for DB-loaded episodes) - Expectation-declared schema (
checkpoint_schema: MyModule), registered in persistent_term when the actor boots
Returns nil when no schema is registered. Used by both the checkpoint
write path (version stamping) and the restore path (migration) — they must
agree on the schema or migration chains break.