PropertyDamage.Persistence (PropertyDamage v0.2.0)

View Source

Save and load failure reports for later analysis and regression testing.

Failures are saved in Erlang term format (.pd files) which preserves all struct information losslessly. This enables:

  • Debugging failures later without re-running tests
  • Building regression test suites from discovered bugs
  • Sharing failures across team members
  • Tracking which bugs have been fixed

Usage

# Save a failure
{:error, failure} = PropertyDamage.run(model: M, adapter: A)
{:ok, path} = PropertyDamage.save_failure(failure, "failures/")

# Load and replay later
{:ok, failure} = PropertyDamage.load_failure(path)
PropertyDamage.replay(failure)

# List all saved failures
failures = PropertyDamage.list_failures("failures/")

File Format

Files use the .pd extension and contain:

  • Version header for forward compatibility
  • Erlang term-encoded FailureReport struct
  • Checksum for integrity verification

Naming Convention

Auto-generated filenames follow the pattern: {timestamp}-{failure_type}-{check_name}-seed{seed}.pd

Example: 2025-12-26T14-30-00-check_failed-NonNegativeBalance-seed512902757.pd

Sensitive data

A .pd file losslessly preserves the failing run: the command structs, the full event log, and projection state at the point of failure. If those carry personal or otherwise sensitive data (account numbers, emails, tokens), so does the saved file. Treat .pd files as you would the data they capture:

  • Do not commit them to a public repository or attach them to a public issue.
  • Scrub or synthesize sensitive fields in your commands/events before saving if the file will be shared, or keep saved failures in a controlled location.

PropertyDamage does not redact automatically; what the run touched is what the file holds.

Summary

Functions

Capture dependency versions from a failure report.

Delete a saved failure.

Export a failure to JSON format for external tools.

List all saved failures in a directory.

Load a failure report from disk.

Load a failure report, raising on version warnings.

Save a failure report to disk.

Check if a failure file is valid and loadable.

Types

save_opts()

@type save_opts() :: [filename: String.t(), overwrite: boolean()]

warning()

@type warning() ::
  {:property_damage_version_mismatch, String.t(), String.t()}
  | {:dependency_version_mismatch, atom(), String.t(), String.t()}
  | {:dependency_missing, atom(), String.t()}
  | {:struct_shape_drift, [atom()], [atom()]}

Functions

capture_dependency_versions(report)

@spec capture_dependency_versions(PropertyDamage.FailureReport.t()) :: %{
  required(atom()) => String.t()
}

Capture dependency versions from a failure report.

Extracts struct modules from commands and events, then looks up their application versions. This enables version tracking for saved test files.

delete(path)

@spec delete(Path.t()) :: :ok | {:error, term()}

Delete a saved failure.

Returns

  • :ok - File deleted
  • {:error, reason} - File not found, permission denied, etc.

export_json(report)

@spec export_json(PropertyDamage.FailureReport.t()) :: String.t()

Export a failure to JSON format for external tools.

Note: JSON export is lossy - some Elixir-specific data may be simplified. Use save/3 for lossless storage.

list(directory, opts \\ [])

@spec list(
  Path.t(),
  keyword()
) :: [map()]

List all saved failures in a directory.

Returns a list of maps with failure metadata (without loading full reports).

Options

  • :sort - Sort order: :newest, :oldest, :seed (default: :newest)
  • :filter - Filter function (metadata -> boolean)

Examples

# List all failures
failures = Persistence.list("failures/")
# => [%{path: "...", seed: 123, failure_type: :check_failed, ...}, ...]

# List only check failures
failures = Persistence.list("failures/", filter: &(&1.failure_type == :check_failed))

load(path)

@spec load(Path.t()) ::
  {:ok, PropertyDamage.FailureReport.t()}
  | {:ok, PropertyDamage.FailureReport.t(), [warning()]}
  | {:error, term()}

Load a failure report from disk.

Returns

  • {:ok, report} - Successfully loaded FailureReport with no version warnings
  • {:ok, report, warnings} - Loaded with version compatibility warnings
  • {:error, reason} - File not found, corrupted, incompatible version, etc.

Version warnings indicate that the saved test may not reproduce correctly due to changes in PropertyDamage or dependency versions. Warnings include:

  • {:property_damage_version_mismatch, saved_version, current_version}
  • {:dependency_version_mismatch, app, saved_version, current_version}
  • {:dependency_missing, app, saved_version}

Examples

{:ok, failure} = Persistence.load("failures/currency-bug.pd")
PropertyDamage.replay(failure)

# With version warnings
{:ok, failure, warnings} = Persistence.load("failures/old-test.pd")
IO.warn("Version mismatch: #{inspect(warnings)}")

load!(path)

Load a failure report, raising on version warnings.

Use this when you want strict version compatibility. Raises ArgumentError if there are any version mismatches between the saved file and current environment.

Examples

report = Persistence.load!("failures/currency-bug.pd")

save(report, directory, opts \\ [])

@spec save(PropertyDamage.FailureReport.t(), Path.t(), save_opts()) ::
  {:ok, Path.t()} | {:error, term()}

Save a failure report to disk.

Options

  • :filename - Custom filename (default: auto-generated from failure metadata)
  • :overwrite - Whether to overwrite existing files (default: false)

Returns

  • {:ok, path} - Full path to saved file
  • {:error, reason} - File already exists, directory doesn't exist, etc.

Examples

# Save with auto-generated name
{:ok, path} = Persistence.save(failure, "failures/")
# => {:ok, "failures/2025-12-26T14-30-00-check_failed-NonNegativeBalance-seed512902757.pd"}

# Save with custom name
{:ok, path} = Persistence.save(failure, "failures/", filename: "currency-bug.pd")

valid?(path)

@spec valid?(Path.t()) :: boolean()

Check if a failure file is valid and loadable.

Performs integrity check without fully loading the report.