Cyclium.CheckpointSchema (Cyclium v0.4.1)

Copy Markdown View Source

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}
end

Registration

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

assert_json_plain!(state)

@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.

json_plain?(state)

@spec json_plain?(term()) :: boolean()

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(actor_id, expectation_id)

@spec resolve(atom() | binary(), atom() | binary()) :: module() | nil

Resolve the checkpoint schema module for an actor/expectation pair.

Precedence:

  1. App-config override — config :cyclium, :checkpoint_schemas, keyed by {actor_id, expectation_id} or actor_id (matched against the raw episode values, which are strings for DB-loaded episodes)
  2. 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.