diff --git a/lib/goodwizard/actions/brain/helpers.ex b/lib/goodwizard/actions/brain/helpers.ex index ae69971..52a6f26 100644 --- a/lib/goodwizard/actions/brain/helpers.ex +++ b/lib/goodwizard/actions/brain/helpers.ex @@ -123,12 +123,28 @@ defmodule Goodwizard.Actions.Brain.Helpers do def format_error(:path_traversal), do: "Invalid path" def format_error(:body_too_large), do: "Body exceeds maximum size" def format_error(:update_locked), do: "Entity is locked by another operation" + + def format_error(:migration_required), + do: "Migration definition is required when updating a schema" + def format_error(:enoent), do: "File not found" def format_error(:eacces), do: "Permission denied" def format_error({:duplicate_id, _id}), do: "Duplicate entity ID" def format_error({:parse_error, _file, _reason}), do: "Failed to parse entity file" def format_error({:schema_resolution_error, _msg}), do: "Schema resolution failed" + def format_error({:invalid_schema_version, label}), + do: "#{label} version must be a positive integer" + + def format_error({:version_mismatch, expected, got}), + do: "Version mismatch: expected #{expected}, got #{got}" + + def format_error({:invalid_migration_definition, field}), + do: "Invalid migration definition: #{field}" + + def format_error({:migration_version_mismatch, field, expected, got}), + do: "Migration #{field} mismatch: expected #{expected}, got #{got}" + def format_error({:validation, errors}) when is_list(errors), do: format_validation_errors(errors) diff --git a/lib/goodwizard/actions/brain/migrate_entities.ex b/lib/goodwizard/actions/brain/migrate_entities.ex new file mode 100644 index 0000000..a4a5f21 --- /dev/null +++ b/lib/goodwizard/actions/brain/migrate_entities.ex @@ -0,0 +1,53 @@ +defmodule Goodwizard.Actions.Brain.MigrateEntities do + @moduledoc """ + Applies a stored schema migration to all entities of a given type. + """ + + use Jido.Action, + name: "migrate_entities", + description: "Migrate entities of a type from one schema version to the next", + schema: [ + entity_type: [ + type: :string, + required: true, + doc: "The entity type to migrate (e.g. \"people\", \"companies\")" + ], + from_version: [ + type: :integer, + required: true, + doc: "Source schema version" + ], + to_version: [ + type: :integer, + required: true, + doc: "Target schema version" + ], + dry_run: [ + type: :boolean, + default: false, + doc: "When true, validates and reports diffs without writing files" + ] + ] + + alias Goodwizard.Actions.Brain.Helpers + + @impl true + @spec run(map(), map()) :: {:ok, map()} | {:error, String.t()} + def run(params, context) do + workspace = Helpers.workspace(context) + + case Goodwizard.Brain.migrate( + workspace, + params.entity_type, + params.from_version, + params.to_version, + Map.get(params, :dry_run, false) + ) do + {:ok, summary} -> + {:ok, summary} + + {:error, reason} -> + {:error, Helpers.format_error(reason)} + end + end +end diff --git a/lib/goodwizard/actions/brain/save_schema.ex b/lib/goodwizard/actions/brain/save_schema.ex index 94ffc34..20aadba 100644 --- a/lib/goodwizard/actions/brain/save_schema.ex +++ b/lib/goodwizard/actions/brain/save_schema.ex @@ -16,6 +16,12 @@ defmodule Goodwizard.Actions.Brain.SaveSchema do type: {:map, :string, :any}, required: true, doc: "A valid JSON Schema object defining the entity structure" + ], + migration: [ + type: {:map, :string, :any}, + required: false, + doc: + "Optional migration definition map; required when updating an existing schema version" ] ] @@ -30,7 +36,7 @@ defmodule Goodwizard.Actions.Brain.SaveSchema do schema = ensure_metadata_property(params.schema) with :ok <- validate_schema_structure(schema) do - case Schema.save(workspace, params.entity_type, schema) do + case Schema.save(workspace, params.entity_type, schema, Map.get(params, :migration)) do :ok -> Goodwizard.Cache.delete("brain:schema_summaries:#{workspace}") tool_msg = regenerate_tools(workspace, params.entity_type) diff --git a/lib/goodwizard/agent.ex b/lib/goodwizard/agent.ex index 643b169..5dfb812 100644 --- a/lib/goodwizard/agent.ex +++ b/lib/goodwizard/agent.ex @@ -79,6 +79,7 @@ defmodule Goodwizard.Agent do Goodwizard.Actions.Brain.ListEntities, Goodwizard.Actions.Brain.GetSchema, Goodwizard.Actions.Brain.SaveSchema, + Goodwizard.Actions.Brain.MigrateEntities, Goodwizard.Actions.Brain.ListEntityTypes ], model: "anthropic:claude-sonnet-4-5", diff --git a/lib/goodwizard/brain.ex b/lib/goodwizard/brain.ex index 58c5b5f..5d533fb 100644 --- a/lib/goodwizard/brain.ex +++ b/lib/goodwizard/brain.ex @@ -8,7 +8,7 @@ defmodule Goodwizard.Brain do require Logger - alias Goodwizard.Brain.{Entity, Id, Paths, References, Schema, Seeds} + alias Goodwizard.Brain.{Entity, Id, Migration, Paths, References, Schema, Seeds} @system_fields ["id", "created_at", "updated_at"] @max_list_entities 1_000 @@ -209,6 +209,25 @@ defmodule Goodwizard.Brain do end) end + @doc """ + Migrates entities for a type from one schema version to the next. + + Loads the migration definition and executes it, or performs a dry-run + when `dry_run` is true. + """ + @spec migrate(String.t(), String.t(), integer(), integer(), boolean()) :: + {:ok, map()} | {:error, term()} + def migrate(workspace, entity_type, from_version, to_version, dry_run \\ false) do + with {:ok, migration_definition} <- + Migration.load(workspace, entity_type, from_version, to_version) do + if dry_run do + Migration.dry_run(workspace, entity_type, migration_definition) + else + Migration.execute(workspace, entity_type, migration_definition) + end + end + end + @doc """ Lists all entities of a given type. diff --git a/lib/goodwizard/brain/migration.ex b/lib/goodwizard/brain/migration.ex new file mode 100644 index 0000000..6ce5c95 --- /dev/null +++ b/lib/goodwizard/brain/migration.ex @@ -0,0 +1,334 @@ +defmodule Goodwizard.Brain.Migration do + @moduledoc """ + Applies schema migrations to brain entities. + """ + + alias Goodwizard.Brain.{Entity, Paths, Schema} + + @type migration_summary :: %{ + total: non_neg_integer(), + migrated: non_neg_integer(), + skipped: non_neg_integer(), + errors: [map()] + } + + @type dry_run_summary :: %{ + total: non_neg_integer(), + migrated: non_neg_integer(), + skipped: non_neg_integer(), + errors: [map()], + changes: [map()] + } + + @doc """ + Loads a migration definition from disk. + """ + @spec load(String.t(), String.t(), integer(), integer()) :: {:ok, map()} | {:error, term()} + def load(workspace, entity_type, from_version, to_version) do + with {:ok, path} <- Paths.migration_path(workspace, entity_type, from_version, to_version), + {:ok, content} <- File.read(path), + {:ok, migration_definition} <- Jason.decode(content) do + {:ok, migration_definition} + end + end + + @doc """ + Applies migration operations to an entity frontmatter map in order. + """ + @spec apply_operations(map(), list()) :: {:ok, map()} | {:error, term()} + def apply_operations(frontmatter, operations) + when is_map(frontmatter) and is_list(operations) do + operations + |> Enum.with_index(1) + |> Enum.reduce_while({:ok, frontmatter}, fn {operation, index}, {:ok, acc} -> + case apply_operation(acc, operation) do + {:ok, updated} -> + {:cont, {:ok, updated}} + + {:error, reason} -> + {:halt, {:error, {:invalid_operation, index, reason}}} + end + end) + end + + def apply_operations(_frontmatter, _operations), do: {:error, :invalid_operations} + + @doc """ + Executes a migration against all entities of a type and writes changes to disk. + """ + @spec execute(String.t(), String.t(), map()) :: {:ok, migration_summary()} | {:error, term()} + def execute(workspace, entity_type, migration_definition) do + run(workspace, entity_type, migration_definition, false) + end + + @doc """ + Executes a migration in dry-run mode and returns entity diffs without writing files. + """ + @spec dry_run(String.t(), String.t(), map()) :: + {:ok, dry_run_summary()} | {:error, term()} + def dry_run(workspace, entity_type, migration_definition) do + run(workspace, entity_type, migration_definition, true) + end + + @spec run(String.t(), String.t(), map(), boolean()) :: + {:ok, migration_summary()} | {:ok, dry_run_summary()} | {:error, term()} + defp run(workspace, entity_type, migration_definition, dry_run?) do + with {:ok, operations} <- migration_operations(migration_definition), + {:ok, schema} <- Schema.load(workspace, entity_type), + {:ok, type_dir} <- Paths.entity_type_dir(workspace, entity_type), + {:ok, files} <- list_entity_files(type_dir) do + summary = process_entities(type_dir, files, schema, operations, dry_run?) + + if dry_run? do + {:ok, summary} + else + {:ok, Map.drop(summary, [:changes])} + end + end + end + + @spec migration_operations(map()) :: {:ok, list()} | {:error, term()} + defp migration_operations(migration_definition) when is_map(migration_definition) do + operations = migration_value(migration_definition, "operations", :operations) + + if is_list(operations) do + {:ok, operations} + else + {:error, {:invalid_migration_definition, :operations}} + end + end + + defp migration_operations(_migration_definition), + do: {:error, {:invalid_migration_definition, :format}} + + @spec list_entity_files(String.t()) :: {:ok, [String.t()]} | {:error, term()} + defp list_entity_files(type_dir) do + case File.ls(type_dir) do + {:ok, files} -> + entity_files = + files + |> Enum.filter(&String.ends_with?(&1, ".md")) + |> Enum.sort() + + {:ok, entity_files} + + {:error, :enoent} -> + {:ok, []} + + {:error, reason} -> + {:error, reason} + end + end + + @spec process_entities( + String.t(), + [String.t()], + ExJsonSchema.Schema.Root.t(), + [map()], + boolean() + ) :: + dry_run_summary() + defp process_entities(type_dir, files, schema, operations, dry_run?) do + Enum.reduce(files, base_summary(), fn file, summary -> + path = Path.join(type_dir, file) + id = Path.rootname(file) + + case migrate_entity(path, id, schema, operations, dry_run?) do + {:migrated, change} -> + %{ + summary + | total: summary.total + 1, + migrated: summary.migrated + 1, + changes: maybe_add_change(summary.changes, change, dry_run?) + } + + :skipped -> + %{summary | total: summary.total + 1, skipped: summary.skipped + 1} + + {:error, error} -> + %{summary | total: summary.total + 1, errors: [error | summary.errors]} + end + end) + |> then(fn summary -> + %{summary | errors: Enum.reverse(summary.errors), changes: Enum.reverse(summary.changes)} + end) + end + + @spec base_summary() :: dry_run_summary() + defp base_summary do + %{total: 0, migrated: 0, skipped: 0, errors: [], changes: []} + end + + @spec maybe_add_change([map()], map(), boolean()) :: [map()] + defp maybe_add_change(changes, change, true), do: [change | changes] + defp maybe_add_change(changes, _change, false), do: changes + + @spec migrate_entity(String.t(), String.t(), ExJsonSchema.Schema.Root.t(), [map()], boolean()) :: + {:migrated, map()} | :skipped | {:error, map()} + defp migrate_entity(path, fallback_id, schema, operations, dry_run?) do + with {:ok, content} <- File.read(path), + {:ok, {data, body}} <- Entity.parse(content) do + process_loaded_entity(path, fallback_id, data, body, schema, operations, dry_run?) + else + {:error, reason} -> + {:error, %{id: fallback_id, error: reason}} + end + end + + @spec process_loaded_entity( + String.t(), + String.t(), + map(), + String.t(), + ExJsonSchema.Schema.Root.t(), + [map()], + boolean() + ) :: + {:migrated, map()} | :skipped | {:error, map()} + defp process_loaded_entity(path, fallback_id, data, body, schema, operations, dry_run?) do + id = Map.get(data, "id", fallback_id) + + case Schema.validate(schema, data) do + :ok -> + :skipped + + {:error, _} -> + migrate_invalid_entity(path, id, data, body, schema, operations, dry_run?) + end + end + + @spec migrate_invalid_entity( + String.t(), + String.t(), + map(), + String.t(), + ExJsonSchema.Schema.Root.t(), + [map()], + boolean() + ) :: + {:migrated, map()} | {:error, map()} + defp migrate_invalid_entity(path, id, data, body, schema, operations, dry_run?) do + with {:ok, migrated_data} <- apply_operations(data, operations), + migrated_data <- put_migration_timestamp(migrated_data), + :ok <- Schema.validate(schema, migrated_data), + :ok <- maybe_write_entity(path, migrated_data, body, dry_run?) do + {:migrated, %{id: id, before: data, after: migrated_data}} + else + {:error, reason} -> + {:error, %{id: id, error: reason}} + end + end + + @spec put_migration_timestamp(map()) :: map() + defp put_migration_timestamp(data) do + now = DateTime.utc_now() |> DateTime.to_iso8601() + Map.put(data, "updated_at", now) + end + + @spec maybe_write_entity(String.t(), map(), String.t(), boolean()) :: :ok | {:error, term()} + defp maybe_write_entity(_path, _data, _body, true), do: :ok + + defp maybe_write_entity(path, data, body, false) do + File.write(path, Entity.serialize(data, body)) + end + + @spec apply_operation(map(), map()) :: {:ok, map()} | {:error, term()} + defp apply_operation(data, operation) when is_map(operation) do + case migration_value(operation, "op", :op) do + "add_field" -> + add_field(data, operation) + + "rename_field" -> + rename_field(data, operation) + + "remove_field" -> + remove_field(data, operation) + + "set_default" -> + set_default(data, operation) + + other -> + {:error, {:unsupported_operation, other}} + end + end + + defp apply_operation(_data, _operation), do: {:error, :operation_must_be_a_map} + + @spec add_field(map(), map()) :: {:ok, map()} | {:error, term()} + defp add_field(data, operation) do + case migration_value(operation, "field", :field) do + field when is_binary(field) and field != "" -> + if Map.has_key?(data, field) do + {:ok, data} + else + default = migration_value(operation, "default", :default) + {:ok, Map.put(data, field, default)} + end + + _ -> + {:error, :invalid_field} + end + end + + @spec rename_field(map(), map()) :: {:ok, map()} | {:error, term()} + defp rename_field(data, operation) do + from = migration_value(operation, "from", :from) + to = migration_value(operation, "to", :to) + + cond do + not (is_binary(from) and from != "") -> + {:error, :invalid_from_field} + + not (is_binary(to) and to != "") -> + {:error, :invalid_to_field} + + Map.has_key?(data, from) -> + value = Map.fetch!(data, from) + {:ok, data |> Map.delete(from) |> Map.put(to, value)} + + true -> + {:ok, data} + end + end + + @spec remove_field(map(), map()) :: {:ok, map()} | {:error, term()} + defp remove_field(data, operation) do + case migration_value(operation, "field", :field) do + field when is_binary(field) and field != "" -> + {:ok, Map.delete(data, field)} + + _ -> + {:error, :invalid_field} + end + end + + @spec set_default(map(), map()) :: {:ok, map()} | {:error, term()} + defp set_default(data, operation) do + case migration_value(operation, "field", :field) do + field when is_binary(field) and field != "" -> + if Map.has_key?(data, field) do + {:ok, data} + else + value = migration_value(operation, "value", :value) + {:ok, Map.put(data, field, value)} + end + + _ -> + {:error, :invalid_field} + end + end + + @spec migration_value(map(), String.t(), atom()) :: term() + defp migration_value(map, string_key, atom_key) do + cond do + Map.has_key?(map, string_key) -> + Map.get(map, string_key) + + Map.has_key?(map, atom_key) -> + Map.get(map, atom_key) + + true -> + nil + end + end +end diff --git a/lib/goodwizard/brain/paths.ex b/lib/goodwizard/brain/paths.ex index 77790be..8624d11 100644 --- a/lib/goodwizard/brain/paths.ex +++ b/lib/goodwizard/brain/paths.ex @@ -15,6 +15,15 @@ defmodule Goodwizard.Brain.Paths do @spec schemas_dir(String.t()) :: String.t() def schemas_dir(workspace), do: Path.join([workspace, "brain", "schemas"]) + @doc "Returns the `brain/schemas/history/` directory." + @spec schema_history_dir(String.t()) :: String.t() + def schema_history_dir(workspace), do: Path.join([workspace, "brain", "schemas", "history"]) + + @doc "Returns the `brain/schemas/migrations/` directory." + @spec schema_migrations_dir(String.t()) :: String.t() + def schema_migrations_dir(workspace), + do: Path.join([workspace, "brain", "schemas", "migrations"]) + @doc "Returns the `brain//` directory for an entity type." @spec entity_type_dir(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()} def entity_type_dir(workspace, type) do @@ -41,6 +50,31 @@ defmodule Goodwizard.Brain.Paths do end end + @doc "Returns the archived schema path `brain/schemas/history/_v.json`." + @spec schema_history_path(String.t(), String.t(), integer()) :: + {:ok, String.t()} | {:error, String.t()} + def schema_history_path(workspace, type, version) do + with :ok <- validate_segment(type, "schema type"), + :ok <- validate_version(version, "schema version") do + {:ok, Path.join([workspace, "brain", "schemas", "history", "#{type}_v#{version}.json"])} + end + end + + @doc """ + Returns migration definition path + `brain/schemas/migrations/_v_to_v.json`. + """ + @spec migration_path(String.t(), String.t(), integer(), integer()) :: + {:ok, String.t()} | {:error, String.t()} + def migration_path(workspace, type, from_version, to_version) do + with :ok <- validate_segment(type, "schema type"), + :ok <- validate_version(from_version, "from version"), + :ok <- validate_version(to_version, "to version") do + file_name = "#{type}_v#{from_version}_to_v#{to_version}.json" + {:ok, Path.join([workspace, "brain", "schemas", "migrations", file_name])} + end + end + @doc """ Validates that a path segment is safe. Rejects `..`, leading `/`, and null bytes. """ @@ -74,4 +108,8 @@ defmodule Goodwizard.Brain.Paths do :ok end end + + @spec validate_version(integer(), String.t()) :: :ok | {:error, String.t()} + defp validate_version(version, _label) when is_integer(version) and version > 0, do: :ok + defp validate_version(_version, label), do: {:error, "#{label} must be a positive integer"} end diff --git a/lib/goodwizard/brain/schema.ex b/lib/goodwizard/brain/schema.ex index 083cf6b..f1845fb 100644 --- a/lib/goodwizard/brain/schema.ex +++ b/lib/goodwizard/brain/schema.ex @@ -47,18 +47,143 @@ defmodule Goodwizard.Brain.Schema do @doc """ Writes a schema map to disk as `brain/schemas/.json`. - Creates the schemas directory if it doesn't exist. - Returns `:ok` or `{:error, reason}`. + When updating an existing schema, enforces `current_version + 1`, + requires a migration definition, archives the current schema, and + stores the migration definition before overwriting. """ @spec save(String.t(), String.t(), map()) :: :ok | {:error, term()} - def save(workspace, type, schema_map) do + def save(workspace, type, schema_map), do: save(workspace, type, schema_map, nil) + + @spec save(String.t(), String.t(), map(), map() | nil) :: :ok | {:error, term()} + def save(workspace, type, schema_map, migration_definition) do with {:ok, path} <- Paths.schema_path(workspace, type), :ok <- File.mkdir_p(Paths.schemas_dir(workspace)), + :ok <- prepare_update(path, workspace, type, schema_map, migration_definition), {:ok, json} <- Jason.encode(schema_map, pretty: true) do File.write(path, json) end end + @spec prepare_update(String.t(), String.t(), String.t(), map(), map() | nil) :: + :ok | {:error, term()} + defp prepare_update(path, workspace, type, new_schema_map, migration_definition) do + case File.read(path) do + {:error, :enoent} -> + :ok + + {:error, reason} -> + {:error, reason} + + {:ok, content} -> + with {:ok, current_schema_map} <- Jason.decode(content), + {:ok, current_version} <- schema_version(current_schema_map, "current schema"), + {:ok, new_version} <- schema_version(new_schema_map, "new schema"), + :ok <- validate_next_version(current_version, new_version), + :ok <- + validate_migration_definition(migration_definition, current_version, new_version), + :ok <- archive_current_schema(workspace, type, current_version, content), + :ok <- + store_migration( + workspace, + type, + current_version, + new_version, + migration_definition + ) do + :ok + end + end + end + + @spec schema_version(map(), String.t()) :: {:ok, integer()} | {:error, term()} + defp schema_version(schema_map, label) do + case Map.get(schema_map, "version") do + version when is_integer(version) and version > 0 -> + {:ok, version} + + _ -> + {:error, {:invalid_schema_version, label}} + end + end + + @spec validate_next_version(integer(), integer()) :: :ok | {:error, term()} + defp validate_next_version(current_version, new_version) do + expected = current_version + 1 + + if new_version == expected do + :ok + else + {:error, {:version_mismatch, expected, new_version}} + end + end + + @spec validate_migration_definition(map() | nil, integer(), integer()) :: :ok | {:error, term()} + defp validate_migration_definition(nil, _current_version, _new_version), + do: {:error, :migration_required} + + defp validate_migration_definition(migration_definition, current_version, new_version) + when is_map(migration_definition) do + from_version = migration_value(migration_definition, "from_version", :from_version) + to_version = migration_value(migration_definition, "to_version", :to_version) + operations = migration_value(migration_definition, "operations", :operations) + + cond do + not (is_integer(from_version) and from_version > 0) -> + {:error, {:invalid_migration_definition, :from_version}} + + not (is_integer(to_version) and to_version > 0) -> + {:error, {:invalid_migration_definition, :to_version}} + + from_version != current_version -> + {:error, {:migration_version_mismatch, :from_version, current_version, from_version}} + + to_version != new_version -> + {:error, {:migration_version_mismatch, :to_version, new_version, to_version}} + + not is_list(operations) -> + {:error, {:invalid_migration_definition, :operations}} + + true -> + :ok + end + end + + defp validate_migration_definition(_migration_definition, _current_version, _new_version), + do: {:error, {:invalid_migration_definition, :format}} + + @spec archive_current_schema(String.t(), String.t(), integer(), String.t()) :: + :ok | {:error, term()} + defp archive_current_schema(workspace, type, current_version, content) do + with {:ok, history_path} <- Paths.schema_history_path(workspace, type, current_version), + :ok <- File.mkdir_p(Paths.schema_history_dir(workspace)) do + File.write(history_path, content) + end + end + + @spec store_migration(String.t(), String.t(), integer(), integer(), map()) :: + :ok | {:error, term()} + defp store_migration(workspace, type, from_version, to_version, migration_definition) do + with {:ok, path} <- Paths.migration_path(workspace, type, from_version, to_version), + :ok <- File.mkdir_p(Paths.schema_migrations_dir(workspace)), + {:ok, json} <- Jason.encode(migration_definition, pretty: true) do + File.write(path, json) + end + end + + @spec migration_value(map(), String.t(), atom()) :: term() + defp migration_value(migration_definition, string_key, atom_key) do + cond do + Map.has_key?(migration_definition, string_key) -> + Map.get(migration_definition, string_key) + + Map.has_key?(migration_definition, atom_key) -> + Map.get(migration_definition, atom_key) + + true -> + nil + end + end + @system_fields ~w(id created_at updated_at) @doc """ diff --git a/openspec/changes/brain-schema-migration/tasks.md b/openspec/changes/brain-schema-migration/tasks.md index dc87513..93d71b4 100644 --- a/openspec/changes/brain-schema-migration/tasks.md +++ b/openspec/changes/brain-schema-migration/tasks.md @@ -1,29 +1,29 @@ ## 1. Path Helpers -- [ ] 1.1 Add `schema_history_dir/1`, `schema_migrations_dir/1`, `schema_history_path/3`, `migration_path/4` to `Goodwizard.Brain.Paths` -- [ ] 1.2 Write tests for new path helpers — construction, validation +- [x] 1.1 Add `schema_history_dir/1`, `schema_migrations_dir/1`, `schema_history_path/3`, `migration_path/4` to `Goodwizard.Brain.Paths` +- [x] 1.2 Write tests for new path helpers — construction, validation ## 2. Schema Versioning and Archival -- [ ] 2.1 Modify `Goodwizard.Brain.Schema.save/3` to enforce version increment (+1) when updating an existing schema -- [ ] 2.2 Add schema history archival — copy current schema to `brain/schemas/history/_v.json` before overwriting -- [ ] 2.3 Add migration definition storage — write migration to `brain/schemas/migrations/_v_to_v.json` on schema update -- [ ] 2.4 Reject schema updates without a migration definition -- [ ] 2.5 Write tests — version increment enforcement, history archival, migration storage, reject update without migration, reject version mismatch +- [x] 2.1 Modify `Goodwizard.Brain.Schema.save/3` to enforce version increment (+1) when updating an existing schema +- [x] 2.2 Add schema history archival — copy current schema to `brain/schemas/history/_v.json` before overwriting +- [x] 2.3 Add migration definition storage — write migration to `brain/schemas/migrations/_v_to_v.json` on schema update +- [x] 2.4 Reject schema updates without a migration definition +- [x] 2.5 Write tests — version increment enforcement, history archival, migration storage, reject update without migration, reject version mismatch ## 3. Migration Module -- [ ] 3.1 Create `Goodwizard.Brain.Migration` module — `load/4` reads a migration definition from disk, `apply_operations/2` applies operations to an entity's frontmatter map, `execute/3` runs migration across all entities of a type with validation and write-back, `dry_run/3` reports changes without writing -- [ ] 3.2 Implement `add_field` operation — adds field with default value -- [ ] 3.3 Implement `rename_field` operation — renames field preserving value -- [ ] 3.4 Implement `remove_field` operation — drops field from frontmatter -- [ ] 3.5 Implement `set_default` operation — sets default only if field absent -- [ ] 3.6 Write tests for each operation, operation ordering, dry-run diff, validation failure skipping, summary counts, missing migration file error +- [x] 3.1 Create `Goodwizard.Brain.Migration` module — `load/4` reads a migration definition from disk, `apply_operations/2` applies operations to an entity's frontmatter map, `execute/3` runs migration across all entities of a type with validation and write-back, `dry_run/3` reports changes without writing +- [x] 3.2 Implement `add_field` operation — adds field with default value +- [x] 3.3 Implement `rename_field` operation — renames field preserving value +- [x] 3.4 Implement `remove_field` operation — drops field from frontmatter +- [x] 3.5 Implement `set_default` operation — sets default only if field absent +- [x] 3.6 Write tests for each operation, operation ordering, dry-run diff, validation failure skipping, summary counts, missing migration file error ## 4. Agent Action -- [ ] 4.1 Create `Goodwizard.Actions.Brain.MigrateEntities` action — params: entity_type, from_version (integer), to_version (integer), dry_run (boolean, default false) -- [ ] 4.2 Modify `Goodwizard.Actions.Brain.SaveSchema` action — add optional `migration` param (map, required when updating existing schema) -- [ ] 4.3 Add `migrate/4` to `Goodwizard.Brain` public API delegating to Migration module -- [ ] 4.4 Register `MigrateEntities` action in `Goodwizard.Agent` tools list -- [ ] 4.5 Write tests for MigrateEntities action and SaveSchema migration param +- [x] 4.1 Create `Goodwizard.Actions.Brain.MigrateEntities` action — params: entity_type, from_version (integer), to_version (integer), dry_run (boolean, default false) +- [x] 4.2 Modify `Goodwizard.Actions.Brain.SaveSchema` action — add optional `migration` param (map, required when updating existing schema) +- [x] 4.3 Add `migrate/4` to `Goodwizard.Brain` public API delegating to Migration module +- [x] 4.4 Register `MigrateEntities` action in `Goodwizard.Agent` tools list +- [x] 4.5 Write tests for MigrateEntities action and SaveSchema migration param diff --git a/test/goodwizard/actions/brain/brain_actions_test.exs b/test/goodwizard/actions/brain/brain_actions_test.exs index 900e428..ecffb4d 100644 --- a/test/goodwizard/actions/brain/brain_actions_test.exs +++ b/test/goodwizard/actions/brain/brain_actions_test.exs @@ -7,11 +7,14 @@ defmodule Goodwizard.Actions.Brain.BrainActionsTest do GetSchema, ListEntities, ListEntityTypes, + MigrateEntities, ReadEntity, SaveSchema, UpdateEntity } + alias Goodwizard.Brain.{Entity, Paths, Schema} + setup do workspace = Path.join(System.tmp_dir!(), "brain_actions_test_#{:rand.uniform(100_000)}") on_exit(fn -> File.rm_rf!(workspace) end) @@ -242,6 +245,211 @@ defmodule Goodwizard.Actions.Brain.BrainActionsTest do assert msg =~ "Invalid JSON Schema" end + + test "requires migration when updating an existing schema", %{context: context} do + init_brain(context) + + schema_v1 = %{ + "type" => "object", + "version" => 1, + "properties" => %{"name" => %{"type" => "string"}}, + "required" => ["name"] + } + + schema_v2 = %{ + "type" => "object", + "version" => 2, + "properties" => %{ + "name" => %{"type" => "string"}, + "status" => %{"type" => "string"} + }, + "required" => ["name", "status"] + } + + assert {:ok, _} = SaveSchema.run(%{entity_type: "projects", schema: schema_v1}, context) + + assert {:error, message} = + SaveSchema.run(%{entity_type: "projects", schema: schema_v2}, context) + + assert message =~ "Migration definition is required" + end + + test "accepts migration definition when updating an existing schema", %{ + context: context, + workspace: workspace + } do + init_brain(context) + + schema_v1 = %{ + "type" => "object", + "version" => 1, + "properties" => %{"name" => %{"type" => "string"}}, + "required" => ["name"] + } + + schema_v2 = %{ + "type" => "object", + "version" => 2, + "properties" => %{ + "name" => %{"type" => "string"}, + "status" => %{"type" => "string"} + }, + "required" => ["name", "status"] + } + + migration = %{ + "from_version" => 1, + "to_version" => 2, + "operations" => [ + %{"op" => "add_field", "field" => "status", "default" => "pending"} + ] + } + + assert {:ok, _} = SaveSchema.run(%{entity_type: "projects", schema: schema_v1}, context) + + assert {:ok, %{message: message}} = + SaveSchema.run( + %{entity_type: "projects", schema: schema_v2, migration: migration}, + context + ) + + assert message =~ "projects" + + assert {:ok, migration_path} = Paths.migration_path(workspace, "projects", 1, 2) + assert File.exists?(migration_path) + end + end + + describe "MigrateEntities" do + test "runs dry-run migration and returns changes without writing", %{ + context: context, + workspace: workspace + } do + schema_v2 = %{ + "$schema" => "http://json-schema.org/draft-07/schema#", + "title" => "ProjectV2", + "version" => 2, + "type" => "object", + "required" => ["id", "name", "status", "created_at", "updated_at"], + "properties" => %{ + "id" => %{"type" => "string"}, + "name" => %{"type" => "string"}, + "status" => %{"type" => "string"}, + "created_at" => %{"type" => "string"}, + "updated_at" => %{"type" => "string"} + }, + "additionalProperties" => false + } + + migration = %{ + "from_version" => 1, + "to_version" => 2, + "operations" => [ + %{"op" => "rename_field", "from" => "full_name", "to" => "name"}, + %{"op" => "set_default", "field" => "status", "value" => "pending"}, + %{"op" => "remove_field", "field" => "legacy"} + ] + } + + assert :ok = Schema.save(workspace, "projects", schema_v2) + assert :ok = File.mkdir_p(Paths.schema_migrations_dir(workspace)) + {:ok, migration_path} = Paths.migration_path(workspace, "projects", 1, 2) + assert :ok = File.write(migration_path, Jason.encode!(migration)) + + ts = "2026-01-01T00:00:00Z" + + write_entity!( + workspace, + "projects", + "p-1", + %{ + "id" => "p-1", + "full_name" => "Project One", + "legacy" => "deprecated", + "created_at" => ts, + "updated_at" => ts + } + ) + + before = read_entity_data!(workspace, "projects", "p-1") + + assert {:ok, result} = + MigrateEntities.run( + %{entity_type: "projects", from_version: 1, to_version: 2, dry_run: true}, + context + ) + + assert result.total == 1 + assert result.migrated == 1 + assert length(result.changes) == 1 + + after_dry_run = read_entity_data!(workspace, "projects", "p-1") + assert after_dry_run == before + end + + test "executes migration and writes entity updates", %{context: context, workspace: workspace} do + schema_v2 = %{ + "$schema" => "http://json-schema.org/draft-07/schema#", + "title" => "ProjectV2", + "version" => 2, + "type" => "object", + "required" => ["id", "name", "status", "created_at", "updated_at"], + "properties" => %{ + "id" => %{"type" => "string"}, + "name" => %{"type" => "string"}, + "status" => %{"type" => "string"}, + "created_at" => %{"type" => "string"}, + "updated_at" => %{"type" => "string"} + }, + "additionalProperties" => false + } + + migration = %{ + "from_version" => 1, + "to_version" => 2, + "operations" => [ + %{"op" => "rename_field", "from" => "full_name", "to" => "name"}, + %{"op" => "set_default", "field" => "status", "value" => "pending"}, + %{"op" => "remove_field", "field" => "legacy"} + ] + } + + assert :ok = Schema.save(workspace, "projects", schema_v2) + assert :ok = File.mkdir_p(Paths.schema_migrations_dir(workspace)) + {:ok, migration_path} = Paths.migration_path(workspace, "projects", 1, 2) + assert :ok = File.write(migration_path, Jason.encode!(migration)) + + ts = "2026-01-01T00:00:00Z" + + write_entity!( + workspace, + "projects", + "p-2", + %{ + "id" => "p-2", + "full_name" => "Project Two", + "legacy" => "deprecated", + "created_at" => ts, + "updated_at" => ts + } + ) + + assert {:ok, result} = + MigrateEntities.run( + %{entity_type: "projects", from_version: 1, to_version: 2}, + context + ) + + assert result.total == 1 + assert result.migrated == 1 + + migrated = read_entity_data!(workspace, "projects", "p-2") + assert migrated["name"] == "Project Two" + assert migrated["status"] == "pending" + refute Map.has_key?(migrated, "full_name") + refute Map.has_key?(migrated, "legacy") + assert migrated["updated_at"] != ts + end end describe "ListEntityTypes" do @@ -350,4 +558,18 @@ defmodule Goodwizard.Actions.Brain.BrainActionsTest do assert length(ids) == length(Enum.uniq(ids)) end end + + defp write_entity!(workspace, entity_type, id, data) do + {:ok, type_dir} = Paths.entity_type_dir(workspace, entity_type) + :ok = File.mkdir_p(type_dir) + {:ok, path} = Paths.entity_path(workspace, entity_type, id) + :ok = File.write(path, Entity.serialize(data, "")) + end + + defp read_entity_data!(workspace, entity_type, id) do + {:ok, path} = Paths.entity_path(workspace, entity_type, id) + {:ok, content} = File.read(path) + {:ok, {data, _body}} = Entity.parse(content) + data + end end diff --git a/test/goodwizard/agent_test.exs b/test/goodwizard/agent_test.exs index 4967738..3a0de06 100644 --- a/test/goodwizard/agent_test.exs +++ b/test/goodwizard/agent_test.exs @@ -53,6 +53,7 @@ defmodule Goodwizard.AgentTest do assert Goodwizard.Actions.Brain.ListEntities in tools assert Goodwizard.Actions.Brain.GetSchema in tools assert Goodwizard.Actions.Brain.SaveSchema in tools + assert Goodwizard.Actions.Brain.MigrateEntities in tools assert Goodwizard.Actions.Brain.ListEntityTypes in tools end diff --git a/test/goodwizard/brain/migration_test.exs b/test/goodwizard/brain/migration_test.exs new file mode 100644 index 0000000..5c5d7d9 --- /dev/null +++ b/test/goodwizard/brain/migration_test.exs @@ -0,0 +1,244 @@ +defmodule Goodwizard.Brain.MigrationTest do + use ExUnit.Case, async: true + + alias Goodwizard.Brain.{Entity, Migration, Paths, Schema} + + @schema_v2 %{ + "$schema" => "http://json-schema.org/draft-07/schema#", + "title" => "PersonV2", + "version" => 2, + "type" => "object", + "required" => ["id", "name", "status", "created_at", "updated_at"], + "properties" => %{ + "id" => %{"type" => "string"}, + "name" => %{"type" => "string"}, + "status" => %{"type" => "string"}, + "created_at" => %{"type" => "string"}, + "updated_at" => %{"type" => "string"} + }, + "additionalProperties" => false + } + + @migration_v1_to_v2 %{ + "from_version" => 1, + "to_version" => 2, + "operations" => [ + %{"op" => "rename_field", "from" => "full_name", "to" => "name"}, + %{"op" => "set_default", "field" => "status", "value" => "pending"}, + %{"op" => "remove_field", "field" => "legacy"} + ] + } + + setup do + workspace = Path.join(System.tmp_dir!(), "brain_migration_test_#{:rand.uniform(100_000)}") + on_exit(fn -> File.rm_rf!(workspace) end) + %{workspace: workspace} + end + + describe "load/4" do + test "returns missing file error when migration file does not exist", %{workspace: workspace} do + assert {:error, :enoent} = Migration.load(workspace, "people", 1, 2) + end + + test "loads a stored migration definition", %{workspace: workspace} do + assert :ok = File.mkdir_p(Paths.schema_migrations_dir(workspace)) + {:ok, path} = Paths.migration_path(workspace, "people", 1, 2) + assert :ok = File.write(path, Jason.encode!(@migration_v1_to_v2)) + + assert {:ok, loaded} = Migration.load(workspace, "people", 1, 2) + assert loaded["from_version"] == 1 + assert loaded["to_version"] == 2 + assert is_list(loaded["operations"]) + end + end + + describe "apply_operations/2" do + test "add_field adds only when field is absent" do + data = %{"id" => "1", "name" => "Alice"} + ops = [%{"op" => "add_field", "field" => "status", "default" => "pending"}] + + assert {:ok, updated} = Migration.apply_operations(data, ops) + assert updated["status"] == "pending" + + assert {:ok, unchanged} = Migration.apply_operations(updated, ops) + assert unchanged["status"] == "pending" + end + + test "add_field preserves explicit false defaults from string keys" do + data = %{"id" => "1"} + + ops = [ + %{ + "op" => "add_field", + "field" => "is_active", + "default" => false, + default: true + } + ] + + assert {:ok, updated} = Migration.apply_operations(data, ops) + assert updated["is_active"] == false + end + + test "rename_field preserves value under new key" do + data = %{"full_name" => "Alice"} + ops = [%{"op" => "rename_field", "from" => "full_name", "to" => "name"}] + + assert {:ok, updated} = Migration.apply_operations(data, ops) + refute Map.has_key?(updated, "full_name") + assert updated["name"] == "Alice" + end + + test "remove_field drops existing key" do + data = %{"id" => "1", "legacy" => "old"} + ops = [%{"op" => "remove_field", "field" => "legacy"}] + + assert {:ok, updated} = Migration.apply_operations(data, ops) + refute Map.has_key?(updated, "legacy") + end + + test "set_default sets value only when missing" do + data = %{"id" => "1"} + ops = [%{"op" => "set_default", "field" => "status", "value" => "pending"}] + + assert {:ok, updated} = Migration.apply_operations(data, ops) + assert updated["status"] == "pending" + + assert {:ok, unchanged} = Migration.apply_operations(%{"status" => "active"}, ops) + assert unchanged["status"] == "active" + end + + test "set_default preserves explicit false values from atom keys" do + data = %{"id" => "1"} + ops = [%{op: "set_default", field: "is_active", value: false}] + + assert {:ok, updated} = Migration.apply_operations(data, ops) + assert updated["is_active"] == false + end + + test "applies operations in order" do + data = %{"notes_ref" => "N-1"} + + ops = [ + %{"op" => "rename_field", "from" => "notes_ref", "to" => "notes"}, + %{"op" => "set_default", "field" => "notes", "value" => "N-2"} + ] + + assert {:ok, updated} = Migration.apply_operations(data, ops) + assert updated["notes"] == "N-1" + end + end + + describe "execute/3 and dry_run/3" do + test "execute migrates valid candidates, skips already-valid entities, and reports errors", %{ + workspace: workspace + } do + assert :ok = Schema.save(workspace, "people", @schema_v2) + + ts = "2026-01-01T00:00:00Z" + + write_entity!( + workspace, + "people", + "needs_migration", + %{ + "id" => "needs_migration", + "full_name" => "Alice", + "legacy" => "deprecated", + "created_at" => ts, + "updated_at" => ts + } + ) + + write_entity!( + workspace, + "people", + "already_valid", + %{ + "id" => "already_valid", + "name" => "Bob", + "status" => "active", + "created_at" => ts, + "updated_at" => ts + } + ) + + write_entity!( + workspace, + "people", + "will_fail_validation", + %{ + "id" => "will_fail_validation", + "legacy" => "deprecated", + "created_at" => ts, + "updated_at" => ts + } + ) + + assert {:ok, summary} = Migration.execute(workspace, "people", @migration_v1_to_v2) + assert summary.total == 3 + assert summary.migrated == 1 + assert summary.skipped == 1 + assert length(summary.errors) == 1 + + assert [%{id: "will_fail_validation"}] = summary.errors + + migrated_data = read_entity_data!(workspace, "people", "needs_migration") + assert migrated_data["name"] == "Alice" + assert migrated_data["status"] == "pending" + refute Map.has_key?(migrated_data, "full_name") + refute Map.has_key?(migrated_data, "legacy") + assert migrated_data["updated_at"] != ts + end + + test "dry_run reports diffs without writing files", %{workspace: workspace} do + assert :ok = Schema.save(workspace, "people", @schema_v2) + + ts = "2026-01-01T00:00:00Z" + + write_entity!( + workspace, + "people", + "dry_run_target", + %{ + "id" => "dry_run_target", + "full_name" => "Carol", + "legacy" => "deprecated", + "created_at" => ts, + "updated_at" => ts + } + ) + + before = read_entity_data!(workspace, "people", "dry_run_target") + + assert {:ok, summary} = Migration.dry_run(workspace, "people", @migration_v1_to_v2) + assert summary.total == 1 + assert summary.migrated == 1 + assert summary.skipped == 0 + assert summary.errors == [] + assert length(summary.changes) == 1 + + [change] = summary.changes + assert change.id == "dry_run_target" + assert change.before["full_name"] == "Carol" + assert change.after["name"] == "Carol" + + after_dry_run = read_entity_data!(workspace, "people", "dry_run_target") + assert after_dry_run == before + end + end + + defp write_entity!(workspace, entity_type, id, data) do + {:ok, type_dir} = Paths.entity_type_dir(workspace, entity_type) + :ok = File.mkdir_p(type_dir) + {:ok, path} = Paths.entity_path(workspace, entity_type, id) + :ok = File.write(path, Entity.serialize(data, "")) + end + + defp read_entity_data!(workspace, entity_type, id) do + {:ok, path} = Paths.entity_path(workspace, entity_type, id) + {:ok, content} = File.read(path) + {:ok, {data, _body}} = Entity.parse(content) + data + end +end diff --git a/test/goodwizard/brain/paths_test.exs b/test/goodwizard/brain/paths_test.exs index dd13aa7..6c5b2fb 100644 --- a/test/goodwizard/brain/paths_test.exs +++ b/test/goodwizard/brain/paths_test.exs @@ -17,6 +17,19 @@ defmodule Goodwizard.Brain.PathsTest do end end + describe "schema_history_dir/1" do + test "returns schema history directory under schemas" do + assert Paths.schema_history_dir(@workspace) == "/tmp/test_workspace/brain/schemas/history" + end + end + + describe "schema_migrations_dir/1" do + test "returns schema migrations directory under schemas" do + assert Paths.schema_migrations_dir(@workspace) == + "/tmp/test_workspace/brain/schemas/migrations" + end + end + describe "entity_type_dir/2" do test "returns entity type directory" do assert {:ok, "/tmp/test_workspace/brain/people"} = @@ -78,6 +91,45 @@ defmodule Goodwizard.Brain.PathsTest do end end + describe "schema_history_path/3" do + test "returns versioned schema history path" do + assert {:ok, "/tmp/test_workspace/brain/schemas/history/people_v2.json"} = + Paths.schema_history_path(@workspace, "people", 2) + end + + test "rejects traversal in schema type" do + assert {:error, "schema type contains path traversal"} = + Paths.schema_history_path(@workspace, "..", 1) + end + + test "rejects non-positive versions" do + assert {:error, "schema version must be a positive integer"} = + Paths.schema_history_path(@workspace, "people", 0) + end + end + + describe "migration_path/4" do + test "returns versioned migration path" do + assert {:ok, "/tmp/test_workspace/brain/schemas/migrations/people_v1_to_v2.json"} = + Paths.migration_path(@workspace, "people", 1, 2) + end + + test "rejects traversal in schema type" do + assert {:error, "schema type contains path traversal"} = + Paths.migration_path(@workspace, "../evil", 1, 2) + end + + test "rejects invalid from version" do + assert {:error, "from version must be a positive integer"} = + Paths.migration_path(@workspace, "people", -1, 2) + end + + test "rejects invalid to version" do + assert {:error, "to version must be a positive integer"} = + Paths.migration_path(@workspace, "people", 1, 0) + end + end + describe "validate_segment/2" do test "accepts valid segment" do assert :ok = Paths.validate_segment("people", "test") diff --git a/test/goodwizard/brain/schema_test.exs b/test/goodwizard/brain/schema_test.exs index 589b5b4..d280c3c 100644 --- a/test/goodwizard/brain/schema_test.exs +++ b/test/goodwizard/brain/schema_test.exs @@ -1,7 +1,7 @@ defmodule Goodwizard.Brain.SchemaTest do use ExUnit.Case, async: true - alias Goodwizard.Brain.Schema + alias Goodwizard.Brain.{Paths, Schema} @test_schema %{ "$schema" => "http://json-schema.org/draft-07/schema#", @@ -20,6 +20,14 @@ defmodule Goodwizard.Brain.SchemaTest do "additionalProperties" => false } + @migration_v1_to_v2 %{ + "from_version" => 1, + "to_version" => 2, + "operations" => [ + %{"op" => "add_field", "field" => "nickname", "default" => nil} + ] + } + setup do workspace = Path.join(System.tmp_dir!(), "brain_schema_test_#{:rand.uniform(100_000)}") schemas_dir = Path.join([workspace, "brain", "schemas"]) @@ -47,6 +55,95 @@ defmodule Goodwizard.Brain.SchemaTest do assert decoded["version"] == 1 end + test "save allows updating a schema only when version increments by one", %{ + workspace: workspace + } do + assert :ok = Schema.save(workspace, "test_entity", @test_schema) + + updated_schema = + @test_schema + |> Map.put("version", 2) + |> Map.put("title", "TestEntityV2") + + assert :ok = Schema.save(workspace, "test_entity", updated_schema, @migration_v1_to_v2) + assert {:ok, resolved} = Schema.load(workspace, "test_entity") + assert %ExJsonSchema.Schema.Root{} = resolved + end + + test "save rejects update when new version is not current+1", %{workspace: workspace} do + assert :ok = Schema.save(workspace, "test_entity", @test_schema) + + same_version = Map.put(@test_schema, "title", "TestEntitySameVersion") + skipped_version = Map.put(@test_schema, "version", 3) + + assert {:error, {:version_mismatch, 2, 1}} = + Schema.save(workspace, "test_entity", same_version) + + assert {:error, {:version_mismatch, 2, 3}} = + Schema.save(workspace, "test_entity", skipped_version) + end + + test "save rejects schema updates without a migration definition", %{workspace: workspace} do + assert :ok = Schema.save(workspace, "test_entity", @test_schema) + + updated_schema = + @test_schema + |> Map.put("version", 2) + |> Map.put("title", "TestEntityV2") + + assert {:error, :migration_required} = Schema.save(workspace, "test_entity", updated_schema) + end + + test "save preserves explicit false values when reading migration keys", %{ + workspace: workspace + } do + assert :ok = Schema.save(workspace, "test_entity", @test_schema) + + updated_schema = + @test_schema + |> Map.put("version", 2) + |> Map.put("title", "TestEntityV2") + + invalid_migration = %{ + "from_version" => false, + :from_version => 1, + "to_version" => 2, + :to_version => 2, + "operations" => [] + } + + assert {:error, {:invalid_migration_definition, :from_version}} = + Schema.save(workspace, "test_entity", updated_schema, invalid_migration) + end + + test "save archives current schema before overwrite and stores migration", %{ + workspace: workspace + } do + assert :ok = Schema.save(workspace, "test_entity", @test_schema) + + updated_schema = + @test_schema + |> Map.put("version", 2) + |> Map.put("title", "TestEntityV2") + + assert :ok = Schema.save(workspace, "test_entity", updated_schema, @migration_v1_to_v2) + + assert {:ok, history_path} = Paths.schema_history_path(workspace, "test_entity", 1) + + assert {:ok, history_content} = File.read(history_path) + assert {:ok, history_schema} = Jason.decode(history_content) + assert history_schema["version"] == 1 + assert history_schema["title"] == "TestEntity" + + assert {:ok, migration_path} = Paths.migration_path(workspace, "test_entity", 1, 2) + + assert {:ok, migration_content} = File.read(migration_path) + assert {:ok, stored_migration} = Jason.decode(migration_content) + assert stored_migration["from_version"] == 1 + assert stored_migration["to_version"] == 2 + assert is_list(stored_migration["operations"]) + end + test "load returns error for non-existent schema", %{workspace: workspace} do assert {:error, :enoent} = Schema.load(workspace, "nonexistent") end