# functions

id: duckdb_open_go_infra
name: duckdb_open
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func DuckDBOpen(path string) (*sql.DB, error)
description: Abre (o crea) una base de datos DuckDB. Path vacio o ':memory:' abre una base en memoria.
tags: ["database","duckdb","connection","sql","analytics"]
uses_functions: []
uses_types: ["db_config_go_infra"]
returns: []
error_type: error_go_core
imports: ["database/sql","github.com/marcboeker/go-duckdb"]
tests: []
file_path: functions/infra/duckdb_open.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: e72cfe0757201e5b25126c7f58cbf726b29a352615f0851e901a5b4051369c34
example:
  ```go
  // In-memory para analisis temporal
  db, err := DuckDBOpen("")
  if err != nil {
      log.Fatal(err)
  }
  defer DBClose(db)

  rows, err := DBQuery(db, "SELECT * FROM read_parquet('/data/sales.parquet')")
  ```

notes:
  Usa el driver `github.com/marcboeker/go-duckdb` (CGO). DuckDB es una base de datos OLAP embebida, ideal para analisis de datos. Path vacio equivale a `:memory:`. Hace ping al abrir para detectar errores temprano.

code:
  //go:build !noduckdb

  package infra

  import (
  	"database/sql"
  	"fmt"

  	_ "github.com/marcboeker/go-duckdb"
  )

  // DuckDBOpen opens (or creates) a DuckDB database file.
  // Pass an empty path or ":memory:" for an in-memory database.
  // Returns a ready-to-use *sql.DB or an error.
  func DuckDBOpen(path string) (*sql.DB, error) {
  	dsn := path
  	if dsn == "" {
  		dsn = ":memory:"
  	}
  	db, err := sql.Open("duckdb", dsn)
  	if err != nil {
  		return nil, fmt.Errorf("duckdb_open: open %q: %w", dsn, err)
  	}
  	if err := db.Ping(); err != nil {
  		db.Close()
  		return nil, fmt.Errorf("duckdb_open: ping %q: %w", dsn, err)
  	}
  	return db, nil
  }

params_schema:
  {"params":[{"name":"path","desc":"ruta del archivo DuckDB o vacio/:memory: para base en memoria"}],"output":"conexion sql.DB abierta a DuckDB"}


id: email_build_html_go_infra
name: email_build_html
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func EmailBuildHTML(from string, to []string, subject, bodyHTML string) EmailMessage
tags: ["email","smtp","html","builder"]
uses_functions: []
uses_types: ["EmailMessage_go_infra"]
returns: ["EmailMessage_go_infra"]
imports: []
test_file_path: functions/infra/email_build_test.go
file_path: functions/infra/email_build_html.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: ea2446cc5667ea2c9a9264f842ca210f5d355505a7a27ab343090414de4bff02
description:
  Construye un EmailMessage con cuerpo HTML. Retorna una nueva estructura sin CC, BCC, adjuntos ni headers custom.

example:
  ```go
  msg := EmailBuildHTML(
      "alice@example.com",
      []string{"bob@example.com", "carol@example.com"},
      "Reporte mensual",
      "<h1>Hola</h1><p>Ver adjunto.</p>",
  )
  // msg.BodyHTML = "<h1>Hola</h1><p>Ver adjunto.</p>"
  // msg.BodyText = ""
  ```

tests:
  ["construye mensaje html con campos basicos","copia el slice to para evitar aliasing","campos no usados quedan vacios"]

notes:
  Funcion pura. Copia el slice `to` para evitar aliasing con el caller. Para anadir adjuntos usar `EmailWithAttachment`. Para texto plano usar `EmailBuildText`.

code:
  package infra

  // EmailBuildHTML constructs an EmailMessage with an HTML body.
  // to is a list of recipient addresses.
  // subject is the email subject line.
  // bodyHTML is the HTML content of the email.
  // Returns a new EmailMessage with no CC, BCC, attachments or custom headers.
  func EmailBuildHTML(from string, to []string, subject, bodyHTML string) EmailMessage {
  	toSlice := make([]string, len(to))
  	copy(toSlice, to)
  	return EmailMessage{
  		From:    from,
  		To:      toSlice,
  		Subject: subject,
  		BodyHTML: bodyHTML,
  	}
  }

params_schema:
  {"params":[{"name":"from","desc":"direccion del remitente (ej: 'Alice \u003calice@example.com\u003e')"},{"name":"to","desc":"lista de direcciones de destinatarios principales"},{"name":"subject","desc":"asunto del correo"},{"name":"bodyHTML","desc":"contenido HTML del cuerpo del mensaje"}],"output":"EmailMessage listo para enviar con cuerpo HTML"}


id: email_build_text_go_infra
name: email_build_text
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func EmailBuildText(from string, to []string, subject, bodyText string) EmailMessage
tags: ["email","smtp","text","builder"]
uses_functions: []
uses_types: ["EmailMessage_go_infra"]
returns: ["EmailMessage_go_infra"]
imports: []
tests: ["construye mensaje text con campos basicos","body html queda vacio"]
test_file_path: functions/infra/email_build_test.go
file_path: functions/infra/email_build_text.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: 2bbb9182e46a19f9315f4e19ccfe03dbd844b8268579db54a8b98e937d09e1b6
description:
  Construye un EmailMessage con cuerpo de texto plano. Retorna una nueva estructura sin CC, BCC, adjuntos ni headers custom.

example:
  ```go
  msg := EmailBuildText(
      "alice@example.com",
      []string{"bob@example.com"},
      "Alerta critica",
      "El servidor cayó a las 03:42 UTC.",
  )
  // msg.BodyText = "El servidor cayó a las 03:42 UTC."
  // msg.BodyHTML = ""
  ```

notes:
  Funcion pura. Analogo de `EmailBuildHTML` para texto plano. Para anadir adjuntos usar `EmailWithAttachment`.

code:
  package infra

  // EmailBuildText constructs an EmailMessage with a plain text body.
  // to is a list of recipient addresses.
  // subject is the email subject line.
  // bodyText is the plain text content of the email.
  // Returns a new EmailMessage with no CC, BCC, attachments or custom headers.
  func EmailBuildText(from string, to []string, subject, bodyText string) EmailMessage {
  	toSlice := make([]string, len(to))
  	copy(toSlice, to)
  	return EmailMessage{
  		From:     from,
  		To:       toSlice,
  		Subject:  subject,
  		BodyText: bodyText,
  	}
  }

params_schema:
  {"params":[{"name":"from","desc":"direccion del remitente (ej: 'Alice \u003calice@example.com\u003e')"},{"name":"to","desc":"lista de direcciones de destinatarios principales"},{"name":"subject","desc":"asunto del correo"},{"name":"bodyText","desc":"contenido de texto plano del cuerpo del mensaje"}],"output":"EmailMessage listo para enviar con cuerpo de texto plano"}


id: email_template_render_go_infra
name: email_template_render
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func EmailTemplateRender(tmplStr string, data map[string]any) (string, error)
tags: ["email","template","render","text/template"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["bytes","fmt","text/template"]
test_file_path: functions/infra/email_template_render_test.go
file_path: functions/infra/email_template_render.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: c29df0297d417df83aab153c15a2a64360250dc0855765f4ce7691c56af30046
description:
  Renderiza un Go text/template con el data map dado y retorna el string resultante. Falla si el template tiene sintaxis invalida o si la ejecucion produce un error.

example:
  ```go
  tmpl := "Hola {{.Name}}, tu pedido {{.OrderID}} esta listo."
  body, err := EmailTemplateRender(tmpl, map[string]any{
      "Name":    "Alice",
      "OrderID": "ORD-1234",
  })
  // body = "Hola Alice, tu pedido ORD-1234 esta listo."
  ```

tests:
  ["renderiza template simple con datos","sustituye multiples variables","error en template invalido","template vacio retorna string vacio"]

notes:
  Usa `text/template` de la stdlib de Go. Para HTML con escape automatico usar `html/template` en su lugar. Util para generar el body de emails con datos dinamicos antes de llamar a `EmailBuildHTML` o `EmailBuildText`.

code:
  package infra

  import (
  	"bytes"
  	"fmt"
  	"text/template"
  )

  // EmailTemplateRender executes a Go text/template with the given data map
  // and returns the rendered string.
  // tmplStr is the template source (Go text/template syntax).
  // data is a map of string keys to any values available inside the template.
  // Returns an error if the template fails to parse or execute.
  func EmailTemplateRender(tmplStr string, data map[string]any) (string, error) {
  	tmpl, err := template.New("email").Parse(tmplStr)
  	if err != nil {
  		return "", fmt.Errorf("email_template_render: parse: %w", err)
  	}
  	var buf bytes.Buffer
  	if err := tmpl.Execute(&buf, data); err != nil {
  		return "", fmt.Errorf("email_template_render: execute: %w", err)
  	}
  	return buf.String(), nil
  }

params_schema:
  {"params":[{"name":"tmplStr","desc":"fuente del template en sintaxis Go text/template (ej: 'Hola {{.Name}}')"},{"name":"data","desc":"mapa de variables disponibles dentro del template (clave string, valor any)"}],"output":"string resultado del template renderizado con los datos provistos"}


id: email_with_attachment_go_infra
name: email_with_attachment
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func EmailWithAttachment(msg EmailMessage, att EmailAttachment) EmailMessage
description: Retorna una copia del EmailMessage con el adjunto añadido al final. No muta el mensaje original.
tags: ["email","smtp","attachment","immutable","builder"]
uses_functions: []
uses_types: ["EmailMessage_go_infra","EmailAttachment_go_infra"]
returns: ["EmailMessage_go_infra"]
imports: []
test_file_path: functions/infra/email_build_test.go
file_path: functions/infra/email_with_attachment.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: 6b187a04503c38b9b61a1015a76729abb8e26fa400e3829637c59e628a50b933
example:
  ```go
  msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "Informe", "<p>Ver adjunto</p>")
  att := EmailAttachment{Filename: "report.pdf", ContentType: "application/pdf", Data: pdfBytes}
  msg2 := EmailWithAttachment(msg, att)
  // len(msg.Attachments) == 0  (original no mutado)
  // len(msg2.Attachments) == 1
  ```

tests:
  ["agrega adjunto sin mutar el original","multiples adjuntos se acumulan","copia todos los campos del mensaje"]

notes:
  Funcion pura. Implementa el patron immutable builder — cada llamada retorna un nuevo `EmailMessage` con copia profunda de todos los slices y mapas. Se puede encadenar varias veces para agregar multiples adjuntos.

code:
  package infra

  // EmailWithAttachment returns a copy of msg with the given attachment appended.
  // Does not mutate the original message — always returns a new EmailMessage.
  func EmailWithAttachment(msg EmailMessage, att EmailAttachment) EmailMessage {
  	newAtts := make([]EmailAttachment, len(msg.Attachments)+1)
  	copy(newAtts, msg.Attachments)
  	newAtts[len(msg.Attachments)] = att

  	toSlice := make([]string, len(msg.To))
  	copy(toSlice, msg.To)

  	ccSlice := make([]string, len(msg.CC))
  	copy(ccSlice, msg.CC)

  	bccSlice := make([]string, len(msg.BCC))
  	copy(bccSlice, msg.BCC)

  	var headers map[string]string
  	if len(msg.Headers) > 0 {
  		headers = make(map[string]string, len(msg.Headers))
  		for k, v := range msg.Headers {
  			headers[k] = v
  		}
  	}

  	return EmailMessage{
  		From:        msg.From,
  		To:          toSlice,
  		CC:          ccSlice,
  		BCC:         bccSlice,
  		Subject:     msg.Subject,
  		BodyHTML:    msg.BodyHTML,
  		BodyText:    msg.BodyText,
  		Attachments: newAtts,
  		Headers:     headers,
  	}
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje email existente del que se obtiene la copia base"},{"name":"att","desc":"adjunto a agregar: nombre, MIME type y datos binarios"}],"output":"nuevo EmailMessage con todos los campos del original mas el adjunto añadido"}


id: env_require_go_infra
name: env_require
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func EnvRequire(key string) (string, error)
tags: ["env","config","os","infra","required"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os"]
test_file_path: functions/infra/env_require_test.go
file_path: functions/infra/env_require.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 3dfca365c5d80a17cc04558025f2e736d1cfb1a26c7ff5965176a283e13a0aab
description:
  Lee una variable de entorno con os.Getenv y retorna error descriptivo si esta vacia o no seteada. Fail-fast para variables obligatorias al arrancar la app.

example:
  ```go
  dbURL, err := EnvRequire("DATABASE_URL")
  if err != nil {
      log.Fatal(err) // "env_require: environment variable \"DATABASE_URL\" is not set or empty"
  }
  ```

tests:
  ["retorna valor cuando variable esta seteada","retorna error con mensaje descriptivo si variable no existe","variable vacia se trata como no seteada"]

notes:
  Wrapper minimo sobre os.Getenv. Trata vacio y no-seteado igual (ambos son error). Para multiples variables, usar EnvRequireAll que acumula todos los errores en una sola llamada.

code:
  package infra

  import (
  	"fmt"
  	"os"
  )

  // EnvRequire returns the value of the environment variable named key.
  // If the variable is unset or empty it returns an error with a descriptive message.
  func EnvRequire(key string) (string, error) {
  	val := os.Getenv(key)
  	if val == "" {
  		return "", fmt.Errorf("env_require: environment variable %q is not set or empty", key)
  	}
  	return val, nil
  }

params_schema:
  {"params":[{"name":"key","desc":"nombre de la variable de entorno requerida (ej: DATABASE_URL, API_KEY)"}],"output":"valor de la variable si esta presente y no vacia, error descriptivo si falta"}


id: env_require_all_go_infra
name: env_require_all
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func EnvRequireAll(keys []string) (map[string]string, error)
tags: ["env","config","os","infra","required","batch"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","strings"]
test_file_path: functions/infra/env_require_test.go
file_path: functions/infra/env_require_all.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 9bc04499edeb5e4333b4e098eaa354f7d9ca87d5adcf50e924c3026aa0da0364
description:
  Verifica y retorna multiples variables de entorno. Todas las claves se comprueban aunque algunas fallen, acumulando los nombres de las faltantes en un unico error. Util para validacion exhaustiva al arranque.

example:
  ```go
  vars, err := EnvRequireAll([]string{"DATABASE_URL", "API_KEY", "JWT_SECRET"})
  if err != nil {
      log.Fatal(err)
      // "env_require_all: missing environment variables: API_KEY, JWT_SECRET"
  }
  dbURL := vars["DATABASE_URL"]
  ```

tests:
  ["retorna mapa con todos los valores cuando estan seteados","acumula todos los errores de variables faltantes","lista vacia retorna mapa vacio sin error"]

notes:
  Diferencia con EnvRequire: comprueba todas las claves de golpe y lista todas las faltantes en el error. Mejor UX en apps con muchas variables requeridas — el usuario ve todo lo que le falta en un solo error.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"strings"
  )

  // EnvRequireAll returns a map of all requested environment variables.
  // All keys are checked even if some are missing. If any variable is unset or
  // empty, a single error listing all missing variables is returned.
  // On success, the returned map contains every key with its current value.
  func EnvRequireAll(keys []string) (map[string]string, error) {
  	result := make(map[string]string, len(keys))
  	var missing []string

  	for _, key := range keys {
  		val := os.Getenv(key)
  		if val == "" {
  			missing = append(missing, key)
  		} else {
  			result[key] = val
  		}
  	}

  	if len(missing) > 0 {
  		return nil, fmt.Errorf("env_require_all: missing environment variables: %s", strings.Join(missing, ", "))
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"keys","desc":"lista de nombres de variables de entorno que deben estar presentes y no vacias"}],"output":"mapa key→value con todos los valores si todos existen, error con lista de las faltantes si alguna falla"}


id: file_delete_go_infra
name: file_delete
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func FileDelete(path string) error
tags: ["file","delete","disk","storage","security","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath","strings"]
test_file_path: functions/infra/file_delete_test.go
file_path: functions/infra/file_delete.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: d9d7ee5e53a7172d866cd8712d009a2c20d7873ea09f8c97ed086c2b29d00fe7
description:
  Elimina un archivo del disco. Rechaza paths con ".." para evitar path traversal. Retorna error si el archivo no existe o si falla el remove.

example:
  ```go
  err := FileDelete("./uploads/a1b2c3d4.png")
  if err != nil {
      log.Printf("delete fallo: %v", err)
  }
  ```

tests:
  ["elimina archivo existente","rechaza path con ..","rechaza path vacio","retorna error si no existe"]

notes:
  La proteccion contra path traversal es defensiva pero NO es suficiente por si sola: la app debe pasar paths que ya estan resueltos al directorio de storage (usar `filepath.Join(baseDir, storedName)`). Esta funcion es un cinturon adicional contra bugs en la app que llamaria.

  NO sigue symlinks de forma especial — `os.Remove` borra el symlink, no el target.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  	"strings"
  )

  // FileDelete elimina un archivo del disco. Rechaza paths que contengan ".." para
  // evitar path traversal fuera del directorio esperado.
  //
  // Retorna error si el archivo no existe (os.ErrNotExist), si el path contiene "..",
  // o si la operacion de remove falla por permisos.
  func FileDelete(path string) error {
  	if path == "" {
  		return fmt.Errorf("file_delete: path vacio")
  	}

  	// Rechazar cualquier path traversal explicito en el input original
  	// (filepath.Clean resuelve `..` y borraria la huella, asi que comprobamos antes)
  	if containsParentRef(path) {
  		return fmt.Errorf("file_delete: path traversal no permitido en %q", path)
  	}
  	clean := filepath.Clean(path)

  	if _, err := os.Stat(clean); err != nil {
  		return fmt.Errorf("file_delete: stat %s: %w", clean, err)
  	}

  	if err := os.Remove(clean); err != nil {
  		return fmt.Errorf("file_delete: remove %s: %w", clean, err)
  	}
  	return nil
  }

  // containsParentRef detecta si el path tiene un segmento ".." entre separadores.
  // Acepta tanto "/" como "\" como separadores. No marca como malo nombres como "..bashrc".
  func containsParentRef(path string) bool {
  	// Normalizar a slashes
  	p := strings.ReplaceAll(path, "\\", "/")
  	for _, seg := range strings.Split(p, "/") {
  		if seg == ".." {
  			return true
  		}
  	}
  	return false
  }

params_schema:
  {"params":[{"name":"path","desc":"ruta del archivo a eliminar (no debe contener \"..\")"}],"output":"nil si el archivo se elimino correctamente, error si el path es vacio, contiene path traversal, no existe o falla la operacion"}


id: file_save_disk_go_infra
name: file_save_disk
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func FileSaveDisk(baseDir string, filename string, data io.Reader) (UploadedFile, error)
tags: ["file","save","disk","storage","upload","infra"]
uses_functions: ["file_unique_name_go_infra"]
uses_types: ["UploadedFile_go_infra"]
returns: ["UploadedFile_go_infra"]
error_type: error_go_core
imports: ["fmt","io","mime","os","path/filepath","strings","time"]
test_file_path: functions/infra/file_save_disk_test.go
file_path: functions/infra/file_save_disk.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: b5e93172e2bb00c3dfe811161bba0caf5a2b17b626719dbf6d994aea12c818b5
description:
  Escribe el contenido de un io.Reader a disco en baseDir con un nombre unico (UUID + extension). Crea el directorio si no existe. Retorna UploadedFile con metadata.

example:
  ```go
  f, _ := os.Open("./input.png")
  defer f.Close()
  uploaded, err := FileSaveDisk("./uploads", "input.png", f)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println(uploaded.Path) // ./uploads/{uuid}.png
  ```

tests:
  ["guarda contenido en baseDir con nombre UUID","crea baseDir si no existe","tamano coincide con bytes escritos","infiere ContentType desde la extension"]

notes:
  - El nombre original NUNCA se usa como nombre en disco (riesgo path traversal). Solo se preserva como metadata en el campo `Filename` para trazabilidad.
  - ContentType se infiere de la extension via `mime.TypeByExtension`. Para validacion estricta del tipo real, llamar `FileValidateType` ANTES de guardar y/o sobreescribir el campo.
  - Si falla el `io.Copy`, el archivo parcial se borra automaticamente.

code:
  package infra

  import (
  	"fmt"
  	"io"
  	"mime"
  	"os"
  	"path/filepath"
  	"strings"
  	"time"
  )

  // FileSaveDisk escribe el contenido de data en baseDir con un nombre unico generado a
  // partir de filename original. Crea baseDir si no existe.
  //
  // Retorna el UploadedFile con la metadata y la ruta completa en disco. El campo
  // ContentType se infiere de la extension via mime.TypeByExtension; si la app necesita
  // validacion mas estricta, debe usar FileValidateType antes y/o sobreescribir el campo.
  func FileSaveDisk(baseDir string, filename string, data io.Reader) (UploadedFile, error) {
  	if err := os.MkdirAll(baseDir, 0o755); err != nil {
  		return UploadedFile{}, fmt.Errorf("file_save_disk: mkdir %s: %w", baseDir, err)
  	}

  	stored := FileUniqueName(filename)
  	dst := filepath.Join(baseDir, stored)

  	f, err := os.Create(dst)
  	if err != nil {
  		return UploadedFile{}, fmt.Errorf("file_save_disk: create %s: %w", dst, err)
  	}
  	defer f.Close()

  	n, err := io.Copy(f, data)
  	if err != nil {
  		_ = os.Remove(dst)
  		return UploadedFile{}, fmt.Errorf("file_save_disk: copy: %w", err)
  	}

  	ext := strings.ToLower(filepath.Ext(stored))
  	ct := mime.TypeByExtension(ext)
  	if ct == "" {
  		ct = "application/octet-stream"
  	}

  	return UploadedFile{
  		Filename:    filename,
  		StoredName:  stored,
  		Size:        n,
  		ContentType: ct,
  		Path:        dst,
  		CreatedAt:   time.Now().UTC(),
  	}, nil
  }

params_schema:
  {"params":[{"name":"baseDir","desc":"directorio destino (se crea si no existe, permisos 0755)"},{"name":"filename","desc":"nombre original del archivo (solo se usa para extraer la extension)"},{"name":"data","desc":"reader con el contenido binario a escribir"}],"output":"UploadedFile con StoredName (UUID-based), Path completo, Size en bytes, ContentType inferido por extension y CreatedAt (UTC). Error si falla mkdir, create o copy"}


id: file_serve_go_infra
name: file_serve
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func FileServe(dir string, pathPrefix string, maxAge int) http.Handler
tags: ["http","file","serve","static","cache","security","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","net/http","strings"]
test_file_path: functions/infra/file_serve_test.go
file_path: functions/infra/file_serve.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 7164781198b76e135b8a8e97a3fa8fc93af552d8e63f161257b80c8b744ead7c
description:
  Retorna un http.Handler que sirve archivos estaticos desde dir, stripeando pathPrefix del URL. Setea Cache-Control con max-age. Rechaza paths con "..".

example:
  ```go
  mux := http.NewServeMux()
  mux.Handle("/files/", FileServe("./uploads", "/files/", 3600))
  http.ListenAndServe(":8080", mux)
  ```

tests:
  ["sirve archivo existente con headers de cache","responde 404 para archivo inexistente","rechaza path con .. con 400"]

notes:
  Wrapper sobre `http.FileServer` con dos refuerzos: rechazo explicito de paths con `..` y header `Cache-Control` configurable. `http.FileServer` ya normaliza paths, pero la doble verificacion es barata y reduce la superficie de ataque. Para servir archivos generados dinamicamente o detras de auth, no usar esta funcion — usar handlers custom.

code:
  package infra

  import (
  	"fmt"
  	"net/http"
  	"strings"
  )

  // FileServe retorna un http.Handler que sirve archivos estaticos desde dir.
  // Stripea pathPrefix del request URL antes de buscar el archivo, y setea el
  // header Cache-Control con max-age=maxAge segundos.
  //
  // El handler rechaza cualquier path que contenga ".." para mitigar path traversal,
  // aunque http.FileServer ya hace su propia normalizacion.
  func FileServe(dir string, pathPrefix string, maxAge int) http.Handler {
  	fs := http.FileServer(http.Dir(dir))
  	cacheControl := fmt.Sprintf("public, max-age=%d", maxAge)

  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		// Rechazar path traversal explicito
  		if strings.Contains(r.URL.Path, "..") {
  			http.Error(w, "path traversal not allowed", http.StatusBadRequest)
  			return
  		}
  		w.Header().Set("Cache-Control", cacheControl)
  		http.StripPrefix(pathPrefix, fs).ServeHTTP(w, r)
  	})
  }

params_schema:
  {"params":[{"name":"dir","desc":"directorio raiz desde donde se sirven los archivos"},{"name":"pathPrefix","desc":"prefijo del URL a remover antes de buscar (ej: \"/files/\")"},{"name":"maxAge","desc":"segundos para el header Cache-Control max-age"}],"output":"http.Handler listo para registrar en un mux. No retorna error directo; el handler responde 400 si detecta path traversal y delega al http.FileServer en otros casos"}


id: file_unique_name_go_infra
name: file_unique_name
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func FileUniqueName(originalName string) string
tags: ["file","unique","name","uuid","upload","infra"]
uses_functions: []
uses_types: []
returns: []
imports: ["path/filepath","strings","unicode","github.com/google/uuid"]
test_file_path: functions/infra/file_unique_name_test.go
file_path: functions/infra/file_unique_name.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: a21124ea1d56a2877ead6058ff70521c5a5390ab844a781e54c1bb67f62a548e
description:
  Genera un nombre de archivo unico combinando un UUID v4 con la extension sanitizada del nombre original. Evita colisiones y elimina problemas con caracteres especiales.

example:
  ```go
  n1 := FileUniqueName("vacaciones.PNG")
  // n1 = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"

  n2 := FileUniqueName("contrato sin extension")
  // n2 = "f9b6c2d1-..."  (solo UUID)

  n3 := FileUniqueName("malicious; rm -rf /.exe.txt")
  // n3 = "{uuid}.txt"
  ```

tests:
  ["preserva extension comun como png","convierte extension a minusculas","remueve caracteres especiales en extension","genera UUID sin extension si el archivo no tiene","trunca extensiones extremadamente largas"]

notes:
  Marcada como `pure` por contrato (no hace I/O ni depende de estado mutable explicitamente), pero internamente la generacion del UUID v4 usa un PRNG por lo que el resultado NO es determinista. Esto es aceptable en la convencion del registry: la pureza se refiere a la ausencia de side effects observables (no escribe a disco, red, ni globals), no al determinismo bit a bit.

  La extension se sanitiza para evitar:
  - Path traversal en disco (ej: `../../etc/passwd`)
  - Inyeccion de comandos en logs/UI
  - Ambiguedad de filesystem entre mayus/minus

code:
  package infra

  import (
  	"path/filepath"
  	"strings"
  	"unicode"

  	"github.com/google/uuid"
  )

  // FileUniqueName genera un nombre de archivo unico combinando un UUID v4 con la
  // extension sanitizada del nombre original.
  //
  // Ejemplo: "vacaciones.PNG" -> "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
  //
  // La extension se sanitiza: solo se conservan caracteres alfanumericos en minusculas
  // y se trunca a 16 caracteres como maximo. Si el archivo no tiene extension, se
  // retorna solo el UUID.
  //
  // La funcion es "pura en intencion" en el sentido de que su firma no depende del
  // contexto, pero internamente usa un generador de UUIDs aleatorios — el resultado
  // no es determinista.
  func FileUniqueName(originalName string) string {
  	id := uuid.NewString()

  	ext := filepath.Ext(originalName)
  	ext = strings.TrimPrefix(ext, ".")
  	ext = sanitizeExt(ext)
  	if ext == "" {
  		return id
  	}
  	return id + "." + ext
  }

  // sanitizeExt deja solo caracteres alfanumericos en minusculas y trunca a 16 chars.
  func sanitizeExt(ext string) string {
  	var b strings.Builder
  	for _, r := range strings.ToLower(ext) {
  		if unicode.IsLetter(r) || unicode.IsDigit(r) {
  			b.WriteRune(r)
  		}
  		if b.Len() >= 16 {
  			break
  		}
  	}
  	return b.String()
  }

params_schema:
  {"params":[{"name":"originalName","desc":"nombre original del archivo (puede contener path, espacios, caracteres especiales)"}],"output":"nombre unico {uuid}.{ext} con extension sanitizada (alfanumerica, minusculas, max 16 chars). Si no hay extension retorna solo el UUID"}


id: file_validate_type_go_infra
name: file_validate_type
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func FileValidateType(header []byte, allowedTypes []string) (string, bool)
tags: ["file","validate","mime","magic","security","upload","infra"]
uses_functions: []
uses_types: []
returns: []
imports: ["bytes"]
test_file_path: functions/infra/file_validate_type_test.go
file_path: functions/infra/file_validate_type.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: a3603bd7a47cc71bc88b0b12e8cd6d157fe3dc563292001632a4a11fbbb75b24
description:
  Detecta el MIME type real de un archivo a partir de sus primeros bytes (magic bytes) y verifica que esta en la lista de tipos permitidos. Mas seguro que confiar en el header Content-Type del request.

example:
  ```go
  data, _ := os.ReadFile("./uploads/some.bin")
  mime, ok := FileValidateType(data[:12], []string{"image/png", "image/jpeg"})
  if !ok {
      log.Printf("tipo no permitido: %s", mime)
  }
  ```

tests:
  ["detecta JPEG por magic bytes","detecta PNG por magic bytes","detecta PDF","detecta WebP con prefix RIFF y suffix WEBP","rechaza tipo no permitido","tipo desconocido retorna vacio"]

notes:
  Funcion pura — sin I/O, determinista. Tabla interna de signatures soportados:

  | Tipo | Magic bytes |
  |------|-------------|
  | JPEG | `FF D8 FF` |
  | PNG | `89 50 4E 47 0D 0A 1A 0A` |
  | GIF | `47 49 46 38` |
  | PDF | `25 50 44 46` |
  | WebP | `52 49 46 46 ?? ?? ?? ?? 57 45 42 50` |
  | ZIP | `50 4B 03 04` |

  NO es un antivirus. Solo verifica los primeros bytes — un archivo puede tener magic valido pero contenido malicioso despues. Para apps con requisitos de seguridad altos, complementar con escaneo adicional.

code:
  package infra

  import "bytes"

  // fileSignature describe el magic byte signature de un tipo de archivo.
  type fileSignature struct {
  	mime   string
  	prefix []byte
  	// Para WebP: el prefix son los primeros 4 bytes "RIFF", luego 4 bytes de tamaño,
  	// luego suffix en offset 8: "WEBP".
  	suffix       []byte
  	suffixOffset int
  }

  // fileSignatures es la tabla interna de magic bytes soportados.
  var fileSignatures = []fileSignature{
  	{mime: "image/jpeg", prefix: []byte{0xFF, 0xD8, 0xFF}},
  	{mime: "image/png", prefix: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}},
  	{mime: "image/gif", prefix: []byte{0x47, 0x49, 0x46, 0x38}},
  	{mime: "application/pdf", prefix: []byte{0x25, 0x50, 0x44, 0x46}},
  	{mime: "image/webp", prefix: []byte{0x52, 0x49, 0x46, 0x46}, suffix: []byte{0x57, 0x45, 0x42, 0x50}, suffixOffset: 8},
  	{mime: "application/zip", prefix: []byte{0x50, 0x4B, 0x03, 0x04}},
  }

  // FileValidateType detecta el MIME type real de un archivo a partir de sus primeros
  // bytes (magic bytes / file signature) y verifica que esta en la lista permitida.
  //
  // Retorna el MIME type detectado y true si esta permitido. Si no se puede detectar
  // el tipo o no esta en allowedTypes, retorna "" y false.
  //
  // Funcion pura — no hace I/O. La validacion por magic bytes es mas segura que confiar
  // en el header Content-Type del request, que puede ser falsificado.
  func FileValidateType(header []byte, allowedTypes []string) (string, bool) {
  	mime := detectMimeType(header)
  	if mime == "" {
  		return "", false
  	}
  	for _, allowed := range allowedTypes {
  		if allowed == mime {
  			return mime, true
  		}
  	}
  	return mime, false
  }

  // detectMimeType busca el primer signature que matchee header.
  func detectMimeType(header []byte) string {
  	for _, sig := range fileSignatures {
  		if len(header) < len(sig.prefix) {
  			continue
  		}
  		if !bytes.Equal(header[:len(sig.prefix)], sig.prefix) {
  			continue
  		}
  		if len(sig.suffix) > 0 {
  			end := sig.suffixOffset + len(sig.suffix)
  			if len(header) < end {
  				continue
  			}
  			if !bytes.Equal(header[sig.suffixOffset:end], sig.suffix) {
  				continue
  			}
  		}
  		return sig.mime
  	}
  	return ""
  }

params_schema:
  {"params":[{"name":"header","desc":"primeros bytes del archivo (al menos 12 bytes para detectar todos los formatos soportados)"},{"name":"allowedTypes","desc":"lista blanca de MIME types permitidos (ej: [\"image/png\", \"image/jpeg\", \"application/pdf\"])"}],"output":"tupla (mime_detectado, permitido). Si no se reconoce el tipo retorna (\"\", false). Si se reconoce pero no esta en allowedTypes retorna (mime, false)"}


id: generate_dockerfile_go_infra
name: generate_dockerfile
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func GenerateDockerfile(binaryName string, port int, envVars map[string]string) string
tags: ["docker","dockerfile","go","build","deploy","infra","pure"]
uses_functions: []
uses_types: []
returns: []
imports: ["fmt","sort","strings"]
test_file_path: functions/infra/generate_dockerfile_test.go
file_path: functions/infra/generate_dockerfile.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: a5a84109011ff28a5dfd12c101744d0c6d1464c2bbe0eb272a4e1fb06b2277af
description:
  Genera el texto de un Dockerfile multi-stage para una app Go. Stage build con golang:1.23-alpine, stage final con alpine:latest. Incluye ENV vars del map con orden determinista. Funcion pura sin I/O.

example:
  ```go
  content := GenerateDockerfile("myapp", 8080, map[string]string{
      "DB_HOST": "localhost",
      "PORT":    "8080",
  })
  fmt.Println(content)
  // FROM golang:1.23-alpine AS builder
  // ...
  // EXPOSE 8080
  // ENTRYPOINT ["./myapp"]
  ```

tests:
  ["contiene stage builder con golang:1.23-alpine","contiene stage final con alpine:latest","incluye EXPOSE cuando port mayor a cero","no incluye EXPOSE cuando port es cero","env vars aparecen ordenadas alfabeticamente","binaryName aparece en ENTRYPOINT","env vars vacias no generan instrucciones ENV"]

notes:
  Funcion pura — no toca el sistema de archivos. Componer con WriteDockerfile para persistir el resultado. Las ENV vars se ordenan alfabeticamente para garantizar Dockerfiles deterministas (mismo input => mismo output exacto). El stage build usa CGO_ENABLED=0 para binarios estáticos compatibles con alpine. Si port <= 0, omite la instruccion EXPOSE.

code:
  package infra

  import (
  	"fmt"
  	"sort"
  	"strings"
  )

  // GenerateDockerfile genera el texto de un Dockerfile multi-stage para una app Go.
  // Stage build: golang:1.23-alpine — descarga dependencias y compila.
  // Stage final: alpine:latest — copia el binario, expone el puerto y lo ejecuta.
  // Las envVars se inyectan como instrucciones ENV en el stage final.
  // Funcion pura: no realiza I/O, solo genera texto.
  func GenerateDockerfile(binaryName string, port int, envVars map[string]string) string {
  	var sb strings.Builder

  	// Stage build
  	sb.WriteString("# Stage build\n")
  	sb.WriteString("FROM golang:1.23-alpine AS builder\n\n")
  	sb.WriteString("WORKDIR /app\n\n")
  	sb.WriteString("COPY go.mod go.sum ./\n")
  	sb.WriteString("RUN go mod download\n\n")
  	sb.WriteString("COPY . .\n")
  	sb.WriteString(fmt.Sprintf("RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags=\"-s -w\" -o %s .\n\n", binaryName))

  	// Stage final
  	sb.WriteString("# Stage final\n")
  	sb.WriteString("FROM alpine:latest\n\n")
  	sb.WriteString("RUN apk --no-cache add ca-certificates tzdata\n\n")
  	sb.WriteString("WORKDIR /app\n\n")
  	sb.WriteString(fmt.Sprintf("COPY --from=builder /app/%s .\n\n", binaryName))

  	// ENV vars (orden determinista)
  	if len(envVars) > 0 {
  		keys := make([]string, 0, len(envVars))
  		for k := range envVars {
  			keys = append(keys, k)
  		}
  		sort.Strings(keys)
  		for _, k := range keys {
  			sb.WriteString(fmt.Sprintf("ENV %s=%s\n", k, envVars[k]))
  		}
  		sb.WriteString("\n")
  	}

  	if port > 0 {
  		sb.WriteString(fmt.Sprintf("EXPOSE %d\n\n", port))
  	}

  	sb.WriteString(fmt.Sprintf("ENTRYPOINT [\"./%s\"]\n", binaryName))

  	return sb.String()
  }

params_schema:
  {"params":[{"name":"binaryName","desc":"nombre del binario Go a ejecutar en el contenedor"},{"name":"port","desc":"puerto a exponer en el Dockerfile (0 omite la instruccion EXPOSE)"},{"name":"envVars","desc":"mapa de variables de entorno a incluir en el Dockerfile"}],"output":"string con el contenido del Dockerfile multi-stage"}


id: go_build_binary_go_infra
name: go_build_binary
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func GoBuildBinary(projectDir, outputPath string, ldflags string, tags string) error
tags: ["go","build","binary","compile","infra","deploy"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","os/exec","path/filepath","strings"]
test_file_path: functions/infra/go_build_binary_test.go
file_path: functions/infra/go_build_binary.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 98709b5938aadaace7fd56f9211521c76d9c8c58f067ee3b62fcba8dabc070b4
description:
  Compila un binario Go desde un directorio de proyecto. Si ldflags está vacío usa -s -w (strip debug). Si outputPath está vacío usa build/{dirname} dentro del projectDir. Ejecuta con CGO_ENABLED=0.

example:
  ```go
  // Compilar con opciones por defecto
  err := GoBuildBinary("/home/user/apps/myapp", "", "", "")
  // genera /home/user/apps/myapp/build/myapp

  // Compilar con output y tags explícitos
  err = GoBuildBinary("/home/user/apps/myapp", "/tmp/myapp-bin", "-s -w -X main.version=1.0", "fts5")
  if err != nil {
      log.Fatal(err)
  }
  ```

tests:
  ["compila proyecto valido sin error","outputPath vacio usa build/dirname por defecto","ldflags vacio usa -s -w por defecto","error si projectDir no existe"]

notes:
  Usa `CGO_ENABLED=0` para binarios estáticos compatibles con imágenes alpine. El flag `-trimpath` elimina rutas absolutas del binario para reproducibilidad. Los flags `-s -w` reducen el tamaño eliminando información de debug y la tabla de símbolos. Compatible con el flujo deploy_app que genera Dockerfile multi-stage.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"os/exec"
  	"path/filepath"
  	"strings"
  )

  // GoBuildBinary compila un binario Go desde projectDir hacia outputPath.
  // Si ldflags está vacío usa "-s -w" (strip debug info). Si outputPath está vacío
  // usa "build/{dirname}" dentro del projectDir. Requiere Go instalado en PATH.
  func GoBuildBinary(projectDir, outputPath string, ldflags string, tags string) error {
  	if ldflags == "" {
  		ldflags = "-s -w"
  	}
  	if outputPath == "" {
  		outputPath = filepath.Join(projectDir, "build", filepath.Base(projectDir))
  	}

  	// Crear directorio destino si no existe
  	if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
  		return fmt.Errorf("go_build_binary: crear directorio destino %s: %w", filepath.Dir(outputPath), err)
  	}

  	args := []string{"build", "-trimpath", "-ldflags=" + ldflags}
  	if tags != "" {
  		args = append(args, "-tags="+tags)
  	}
  	args = append(args, "-o", outputPath, ".")

  	cmd := exec.Command("go", args...)
  	cmd.Dir = projectDir
  	cmd.Env = append(os.Environ(), "CGO_ENABLED=0")

  	out, err := cmd.CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("go build en %s: %s", projectDir, strings.TrimSpace(string(out)))
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"projectDir","desc":"ruta del directorio Go (con main.go o go.mod)"},{"name":"outputPath","desc":"ruta donde guardar el binario o vacio para usar build/{dirname}"},{"name":"ldflags","desc":"flags de vinculacion Go o vacio para usar -s -w (strip)"},{"name":"tags","desc":"build tags Go (ej: fts5,cgo)"}],"output":"nil si la compilacion fue exitosa, error en caso contrario"}


id: health_check_http_go_infra
name: health_check_http
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HealthCheckHTTP(url string, timeoutSecs, intervalMs int) error
tags: ["http","health","check","polling","wait","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","net/http","time"]
test_file_path: functions/infra/health_check_http_test.go
file_path: functions/infra/health_check_http.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 650dc42911137ce40ed6a29c7c75ba8185704e91ec278899ad53cb97687bcaee
description:
  Hace polling HTTP GET a un endpoint hasta recibir status 200 o hasta agotar el timeout. Útil para esperar que un servicio levante antes de continuar un pipeline.

example:
  ```go
  // Esperar hasta 60s a que Metabase levante, polling cada 2s
  err := HealthCheckHTTP("http://localhost:3000/api/health", 60, 2000)
  if err != nil {
      log.Fatal("Servicio no disponible:", err)
  }
  fmt.Println("Servicio listo")
  ```

tests:
  ["retorna nil cuando el servidor responde 200","retorna error si el timeout se agota","respeta el intervalo entre intentos"]

notes:
  Usa solo net/http de la stdlib, sin dependencias externas. El cliente HTTP tiene timeout de intervalMs + 500ms para no bloquear el loop. Retorna el ultimo error si el timeout expira. No sigue redirects especiales — cualquier respuesta 200 OK es exito.

code:
  package infra

  import (
  	"fmt"
  	"net/http"
  	"time"
  )

  // HealthCheckHTTP hace polling HTTP GET a url hasta recibir status 200
  // o hasta que pasen timeoutSecs segundos. Entre intentos espera intervalMs milisegundos.
  func HealthCheckHTTP(url string, timeoutSecs, intervalMs int) error {
  	deadline := time.Now().Add(time.Duration(timeoutSecs) * time.Second)
  	interval := time.Duration(intervalMs) * time.Millisecond

  	client := &http.Client{
  		Timeout: time.Duration(intervalMs)*time.Millisecond + 500*time.Millisecond,
  	}

  	var lastErr error
  	for time.Now().Before(deadline) {
  		resp, err := client.Get(url)
  		if err == nil {
  			resp.Body.Close()
  			if resp.StatusCode == http.StatusOK {
  				return nil
  			}
  			lastErr = fmt.Errorf("status %d", resp.StatusCode)
  		} else {
  			lastErr = err
  		}
  		time.Sleep(interval)
  	}

  	if lastErr != nil {
  		return fmt.Errorf("health check %s timeout after %ds: %w", url, timeoutSecs, lastErr)
  	}
  	return fmt.Errorf("health check %s timeout after %ds", url, timeoutSecs)
  }

params_schema:
  {"params":[{"name":"url","desc":"URL del endpoint HTTP a verificar (GET)"},{"name":"timeoutSecs","desc":"tiempo maximo en segundos antes de agotar los intentos"},{"name":"intervalMs","desc":"intervalo en milisegundos entre intentos de verificacion"}],"output":"nil si el endpoint responde 200, error si se agota el timeout"}


id: http_cors_middleware_go_infra
name: http_cors_middleware
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func HTTPCORSMiddleware(origins []string, methods []string) Middleware
tags: ["http","cors","middleware","headers","server","infra"]
uses_functions: []
uses_types: ["Middleware_go_infra"]
returns: ["Middleware_go_infra"]
imports: ["net/http","strings"]
test_file_path: functions/infra/http_cors_middleware_test.go
file_path: functions/infra/http_cors_middleware.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 9d99b46ebfe605c1cbda0adac71b5fc4728f7b6c59f13a466cf259aa2389d2e6
description:
  Retorna un Middleware que setea headers CORS en cada respuesta HTTP. Maneja automaticamente las peticiones preflight OPTIONS con status 204.

example:
  ```go
  cors := HTTPCORSMiddleware(
      []string{"https://app.example.com"},
      []string{"GET", "POST", "PUT", "DELETE"},
  )
  chain := HTTPMiddlewareChain(cors)
  http.ListenAndServe(":8080", chain(mux))
  ```

tests:
  ["setea headers CORS en respuesta normal","responde 204 a peticion OPTIONS preflight","headers incluyen los origenes configurados","headers incluyen los metodos configurados"]

notes:
  Funcion pura — construye el middleware a partir de los parametros sin I/O. El header Access-Control-Allow-Headers siempre incluye "Content-Type, Authorization". Las peticiones OPTIONS reciben 204 No Content sin llegar al handler de negocio.

code:
  package infra

  import (
  	"net/http"
  	"strings"
  )

  // HTTPCORSMiddleware retorna un Middleware que setea headers CORS en cada respuesta.
  // origins es la lista de origenes permitidos (usa "*" para cualquiera).
  // methods es la lista de metodos HTTP permitidos.
  func HTTPCORSMiddleware(origins []string, methods []string) Middleware {
  	originsHeader := strings.Join(origins, ", ")
  	methodsHeader := strings.Join(methods, ", ")

  	return func(next http.Handler) http.Handler {
  		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.Header().Set("Access-Control-Allow-Origin", originsHeader)
  			w.Header().Set("Access-Control-Allow-Methods", methodsHeader)
  			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

  			// Responder preflight OPTIONS sin pasar al siguiente handler
  			if r.Method == http.MethodOptions {
  				w.WriteHeader(http.StatusNoContent)
  				return
  			}

  			next.ServeHTTP(w, r)
  		})
  	}
  }

params_schema:
  {"params":[{"name":"origins","desc":"lista de origenes permitidos (ej: [\"https://example.com\"] o [\"*\"] para cualquiera)"},{"name":"methods","desc":"lista de metodos HTTP permitidos (ej: [\"GET\", \"POST\", \"PUT\", \"DELETE\"])"}],"output":"Middleware que inyecta Access-Control-Allow-Origin, Access-Control-Allow-Methods y Access-Control-Allow-Headers en cada respuesta"}


id: http_download_file_go_infra
name: http_download_file
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["http","download","file","streaming","atomic","network","stdlib","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","io","net/http","os","path/filepath","time"]
test_file_path: functions/infra/http_download_file_test.go
file_path: functions/infra/http_download_file.go
created_at: 2026-04-05T11:25:56Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: dd015de01e491493a67384343824c57a1fad3621fc4ac5323d402bf2002916f7
signature:
  func HttpDownloadFile(url, destPath string, headers map[string]string, timeout time.Duration) (int64, error)

description:
  Descarga url en destPath en streaming con io.Copy. Crea directorios con os.MkdirAll. Usa archivo temporal + rename para atomicidad (no deja archivo corrupto si falla). Retorna bytes escritos.

example:
  ```go
  n, err := HttpDownloadFile(
      "https://example.com/report.pdf",
      "/tmp/reports/report.pdf",
      nil,
      2*time.Minute,
  )
  if err != nil {
      return err
  }
  fmt.Printf("Downloaded %d bytes\n", n)
  ```

tests:
  ["httptest.Server sirve archivo binario","Directorio creado automaticamente","Archivo temporal + rename (no deja basura si falla)","Size retornado coincide"]

notes:
  Solo usa stdlib. El archivo temporal se crea en el mismo directorio que destPath para que el rename sea atomico (mismo filesystem). Si la descarga falla, el archivo temporal se elimina con os.Remove (el defer lo garantiza). Compatible con archivos de cualquier tamano ya que usa streaming con io.Copy.

code:
  package infra

  import (
  	"fmt"
  	"io"
  	"net/http"
  	"os"
  	"path/filepath"
  	"time"
  )

  // HttpDownloadFile descarga url en destPath en streaming con io.Copy.
  // Crea directorios intermedios con os.MkdirAll. Usa archivo temporal + rename
  // para garantizar atomicidad (no deja archivo corrupto si falla a mitad).
  // Retorna los bytes escritos.
  func HttpDownloadFile(url, destPath string, headers map[string]string, timeout time.Duration) (int64, error) {
  	client := &http.Client{Timeout: timeout}

  	req, err := http.NewRequest(http.MethodGet, url, nil)
  	if err != nil {
  		return 0, fmt.Errorf("http_download_file: build request: %w", err)
  	}
  	for k, v := range headers {
  		req.Header.Set(k, v)
  	}

  	resp, err := client.Do(req)
  	if err != nil {
  		return 0, fmt.Errorf("http_download_file: %w", err)
  	}
  	defer resp.Body.Close()

  	if resp.StatusCode >= 400 {
  		shortURL := url
  		if len(shortURL) > 100 {
  			shortURL = shortURL[:100]
  		}
  		return 0, fmt.Errorf("http_download_file: HTTP %d at %q", resp.StatusCode, shortURL)
  	}

  	dir := filepath.Dir(destPath)
  	if err := os.MkdirAll(dir, 0o755); err != nil {
  		return 0, fmt.Errorf("http_download_file: create dirs: %w", err)
  	}

  	// Archivo temporal en el mismo directorio para que rename sea atomico
  	tmp, err := os.CreateTemp(dir, ".download-*")
  	if err != nil {
  		return 0, fmt.Errorf("http_download_file: create temp file: %w", err)
  	}
  	tmpPath := tmp.Name()
  	defer func() {
  		tmp.Close()
  		os.Remove(tmpPath) // no-op si rename tuvo exito
  	}()

  	n, err := io.Copy(tmp, resp.Body)
  	if err != nil {
  		return 0, fmt.Errorf("http_download_file: write: %w", err)
  	}

  	if err := tmp.Close(); err != nil {
  		return 0, fmt.Errorf("http_download_file: close temp: %w", err)
  	}

  	if err := os.Rename(tmpPath, destPath); err != nil {
  		return 0, fmt.Errorf("http_download_file: rename: %w", err)
  	}

  	return n, nil
  }

params_schema:
  {"params":[{"name":"url","desc":"URL del archivo a descargar"},{"name":"destPath","desc":"ruta de destino del archivo descargado"},{"name":"headers","desc":"mapa de headers custom a incluir en la solicitud"},{"name":"timeout","desc":"duracion maxima para completar la descarga"}],"output":"numero de bytes descargados exitosamente"}


id: http_error_response_go_infra
name: http_error_response
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPErrorResponse(w http.ResponseWriter, err HTTPError)
tags: ["http","error","response","json","server","infra"]
uses_functions: []
uses_types: ["HTTPError_go_infra"]
returns: []
error_type: error_go_core
imports: ["encoding/json","net/http"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_error_response.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 3d30346fa16669186935eb7136997fec72b853ddfd9234635894ed21137b432f
description:
  Escribe un HTTPError como JSON en el ResponseWriter. Usa err.Status como status code HTTP y serializa los campos Code y Message en el body.

example:
  ```go
  func createUser(w http.ResponseWriter, r *http.Request) {
      var user User
      if err := HTTPParseBody(r, &user, 1<<20); err != nil {
          HTTPErrorResponse(w, HTTPError{
              Status:  http.StatusBadRequest,
              Code:    "invalid_body",
              Message: err.Error(),
          })
          return
      }
      // ...
  }
  ```

tests:
  ["escribe el status code del HTTPError","setea Content-Type application/json","serializa Code y Message en el body JSON"]

notes:
  Conveniente para APIs JSON que siempre retornan errores en formato estructurado. Se puede componer con HTTPParseBody: si el parse falla, llamar HTTPErrorResponse con 400. El body JSON tiene la forma `{"Status":400,"Code":"...","Message":"..."}`.

code:
  package infra

  import (
  	"encoding/json"
  	"net/http"
  )

  // HTTPErrorResponse escribe un HTTPError como JSON en w.
  // Setea Content-Type a application/json y usa err.Status como status code.
  func HTTPErrorResponse(w http.ResponseWriter, err HTTPError) {
  	w.Header().Set("Content-Type", "application/json")
  	w.WriteHeader(err.Status)
  	json.NewEncoder(w).Encode(err)
  }

params_schema:
  {"params":[{"name":"w","desc":"ResponseWriter donde se escribe la respuesta de error"},{"name":"err","desc":"HTTPError con Status (codigo HTTP), Code (identificador maquina) y Message (texto legible)"}],"output":"escribe la respuesta de error JSON directamente en w, sin valor de retorno"}


id: http_get_json_go_infra
name: http_get_json
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["http","json","get","client","network","stdlib","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["encoding/json","fmt","io","net/http","time"]
tests: ["httptest.Server con respuesta JSON","Status 404 → error","Timeout → error","Headers custom"]
test_file_path: functions/infra/http_get_json_test.go
file_path: functions/infra/http_get_json.go
created_at: 2026-04-05T11:25:56Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: cf0392ffb51629fb2e1ae74d742314641fd5348ce9b2d598c1b91492dc82a545
signature:
  func HttpGetJSON(url string, headers map[string]string, timeout time.Duration) (map[string]any, error)

description:
  GET request que espera JSON. Agrega Accept: application/json automaticamente. Retorna error con status code si >= 400. Siempre cierra body con defer.

example:
  ```go
  result, err := HttpGetJSON(
      "https://api.example.com/users",
      map[string]string{"X-Api-Key": "secret"},
      10*time.Second,
  )
  if err != nil {
      return nil, err
  }
  fmt.Println(result["total"])
  ```

notes:
  Solo usa stdlib (net/http, encoding/json). El timeout se configura en el http.Client. El error incluye los primeros 200 bytes del body para facilitar debugging. Los headers custom se fusionan con Accept: application/json (custom tiene precedencia).

code:
  package infra

  import (
  	"encoding/json"
  	"fmt"
  	"io"
  	"net/http"
  	"time"
  )

  // HttpGetJSON realiza un GET request a url y parsea la respuesta como JSON.
  // Agrega Accept: application/json automaticamente. Retorna error si status >= 400
  // incluyendo el status code y los primeros 200 bytes del body.
  func HttpGetJSON(url string, headers map[string]string, timeout time.Duration) (map[string]any, error) {
  	client := &http.Client{Timeout: timeout}

  	req, err := http.NewRequest(http.MethodGet, url, nil)
  	if err != nil {
  		return nil, fmt.Errorf("http_get_json: build request: %w", err)
  	}

  	req.Header.Set("Accept", "application/json")
  	for k, v := range headers {
  		req.Header.Set(k, v)
  	}

  	resp, err := client.Do(req)
  	if err != nil {
  		return nil, fmt.Errorf("http_get_json: %w", err)
  	}
  	defer resp.Body.Close()

  	body, err := io.ReadAll(resp.Body)
  	if err != nil {
  		return nil, fmt.Errorf("http_get_json: read body: %w", err)
  	}

  	if resp.StatusCode >= 400 {
  		preview := body
  		if len(preview) > 200 {
  			preview = preview[:200]
  		}
  		shortURL := url
  		if len(shortURL) > 100 {
  			shortURL = shortURL[:100]
  		}
  		return nil, fmt.Errorf("http_get_json: HTTP %d at %q — %s", resp.StatusCode, shortURL, preview)
  	}

  	var result map[string]any
  	if err := json.Unmarshal(body, &result); err != nil {
  		return nil, fmt.Errorf("http_get_json: parse JSON: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"url","desc":"URL del endpoint HTTP a consultar"},{"name":"headers","desc":"mapa de headers custom a incluir en la solicitud"},{"name":"timeout","desc":"duracion maxima para completar la solicitud"}],"output":"mapa con la estructura JSON decodificada del response"}


id: http_json_response_go_infra
name: http_json_response
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPJSONResponse(w http.ResponseWriter, status int, data any)
tags: ["http","json","response","server","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["encoding/json","net/http"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_json_response.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 7fac420df1de9e296ab912acb1caa86e1f497e8ab81f9efa6b384b11de1f5f4a
description:
  Escribe data como JSON en el ResponseWriter con el status code dado. Setea Content-Type: application/json automaticamente.

example:
  ```go
  func getUser(w http.ResponseWriter, r *http.Request) {
      user := map[string]string{"id": "1", "name": "Lucas"}
      HTTPJSONResponse(w, http.StatusOK, user)
  }
  ```

tests:
  ["escribe status code correcto","setea Content-Type application/json","serializa datos correctamente a JSON"]

notes:
  Usa json.NewEncoder para streaming directo al ResponseWriter sin buffer intermedio. El header Content-Type debe setearse antes de WriteHeader. Errores de serializacion se propagan parcialmente al body — asegurarse de pasar tipos serializable a JSON.

code:
  package infra

  import (
  	"encoding/json"
  	"net/http"
  )

  // HTTPJSONResponse escribe data como JSON en w con el status code dado.
  // Setea Content-Type a application/json antes de escribir el body.
  func HTTPJSONResponse(w http.ResponseWriter, status int, data any) {
  	w.Header().Set("Content-Type", "application/json")
  	w.WriteHeader(status)
  	json.NewEncoder(w).Encode(data)
  }

params_schema:
  {"params":[{"name":"w","desc":"ResponseWriter donde se escribe la respuesta HTTP"},{"name":"status","desc":"codigo de status HTTP (ej: 200, 201, 400, 500)"},{"name":"data","desc":"cualquier valor serializable a JSON que se escribe como body de la respuesta"}],"output":"escribe la respuesta JSON directamente en w, sin valor de retorno"}


id: http_logger_middleware_go_infra
name: http_logger_middleware
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPLoggerMiddleware(logger io.Writer) Middleware
tags: ["http","logger","middleware","logging","server","infra"]
uses_functions: []
uses_types: ["Middleware_go_infra"]
returns: ["Middleware_go_infra"]
error_type: error_go_core
imports: ["fmt","io","net/http","time"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_logger_middleware.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 3a94457c67b36da3cc14ad1b7decede23ebaaa4b6ff4fa4f4ae01e0f82a2d023
description:
  Retorna un Middleware que loguea metodo, path, status code y duracion de cada request HTTP. El formato es: METHOD /path STATUS DURACIONms

example:
  ```go
  logger := HTTPLoggerMiddleware(os.Stderr)
  chain := HTTPMiddlewareChain(logger)
  // Cada request imprime: GET /api/users 200 5ms
  http.ListenAndServe(":8080", chain(mux))
  ```

tests:
  ["loguea metodo y path del request","loguea el status code de la respuesta","loguea la duracion en milisegundos"]

notes:
  Captura el status code con un responseWriter envolvente que intercepta WriteHeader. Si el handler no llama WriteHeader explicitamente, el status por defecto es 200. El logger recibe cualquier io.Writer — util para tests usando bytes.Buffer o para escribir a archivos de log.

code:
  package infra

  import (
  	"fmt"
  	"io"
  	"net/http"
  	"time"
  )

  // responseWriter captura el status code escrito al ResponseWriter.
  type responseWriter struct {
  	http.ResponseWriter
  	status int
  }

  func (rw *responseWriter) WriteHeader(status int) {
  	rw.status = status
  	rw.ResponseWriter.WriteHeader(status)
  }

  // HTTPLoggerMiddleware retorna un Middleware que loguea metodo, path, status y duracion de cada request.
  // El formato de cada linea es: METHOD /path STATUS DURACIONms
  func HTTPLoggerMiddleware(logger io.Writer) Middleware {
  	return func(next http.Handler) http.Handler {
  		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			start := time.Now()
  			rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
  			next.ServeHTTP(rw, r)
  			duration := time.Since(start)
  			fmt.Fprintf(logger, "%s %s %d %dms\n",
  				r.Method, r.URL.Path, rw.status, duration.Milliseconds())
  		})
  	}
  }

params_schema:
  {"params":[{"name":"logger","desc":"io.Writer donde se escriben las lineas de log (ej: os.Stderr, un archivo, bytes.Buffer)"}],"output":"Middleware que intercepta cada request, llama al siguiente handler, y loguea los detalles al terminar"}


id: http_middleware_chain_go_infra
name: http_middleware_chain
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func HTTPMiddlewareChain(middlewares ...Middleware) Middleware
tags: ["http","middleware","chain","compose","server","infra"]
uses_functions: []
uses_types: ["Middleware_go_infra"]
returns: ["Middleware_go_infra"]
imports: ["net/http"]
test_file_path: functions/infra/http_middleware_chain_test.go
file_path: functions/infra/http_middleware_chain.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: c5cb47cbc1cfc1b2029287811719064faeb314065a51fbf0c689e1ab1f6e654a
description:
  Compone N middlewares en uno solo. El primer middleware de la lista es el mas externo (se ejecuta primero en el request y ultimo en el response).

example:
  ```go
  chain := HTTPMiddlewareChain(
      HTTPLoggerMiddleware(os.Stderr),
      HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST"}),
  )
  handler := chain(myHandler)
  http.ListenAndServe(":8080", handler)
  ```

tests:
  ["sin middlewares pasa al handler final","un middleware se aplica correctamente","dos middlewares se aplican en orden izquierda-derecha"]

notes:
  Funcion pura — solo combina funciones, sin I/O ni estado. La composicion es de derecha a izquierda internamente para que el primer middleware quede en la capa mas externa. Con cero middlewares retorna un Middleware identidad que no modifica el handler.

code:
  package infra

  import "net/http"

  // HTTPMiddlewareChain compone N middlewares en uno solo aplicandolos de izquierda a derecha.
  // El primer middleware de la lista es el mas externo (se ejecuta primero).
  // Si no se pasan middlewares retorna un Middleware que no modifica el handler.
  func HTTPMiddlewareChain(middlewares ...Middleware) Middleware {
  	return func(final http.Handler) http.Handler {
  		// Aplicar en orden inverso para que el primero quede mas externo
  		h := final
  		for i := len(middlewares) - 1; i >= 0; i-- {
  			h = middlewares[i](h)
  		}
  		return h
  	}
  }

params_schema:
  {"params":[{"name":"middlewares","desc":"lista variadic de middlewares a componer, aplicados de izquierda a derecha (el primero es el mas externo)"}],"output":"un Middleware unico que aplica todos los middlewares en orden"}


id: http_parse_body_go_infra
name: http_parse_body
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPParseBody(r *http.Request, dst any, maxBytes int64) error
tags: ["http","json","parse","body","request","server","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["encoding/json","fmt","io","net/http"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_parse_body.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 9447951d2ea815392cb2c7c9d6cc7c87df5516883cb416d4f3e544261b276788
description:
  Decodifica el body JSON de un http.Request en dst, limitando la lectura a maxBytes. Rechaza campos desconocidos y multiples objetos JSON.

example:
  ```go
  func createUser(w http.ResponseWriter, r *http.Request) {
      var input struct {
          Name  string `json:"name"`
          Email string `json:"email"`
      }
      if err := HTTPParseBody(r, &input, 1<<20); err != nil {
          HTTPErrorResponse(w, HTTPError{Status: 400, Code: "invalid_body", Message: err.Error()})
          return
      }
      // usar input.Name, input.Email
  }
  ```

tests:
  ["decodifica JSON valido correctamente","retorna error si el body supera maxBytes","retorna error si el JSON es invalido","retorna error si hay campos desconocidos"]

notes:
  Usa http.MaxBytesReader para limitar el body antes de decodificar. DisallowUnknownFields rechaza campos JSON no mapeados en dst — util para validacion estricta de contratos API. Consume el body restante con io.Discard despues del decode.

code:
  package infra

  import (
  	"encoding/json"
  	"fmt"
  	"io"
  	"net/http"
  )

  // HTTPParseBody decodifica el body JSON de r en dst, limitando la lectura a maxBytes.
  // Retorna error si el body excede maxBytes, si el JSON es invalido, o si hay error de lectura.
  func HTTPParseBody(r *http.Request, dst any, maxBytes int64) error {
  	r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
  	dec := json.NewDecoder(r.Body)
  	dec.DisallowUnknownFields()
  	if err := dec.Decode(dst); err != nil {
  		return fmt.Errorf("parse body: %w", err)
  	}
  	// Verificar que no queda mas data despues del primer objeto JSON
  	if dec.More() {
  		return fmt.Errorf("parse body: request body must contain a single JSON object")
  	}
  	// Consumir el resto para liberar el body
  	io.Copy(io.Discard, r.Body)
  	return nil
  }

params_schema:
  {"params":[{"name":"r","desc":"http.Request cuyo body se va a decodificar"},{"name":"dst","desc":"puntero al valor donde se decodifica el JSON (ej: \u0026myStruct)"},{"name":"maxBytes","desc":"limite maximo de bytes del body (ej: 1\u003c\u003c20 para 1 MB)"}],"output":"nil si el parse fue exitoso, error si el body excede maxBytes, si el JSON es invalido, si hay campos desconocidos, o si hay multiples objetos"}


id: http_post_json_go_infra
name: http_post_json
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["http","json","post","client","network","stdlib","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["bytes","encoding/json","fmt","io","net/http","time"]
tests: ["httptest.Server recibe body correcto","Status 201 → exito","Status 500 → error con body parcial"]
test_file_path: functions/infra/http_post_json_test.go
file_path: functions/infra/http_post_json.go
created_at: 2026-04-05T11:25:56Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5e9198ac0d3036b2edf85c00b4ef56d0d9c2049bfc97937e17601d403a6425c4
signature:
  func HttpPostJSON(url string, body any, headers map[string]string, timeout time.Duration) (map[string]any, error)

description:
  POST request con body JSON serializado con json.Marshal. Agrega Content-Type: application/json y Accept: application/json. Retorna error con status code si >= 400.

example:
  ```go
  result, err := HttpPostJSON(
      "https://api.example.com/users",
      map[string]any{"name": "Alice", "role": "admin"},
      map[string]string{"X-Api-Key": "secret"},
      10*time.Second,
  )
  if err != nil {
      return nil, err
  }
  fmt.Println(result["id"])
  ```

notes:
  Solo usa stdlib. El body acepta `any` y se serializa con json.Marshal. Headers custom se fusionan con Content-Type y Accept por defecto. El error incluye los primeros 200 bytes del body de respuesta.

code:
  package infra

  import (
  	"bytes"
  	"encoding/json"
  	"fmt"
  	"io"
  	"net/http"
  	"time"
  )

  // HttpPostJSON realiza un POST request con body JSON y parsea la respuesta como JSON.
  // Agrega Content-Type: application/json y Accept: application/json automaticamente.
  // Retorna error si status >= 400 incluyendo status code y los primeros 200 bytes del body.
  func HttpPostJSON(url string, body any, headers map[string]string, timeout time.Duration) (map[string]any, error) {
  	data, err := json.Marshal(body)
  	if err != nil {
  		return nil, fmt.Errorf("http_post_json: marshal body: %w", err)
  	}

  	client := &http.Client{Timeout: timeout}

  	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
  	if err != nil {
  		return nil, fmt.Errorf("http_post_json: build request: %w", err)
  	}

  	req.Header.Set("Content-Type", "application/json")
  	req.Header.Set("Accept", "application/json")
  	for k, v := range headers {
  		req.Header.Set(k, v)
  	}

  	resp, err := client.Do(req)
  	if err != nil {
  		return nil, fmt.Errorf("http_post_json: %w", err)
  	}
  	defer resp.Body.Close()

  	respBody, err := io.ReadAll(resp.Body)
  	if err != nil {
  		return nil, fmt.Errorf("http_post_json: read body: %w", err)
  	}

  	if resp.StatusCode >= 400 {
  		preview := respBody
  		if len(preview) > 200 {
  			preview = preview[:200]
  		}
  		shortURL := url
  		if len(shortURL) > 100 {
  			shortURL = shortURL[:100]
  		}
  		return nil, fmt.Errorf("http_post_json: HTTP %d at %q — %s", resp.StatusCode, shortURL, preview)
  	}

  	var result map[string]any
  	if err := json.Unmarshal(respBody, &result); err != nil {
  		return nil, fmt.Errorf("http_post_json: parse JSON: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"url","desc":"URL del endpoint HTTP"},{"name":"body","desc":"datos a serializar como JSON en el cuerpo de la solicitud"},{"name":"headers","desc":"mapa de headers custom a fusionar con los headers por defecto"},{"name":"timeout","desc":"duracion maxima para completar la solicitud"}],"output":"mapa con la estructura JSON decodificada del response"}


id: http_router_go_infra
name: http_router
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPRouter(routes []Route) *http.ServeMux
tags: ["http","router","mux","server","routes","infra"]
uses_functions: []
uses_types: ["Route_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","net/http"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_router.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: fac187450b01a85f9cfe9996b59979b669d9b3319ae07ef57c9d2b66002c96c2
description:
  Crea un http.ServeMux y registra las rutas dadas usando la sintaxis de Go 1.22+ (METHOD /path). Retorna el mux listo para usar con http.ListenAndServe.

example:
  ```go
  mux := HTTPRouter([]Route{
      {Method: "GET",  Path: "/api/health", Handler: http.HandlerFunc(healthHandler)},
      {Method: "POST", Path: "/api/users",  Handler: http.HandlerFunc(createUserHandler)},
      {Method: "GET",  Path: "/api/users/{id}", Handler: http.HandlerFunc(getUserHandler)},
  })
  http.ListenAndServe(":8080", mux)
  ```

tests:
  ["registra una ruta y responde correctamente","registra multiples rutas en el mismo mux","ruta no registrada retorna 404"]

notes:
  Usa la sintaxis "METHOD /path" de Go 1.22+ para registrar rutas con metodo especifico. Soporta parametros de path como {id} accesibles via r.PathValue("id"). Sin rutas registradas retorna un mux vacio que responde 404 a todo.

code:
  package infra

  import (
  	"fmt"
  	"net/http"
  )

  // HTTPRouter crea un http.ServeMux y registra las rutas dadas.
  // Usa la sintaxis de Go 1.22+: "METHOD /path" como patron del mux.
  func HTTPRouter(routes []Route) *http.ServeMux {
  	mux := http.NewServeMux()
  	for _, route := range routes {
  		pattern := fmt.Sprintf("%s %s", route.Method, route.Path)
  		mux.Handle(pattern, route.Handler)
  	}
  	return mux
  }

params_schema:
  {"params":[{"name":"routes","desc":"slice de Route con Method, Path y Handler a registrar en el mux"}],"output":"http.ServeMux configurado con todas las rutas"}


id: http_serve_go_infra
name: http_serve
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPServe(addr string, handler http.Handler, ctx context.Context) error
tags: ["http","server","serve","graceful","shutdown","context","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["context","fmt","net/http","time"]
tests: ["servidor arranca y responde peticiones","graceful shutdown al cancelar contexto"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_serve.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: e529c06b21bd291290afbcfe47171e8ca704f7e8acfb9732eef3964499323579
description:
  Arranca un servidor HTTP en addr y realiza graceful shutdown cuando ctx se cancela. Espera hasta 30 segundos para que las conexiones activas cierren.

example:
  ```go
  ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  defer cancel()

  mux := HTTPRouter(routes)
  chain := HTTPMiddlewareChain(HTTPLoggerMiddleware(os.Stderr))
  if err := HTTPServe(":8080", chain(mux), ctx); err != nil {
      log.Fatal(err)
  }
  ```

notes:
  Arranca ListenAndServe en una goroutine y espera en un select: si el servidor falla devuelve el error inmediatamente, si ctx se cancela hace Shutdown con timeout de 30s. El patron estandar es usar signal.NotifyContext con SIGINT/SIGTERM para el graceful shutdown.

code:
  package infra

  import (
  	"context"
  	"fmt"
  	"net/http"
  	"time"
  )

  // HTTPServe arranca un servidor HTTP en addr y realiza graceful shutdown cuando ctx se cancela.
  // Espera hasta 30 segundos para que las conexiones activas cierren antes de forzar el shutdown.
  func HTTPServe(addr string, handler http.Handler, ctx context.Context) error {
  	srv := &http.Server{
  		Addr:    addr,
  		Handler: handler,
  	}

  	errCh := make(chan error, 1)
  	go func() {
  		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
  			errCh <- fmt.Errorf("http serve %s: %w", addr, err)
  		}
  		close(errCh)
  	}()

  	select {
  	case err := <-errCh:
  		return err
  	case <-ctx.Done():
  		shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  		defer cancel()
  		if err := srv.Shutdown(shutdownCtx); err != nil {
  			return fmt.Errorf("http shutdown %s: %w", addr, err)
  		}
  		return nil
  	}
  }

params_schema:
  {"params":[{"name":"addr","desc":"direccion donde escuchar (ej: \":8080\", \"0.0.0.0:9090\")"},{"name":"handler","desc":"http.Handler que procesa las peticiones (normalmente un *http.ServeMux con middlewares)"},{"name":"ctx","desc":"contexto que al cancelarse dispara el graceful shutdown del servidor"}],"output":"nil si el servidor se detuvo correctamente por cancelacion de contexto, error si fallo al arrancar o al hacer shutdown"}


id: job_cleanup_go_infra
name: job_cleanup
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobCleanup(q *JobQueue, olderThan time.Duration) (int64, error)
tags: ["job","queue","cleanup","delete","maintenance","sqlite","async","background","infra"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","time"]
tests: ["job_cleanup_removes_old_terminal_jobs","job_cleanup_keeps_recent_jobs"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_cleanup.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 5217b304dc1aac29ac89e34db68cd8329830aeba828707001d4396f19d7efcc6
description:
  Elimina jobs en estados terminales (completed, failed, dead) cuyo created_at sea mas antiguo que olderThan. Retorna el numero de filas eliminadas. Util para mantener la tabla compacta en apps de larga duracion.

example:
  ```go
  // Limpiar jobs terminados hace mas de 7 dias
  n, err := JobCleanup(q, 7*24*time.Hour)
  fmt.Printf("eliminados: %d jobs\n", n)
  ```

notes:
  Solo elimina jobs en estados terminales: completed, failed, dead. Los jobs pending y running nunca se eliminan. El cutoff se calcula en UTC. Llamar periodicamente con CronTicker u otro scheduler para mantener la tabla compacta.

code:
  package infra

  import (
  	"fmt"
  	"time"
  )

  // JobCleanup deletes jobs in terminal states (completed, failed, dead) that are
  // older than olderThan. Returns the number of rows deleted.
  // This is useful for keeping the jobs table small in long-running applications.
  func JobCleanup(q *JobQueue, olderThan time.Duration) (int64, error) {
  	if q == nil {
  		return 0, fmt.Errorf("job_cleanup: queue must not be nil")
  	}

  	cutoff := time.Now().UTC().Add(-olderThan).Format(time.RFC3339)

  	query := fmt.Sprintf(`
  DELETE FROM %s
  WHERE status IN ('completed', 'failed', 'dead')
    AND created_at < ?
  `, q.TableName)

  	res, err := q.DB.Exec(query, cutoff)
  	if err != nil {
  		return 0, fmt.Errorf("job_cleanup: delete: %w", err)
  	}
  	n, err := res.RowsAffected()
  	if err != nil {
  		return 0, fmt.Errorf("job_cleanup: rows affected: %w", err)
  	}
  	return n, nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"olderThan","desc":"duracion maxima de retension; jobs mas viejos que esto se eliminan (ej: 24*time.Hour)"}],"output":"numero de filas eliminadas"}


id: job_complete_go_infra
name: job_complete
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobComplete(q *JobQueue, jobID string, result string) error
tags: ["job","queue","complete","sqlite","async","background","infra"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","time"]
example:
  ```go
  err := JobComplete(q, job.ID, `{"rows_processed":42}`)
  ```

tests: ["complete_fail_transitions"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_complete.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 76c75d629216ac8e46dc5b3adfb999afc6132864b87f101cc301d44c810b06d6
description:
  Marca un job como completado, setea completed_at y almacena el resultado opcional. result puede ser vacio si no hay payload de resultado.

notes:
  Setea `status='completed'`, `completed_at=now`, y `result` (NULL si vacio). Retorna error si el job no existe (0 rows affected).

code:
  package infra

  import (
  	"fmt"
  	"time"
  )

  // JobComplete marks a job as completed and stores the optional result JSON string.
  // result may be empty ("") to indicate no result payload.
  func JobComplete(q *JobQueue, jobID string, result string) error {
  	if q == nil {
  		return fmt.Errorf("job_complete: queue must not be nil")
  	}
  	if jobID == "" {
  		return fmt.Errorf("job_complete: jobID must not be empty")
  	}

  	now := time.Now().UTC().Format(time.RFC3339)
  	var resultPtr *string
  	if result != "" {
  		resultPtr = &result
  	}

  	query := fmt.Sprintf(`
  UPDATE %s
  SET status = 'completed', completed_at = ?, result = ?
  WHERE id = ?
  `, q.TableName)

  	res, err := q.DB.Exec(query, now, resultPtr, jobID)
  	if err != nil {
  		return fmt.Errorf("job_complete: update: %w", err)
  	}
  	n, _ := res.RowsAffected()
  	if n == 0 {
  		return fmt.Errorf("job_complete: job %q not found", jobID)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"jobID","desc":"UUID del job a marcar como completado"},{"name":"result","desc":"JSON string con el resultado del job; puede ser vacio"}],"output":"error si el job no existe o falla el UPDATE"}


id: job_dequeue_go_infra
name: job_dequeue
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobDequeue(q *JobQueue, jobTypes []string) (*Job, error)
tags: ["job","queue","dequeue","atomic","transaction","sqlite","async","background","infra"]
uses_functions: []
uses_types: ["job_queue_go_infra","job_go_infra","job_status_go_infra"]
returns: ["job_go_infra"]
error_type: error_go_core
imports: ["database/sql","errors","fmt","strings","time"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_dequeue.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 5e547fa60cdd0396880dd28b7d6930cb4ba34dc468eb6c9ab4b1c059652f8da3
description:
  Extrae atomicamente el siguiente job disponible usando SELECT+UPDATE en una transaccion exclusiva. Retorna nil, nil si la cola esta vacia o no hay jobs que cumplan el filtro. jobTypes limita los tipos dequeued; slice vacio significa todos.

example:
  ```go
  // Desencolar cualquier tipo
  job, err := JobDequeue(q, nil)
  if job == nil {
      // cola vacia
  }

  // Filtrar por tipo
  job, err = JobDequeue(q, []string{"send_email", "send_sms"})
  ```

tests:
  ["enqueue_dequeue_atomicidad","dequeue_empty_queue_returns_nil","dequeue_priority_order","dequeue_jobtype_filter","dequeue_scheduled_in_future_waits"]

notes:
  Usa `db.Begin()` + `tx.Rollback()` (deferred) + `tx.Commit()`. El SELECT filtra por `status='pending' AND scheduled_at <= now` y ordena por `priority DESC, scheduled_at ASC`. El UPDATE atomico cambia el status a 'running' y setea `started_at`. Safe para multiples workers concurrentes — SQLite serializa la transaccion.

code:
  package infra

  import (
  	"database/sql"
  	"errors"
  	"fmt"
  	"strings"
  	"time"
  )

  // JobDequeue atomically claims the next available job by running a
  // SELECT + UPDATE inside an EXCLUSIVE transaction. Returns nil, nil when the
  // queue is empty (or no job matches the jobTypes filter).
  // jobTypes restricts which job types are dequeued; an empty slice means all types.
  func JobDequeue(q *JobQueue, jobTypes []string) (*Job, error) {
  	if q == nil {
  		return nil, fmt.Errorf("job_dequeue: queue must not be nil")
  	}

  	tx, err := q.DB.Begin()
  	if err != nil {
  		return nil, fmt.Errorf("job_dequeue: begin tx: %w", err)
  	}
  	defer tx.Rollback() //nolint:errcheck

  	// Build the SELECT query.
  	now := time.Now().UTC().Format(time.RFC3339)

  	var selectQ string
  	var args []any

  	if len(jobTypes) > 0 {
  		placeholders := make([]string, len(jobTypes))
  		for i, t := range jobTypes {
  			placeholders[i] = "?"
  			args = append(args, t)
  		}
  		selectQ = fmt.Sprintf(`
  SELECT id, type, payload, status, priority, attempts, max_attempts,
         scheduled_at, started_at, completed_at, result, error, created_at
  FROM %s
  WHERE status = 'pending'
    AND scheduled_at <= ?
    AND type IN (%s)
  ORDER BY priority DESC, scheduled_at ASC
  LIMIT 1
  `, q.TableName, strings.Join(placeholders, ","))
  		args = append(args, now)
  		// rearrange: now goes before jobTypes in the query
  		newArgs := []any{now}
  		newArgs = append(newArgs, args[:len(args)-1]...)
  		args = newArgs
  	} else {
  		selectQ = fmt.Sprintf(`
  SELECT id, type, payload, status, priority, attempts, max_attempts,
         scheduled_at, started_at, completed_at, result, error, created_at
  FROM %s
  WHERE status = 'pending'
    AND scheduled_at <= ?
  ORDER BY priority DESC, scheduled_at ASC
  LIMIT 1
  `, q.TableName)
  		args = []any{now}
  	}

  	row := tx.QueryRow(selectQ, args...)

  	var job Job
  	var scheduledAt string
  	var startedAt, completedAt sql.NullString
  	var result, jobError sql.NullString
  	var createdAt string

  	err = row.Scan(
  		&job.ID, &job.Type, &job.Payload, (*string)(&job.Status),
  		&job.Priority, &job.Attempts, &job.MaxAttempts,
  		&scheduledAt, &startedAt, &completedAt,
  		&result, &jobError, &createdAt,
  	)
  	if errors.Is(err, sql.ErrNoRows) {
  		return nil, nil
  	}
  	if err != nil {
  		return nil, fmt.Errorf("job_dequeue: scan: %w", err)
  	}

  	// Mark as running.
  	updateQ := fmt.Sprintf(`
  UPDATE %s SET status = 'running', started_at = ? WHERE id = ?
  `, q.TableName)
  	if _, err := tx.Exec(updateQ, now, job.ID); err != nil {
  		return nil, fmt.Errorf("job_dequeue: update status: %w", err)
  	}

  	if err := tx.Commit(); err != nil {
  		return nil, fmt.Errorf("job_dequeue: commit: %w", err)
  	}

  	// Hydrate optional fields.
  	if t, err := time.Parse(time.RFC3339, scheduledAt); err == nil {
  		job.ScheduledAt = t
  	}
  	if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
  		job.CreatedAt = t
  	}
  	if startedAt.Valid {
  		if t, err := time.Parse(time.RFC3339, startedAt.String); err == nil {
  			job.StartedAt = &t
  		}
  	}
  	if completedAt.Valid {
  		if t, err := time.Parse(time.RFC3339, completedAt.String); err == nil {
  			job.CompletedAt = &t
  		}
  	}
  	if result.Valid {
  		job.Result = &result.String
  	}
  	if jobError.Valid {
  		job.Error = &jobError.String
  	}
  	job.Status = JobStatusRunning

  	return &job, nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"jobTypes","desc":"lista de tipos a desencolar; slice vacio o nil = todos los tipos"}],"output":"puntero a Job con status=running, o nil si la cola esta vacia"}


id: job_enqueue_go_infra
name: job_enqueue
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobEnqueue(q *JobQueue, jobType string, payload string, opts ...EnqueueOption) (string, error)
tags: ["job","queue","enqueue","insert","async","background","infra","sqlite"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","time","github.com/google/uuid"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_enqueue.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: cdd389c17e29028906786fc12f3fd5fb2c73bb3ec5a0a6bf708eddede5c5f614
description:
  Inserta un nuevo job en la cola con UUID generado, tipo, payload JSON y opciones. Retorna el UUID del job. Opciones: WithPriority, WithScheduledAt, WithMaxAttempts.

example:
  ```go
  id, err := JobEnqueue(q, "send_email", `{"to":"user@example.com"}`,
      WithPriority(5),
      WithMaxAttempts(5),
  )
  ```

tests:
  ["enqueue_dequeue_atomicidad","dequeue_priority_order","dequeue_jobtype_filter","dequeue_scheduled_in_future_waits"]

notes:
  Usa `github.com/google/uuid` para generar el ID. El payload por defecto es "{}". El campo `scheduled_at` por defecto es `time.Now().UTC()`. La prioridad mas alta se procesa primero (ORDER BY priority DESC).

code:
  package infra

  import (
  	"fmt"
  	"time"

  	"github.com/google/uuid"
  )

  // JobEnqueue inserts a new job into the queue and returns its UUID.
  // jobType identifies the kind of work (e.g. "send_email", "resize_image").
  // payload is a JSON string with the job data (defaults to "{}" if empty).
  // Options: WithPriority, WithScheduledAt, WithMaxAttempts.
  func JobEnqueue(q *JobQueue, jobType string, payload string, opts ...EnqueueOption) (string, error) {
  	if q == nil {
  		return "", fmt.Errorf("job_enqueue: queue must not be nil")
  	}
  	if jobType == "" {
  		return "", fmt.Errorf("job_enqueue: jobType must not be empty")
  	}
  	if payload == "" {
  		payload = "{}"
  	}

  	cfg := &enqueueConfig{
  		priority:    0,
  		scheduledAt: time.Now().UTC(),
  		maxAttempts: 3,
  	}
  	for _, o := range opts {
  		o(cfg)
  	}

  	id := uuid.New().String()
  	scheduledAt := cfg.scheduledAt.Format(time.RFC3339)

  	q2 := fmt.Sprintf(`
  INSERT INTO %s (id, type, payload, status, priority, max_attempts, scheduled_at)
  VALUES (?, ?, ?, 'pending', ?, ?, ?)
  `, q.TableName)

  	_, err := q.DB.Exec(q2, id, jobType, payload, cfg.priority, cfg.maxAttempts, scheduledAt)
  	if err != nil {
  		return "", fmt.Errorf("job_enqueue: insert: %w", err)
  	}
  	return id, nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"jobType","desc":"identificador del tipo de trabajo (ej: 'send_email', 'resize_image')"},{"name":"payload","desc":"JSON string con los datos del job; si vacio se usa '{}'"},{"name":"opts","desc":"opciones: WithPriority(n), WithScheduledAt(t), WithMaxAttempts(n)"}],"output":"UUID del job insertado como string"}


id: job_fail_go_infra
name: job_fail
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobFail(q *JobQueue, jobID string, errMsg string) error
tags: ["job","queue","fail","retry","dead","sqlite","async","background","infra"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","time"]
example:
  ```go
  if err := handler(job); err != nil {
  _ = JobFail(q, job.ID, err.Error())
  }
  ```

tests: ["fail_increments_attempts","fail_transitions_to_dead"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_fail.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 169c323d230c5439f93997b8940166a95365fd3cd3a4c4dcb9f4f277080bc008
description:
  Incrementa el contador de intentos y transiciona el job a 'failed' (reintentable) o 'dead' (sin mas intentos) segun si attempts >= max_attempts. Almacena el mensaje de error.

notes:
  El UPDATE es atomico: incrementa attempts y decide el nuevo status en un solo SQL con CASE WHEN. Si `(attempts + 1) >= max_attempts` → status='dead', sino → status='failed'. Los jobs failed pueden ser reintentados manualmente reseteando su status a 'pending'. Los jobs dead no se procesan automaticamente.

code:
  package infra

  import (
  	"fmt"
  	"time"
  )

  // JobFail increments the attempt counter and transitions the job to:
  //   - "failed"  when attempts < max_attempts (eligible for retry)
  //   - "dead"    when attempts >= max_attempts (no more retries)
  //
  // errMsg is stored in the error column for debugging.
  func JobFail(q *JobQueue, jobID string, errMsg string) error {
  	if q == nil {
  		return fmt.Errorf("job_fail: queue must not be nil")
  	}
  	if jobID == "" {
  		return fmt.Errorf("job_fail: jobID must not be empty")
  	}

  	now := time.Now().UTC().Format(time.RFC3339)

  	// Atomically increment attempts and decide status.
  	query := fmt.Sprintf(`
  UPDATE %s
  SET
      attempts    = attempts + 1,
      status      = CASE WHEN (attempts + 1) >= max_attempts THEN 'dead' ELSE 'failed' END,
      error       = ?,
      completed_at = ?
  WHERE id = ?
  `, q.TableName)

  	res, err := q.DB.Exec(query, errMsg, now, jobID)
  	if err != nil {
  		return fmt.Errorf("job_fail: update: %w", err)
  	}
  	n, _ := res.RowsAffected()
  	if n == 0 {
  		return fmt.Errorf("job_fail: job %q not found", jobID)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"jobID","desc":"UUID del job que fallo"},{"name":"errMsg","desc":"mensaje de error para almacenar en la columna error"}],"output":"error si el job no existe o falla el UPDATE"}


id: job_queue_create_go_infra
name: job_queue_create
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobQueueCreate(db *sql.DB, tableName string) (*JobQueue, error)
tags: ["job","queue","sqlite","async","background","infra","create","schema"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: ["job_queue_go_infra"]
error_type: error_go_core
imports: ["database/sql","fmt"]
tests: ["test_queue_create","test_queue_create_default_table_name","test_queue_create_nil_db"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_queue_create.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 0c9b43d6ce5ae084894a37ae42ed06c1ffd59f283d2a9c0279b9f9ca788800e7
description:
  Crea (o verifica) la tabla de jobs con indices en SQLite, activa WAL mode, y retorna un *JobQueue listo para usar. tableName puede ser cualquier identificador SQLite valido (default: 'jobs').

example:
  ```go
  db, _ := sql.Open("sqlite3", "jobs.db?_journal_mode=WAL")
  q, err := JobQueueCreate(db, "jobs")
  if err != nil {
      log.Fatal(err)
  }
  // q esta listo para JobEnqueue, JobDequeue, etc.
  ```

notes:
  Activa WAL mode en la DB. Usa `CREATE TABLE IF NOT EXISTS` y `CREATE INDEX IF NOT EXISTS` — es seguro llamarlo multiples veces. Indices creados: `idx_{table}_dequeue` (filtrado partial por status='pending'), `idx_{table}_status`, `idx_{table}_type`.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  )

  // JobQueueCreate creates (or verifies) the jobs table and required indices in
  // the given SQLite database, activates WAL mode, and returns a ready *JobQueue.
  // tableName is typically "jobs" but can be any valid SQLite identifier.
  //
  // Schema created:
  //
  //	CREATE TABLE IF NOT EXISTS jobs (
  //	    id TEXT PRIMARY KEY,
  //	    type TEXT NOT NULL,
  //	    payload TEXT NOT NULL DEFAULT '{}',
  //	    status TEXT NOT NULL DEFAULT 'pending',
  //	    priority INTEGER NOT NULL DEFAULT 0,
  //	    attempts INTEGER NOT NULL DEFAULT 0,
  //	    max_attempts INTEGER NOT NULL DEFAULT 3,
  //	    scheduled_at TEXT NOT NULL,
  //	    started_at TEXT,
  //	    completed_at TEXT,
  //	    result TEXT,
  //	    error TEXT,
  //	    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
  //	);
  func JobQueueCreate(db *sql.DB, tableName string) (*JobQueue, error) {
  	if db == nil {
  		return nil, fmt.Errorf("job_queue_create: db must not be nil")
  	}
  	if tableName == "" {
  		tableName = "jobs"
  	}

  	// Enable WAL mode for concurrent read/write access.
  	if _, err := db.Exec(`PRAGMA journal_mode=WAL`); err != nil {
  		return nil, fmt.Errorf("job_queue_create: enable WAL: %w", err)
  	}

  	schema := fmt.Sprintf(`
  CREATE TABLE IF NOT EXISTS %s (
      id TEXT PRIMARY KEY,
      type TEXT NOT NULL,
      payload TEXT NOT NULL DEFAULT '{}',
      status TEXT NOT NULL DEFAULT 'pending',
      priority INTEGER NOT NULL DEFAULT 0,
      attempts INTEGER NOT NULL DEFAULT 0,
      max_attempts INTEGER NOT NULL DEFAULT 3,
      scheduled_at TEXT NOT NULL,
      started_at TEXT,
      completed_at TEXT,
      result TEXT,
      error TEXT,
      created_at TEXT NOT NULL DEFAULT (strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'))
  );
  CREATE INDEX IF NOT EXISTS idx_%s_dequeue ON %s (status, priority DESC, scheduled_at ASC) WHERE status = 'pending';
  CREATE INDEX IF NOT EXISTS idx_%s_status ON %s (status);
  CREATE INDEX IF NOT EXISTS idx_%s_type ON %s (type);
  `, tableName,
  		tableName, tableName,
  		tableName, tableName,
  		tableName, tableName,
  	)

  	if _, err := db.Exec(schema); err != nil {
  		return nil, fmt.Errorf("job_queue_create: create schema: %w", err)
  	}

  	return &JobQueue{DB: db, TableName: tableName}, nil
  }

params_schema:
  {"params":[{"name":"db","desc":"conexion SQLite abierta (puede ser :memory: o file-based)"},{"name":"tableName","desc":"nombre de la tabla de jobs; si esta vacio se usa 'jobs'"}],"output":"puntero a JobQueue con DB y TableName configurados, listo para enqueue/dequeue"}


id: job_status_summary_go_infra
name: job_status_summary
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func JobStatusSummary(counts map[string]int) string
tags: ["job","queue","status","summary","format","pure","infra"]
uses_functions: []
uses_types: []
returns: []
imports: ["fmt","strings"]
tests: ["job_status_summary_format","job_status_summary_empty_map"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_status.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 402fefd5b1b34f346282b6c4f50d08b2ac34a9abc4aa84e20679b391423e0b59
description:
  Formatea un mapa de status→conteo en un resumen legible. Siempre incluye los cinco estados canonicos en orden fijo: pending, running, completed, failed, dead.

example:
  ```go
  counts := map[string]int{"pending": 5, "running": 2, "completed": 10}
  summary := JobStatusSummary(counts)
  // "pending: 5, running: 2, completed: 10, failed: 0, dead: 0"
  ```

notes:
  Funcion pura. Los estados no presentes en el mapa se tratan como 0. El orden de salida es siempre fijo: pending, running, completed, failed, dead.

code:
  package infra

  import (
  	"fmt"
  	"strings"
  )

  // JobStatus formats a map of status → count into a human-readable summary string.
  // Example output: "pending: 5, running: 2, completed: 10, failed: 1, dead: 0"
  // The output always includes all five canonical statuses in a fixed order.
  // This is a pure function — no I/O, no state.
  func JobStatusSummary(counts map[string]int) string {
  	statuses := []string{"pending", "running", "completed", "failed", "dead"}
  	parts := make([]string, 0, len(statuses))
  	for _, s := range statuses {
  		parts = append(parts, fmt.Sprintf("%s: %d", s, counts[s]))
  	}
  	return strings.Join(parts, ", ")
  }

params_schema:
  {"params":[{"name":"counts","desc":"mapa de nombre de status (pending/running/completed/failed/dead) a numero de jobs en ese estado"}],"output":"string formateado 'pending: N, running: N, completed: N, failed: N, dead: N'"}


id: job_worker_go_infra
name: job_worker
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobWorker(ctx context.Context, q *JobQueue, handler JobHandler, opts ...WorkerOption) error
tags: ["job","queue","worker","goroutine","poll","async","background","infra","concurrency"]
uses_functions: ["job_dequeue_go_infra","job_complete_go_infra","job_fail_go_infra"]
uses_types: ["job_queue_go_infra","job_go_infra"]
returns: []
error_type: error_go_core
imports: ["context","fmt","time"]
tests: ["worker_graceful_shutdown","worker_processes_jobs"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_worker.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 46c119f41e3a3c5b663c742e05a93ffd6b92616e859ec4d07561b865f613d15d
description:
  Ejecuta un poll loop bloqueante que desencola y procesa jobs hasta que el context sea cancelado. El handler determina complete vs fail. Opciones: WithPollInterval (default 1s), WithJobTypes.

example:
  ```go
  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()

  go JobWorker(ctx, q, func(job Job) error {
      fmt.Println("processing", job.ID, job.Type)
      return nil // complete
  }, WithPollInterval(500*time.Millisecond), WithJobTypes("send_email"))
  ```

notes:
  Funcion bloqueante — ejecutar en una goroutine. Retorna `ctx.Err()` cuando el context se cancela (graceful shutdown). Los errores transitorios de dequeue (DB) generan un sleep y retry. El handler tiene la responsabilidad completa del procesamiento; JobWorker se encarga de complete/fail.

code:
  package infra

  import (
  	"context"
  	"fmt"
  	"time"
  )

  // JobWorker runs a poll loop in the calling goroutine, dequeuing and processing
  // jobs until ctx is cancelled. handler is called for each job; its return value
  // drives complete vs fail logic. The worker sleeps pollInterval between empty
  // dequeue attempts.
  //
  // Options: WithPollInterval (default 1s), WithJobTypes (default all types).
  //
  // This function blocks until ctx is done. Run it in a goroutine:
  //
  //	go JobWorker(ctx, q, handler, WithPollInterval(2*time.Second))
  func JobWorker(ctx context.Context, q *JobQueue, handler JobHandler, opts ...WorkerOption) error {
  	if q == nil {
  		return fmt.Errorf("job_worker: queue must not be nil")
  	}
  	if handler == nil {
  		return fmt.Errorf("job_worker: handler must not be nil")
  	}

  	cfg := &workerConfig{
  		pollInterval: time.Second,
  	}
  	for _, o := range opts {
  		o(cfg)
  	}

  	for {
  		select {
  		case <-ctx.Done():
  			return ctx.Err()
  		default:
  		}

  		job, err := JobDequeue(q, cfg.jobTypes)
  		if err != nil {
  			// Transient DB error — wait and retry.
  			select {
  			case <-ctx.Done():
  				return ctx.Err()
  			case <-time.After(cfg.pollInterval):
  			}
  			continue
  		}

  		if job == nil {
  			// Queue empty — wait before polling again.
  			select {
  			case <-ctx.Done():
  				return ctx.Err()
  			case <-time.After(cfg.pollInterval):
  			}
  			continue
  		}

  		// Process the job.
  		handlerErr := handler(*job)
  		if handlerErr != nil {
  			_ = JobFail(q, job.ID, handlerErr.Error())
  		} else {
  			_ = JobComplete(q, job.ID, "")
  		}
  	}
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context que al cancelarse detiene el worker limpiamente (graceful shutdown)"},{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"handler","desc":"funcion que procesa un job; retorno nil = completado, error = fallo"},{"name":"opts","desc":"opciones: WithPollInterval(d), WithJobTypes(types...)"}],"output":"ctx.Err() cuando el context se cancela, o error de configuracion"}


id: job_worker_pool_go_infra
name: job_worker_pool
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
uses_functions: ["job_worker_go_infra"]
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["context","fmt","golang.org/x/sync/errgroup"]
tests: ["concurrency_multiples_goroutines_dequeue"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_worker_pool.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: f8dac7ee6c165dcc5c54c688b93270786db778d3a7b70e57f7171438a18a3f14
signature:
  func JobWorkerPool(ctx context.Context, q *JobQueue, n int, handler JobHandler, opts ...WorkerOption) error

description:
  Lanza n workers concurrentes que comparten la misma cola y handler. Usa errgroup para concurrencia estructurada y soporta graceful shutdown via context. Bloquea hasta que todos los workers terminen.

tags:
  ["job","queue","worker","pool","concurrency","errgroup","async","background","infra","graceful-shutdown"]

example:
  ```go
  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()

  // 5 workers procesando emails en paralelo
  err := JobWorkerPool(ctx, q, 5, func(job Job) error {
      return sendEmail(job)
  }, WithPollInterval(100*time.Millisecond))
  ```

notes:
  Usa `golang.org/x/sync/errgroup`. Si un worker retorna un error no-context, `errgroup` cancela el context derivado y todos los demas workers se detienen. El graceful shutdown ocurre al cancelar el context original.

code:
  package infra

  import (
  	"context"
  	"fmt"

  	"golang.org/x/sync/errgroup"
  )

  // JobWorkerPool starts n workers concurrently, all sharing the same queue and
  // handler. It uses errgroup for structured concurrency and supports graceful
  // shutdown via ctx cancellation.
  //
  // The function blocks until all workers exit. It returns the first non-context
  // error encountered, or ctx.Err() if all workers stopped due to cancellation.
  //
  // Options: WithPollInterval, WithJobTypes (applied to every worker).
  func JobWorkerPool(ctx context.Context, q *JobQueue, n int, handler JobHandler, opts ...WorkerOption) error {
  	if q == nil {
  		return fmt.Errorf("job_worker_pool: queue must not be nil")
  	}
  	if handler == nil {
  		return fmt.Errorf("job_worker_pool: handler must not be nil")
  	}
  	if n <= 0 {
  		return fmt.Errorf("job_worker_pool: n must be > 0, got %d", n)
  	}

  	g, gctx := errgroup.WithContext(ctx)
  	for i := 0; i < n; i++ {
  		g.Go(func() error {
  			return JobWorker(gctx, q, handler, opts...)
  		})
  	}
  	return g.Wait()
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context que al cancelarse detiene todos los workers; si uno falla, los demas tambien se detienen"},{"name":"q","desc":"cola de jobs compartida por todos los workers"},{"name":"n","desc":"numero de workers concurrentes (debe ser \u003e 0)"},{"name":"handler","desc":"funcion de procesamiento compartida por todos los workers"},{"name":"opts","desc":"opciones aplicadas a cada worker: WithPollInterval, WithJobTypes"}],"output":"primer error no-context de cualquier worker, o ctx.Err() si todos terminaron por cancelacion"}


id: log_debug_go_infra
name: log_debug
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func LogDebug(logger *Logger, msg string, fields ...any)
tags: ["logging","log","debug","slog","infra"]
uses_functions: []
uses_types: ["Logger_go_infra"]
returns: []
error_type: error_go_core
imports: []
test_file_path: functions/infra/logger_test.go
file_path: functions/infra/log_debug.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: f7b514b8c40d82919bff617200fe78c758ab489919785d384bd43a47ab8593a7
description:
  Emite un log a nivel debug en el Logger. Los fields son pares key-value variadicos. Si el nivel del logger es mayor que Debug, el mensaje se descarta silenciosamente.

example:
  ```go
  logger, _ := LoggerNew(LogLevelDebug, os.Stdout, "json")
  LogDebug(logger, "parsing body", "content_type", "application/json", "size", 1024)
  // {"time":"...","level":"DEBUG","msg":"parsing body","content_type":"application/json","size":1024}
  ```

tests:
  ["LogDebug emite nivel DEBUG","campos inline en la llamada aparecen en el JSON","logger nil no hace panic en las funciones de log"]

notes:
  Funcion impura — delega a `slog.Logger.Debug()`. El filtrado por nivel lo hace el handler de slog internamente (no se evalua el costo de los campos si el nivel esta debajo). Los fields deben venir en pares: si el numero es impar slog lo marca como `!BADKEY`. Usar este nivel para trazas detalladas de desarrollo que normalmente no se ven en produccion.

code:
  package infra

  // LogDebug emite un log a nivel debug en el Logger.
  // Los fields son pares key-value variadicos (ej: "port", 8484, "user", "lucas").
  // Si el nivel del logger es mayor que Debug, el mensaje se descarta.
  func LogDebug(logger *Logger, msg string, fields ...any) {
  	if logger == nil || logger.inner == nil {
  		return
  	}
  	logger.inner.Debug(msg, fields...)
  }

params_schema:
  {"params":[{"name":"logger","desc":"Logger al que emitir el mensaje. Si es nil la funcion no hace nada"},{"name":"msg","desc":"mensaje principal del log"},{"name":"fields","desc":"pares key-value variadicos (ej: \"port\", 8484, \"user\", \"lucas\")"}],"output":"nada (side effect: escribe al Output del Logger)"}


id: log_error_go_infra
name: log_error
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func LogError(logger *Logger, msg string, fields ...any)
tags: ["logging","log","error","slog","infra"]
uses_functions: []
uses_types: ["Logger_go_infra"]
returns: []
error_type: error_go_core
imports: []
tests: ["LogError emite nivel ERROR","logger nil no hace panic en las funciones de log"]
test_file_path: functions/infra/logger_test.go
file_path: functions/infra/log_error.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 60e1f957cb0096465e80f539405227a62d9bbc9d7a282d1c0fab9b37a6ac444f
description:
  Emite un log a nivel error en el Logger. Los fields son pares key-value variadicos. Nivel maximo de severidad, siempre se emite salvo que el logger tenga un handler que lo filtre explicitamente.

example:
  ```go
  logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
  err := db.QueryRow(...).Scan(&x)
  if err != nil {
      LogError(logger, "db query failed", "err", err.Error(), "table", "users")
  }
  // {"time":"...","level":"ERROR","msg":"db query failed","err":"connection refused","table":"users"}
  ```

notes:
  Funcion impura — delega a `slog.Logger.Error()`. Usar para fallos que requieren atencion: panics capturados, errores de I/O, estados invalidos. No aborta el programa por si solo — el caller decide que hacer. Para convertir un `error` en campo se recomienda usar `err.Error()` directamente, aunque slog tambien acepta el tipo `error` como valor (lo serializa con `.Error()` internamente).

code:
  package infra

  // LogError emite un log a nivel error en el Logger.
  // Los fields son pares key-value variadicos (ej: "err", err, "table", "users").
  // El nivel error siempre se emite (es el mas severo).
  func LogError(logger *Logger, msg string, fields ...any) {
  	if logger == nil || logger.inner == nil {
  		return
  	}
  	logger.inner.Error(msg, fields...)
  }

params_schema:
  {"params":[{"name":"logger","desc":"Logger al que emitir el mensaje. Si es nil la funcion no hace nada"},{"name":"msg","desc":"mensaje principal del log"},{"name":"fields","desc":"pares key-value variadicos (ej: \"err\", err.Error(), \"table\", \"users\", \"query\", sql)"}],"output":"nada (side effect: escribe al Output del Logger)"}


id: log_info_go_infra
name: log_info
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func LogInfo(logger *Logger, msg string, fields ...any)
tags: ["logging","log","info","slog","infra"]
uses_functions: []
uses_types: ["Logger_go_infra"]
returns: []
error_type: error_go_core
imports: []
test_file_path: functions/infra/logger_test.go
file_path: functions/infra/log_info.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: aff9a21e52ea16e550e0f22d193232798fd99092e355af85a4428eb93e4d199e
description:
  Emite un log a nivel info en el Logger. Los fields son pares key-value variadicos. Si el nivel del logger es mayor que Info, el mensaje se descarta silenciosamente.

example:
  ```go
  logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
  LogInfo(logger, "server starting", "port", 8484, "app", "sqlite_api")
  // {"time":"...","level":"INFO","msg":"server starting","port":8484,"app":"sqlite_api"}
  ```

tests:
  ["LogInfo emite nivel INFO","emite JSON valido al escribir","campos inline en la llamada aparecen en el JSON"]

notes:
  Funcion impura — delega a `slog.Logger.Info()`. Nivel por defecto recomendado para eventos normales del ciclo de vida de la app (arranque, conexiones establecidas, requests completadas). Para errores usar `LogError`, para situaciones anomalas no fatales usar `LogWarn`.

code:
  package infra

  // LogInfo emite un log a nivel info en el Logger.
  // Los fields son pares key-value variadicos (ej: "port", 8484, "user", "lucas").
  // Si el nivel del logger es mayor que Info, el mensaje se descarta.
  func LogInfo(logger *Logger, msg string, fields ...any) {
  	if logger == nil || logger.inner == nil {
  		return
  	}
  	logger.inner.Info(msg, fields...)
  }

params_schema:
  {"params":[{"name":"logger","desc":"Logger al que emitir el mensaje. Si es nil la funcion no hace nada"},{"name":"msg","desc":"mensaje principal del log"},{"name":"fields","desc":"pares key-value variadicos (ej: \"port\", 8484, \"user\", \"lucas\")"}],"output":"nada (side effect: escribe al Output del Logger)"}


id: log_warn_go_infra
name: log_warn
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func LogWarn(logger *Logger, msg string, fields ...any)
tags: ["logging","log","warn","slog","infra"]
uses_functions: []
uses_types: ["Logger_go_infra"]
returns: []
error_type: error_go_core
imports: []
tests: ["LogWarn emite nivel WARN","filtra mensajes debajo del nivel configurado"]
test_file_path: functions/infra/logger_test.go
file_path: functions/infra/log_warn.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 77464b7c4ac9898803af81e417c978ed94d05c78a6087a846f1618428e4da05b
description:
  Emite un log a nivel warn en el Logger. Los fields son pares key-value variadicos. Indica situaciones anomalas que no impiden el funcionamiento del sistema.

example:
  ```go
  logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
  LogWarn(logger, "retry attempt", "attempt", 2, "max", 5, "err", "timeout")
  // {"time":"...","level":"WARN","msg":"retry attempt","attempt":2,"max":5,"err":"timeout"}
  ```

notes:
  Funcion impura — delega a `slog.Logger.Warn()`. Usar para eventos recuperables: reintentos, fallos de cache, deprecaciones, datos inesperados pero no invalidos. Si el evento requiere intervencion humana usar `LogError`.

code:
  package infra

  // LogWarn emite un log a nivel warn en el Logger.
  // Los fields son pares key-value variadicos (ej: "port", 8484, "user", "lucas").
  // Si el nivel del logger es mayor que Warn, el mensaje se descarta.
  func LogWarn(logger *Logger, msg string, fields ...any) {
  	if logger == nil || logger.inner == nil {
  		return
  	}
  	logger.inner.Warn(msg, fields...)
  }

params_schema:
  {"params":[{"name":"logger","desc":"Logger al que emitir el mensaje. Si es nil la funcion no hace nada"},{"name":"msg","desc":"mensaje principal del log"},{"name":"fields","desc":"pares key-value variadicos (ej: \"retry_count\", 3, \"endpoint\", \"/api/users\")"}],"output":"nada (side effect: escribe al Output del Logger)"}


id: logger_middleware_go_infra
name: logger_middleware
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func LoggerMiddleware(logger *Logger) Middleware
tags: ["logging","log","slog","middleware","http","server","infra"]
uses_functions: ["log_info_go_infra"]
uses_types: ["Logger_go_infra","Middleware_go_infra"]
returns: ["Middleware_go_infra"]
error_type: error_go_core
imports: ["net/http","time"]
test_file_path: functions/infra/logger_test.go
file_path: functions/infra/logger_middleware.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 6a0d00fd6212a4275405c930169e93188b8952ad3d41c3acbeb57f3a098a6e63
description:
  Retorna un Middleware HTTP que emite un log estructurado por cada request. Cada request produce una entrada info con method, path, status y duration_ms. Respeta los campos contextuales del Logger (app, version, request_id...).

example:
  ```go
  base, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
  appLog := LoggerWith(base, map[string]any{"app": "sqlite_api"})

  mux := HTTPRouter(routes)
  chain := HTTPMiddlewareChain(LoggerMiddleware(appLog), HTTPCORSMiddleware([]string{"*"}, []string{"GET"}))

  http.ListenAndServe(":8484", chain(mux))
  // Cada request produce:
  // {"time":"...","level":"INFO","msg":"http request","app":"sqlite_api","method":"GET","path":"/health","status":200,"duration_ms":1}
  ```

tests:
  ["loguea method, path, status y duration_ms","usa status 200 si el handler no llama WriteHeader","preserva los campos contextuales del logger"]

notes:
  Funcion impura — captura el status code con un `loggerResponseWriter` envolvente que intercepta `WriteHeader`. Si el handler no llama `WriteHeader` explicitamente el status por defecto es 200. La duracion se mide desde el inicio del middleware hasta despues de que el handler siguiente termine — incluye el tiempo de los middlewares internos pero no los externos en la cadena. El mensaje emitido es `"http request"` a nivel info para facilitar filtrado via `msg:"http request"` en queries downstream.

code:
  package infra

  import (
  	"net/http"
  	"time"
  )

  // LoggerMiddleware retorna un Middleware que emite un log estructurado por cada request HTTP.
  // Cada request produce una entrada a nivel info con method, path, status y duration_ms.
  // Respeta los campos contextuales que ya tenga el logger (app, version, request_id...).
  func LoggerMiddleware(logger *Logger) Middleware {
  	return func(next http.Handler) http.Handler {
  		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			start := time.Now()
  			rw := &loggerResponseWriter{ResponseWriter: w, status: http.StatusOK}
  			next.ServeHTTP(rw, r)
  			duration := time.Since(start)
  			LogInfo(logger, "http request",
  				"method", r.Method,
  				"path", r.URL.Path,
  				"status", rw.status,
  				"duration_ms", duration.Milliseconds(),
  			)
  		})
  	}
  }

  // loggerResponseWriter captura el status code escrito al ResponseWriter.
  // Nombrado distinto de responseWriter (http_logger_middleware.go) para evitar colision.
  type loggerResponseWriter struct {
  	http.ResponseWriter
  	status int
  }

  func (rw *loggerResponseWriter) WriteHeader(status int) {
  	rw.status = status
  	rw.ResponseWriter.WriteHeader(status)
  }

params_schema:
  {"params":[{"name":"logger","desc":"Logger estructurado al que emitir cada request. Hereda los campos contextuales (app, version...)"}],"output":"Middleware que loguea cada request HTTP tras su procesamiento"}


id: logger_new_go_infra
name: logger_new
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func LoggerNew(level LogLevel, output io.Writer, format string) (*Logger, error)
tags: ["logging","log","slog","logger","infra"]
uses_functions: []
uses_types: ["Logger_go_infra","LogLevel_go_infra"]
returns: ["Logger_go_infra"]
error_type: error_go_core
imports: ["fmt","io","log/slog","os"]
test_file_path: functions/infra/logger_test.go
file_path: functions/infra/logger_new.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: ef17b507975f179cb4971083bd01ecd677d72b8b745acd3d1a65e300551a3d7e
description:
  Crea un Logger estructurado sobre log/slog con nivel, destino y formato configurables. Formato soportado: json o text. Si output es nil cae en os.Stderr.

example:
  ```go
  logger, err := LoggerNew(LogLevelInfo, os.Stdout, "json")
  if err != nil {
      log.Fatal(err)
  }
  LogInfo(logger, "server starting", "port", 8484)
  // {"time":"...","level":"INFO","msg":"server starting","port":8484}
  ```

tests:
  ["crea logger JSON valido","crea logger text valido","rechaza formato invalido","output nil cae en os.Stderr sin panic","emite JSON valido al escribir","filtra mensajes debajo del nivel configurado"]

notes:
  Funcion impura — internamente construye `slog.NewJSONHandler` o `slog.NewTextHandler` segun el formato y lo envuelve en `slog.New()`. El campo privado `inner` del Logger es el `*slog.Logger` real. Cada Logger es inmutable una vez creado: para anadir campos usar `LoggerWith`, que retorna una copia.

code:
  package infra

  import (
  	"fmt"
  	"io"
  	"log/slog"
  	"os"
  )

  // LoggerNew crea un Logger con nivel, destino y formato configurables.
  // format debe ser "json" o "text". Si output es nil se usa os.Stderr.
  // Retorna error si el formato no es valido.
  func LoggerNew(level LogLevel, output io.Writer, format string) (*Logger, error) {
  	if output == nil {
  		output = os.Stderr
  	}

  	slogLevel := toSlogLevel(level)
  	opts := &slog.HandlerOptions{Level: slogLevel}

  	var handler slog.Handler
  	switch format {
  	case "json":
  		handler = slog.NewJSONHandler(output, opts)
  	case "text":
  		handler = slog.NewTextHandler(output, opts)
  	default:
  		return nil, fmt.Errorf("logger_new: formato invalido %q, usa \"json\" o \"text\"", format)
  	}

  	return &Logger{
  		Level:  level,
  		Output: output,
  		Format: format,
  		Fields: map[string]any{},
  		inner:  slog.New(handler),
  	}, nil
  }

  // toSlogLevel convierte LogLevel a slog.Level.
  func toSlogLevel(level LogLevel) slog.Level {
  	switch level {
  	case LogLevelDebug:
  		return slog.LevelDebug
  	case LogLevelInfo:
  		return slog.LevelInfo
  	case LogLevelWarn:
  		return slog.LevelWarn
  	case LogLevelError:
  		return slog.LevelError
  	default:
  		return slog.LevelInfo
  	}
  }

params_schema:
  {"params":[{"name":"level","desc":"nivel minimo de log (LogLevelDebug, LogLevelInfo, LogLevelWarn o LogLevelError)"},{"name":"output","desc":"destino de los logs (os.Stdout, os.Stderr, un archivo, bytes.Buffer). Si es nil se usa os.Stderr"},{"name":"format","desc":"formato de los logs: \"json\" para maquina o \"text\" para desarrollo local"}],"output":"Logger listo para usar con LogInfo/LogWarn/... o error si el formato no es valido"}


id: logger_with_go_infra
name: logger_with
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func LoggerWith(logger *Logger, fields map[string]any) *Logger
tags: ["logging","log","slog","logger","context","pure","infra"]
uses_functions: []
uses_types: ["Logger_go_infra"]
returns: ["Logger_go_infra"]
imports: ["sort"]
test_file_path: functions/infra/logger_test.go
file_path: functions/infra/logger_with.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: d169b88b1582fb53021f0dee71e3d0d1b51e050a391bd5736292f42b01c04621
description:
  Retorna una copia del Logger con campos contextuales adicionales. No muta el logger original — los campos se apilan sobre los existentes y aparecen en cada entrada del nuevo logger.

example:
  ```go
  base, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
  appLog := LoggerWith(base, map[string]any{"app": "sqlite_api", "version": "1.0.0"})
  reqLog := LoggerWith(appLog, map[string]any{"request_id": "abc-123"})

  LogInfo(reqLog, "evento")
  // {"...","msg":"evento","app":"sqlite_api","version":"1.0.0","request_id":"abc-123"}
  ```

tests:
  ["anade campos al logger","no muta el logger original","apila fields sobre un logger ya contextualizado","retorna nil si recibe nil"]

notes:
  Funcion pura — no hace I/O, no muta estado. Internamente llama a `slog.Logger.With()` que ya retorna un nuevo logger. Los campos se pasan en orden alfabetico a `With()` para que el output sea determinista (util para tests). El campo `Fields` del `*Logger` mantiene la union combinada (base + nuevos) para permitir inspeccion programatica.

code:
  package infra

  import "sort"

  // LoggerWith retorna una copia del Logger con campos adicionales.
  // No muta el logger original — los campos se apilan sobre los ya existentes.
  // Funcion pura: misma entrada produce siempre la misma salida sin I/O.
  func LoggerWith(logger *Logger, fields map[string]any) *Logger {
  	if logger == nil {
  		return nil
  	}

  	// Combinar fields existentes + nuevos (los nuevos tienen precedencia)
  	combined := make(map[string]any, len(logger.Fields)+len(fields))
  	for k, v := range logger.Fields {
  		combined[k] = v
  	}
  	for k, v := range fields {
  		combined[k] = v
  	}

  	// Convertir a args key-value ordenados para slog.With (orden determinista)
  	keys := make([]string, 0, len(fields))
  	for k := range fields {
  		keys = append(keys, k)
  	}
  	sort.Strings(keys)

  	args := make([]any, 0, len(keys)*2)
  	for _, k := range keys {
  		args = append(args, k, fields[k])
  	}

  	var inner = logger.inner
  	if inner != nil && len(args) > 0 {
  		inner = inner.With(args...)
  	}

  	return &Logger{
  		Level:  logger.Level,
  		Output: logger.Output,
  		Format: logger.Format,
  		Fields: combined,
  		inner:  inner,
  	}
  }

params_schema:
  {"params":[{"name":"logger","desc":"Logger base a clonar. Si es nil retorna nil"},{"name":"fields","desc":"mapa de campos key-value a anadir al logger (ej: {\"app\": \"api\", \"request_id\": \"abc\"})"}],"output":"nuevo Logger con los fields combinados (los del parametro tienen precedencia sobre los del logger base)"}


id: metabase_auth_go_infra
name: metabase_auth
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error)
tags: ["metabase","auth","session","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: ["MetabaseClient_go_infra"]
error_type: error_go_core
imports: ["bytes","encoding/json","fmt","io","net/http"]
tests: []
file_path: functions/infra/metabase_auth.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b40c6ef04255880f42b4ba5509e3557898a991b1f2fe0bda543226baa886e73b
description:
  Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias (configurable con MAX_SESSION_AGE en Metabase). Endpoint: POST /api/session.

example:
  ```go
  // Autenticar con credenciales
  client, err := MetabaseAuth("http://localhost:3000", "admin@example.com", "password123")
  if err != nil {
      log.Fatal(err)
  }
  // client.Token contiene el session token

  // Alternativa: usar API key directamente
  client := MetabaseNewClient("http://localhost:3000", "mb_api_key_xxxxx")
  ```

notes:
  Dos formas de obtener un MetabaseClient:
  - `MetabaseAuth`: login con email/password, obtiene session token via POST /api/session. Token expira en 14 dias por defecto.
  - `MetabaseNewClient`: usa una API key creada en el admin UI. No expira. Recomendado para automatizacion.

  El token se envia como header `X-Metabase-Session` en todas las llamadas subsiguientes.

  ### Para un LLM que use estas funciones

  1. Primero obtener un client con `MetabaseAuth()` o `MetabaseNewClient()`
  2. Pasar el client a todas las funciones CRUD (usuarios, cards, dashboards)
  3. Si recibes error 401, el token expiro — re-autenticar
  4. Rate limiting: Metabase limita intentos de login fallidos

code:
  package infra

  import (
  	"bytes"
  	"encoding/json"
  	"fmt"
  	"io"
  	"net/http"
  )

  // MetabaseAuth autentica con email y password contra una instancia Metabase.
  // Retorna un MetabaseClient con el session token listo para usar.
  // baseURL es la URL base sin trailing slash (ej: "http://localhost:3000").
  func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error) {
  	payload, _ := json.Marshal(map[string]string{
  		"username": email,
  		"password": password,
  	})

  	resp, err := http.Post(baseURL+"/api/session", "application/json", bytes.NewReader(payload))
  	if err != nil {
  		return MetabaseClient{}, fmt.Errorf("metabase auth: %w", err)
  	}
  	defer resp.Body.Close()

  	body, err := io.ReadAll(resp.Body)
  	if err != nil {
  		return MetabaseClient{}, fmt.Errorf("read auth response: %w", err)
  	}

  	if resp.StatusCode != 200 {
  		return MetabaseClient{}, fmt.Errorf("metabase auth: status %d: %s", resp.StatusCode, string(body))
  	}

  	var result struct {
  		ID string `json:"id"`
  	}
  	if err := json.Unmarshal(body, &result); err != nil {
  		return MetabaseClient{}, fmt.Errorf("parse auth response: %w", err)
  	}

  	return MetabaseClient{BaseURL: baseURL, Token: result.ID}, nil
  }

  // MetabaseNewClient crea un MetabaseClient usando una API key en lugar de session token.
  // Las API keys se crean en Settings > Authentication > API Keys del admin de Metabase.
  func MetabaseNewClient(baseURL, apiKey string) MetabaseClient {
  	return MetabaseClient{BaseURL: baseURL, Token: apiKey}
  }

params_schema:
  {"params":[{"name":"baseURL","desc":"URL base de Metabase (ej: http://localhost:3000)"},{"name":"email","desc":"email de la cuenta para autenticarse"},{"name":"password","desc":"contraseña de la cuenta"}],"output":"cliente MetabaseClient con session token valido por 14 dias"}


id: metabase_create_card_go_infra
name: metabase_create_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
description: Crea una nueva card/pregunta en Metabase con query SQL nativa o MBQL. Endpoint: POST /api/card.
tags: ["metabase","card","question","create","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_create_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 89423d94986dae277ea7ba6a867840b734c837329272c2bd51704d93a16aae30
signature:
  func MetabaseCreateCard(client MetabaseClient, name string, datasetQuery map[string]any, display string, collectionID int, description string) (map[string]any, error)

example:
  ```go
  // Crear pregunta con SQL nativo
  card, err := MetabaseCreateCard(client, "Revenue by Month", map[string]any{
      "database": 1,
      "type":     "native",
      "native": map[string]any{
          "query": "SELECT date_trunc('month', created_at) as month, SUM(total) as revenue FROM orders GROUP BY 1 ORDER BY 1",
      },
  }, "line", 5, "Monthly revenue trend")

  // Crear pregunta con MBQL (structured query)
  card, err := MetabaseCreateCard(client, "Order Count", map[string]any{
      "database": 1,
      "type":     "query",
      "query": map[string]any{
          "source-table": 4,
          "aggregation":  []any{[]any{"count"}},
      },
  }, "scalar", 0, "Total number of orders")
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | name | string | si | Nombre de la pregunta |
  | datasetQuery | map[string]any | si | Query. Ver estructura abajo |
  | display | string | si | Tipo de visualizacion |
  | collectionID | int | no | ID de coleccion. 0 = root collection |
  | description | string | no | Descripcion. Vacio = sin descripcion |

  ### Estructura de datasetQuery

  **SQL nativo:**
  ```json
  {
    "database": <database_id>,
    "type": "native",
    "native": {"query": "SELECT ..."}
  }
  ```

  **MBQL (structured):**
  ```json
  {
    "database": <database_id>,
    "type": "query",
    "query": {
      "source-table": <table_id>,
      "aggregation": [["count"]],
      "breakout": [["field", <field_id>, {"temporal-unit": "month"}]],
      "filter": ["=", ["field", <field_id>, null], "value"]
    }
  }
  ```

  ### Valores de display

  table, bar, line, pie, scalar, area, row, combo, funnel, map, scatter, waterfall, progress, gauge

code:
  package infra

  import "fmt"

  // MetabaseCreateCard crea una nueva card/pregunta en Metabase.
  // name: nombre de la pregunta (obligatorio).
  // datasetQuery: query de la card (obligatorio). Estructura:
  //
  //	SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
  //	MBQL:       {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
  //
  // display: tipo de visualizacion ("table", "bar", "line", "pie", "scalar", etc.).
  // collectionID: ID de la coleccion/carpeta (0 = root).
  // description: descripcion opcional (vacio = sin descripcion).
  func MetabaseCreateCard(client MetabaseClient, name string, datasetQuery map[string]any, display string, collectionID int, description string) (map[string]any, error) {
  	body := map[string]any{
  		"name":                   name,
  		"dataset_query":          datasetQuery,
  		"display":                display,
  		"visualization_settings": map[string]any{},
  	}
  	if collectionID > 0 {
  		body["collection_id"] = collectionID
  	}
  	if description != "" {
  		body["description"] = description
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/card", body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase create card: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"name","desc":"nombre de la pregunta/card"},{"name":"datasetQuery","desc":"estructura de query SQL nativa o MBQL (structured query)"},{"name":"display","desc":"tipo de visualizacion (table, bar, line, pie, scalar, etc.)"},{"name":"collectionID","desc":"ID de la coleccion destino (0 = root collection)"},{"name":"description","desc":"descripcion de la pregunta o vacio"}],"output":"mapa con los detalles de la card creada incluyendo su ID"}


id: metabase_create_dashboard_go_infra
name: metabase_create_dashboard
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","create","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_create_dashboard.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 060ba8d26bbdd49212d555ad3a4526910623d42bc07108ef55462cf11742e916
signature:
  func MetabaseCreateDashboard(client MetabaseClient, name, description string, collectionID int) (map[string]any, error)

description:
  Crea un nuevo dashboard vacio en Metabase. Para agregar cards usar MetabaseUpdateDashboard con el campo dashcards. Endpoint: POST /api/dashboard.

example:
  ```go
  // Crear dashboard vacio
  dashboard, err := MetabaseCreateDashboard(client, "Sales Overview", "KPIs de ventas", 5)
  if err != nil {
      log.Fatal(err)
  }
  dashboardID := int(dashboard["id"].(float64))

  // Luego agregar cards con MetabaseUpdateDashboard
  MetabaseUpdateDashboard(client, dashboardID, map[string]any{
      "dashcards": []map[string]any{
          {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
      },
  })
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | name | string | si | Nombre del dashboard |
  | description | string | no | Descripcion. Vacio = sin descripcion |
  | collectionID | int | no | Coleccion destino. 0 = root |

  El dashboard se crea vacio. Para agregar cards, usar MetabaseUpdateDashboard con el array dashcards.
  Retorna el objeto dashboard creado.

code:
  package infra

  import "fmt"

  // MetabaseCreateDashboard crea un nuevo dashboard en Metabase.
  // name: nombre del dashboard (obligatorio).
  // description: descripcion opcional (vacio = sin descripcion).
  // collectionID: ID de la coleccion/carpeta (0 = root).
  func MetabaseCreateDashboard(client MetabaseClient, name, description string, collectionID int) (map[string]any, error) {
  	body := map[string]any{
  		"name": name,
  	}
  	if description != "" {
  		body["description"] = description
  	}
  	if collectionID > 0 {
  		body["collection_id"] = collectionID
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/dashboard", body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase create dashboard: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"name","desc":"nombre del dashboard"},{"name":"description","desc":"descripcion del dashboard o vacio"},{"name":"collectionID","desc":"ID de la coleccion destino (0 = root collection)"}],"output":"mapa con los detalles del dashboard creado incluyendo su ID"}


id: metabase_create_user_go_infra
name: metabase_create_user
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","create","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_create_user.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: e4ee437830f0521ebb49d3d5b8dd03bbc4d681a93c15e983e4d35a8bc9ed7849
signature:
  func MetabaseCreateUser(client MetabaseClient, firstName, lastName, email, password string, groupIDs []int) (map[string]any, error)

description:
  Crea un nuevo usuario en Metabase. Si no se provee password, Metabase envia email de invitacion. Requiere permisos de superusuario. Endpoint: POST /api/user.

example:
  ```go
  // Crear usuario con password
  user, err := MetabaseCreateUser(client, "John", "Doe", "john@example.com", "securePass123", nil)

  // Crear usuario sin password (envia invitacion por email)
  user, err := MetabaseCreateUser(client, "Jane", "Smith", "jane@example.com", "", []int{1, 3})
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado con permisos admin |
  | firstName | string | si | Nombre del usuario |
  | lastName | string | si | Apellido del usuario |
  | email | string | si | Email unico del usuario |
  | password | string | no | Password. Vacio = Metabase envia invitacion |
  | groupIDs | []int | no | IDs de grupos. nil = solo grupo default |

  El email debe ser unico. Si ya existe, retorna error 400.
  Retorna el objeto usuario creado como map (mismos campos que MetabaseGetUser).

code:
  package infra

  import "fmt"

  // MetabaseCreateUser crea un nuevo usuario en Metabase.
  // firstName, lastName y email son obligatorios.
  // password es opcional: si esta vacio, Metabase envia email de invitacion.
  // groupIDs es opcional: IDs de grupos a asignar (nil = solo grupo default).
  func MetabaseCreateUser(client MetabaseClient, firstName, lastName, email, password string, groupIDs []int) (map[string]any, error) {
  	body := map[string]any{
  		"first_name": firstName,
  		"last_name":  lastName,
  		"email":      email,
  	}
  	if password != "" {
  		body["password"] = password
  	}
  	if len(groupIDs) > 0 {
  		body["group_ids"] = groupIDs
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/user", body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase create user: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado con permisos admin"},{"name":"firstName","desc":"nombre del usuario"},{"name":"lastName","desc":"apellido del usuario"},{"name":"email","desc":"email unico del usuario"},{"name":"password","desc":"contraseña o vacio para enviar invitacion por email"},{"name":"groupIDs","desc":"slice de IDs de grupos a los que agregar el usuario (nil = grupo default)"}],"output":"mapa con los detalles del usuario creado incluyendo su ID"}


id: metabase_deactivate_user_go_infra
name: metabase_deactivate_user
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseDeactivateUser(client MetabaseClient, userID int) error
tags: ["metabase","user","delete","deactivate","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_deactivate_user.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 578d28a8c502794f881145383f6bc346aad1551bdac22386673d849869cb8eb8
description:
  Desactiva (soft-delete) un usuario en Metabase. El usuario no se elimina permanentemente, solo se marca como inactivo. Para reactivar, usar PUT /api/user/:id/reactivate. Endpoint: DELETE /api/user/:id.

example:
  ```go
  err := MetabaseDeactivateUser(client, 5)
  if err != nil {
      log.Fatal(err)
  }
  // Usuario 5 ahora esta inactivo
  // Para ver desactivados: MetabaseListUsers(client, "deactivated", "", 0, 0)
  ```

notes:
  Es un soft-delete: el usuario se desactiva pero no se borra. Se puede reactivar con PUT /api/user/:id/reactivate.

  Para listar usuarios desactivados, usar `MetabaseListUsers` con status "deactivated".

  Requiere permisos de superusuario. Error 403 si no eres admin.

code:
  package infra

  import "fmt"

  // MetabaseDeactivateUser desactiva (soft-delete) un usuario en Metabase.
  // El usuario no se elimina permanentemente, solo se marca como inactivo.
  // Requiere permisos de superusuario.
  func MetabaseDeactivateUser(client MetabaseClient, userID int) error {
  	path := fmt.Sprintf("/api/user/%d", userID)

  	_, err := metabaseRequest("DELETE", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return fmt.Errorf("metabase deactivate user %d: %w", userID, err)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado con permisos admin"},{"name":"userID","desc":"ID del usuario a desactivar"}],"output":"nil si la desactivacion fue exitosa, error en caso contrario"}


id: metabase_delete_card_go_infra
name: metabase_delete_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseDeleteCard(client MetabaseClient, cardID int) error
tags: ["metabase","card","question","delete","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_delete_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: dff069f36609ad63008f1d96656a722776b15018a150bc70c4c36946486f8f8d
description:
  Elimina permanentemente una card/pregunta de Metabase. Accion irreversible. Para soft-delete usar MetabaseUpdateCard con archived:true. Endpoint: DELETE /api/card/:id.

example:
  ```go
  // Eliminar permanentemente
  err := MetabaseDeleteCard(client, 42)

  // Preferir soft-delete cuando sea posible:
  // MetabaseUpdateCard(client, 42, map[string]any{"archived": true})
  ```

notes:
  **ATENCION**: Esta operacion es irreversible. La card se elimina permanentemente.

  Para un borrado seguro, preferir archivar con `MetabaseUpdateCard(client, cardID, map[string]any{"archived": true})` que permite recuperar la card despues.

code:
  package infra

  import "fmt"

  // MetabaseDeleteCard elimina permanentemente una card/pregunta de Metabase.
  // Para soft-delete, usar MetabaseUpdateCard con archived: true.
  func MetabaseDeleteCard(client MetabaseClient, cardID int) error {
  	path := fmt.Sprintf("/api/card/%d", cardID)

  	_, err := metabaseRequest("DELETE", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return fmt.Errorf("metabase delete card %d: %w", cardID, err)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"cardID","desc":"ID de la card/pregunta a eliminar"}],"output":"nil si la eliminacion fue exitosa, error en caso contrario"}


id: metabase_delete_dashboard_go_infra
name: metabase_delete_dashboard
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseDeleteDashboard(client MetabaseClient, dashboardID int) error
tags: ["metabase","dashboard","delete","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_delete_dashboard.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 3324082719bcd85418c7564c9c1caca517b92094dc3b94cbcf106b23aff7783b
description:
  Elimina permanentemente un dashboard de Metabase. Accion irreversible. Para soft-delete usar MetabaseUpdateDashboard con archived:true. Endpoint: DELETE /api/dashboard/:id.

example:
  ```go
  // Eliminar permanentemente
  err := MetabaseDeleteDashboard(client, 1)

  // Preferir soft-delete:
  // MetabaseUpdateDashboard(client, 1, map[string]any{"archived": true})
  ```

notes:
  **ATENCION**: Esta operacion es irreversible. El dashboard y todas sus dashcards se eliminan permanentemente.

  Para un borrado seguro, preferir archivar con `MetabaseUpdateDashboard(client, dashboardID, map[string]any{"archived": true})`.

code:
  package infra

  import "fmt"

  // MetabaseDeleteDashboard elimina permanentemente un dashboard de Metabase.
  // Para soft-delete, usar MetabaseUpdateDashboard con archived: true.
  func MetabaseDeleteDashboard(client MetabaseClient, dashboardID int) error {
  	path := fmt.Sprintf("/api/dashboard/%d", dashboardID)

  	_, err := metabaseRequest("DELETE", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return fmt.Errorf("metabase delete dashboard %d: %w", dashboardID, err)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"dashboardID","desc":"ID del dashboard a eliminar"}],"output":"nil si la eliminacion fue exitosa, error en caso contrario"}


id: metabase_execute_card_go_infra
name: metabase_execute_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","card","question","execute","query","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_execute_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5f3602a369b6a99be696cf9a0f0da08407afba1176800ac0c4f6e63dc30ae298
signature:
  func MetabaseExecuteCard(client MetabaseClient, cardID int, parameters []map[string]any) (map[string]any, error)

description:
  Ejecuta la query de una card/pregunta guardada en Metabase y retorna los resultados. Soporta parametros para queries parametrizadas. Endpoint: POST /api/card/:id/query.

example:
  ```go
  // Ejecutar sin parametros
  result, err := MetabaseExecuteCard(client, 42, nil)
  if err != nil {
      log.Fatal(err)
  }
  data := result["data"].(map[string]any)
  rows := data["rows"].([]any)
  fmt.Printf("Filas: %d\n", len(rows))

  // Ejecutar con parametros
  result, err := MetabaseExecuteCard(client, 42, []map[string]any{
      {
          "type":   "category",
          "target": []any{"variable", []any{"template-tag", "status"}},
          "value":  "active",
      },
  })
  ```

notes:
  ### Estructura de la respuesta

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | status | string | "completed" o "failed" |
  | row_count | float64 | Numero de filas |
  | running_time | float64 | Tiempo de ejecucion en ms |
  | data.columns | []string | Nombres de columnas |
  | data.rows | [][]any | Filas de datos |
  | data.cols | []map | Metadata de columnas (name, base_type, display_name) |
  | data.native_form.query | string | SQL ejecutado |

  ### Parametros para queries parametrizadas

  ```go
  []map[string]any{
      {
          "type":   "category",                                      // tipo del parametro
          "target": []any{"variable", []any{"template-tag", "tag"}}, // referencia al template-tag
          "value":  "valor",                                         // valor a inyectar
      },
  }
  ```

  Limite por defecto: 2000 filas. Para queries ad-hoc sin card, usar MetabaseExecuteQuery.

code:
  package infra

  import "fmt"

  // MetabaseExecuteCard ejecuta la query de una card/pregunta guardada.
  // parameters: parametros de la query (nil si no tiene parametros).
  // Retorna los resultados con columnas y filas.
  func MetabaseExecuteCard(client MetabaseClient, cardID int, parameters []map[string]any) (map[string]any, error) {
  	path := fmt.Sprintf("/api/card/%d/query", cardID)

  	var body map[string]any
  	if len(parameters) > 0 {
  		body = map[string]any{"parameters": parameters}
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, path, body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase execute card %d: %w", cardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"cardID","desc":"ID de la card/pregunta guardada a ejecutar"},{"name":"parameters","desc":"slice de maps con parametros para queries parametrizadas (nil si no hay parametros)"}],"output":"mapa con status, row_count, running_time, data.columns, data.rows, data.native_form.query"}


id: metabase_execute_query_go_infra
name: metabase_execute_query
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","query","execute","sql","dataset","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_execute_query.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 714e0f7cf8fa5ec70503e6afc687fea4f4671b8cd307a76a4f8c2226dc3d6449
signature:
  func MetabaseExecuteQuery(client MetabaseClient, databaseID int, sql string, maxResults int) (map[string]any, error)

description:
  Ejecuta una query SQL ad-hoc contra una database de Metabase sin guardarla como card. Util para consultas rapidas y exploracion. Endpoint: POST /api/dataset.

example:
  ```go
  // Query simple
  result, err := MetabaseExecuteQuery(client, 1, "SELECT * FROM users LIMIT 10", 0)
  if err != nil {
      log.Fatal(err)
  }
  data := result["data"].(map[string]any)
  rows := data["rows"].([]any)

  // Query con limite custom
  result, err := MetabaseExecuteQuery(client, 1, "SELECT * FROM orders", 5000)
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | databaseID | int | si | ID de la database en Metabase (obtener con GET /api/database) |
  | sql | string | si | Query SQL a ejecutar |
  | maxResults | int | no | Limite de filas. 0 = default 2000 |

  ### Diferencia con MetabaseExecuteCard

  - `MetabaseExecuteQuery`: query ad-hoc, no se guarda. Usa POST /api/dataset.
  - `MetabaseExecuteCard`: ejecuta una card ya guardada. Usa POST /api/card/:id/query.

  Usar esta funcion para exploracion rapida. Si la query se va a reutilizar, crear una card con MetabaseCreateCard.

  ### Estructura de la respuesta

  Misma estructura que MetabaseExecuteCard:
  - `data.columns`: nombres de columnas
  - `data.rows`: filas de datos
  - `row_count`: numero de filas
  - `running_time`: tiempo en ms
  - `status`: "completed" o "failed"

code:
  package infra

  import "fmt"

  // MetabaseExecuteQuery ejecuta una query ad-hoc (sin guardar como card) en Metabase.
  // databaseID: ID de la base de datos en Metabase.
  // sql: query SQL a ejecutar.
  // maxResults: limite de filas (0 = default 2000 de Metabase).
  func MetabaseExecuteQuery(client MetabaseClient, databaseID int, sql string, maxResults int) (map[string]any, error) {
  	body := map[string]any{
  		"database": databaseID,
  		"type":     "native",
  		"native":   map[string]any{"query": sql},
  	}
  	if maxResults > 0 {
  		body["constraints"] = map[string]any{
  			"max-results":          maxResults,
  			"max-results-bare-rows": maxResults,
  		}
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/dataset", body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase execute query: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"databaseID","desc":"ID de la database en Metabase"},{"name":"sql","desc":"query SQL a ejecutar"},{"name":"maxResults","desc":"limite de filas (0 = default 2000)"}],"output":"mapa con status, row_count, running_time, data.columns, data.rows"}


id: metabase_get_card_go_infra
name: metabase_get_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseGetCard(client MetabaseClient, cardID int) (map[string]any, error)
tags: ["metabase","card","question","get","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_get_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 9398fb8fcecc57c982dee969c92b9feea719adc7a1c4e337b849aeb75f43c7c7
description:
  Obtiene los detalles completos de una card/pregunta de Metabase por su ID. Incluye la query, visualizacion y metadata. Endpoint: GET /api/card/:id.

example:
  ```go
  card, err := MetabaseGetCard(client, 42)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println(card["name"], card["display"])
  ```

notes:
  Retorna el objeto card completo. Error 404 si no existe.

  ### Campos principales

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID de la card |
  | name | string | Nombre |
  | description | string | Descripcion |
  | display | string | Tipo visualizacion |
  | dataset_query | map | Query (native.query para SQL, query para MBQL) |
  | visualization_settings | map | Config de visualizacion |
  | collection_id | float64 | Coleccion contenedora |
  | database_id | float64 | Database asociada |
  | archived | bool | Archivada |
  | creator | map | Objeto del usuario creador |
  | created_at | string | Fecha creacion |
  | updated_at | string | Fecha actualizacion |

code:
  package infra

  import "fmt"

  // MetabaseGetCard obtiene una card/pregunta de Metabase por su ID.
  func MetabaseGetCard(client MetabaseClient, cardID int) (map[string]any, error) {
  	path := fmt.Sprintf("/api/card/%d", cardID)

  	result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase get card %d: %w", cardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"cardID","desc":"ID de la card/pregunta"}],"output":"mapa con detalles completos de la card (nombre, query, visualizacion, etc.)"}


id: metabase_get_dashboard_go_infra
name: metabase_get_dashboard
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseGetDashboard(client MetabaseClient, dashboardID int) (map[string]any, error)
tags: ["metabase","dashboard","get","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_get_dashboard.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 8e43345b2af0f1a4300ad479e4d5fac28187f6eb29d3397e01cab7166884ab04
description:
  Obtiene un dashboard completo de Metabase incluyendo todas sus dashcards (cards posicionadas en el dashboard), tabs y parametros. Endpoint: GET /api/dashboard/:id.

example:
  ```go
  dashboard, err := MetabaseGetDashboard(client, 1)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println(dashboard["name"])

  // Acceder a las cards del dashboard
  dashcards := dashboard["dashcards"].([]any)
  for _, dc := range dashcards {
      card := dc.(map[string]any)
      fmt.Printf("Card ID: %v, Position: (%v, %v)\n",
          card["card_id"], card["col"], card["row"])
  }
  ```

notes:
  ### Campos principales

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID del dashboard |
  | name | string | Nombre |
  | description | string | Descripcion |
  | dashcards | []map | Array de dashcards (cards posicionadas) |
  | parameters | []map | Filtros del dashboard |
  | tabs | []map | Tabs del dashboard |
  | collection_id | float64 | Coleccion contenedora |
  | archived | bool | Archivado |

  ### Estructura de cada dashcard

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID del dashcard (positivo) |
  | card_id | float64 | ID de la card/pregunta asociada |
  | card | map | Objeto card completo |
  | size_x | float64 | Ancho en grid (1-18) |
  | size_y | float64 | Alto en grid |
  | col | float64 | Columna en grid (0-based) |
  | row | float64 | Fila en grid (0-based) |
  | dashboard_tab_id | float64 | Tab al que pertenece (null = sin tabs) |
  | parameter_mappings | []map | Mapeo de filtros a la card |
  | visualization_settings | map | Settings de visualizacion |

  Usar estos datos para construir el payload de MetabaseUpdateDashboard.

code:
  package infra

  import "fmt"

  // MetabaseGetDashboard obtiene un dashboard completo de Metabase incluyendo sus cards.
  func MetabaseGetDashboard(client MetabaseClient, dashboardID int) (map[string]any, error) {
  	path := fmt.Sprintf("/api/dashboard/%d", dashboardID)

  	result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase get dashboard %d: %w", dashboardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"dashboardID","desc":"ID del dashboard"}],"output":"mapa con dashboard completo incluyendo dashcards, tabs, parametros"}


id: metabase_get_user_go_infra
name: metabase_get_user
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseGetUser(client MetabaseClient, userID int) (map[string]any, error)
description: Obtiene los detalles de un usuario de Metabase por su ID numerico. Endpoint: GET /api/user/:id.
tags: ["metabase","user","get","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_get_user.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: f4ab4b962dc122cde8cfe823255417da01d6cb2190219b46ea5eb673f823730a
example:
  ```go
  user, err := MetabaseGetUser(client, 1)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println(user["email"], user["first_name"])
  ```

notes:
  Retorna el objeto usuario completo como map. Error 404 si el ID no existe.

  ### Campos del usuario retornado

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID numerico |
  | email | string | Email |
  | first_name | string | Nombre |
  | last_name | string | Apellido |
  | is_superuser | bool | Es admin |
  | is_active | bool | Esta activo |
  | common_name | string | Nombre completo |
  | date_joined | string | Fecha de creacion |
  | last_login | string | Ultimo login |
  | group_ids | []float64 | IDs de grupos |
  | locale | string | Locale del usuario |

code:
  package infra

  import "fmt"

  // MetabaseGetUser obtiene un usuario de Metabase por su ID.
  func MetabaseGetUser(client MetabaseClient, userID int) (map[string]any, error) {
  	path := fmt.Sprintf("/api/user/%d", userID)

  	result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase get user %d: %w", userID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"userID","desc":"ID del usuario"}],"output":"mapa con detalles del usuario (email, nombre, grupo, permisos, etc.)"}


id: metabase_list_cards_go_infra
name: metabase_list_cards
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseListCards(client MetabaseClient, filter string, modelID int) ([]map[string]any, error)
tags: ["metabase","card","question","list","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_list_cards.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 87fa81e68bd21665cf2843762ee756ff80d02c97081b8bb1c9c7a7cf7064b378
description:
  Lista preguntas/cards de Metabase con filtro opcional. Retorna array de cards. Endpoint: GET /api/card.

example:
  ```go
  // Listar todas las cards
  cards, err := MetabaseListCards(client, "all", 0)

  // Solo mis preguntas
  cards, err := MetabaseListCards(client, "mine", 0)

  // Cards de una database especifica
  cards, err := MetabaseListCards(client, "database", 1)

  // Cards archivadas
  cards, err := MetabaseListCards(client, "archived", 0)
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | filter | string | no | "all", "mine", "fav", "archived", "recent", "popular", "database", "table". Vacio = todas |
  | modelID | int | no | ID de database/tabla. Solo aplica con filter "database" o "table". 0 = ignorar |

  No tiene paginacion con offset/limit. Retorna todas las cards que coinciden.

  ### Campos principales de cada card

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID numerico de la card |
  | name | string | Nombre de la pregunta |
  | description | string | Descripcion |
  | display | string | Tipo de visualizacion (table, bar, line, pie, etc.) |
  | collection_id | float64 | ID de la coleccion/carpeta |
  | database_id | float64 | ID de la database |
  | creator_id | float64 | ID del creador |
  | archived | bool | Esta archivada |
  | dataset_query | map | Query de la card (native o structured) |

code:
  package infra

  import "fmt"

  // MetabaseListCards lista preguntas/cards de Metabase.
  // filter: "all", "mine", "fav", "archived", "recent", "popular", "database", "table" (vacio = todas).
  // modelID: ID de database o tabla cuando filter es "database" o "table" (0 = ignorar).
  func MetabaseListCards(client MetabaseClient, filter string, modelID int) ([]map[string]any, error) {
  	path := "/api/card"
  	sep := "?"
  	if filter != "" {
  		path += sep + "f=" + filter
  		sep = "&"
  	}
  	if modelID > 0 {
  		path += fmt.Sprintf("%smodel_id=%d", sep, modelID)
  	}

  	result, err := metabaseRequestList("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase list cards: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"filter","desc":"filtro opcional (all, mine, fav, archived, recent, popular, database, table)"},{"name":"modelID","desc":"ID de database/tabla para filtros database/table (0 = ignorar)"}],"output":"slice de maps con detalles de cada card (ID, nombre, query, visualizacion)"}


id: metabase_list_dashboards_go_infra
name: metabase_list_dashboards
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseListDashboards(client MetabaseClient, filter string) ([]map[string]any, error)
tags: ["metabase","dashboard","list","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_list_dashboards.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 806f9c4e2c3dfe2b64eeb36e7aaba01804e04a6c62e5b042ef70cee396e3813e
description:
  Lista dashboards de Metabase con filtro opcional. Retorna array de dashboards resumidos (sin dashcards). Endpoint: GET /api/dashboard.

example:
  ```go
  // Listar todos los dashboards
  dashboards, err := MetabaseListDashboards(client, "all")

  // Solo mis dashboards
  dashboards, err := MetabaseListDashboards(client, "mine")

  // Dashboards archivados
  dashboards, err := MetabaseListDashboards(client, "archived")
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | filter | string | no | "all", "mine", "archived". Vacio = todas |

  Retorna dashboards resumidos (sin cards). Para ver las cards de un dashboard, usar MetabaseGetDashboard.

  ### Campos principales de cada dashboard

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID del dashboard |
  | name | string | Nombre |
  | description | string | Descripcion |
  | collection_id | float64 | Coleccion contenedora |
  | creator_id | float64 | ID del creador |
  | archived | bool | Archivado |
  | created_at | string | Fecha creacion |

code:
  package infra

  import "fmt"

  // MetabaseListDashboards lista dashboards de Metabase.
  // filter: "all", "mine" o "archived" (vacio = todas).
  func MetabaseListDashboards(client MetabaseClient, filter string) ([]map[string]any, error) {
  	path := "/api/dashboard"
  	if filter != "" {
  		path += "?f=" + filter
  	}

  	result, err := metabaseRequestList("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase list dashboards: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"filter","desc":"filtro opcional (all, mine, archived o vacio para todas)"}],"output":"slice de maps con resumen de cada dashboard (sin dashcards completas)"}


id: metabase_list_users_go_infra
name: metabase_list_users
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","list","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_list_users.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 087b29382631aba55ab030e4da4809ed0eece23a95ef500228d1b6f7bf53291e
signature:
  func MetabaseListUsers(client MetabaseClient, status, query string, limit, offset int) (map[string]any, error)

description:
  Lista usuarios de una instancia Metabase con filtros opcionales por estado, nombre/email y paginacion. Endpoint: GET /api/user. Requiere permisos de superusuario.

example:
  ```go
  client, _ := MetabaseAuth("http://localhost:3000", "admin@example.com", "pass")

  // Listar todos los usuarios activos
  users, err := MetabaseListUsers(client, "active", "", 0, 0)

  // Buscar usuario por email
  users, err := MetabaseListUsers(client, "", "john@", 10, 0)

  // Listar desactivados
  users, err := MetabaseListUsers(client, "deactivated", "", 25, 0)
  ```

notes:
  Retorna un map con la estructura paginada de Metabase:
  - `data`: array de objetos usuario (id, email, first_name, last_name, is_superuser, etc.)
  - `total`: numero total de usuarios que coinciden
  - `limit`: tamanio de pagina usado
  - `offset`: offset usado

  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | status | string | no | "active" (default), "deactivated", "all" |
  | query | string | no | Filtro por nombre o email |
  | limit | int | no | Tamanio de pagina (0 = default Metabase) |
  | offset | int | no | Offset para paginacion (0 = inicio) |

  ### Campos del usuario retornado

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID numerico del usuario |
  | email | string | Email unico |
  | first_name | string | Nombre |
  | last_name | string | Apellido |
  | is_superuser | bool | Es admin |
  | is_active | bool | Esta activo |
  | common_name | string | Nombre completo |
  | last_login | string | Fecha ultimo login |

code:
  package infra

  import "fmt"

  // MetabaseListUsers lista usuarios de Metabase con filtros opcionales.
  // status: "active", "deactivated" o "all" (vacio = "active").
  // query: filtro por nombre o email (vacio = sin filtro).
  // limit/offset: paginacion (0 = valores por defecto de Metabase).
  func MetabaseListUsers(client MetabaseClient, status, query string, limit, offset int) (map[string]any, error) {
  	path := "/api/user?"
  	if status != "" {
  		path += "status=" + status + "&"
  	}
  	if query != "" {
  		path += "query=" + query + "&"
  	}
  	if limit > 0 {
  		path += fmt.Sprintf("limit=%d&", limit)
  	}
  	if offset > 0 {
  		path += fmt.Sprintf("offset=%d&", offset)
  	}

  	result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase list users: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado con permisos admin"},{"name":"status","desc":"filtro por estado (active, deactivated, all o vacio)"},{"name":"query","desc":"filtro por nombre o email (vacio para sin filtro)"},{"name":"limit","desc":"tamanio de pagina (0 = default de Metabase)"},{"name":"offset","desc":"offset para paginacion (0 = inicio)"}],"output":"mapa con data (slice de usuarios), total, limit, offset"}


id: metabase_update_card_go_infra
name: metabase_update_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","card","question","update","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_update_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 24fe499f3ca6b1541be475c9ba77e0f5081a458395bb60a812e1ac38af54a8c8
signature:
  func MetabaseUpdateCard(client MetabaseClient, cardID int, fields map[string]any) (map[string]any, error)

description:
  Actualiza campos de una card/pregunta en Metabase. Solo se modifican los campos incluidos en el map. Endpoint: PUT /api/card/:id.

example:
  ```go
  // Cambiar nombre y descripcion
  card, err := MetabaseUpdateCard(client, 42, map[string]any{
      "name":        "Updated Revenue Chart",
      "description": "Now includes refunds",
  })

  // Archivar una card (soft-delete)
  card, err := MetabaseUpdateCard(client, 42, map[string]any{
      "archived": true,
  })

  // Mover a otra coleccion
  card, err := MetabaseUpdateCard(client, 42, map[string]any{
      "collection_id": 10,
  })

  // Cambiar la query SQL
  card, err := MetabaseUpdateCard(client, 42, map[string]any{
      "dataset_query": map[string]any{
          "database": 1,
          "type":     "native",
          "native":   map[string]any{"query": "SELECT * FROM users LIMIT 100"},
      },
  })
  ```

notes:
  ### Campos actualizables

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | name | string | Nombre de la pregunta |
  | description | string | Descripcion |
  | display | string | Tipo de visualizacion |
  | dataset_query | map | Query SQL o MBQL |
  | visualization_settings | map | Config de visualizacion |
  | collection_id | int | Mover a otra coleccion |
  | archived | bool | Archivar/desarchivar (soft-delete) |
  | enable_embedding | bool | Habilitar embedding publico |
  | embedding_params | map | Parametros de embedding |

  Solo incluir los campos que se quieren cambiar.
  Para eliminar permanentemente usar MetabaseDeleteCard. Para soft-delete usar archived: true.

code:
  package infra

  import "fmt"

  // MetabaseUpdateCard actualiza campos de una card/pregunta en Metabase.
  // fields es un map con los campos a actualizar.
  // Campos comunes: name, description, display, dataset_query, visualization_settings,
  // collection_id, archived, enable_embedding.
  func MetabaseUpdateCard(client MetabaseClient, cardID int, fields map[string]any) (map[string]any, error) {
  	path := fmt.Sprintf("/api/card/%d", cardID)

  	result, err := metabaseRequest("PUT", client.BaseURL, client.Token, path, fields)
  	if err != nil {
  		return nil, fmt.Errorf("metabase update card %d: %w", cardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"cardID","desc":"ID de la card/pregunta a actualizar"},{"name":"fields","desc":"mapa con los campos a modificar (name, description, display, dataset_query, archived, etc.)"}],"output":"mapa con los detalles de la card actualizada"}


id: metabase_update_dashboard_go_infra
name: metabase_update_dashboard
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","update","cards","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_update_dashboard.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 319678921fda2088275e1e6d0df339dd79c6170c1dcc1102876de502f32b203d
signature:
  func MetabaseUpdateDashboard(client MetabaseClient, dashboardID int, fields map[string]any) (map[string]any, error)

description:
  Actualiza un dashboard en Metabase incluyendo metadata, cards y tabs. El campo dashcards representa el estado completo deseado: cards nuevas con ID negativo, existentes con ID positivo, omitidas se eliminan. Endpoint: PUT /api/dashboard/:id.

example:
  ```go
  // Cambiar nombre
  MetabaseUpdateDashboard(client, 1, map[string]any{
      "name": "Updated Dashboard",
  })

  // Agregar una card al dashboard
  // Primero obtener las dashcards existentes
  dash, _ := MetabaseGetDashboard(client, 1)
  existingCards := dash["dashcards"].([]any)

  // Construir nuevo array con las existentes + la nueva
  dashcards := make([]map[string]any, 0)
  for _, dc := range existingCards {
      dashcards = append(dashcards, dc.(map[string]any))
  }
  // Agregar nueva card (ID negativo = nueva)
  dashcards = append(dashcards, map[string]any{
      "id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0,
  })

  MetabaseUpdateDashboard(client, 1, map[string]any{
      "dashcards": dashcards,
  })

  // Archivar dashboard (soft-delete)
  MetabaseUpdateDashboard(client, 1, map[string]any{"archived": true})
  ```

notes:
  ### Gestion de dashcards (IMPORTANTE)

  El array `dashcards` representa el **estado completo deseado** del dashboard:

  | Accion | Como hacerlo |
  |--------|-------------|
  | Agregar card | Incluir con **ID negativo** (-1, -2, etc.) |
  | Actualizar card | Incluir con su **ID positivo** existente |
  | Eliminar card | **Omitir** del array |
  | No cambiar cards | No incluir el campo dashcards |

  **Flujo tipico para agregar una card:**
  1. `MetabaseGetDashboard` para obtener dashcards existentes
  2. Copiar las existentes al nuevo array
  3. Agregar la nueva con ID negativo
  4. Enviar el array completo

  ### Estructura de una dashcard

  ```go
  map[string]any{
      "id":                      -1,              // negativo = nueva, positivo = existente
      "card_id":                 42,              // ID de la card/pregunta
      "size_x":                  6,               // ancho (1-18)
      "size_y":                  4,               // alto
      "col":                     0,               // columna (0-based)
      "row":                     0,               // fila (0-based)
      "dashboard_tab_id":        nil,             // tab (nil = sin tabs)
      "parameter_mappings":      []map[string]any{}, // mapeo de filtros
      "visualization_settings":  map[string]any{},   // settings custom
  }
  ```

  ### Gestion de tabs

  ```go
  map[string]any{
      "tabs": []map[string]any{
          {"id": 1, "name": "Overview"},           // tab existente
          {"id": -1, "name": "Details"},           // tab nuevo (ID negativo)
      },
  }
  ```

  ### Campos actualizables

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | name | string | Nombre del dashboard |
  | description | string | Descripcion |
  | archived | bool | Archivar/desarchivar |
  | dashcards | []map | Estado completo de cards |
  | tabs | []map | Tabs del dashboard |
  | parameters | []map | Filtros del dashboard |
  | collection_id | int | Mover a otra coleccion |

code:
  package infra

  import "fmt"

  // MetabaseUpdateDashboard actualiza un dashboard en Metabase.
  // fields puede incluir metadata del dashboard Y/O la lista completa de dashcards y tabs.
  //
  // Para gestionar cards en el dashboard, incluir "dashcards" en fields:
  //   - Agregar card: incluirla con ID negativo (ej: -1, -2)
  //   - Actualizar card: incluirla con su ID positivo existente
  //   - Eliminar card: omitirla del array (el array es el estado deseado completo)
  //
  // Campos comunes: name, description, archived, parameters, dashcards, tabs, collection_id.
  func MetabaseUpdateDashboard(client MetabaseClient, dashboardID int, fields map[string]any) (map[string]any, error) {
  	path := fmt.Sprintf("/api/dashboard/%d", dashboardID)

  	result, err := metabaseRequest("PUT", client.BaseURL, client.Token, path, fields)
  	if err != nil {
  		return nil, fmt.Errorf("metabase update dashboard %d: %w", dashboardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"dashboardID","desc":"ID del dashboard a actualizar"},{"name":"fields","desc":"mapa con campos a modificar (name, description, dashcards, tabs, archived, etc.)"}],"output":"mapa con los detalles del dashboard actualizado"}


id: metabase_update_user_go_infra
name: metabase_update_user
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","update","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_update_user.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 219f4406a5e9ab48207f662a753bbd5ee03ee2322bab227170d4f88072dcff89
signature:
  func MetabaseUpdateUser(client MetabaseClient, userID int, fields map[string]any) (map[string]any, error)

description:
  Actualiza campos de un usuario en Metabase. Solo se modifican los campos incluidos en el map. Requiere permisos de superusuario. Endpoint: PUT /api/user/:id.

example:
  ```go
  // Cambiar nombre
  user, err := MetabaseUpdateUser(client, 5, map[string]any{
      "first_name": "Jane",
      "last_name":  "Smith",
  })

  // Promover a admin
  user, err := MetabaseUpdateUser(client, 5, map[string]any{
      "is_superuser": true,
  })

  // Cambiar grupos
  user, err := MetabaseUpdateUser(client, 5, map[string]any{
      "group_ids": []int{1, 3, 5},
  })
  ```

notes:
  ### Campos actualizables

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | first_name | string | Nombre |
  | last_name | string | Apellido |
  | email | string | Email (debe ser unico) |
  | is_superuser | bool | Permisos de admin |
  | group_ids | []int | IDs de grupos del usuario |
  | locale | string | Locale (ej: "es", "en") |
  | login_attributes | map | Atributos para sandboxing |

  Solo incluir los campos que se quieren cambiar. Los demas se mantienen sin modificar.
  Retorna el objeto usuario actualizado.

code:
  package infra

  import "fmt"

  // MetabaseUpdateUser actualiza campos de un usuario en Metabase.
  // fields es un map con los campos a actualizar. Campos validos:
  // first_name, last_name, email, is_superuser, group_ids, locale, login_attributes.
  func MetabaseUpdateUser(client MetabaseClient, userID int, fields map[string]any) (map[string]any, error) {
  	path := fmt.Sprintf("/api/user/%d", userID)

  	result, err := metabaseRequest("PUT", client.BaseURL, client.Token, path, fields)
  	if err != nil {
  		return nil, fmt.Errorf("metabase update user %d: %w", userID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado con permisos admin"},{"name":"userID","desc":"ID del usuario a actualizar"},{"name":"fields","desc":"mapa con campos a modificar (first_name, last_name, email, is_superuser, group_ids, locale)"}],"output":"mapa con los detalles del usuario actualizado"}


id: migration_create_go_infra
name: migration_create
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MigrationCreate(dir string, name string) (string, error)
tags: ["migration","database","sql","schema","sqlite","create","file"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath","regexp","strconv","strings"]
test_file_path: functions/infra/migration_create_test.go
file_path: functions/infra/migration_create.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 320541be5222bceacfcdddaa2970b88a283f3635f8d1b387c1a9c7e34c5782f8
description:
  Crea un archivo .sql de migracion con template -- +up / -- +down en el directorio indicado. Calcula automaticamente el siguiente numero de version escaneando archivos .sql existentes. Retorna el path absoluto del archivo creado.

example:
  ```go
  // Primera migracion
  path, err := MigrationCreate("apps/my_app/migrations", "create_users")
  // path = "apps/my_app/migrations/001_create_users.sql"
  // Contenido del archivo:
  // -- 001_create_users.sql
  //
  // -- +up
  //
  //
  // -- +down
  //

  // Segunda migracion (ya existe 001)
  path2, err := MigrationCreate("apps/my_app/migrations", "add_email")
  // path2 = "apps/my_app/migrations/002_add_email.sql"
  ```

tests:
  ["directorio vacio crea archivo con version 001","directorio con migraciones existentes calcula siguiente version","nombre invalido retorna error","directorio inexistente se crea automaticamente"]

notes:
  Crea el directorio si no existe (`os.MkdirAll`). La version se calcula encontrando el maximo numero existente entre los archivos `NNN_*.sql` del directorio y sumando 1. El template generado tiene los marcadores vacios para que el desarrollador complete el SQL. Nombre valido: `^[a-zA-Z][a-zA-Z0-9_]*$` — no puede empezar con numero ni contener espacios o guiones.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  	"regexp"
  	"strconv"
  	"strings"
  )

  var migrationFilePattern = regexp.MustCompile(`^(\d+)_[a-zA-Z0-9_]+\.sql$`)
  var migrationNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`)

  // MigrationCreate creates a new migration file in dir with the given name.
  // It calculates the next version by scanning existing .sql files in dir.
  // The filename follows the pattern NNN_name.sql (e.g. 003_add_index.sql).
  // Returns the absolute path of the created file.
  func MigrationCreate(dir, name string) (string, error) {
  	if !migrationNamePattern.MatchString(name) {
  		return "", fmt.Errorf("migration_create: name %q must match [a-zA-Z][a-zA-Z0-9_]*", name)
  	}

  	if err := os.MkdirAll(dir, 0o755); err != nil {
  		return "", fmt.Errorf("migration_create: cannot create directory %q: %w", dir, err)
  	}

  	next, err := nextMigrationVersion(dir)
  	if err != nil {
  		return "", fmt.Errorf("migration_create: %w", err)
  	}

  	filename := fmt.Sprintf("%03d_%s.sql", next, name)
  	path := filepath.Join(dir, filename)

  	template := fmt.Sprintf("-- %s\n\n-- +up\n\n\n-- +down\n\n", filename)

  	if err := os.WriteFile(path, []byte(template), 0o644); err != nil {
  		return "", fmt.Errorf("migration_create: cannot write file %q: %w", path, err)
  	}

  	return path, nil
  }

  // nextMigrationVersion returns the next version number by scanning .sql files in dir.
  // Returns 1 if the directory is empty or has no migration files.
  func nextMigrationVersion(dir string) (int, error) {
  	entries, err := os.ReadDir(dir)
  	if err != nil {
  		if os.IsNotExist(err) {
  			return 1, nil
  		}
  		return 0, fmt.Errorf("cannot read directory: %w", err)
  	}

  	max := 0
  	for _, e := range entries {
  		if e.IsDir() {
  			continue
  		}
  		name := e.Name()
  		if !strings.HasSuffix(strings.ToLower(name), ".sql") {
  			continue
  		}
  		if !migrationFilePattern.MatchString(name) {
  			continue
  		}

  		idx := strings.Index(name, "_")
  		if idx < 0 {
  			continue
  		}
  		v, err := strconv.Atoi(name[:idx])
  		if err != nil {
  			continue
  		}
  		if v > max {
  			max = v
  		}
  	}

  	return max + 1, nil
  }

params_schema:
  {"params":[{"name":"dir","desc":"directorio donde crear el archivo de migracion (se crea si no existe, ej: apps/my_app/migrations)"},{"name":"name","desc":"nombre descriptivo de la migracion en snake_case (ej: create_users, add_email_column). Solo letras, numeros y underscore."}],"output":"path absoluto del archivo .sql creado (ej: apps/my_app/migrations/001_create_users.sql)"}


id: migration_down_go_infra
name: migration_down
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MigrationDown(db *sql.DB, n int) ([]Migration, error)
tags: ["migration","database","sql","schema","sqlite","rollback","down"]
uses_functions: []
uses_types: ["Migration_go_infra"]
returns: ["Migration_go_infra"]
error_type: error_go_core
imports: ["database/sql","fmt","time"]
test_file_path: functions/infra/migration_down_test.go
file_path: functions/infra/migration_down.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: e6be1d3422ac975a728bbca0d443788924ee080c15f27fb103946917bcd7cdb6
description:
  Revierte las ultimas n migraciones aplicadas ejecutando su down_sql guardado en la tabla _migrations. Las reversiones ocurren en orden inverso de version (la mas alta primero). Cada reversion corre en su propia transaccion. Retorna las migraciones revertidas.

example:
  ```go
  db, _ := SQLiteOpen("", "apps/my_app/operations.db")
  defer db.Close()

  // Revertir la ultima migracion aplicada
  reverted, err := MigrationDown(db, 1)
  if err != nil {
      log.Fatalf("rollback failed: %v", err)
  }
  for _, m := range reverted {
      fmt.Printf("Reverted: %03d_%s\n", m.Version, m.Name)
  }
  // Reverted: 003_add_audit_log
  ```

tests:
  ["revertir ultima migracion elimina registro y ejecuta down_sql","revertir n migraciones revierte en orden descendente","n cero no revierte nada","base de datos sin migraciones retorna slice vacio"]

notes:
  Usa el `down_sql` almacenado en `_migrations`, no el archivo en disco. Esto garantiza que el rollback funciona aunque el archivo haya sido modificado o eliminado. Si `down_sql` esta vacio, solo se elimina el registro de `_migrations` sin ejecutar SQL. ATENCION: `MigrationDown` es destructiva — un `DROP TABLE` en el down elimina datos. Diseñada para desarrollo y no para revertir en produccion con datos vivos.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  	"time"
  )

  // MigrationDown reverts the last n applied migrations by executing their down_sql
  // from the _migrations table. Migrations are reverted in reverse version order
  // (highest version first). Each reversion runs in its own transaction.
  // Returns the list of reverted migrations. If n <= 0, no migrations are reverted.
  func MigrationDown(db *sql.DB, n int) ([]Migration, error) {
  	if n <= 0 {
  		return nil, nil
  	}

  	// Fetch last n applied migrations in descending order
  	const query = `
  		SELECT version, name, up_sql, down_sql, applied_at
  		FROM _migrations
  		ORDER BY version DESC
  		LIMIT ?`

  	rows, err := db.Query(query, n)
  	if err != nil {
  		return nil, fmt.Errorf("migration_down: query _migrations: %w", err)
  	}
  	defer rows.Close()

  	var toRevert []Migration
  	for rows.Next() {
  		var m Migration
  		var appliedAtStr string
  		if err := rows.Scan(&m.Version, &m.Name, &m.UpSQL, &m.DownSQL, &appliedAtStr); err != nil {
  			return nil, fmt.Errorf("migration_down: scan row: %w", err)
  		}
  		m.AppliedAt, _ = time.Parse("2006-01-02 15:04:05", appliedAtStr)
  		toRevert = append(toRevert, m)
  	}
  	if err := rows.Err(); err != nil {
  		return nil, fmt.Errorf("migration_down: rows error: %w", err)
  	}

  	// Revert each migration in its own transaction (already in DESC order)
  	var reverted []Migration
  	for _, m := range toRevert {
  		if err := revertMigration(db, m); err != nil {
  			return reverted, fmt.Errorf("migration_down: reverting version %d (%s): %w", m.Version, m.Name, err)
  		}
  		reverted = append(reverted, m)
  	}

  	return reverted, nil
  }

  // revertMigration executes a migration's DownSQL within a transaction and removes it
  // from _migrations. If DownSQL is empty, only the record is removed.
  func revertMigration(db *sql.DB, m Migration) error {
  	tx, err := db.Begin()
  	if err != nil {
  		return fmt.Errorf("begin transaction: %w", err)
  	}
  	defer tx.Rollback() //nolint:errcheck

  	// Execute the down SQL if present
  	if m.DownSQL != "" {
  		if _, err := tx.Exec(m.DownSQL); err != nil {
  			return fmt.Errorf("exec down_sql: %w", err)
  		}
  	}

  	// Remove the migration record
  	if _, err := tx.Exec("DELETE FROM _migrations WHERE version = ?", m.Version); err != nil {
  		return fmt.Errorf("delete from _migrations: %w", err)
  	}

  	return tx.Commit()
  }

params_schema:
  {"params":[{"name":"db","desc":"conexion *sql.DB abierta a la base de datos SQLite con la tabla _migrations"},{"name":"n","desc":"numero de migraciones a revertir (las ultimas n en orden descendente de version). Si n \u003c= 0 no hace nada."}],"output":"slice de Migration con las migraciones que fueron revertidas (en orden descendente de version)"}


id: migration_parse_go_infra
name: migration_parse
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func MigrationParse(filename string, content string) (Migration, error)
tags: ["migration","database","sql","schema","sqlite","parse"]
uses_functions: []
uses_types: ["Migration_go_infra"]
returns: ["Migration_go_infra"]
imports: ["fmt","strconv","strings"]
test_file_path: functions/infra/migration_parse_test.go
file_path: functions/infra/migration_parse.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 41b963fa86c98eaa2e570a1954bd9b4cca182b130c2df8d761db3922c9907cda
description:
  Parsea el nombre de archivo y el contenido SQL de una migracion. Extrae version y nombre del filename (patron NNN_nombre.sql) y separa bloques up/down por marcadores -- +up / -- +down. Error si el formato es invalido o falta el bloque up.

example:
  ```go
  content := `
  -- +up
  CREATE TABLE users (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL
  );

  -- +down
  DROP TABLE IF EXISTS users;
  `
  m, err := MigrationParse("001_create_users.sql", content)
  // m.Version  = 1
  // m.Name     = "create_users"
  // m.UpSQL    = "CREATE TABLE users (\n    id TEXT PRIMARY KEY,\n    name TEXT NOT NULL\n);"
  // m.DownSQL  = "DROP TABLE IF EXISTS users;"
  ```

tests:
  ["archivo valido con up y down retorna Migration correcta","archivo sin bloque down retorna DownSQL vacio sin error","filename sin separador underscore retorna error","version no numerica retorna error","bloque up vacio retorna error","version cero retorna error","nombre descriptivo vacio retorna error","filename con path completo extrae nombre base"]

notes:
  Funcion pura — no hace I/O. El marcador `-- +up` es obligatorio; `-- +down` es opcional (retorna DownSQL vacio). Si el archivo tiene down antes que up, se parsea igualmente. Los bloques se recortan con `strings.TrimSpace`. El formato de version es un entero positivo con cualquier numero de digitos (001, 01, 1 son equivalentes).

code:
  package infra

  import (
  	"fmt"
  	"strconv"
  	"strings"
  )

  // MigrationParse parses a migration filename and its SQL content into a Migration.
  // The filename must follow the pattern NNN_name.sql (e.g. 001_create_users.sql).
  // The content must contain a -- +up marker; -- +down is optional but recommended.
  // Returns error if the filename format is invalid or the -- +up block is missing.
  func MigrationParse(filename, content string) (Migration, error) {
  	// Strip path prefix if any
  	base := filename
  	if idx := strings.LastIndex(base, "/"); idx >= 0 {
  		base = base[idx+1:]
  	}
  	if idx := strings.LastIndex(base, "\\"); idx >= 0 {
  		base = base[idx+1:]
  	}

  	// Remove .sql extension
  	name := base
  	if strings.HasSuffix(strings.ToLower(name), ".sql") {
  		name = name[:len(name)-4]
  	}

  	// Split on first underscore to get version and descriptive name
  	idx := strings.Index(name, "_")
  	if idx < 0 {
  		return Migration{}, fmt.Errorf("migration_parse: filename %q must follow pattern NNN_name.sql", filename)
  	}

  	versionStr := name[:idx]
  	descriptiveName := name[idx+1:]

  	version, err := strconv.Atoi(versionStr)
  	if err != nil || version <= 0 {
  		return Migration{}, fmt.Errorf("migration_parse: filename %q version %q must be a positive integer", filename, versionStr)
  	}

  	if descriptiveName == "" {
  		return Migration{}, fmt.Errorf("migration_parse: filename %q must have a non-empty descriptive name after the version", filename)
  	}

  	// Parse up/down blocks
  	upSQL, downSQL, err := parseMigrationBlocks(content)
  	if err != nil {
  		return Migration{}, fmt.Errorf("migration_parse: %w", err)
  	}

  	return Migration{
  		Version: version,
  		Name:    descriptiveName,
  		UpSQL:   upSQL,
  		DownSQL: downSQL,
  	}, nil
  }

  // parseMigrationBlocks splits SQL content by -- +up and -- +down markers.
  // Returns (upSQL, downSQL, error). The -- +up block is required.
  func parseMigrationBlocks(content string) (string, string, error) {
  	const markerUp = "-- +up"
  	const markerDown = "-- +down"

  	// Normalize line endings
  	content = strings.ReplaceAll(content, "\r\n", "\n")

  	upIdx := indexMarker(content, markerUp)
  	if upIdx < 0 {
  		return "", "", fmt.Errorf("missing -- +up marker in content")
  	}

  	downIdx := indexMarker(content, markerDown)

  	var upSQL, downSQL string

  	if downIdx < 0 {
  		// No down block
  		upSQL = strings.TrimSpace(content[upIdx+len(markerUp):])
  	} else if downIdx > upIdx {
  		// Normal order: up first, then down
  		upSQL = strings.TrimSpace(content[upIdx+len(markerUp) : downIdx])
  		downSQL = strings.TrimSpace(content[downIdx+len(markerDown):])
  	} else {
  		// Down before up — still valid, just unusual
  		downSQL = strings.TrimSpace(content[downIdx+len(markerDown) : upIdx])
  		upSQL = strings.TrimSpace(content[upIdx+len(markerUp):])
  	}

  	if upSQL == "" {
  		return "", "", fmt.Errorf("-- +up block is empty")
  	}

  	return upSQL, downSQL, nil
  }

  // indexMarker finds the index of a marker at the start of any line in content.
  func indexMarker(content, marker string) int {
  	lines := strings.Split(content, "\n")
  	pos := 0
  	for _, line := range lines {
  		trimmed := strings.TrimSpace(line)
  		if trimmed == marker {
  			return pos
  		}
  		pos += len(line) + 1 // +1 for the newline
  	}
  	return -1
  }

params_schema:
  {"params":[{"name":"filename","desc":"nombre del archivo de migracion (ej: 001_create_users.sql). Puede incluir path completo."},{"name":"content","desc":"contenido completo del archivo .sql con marcadores -- +up y -- +down"}],"output":"Migration con version, nombre, up_sql y down_sql extraidos del archivo"}


id: migration_status_go_infra
name: migration_status
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MigrationGetStatus(db *sql.DB, dir string) ([]MigrationStatus, error)
tags: ["migration","database","sql","schema","sqlite","status","list"]
uses_functions: ["migration_parse_go_infra"]
uses_types: ["MigrationStatus_go_infra"]
returns: ["MigrationStatus_go_infra"]
error_type: error_go_core
imports: ["database/sql","fmt","sort","strings","time"]
test_file_path: functions/infra/migration_status_test.go
file_path: functions/infra/migration_status.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: c2c1e4bb62c0683b28cecc3dd993cbca6e0f4d0e894f0f13781daefb0b617d79
description:
  Cruza los archivos .sql del directorio con los registros en _migrations y retorna una lista ordenada por version con el estado de cada migracion (applied/pending). Migraciones en BD pero sin archivo en disco se marcan como orphaned. Si _migrations no existe aun, todas las migraciones del directorio aparecen como pending.

example:
  ```go
  db, _ := SQLiteOpen("", "apps/my_app/operations.db")
  defer db.Close()

  statuses, err := MigrationGetStatus(db, "apps/my_app/migrations")
  if err != nil {
      log.Fatal(err)
  }
  for _, s := range statuses {
      if s.Applied {
          fmt.Printf("%03d %-30s applied at %s\n", s.Version, s.Name, s.AppliedAt.Format(time.RFC3339))
      } else {
          fmt.Printf("%03d %-30s pending\n", s.Version, s.Name)
      }
  }
  // 001 create_users                  applied at 2026-04-13T10:30:00Z
  // 002 add_roles                     applied at 2026-04-13T10:30:00Z
  // 003 add_audit_log                 pending
  ```

tests:
  ["migraciones en disco pero no en BD aparecen como pending","migraciones aplicadas aparecen con Applied=true y AppliedAt","migraciones aplicadas sin archivo en disco aparecen como orphaned","base de datos sin tabla _migrations retorna todas como pending"]

notes:
  Combina informacion de disco (archivos .sql) y BD (tabla _migrations) para dar una vision completa del estado. Las migraciones "orphaned" son aquellas que aparecen en `_migrations` pero ya no tienen archivo en disco — esto puede indicar que el archivo fue eliminado despues de aplicarse. La tabla `_migrations` se crea con `MigrationUp`; si no existe aun, `MigrationStatus` las trata todas como pending.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  	"sort"
  	"strings"
  	"time"
  )

  // MigrationGetStatus crosses migration files on disk with records in _migrations,
  // returning a sorted list of MigrationStatus entries ordered by version ascending.
  // Migrations present in _migrations but not on disk are included with Applied=true
  // and marked as orphaned in their Name (suffix " (orphaned)").
  // If _migrations does not exist yet, all file-based migrations are returned as pending.
  func MigrationGetStatus(db *sql.DB, dir string) ([]MigrationStatus, error) {
  	// Load files from disk (non-fatal if dir is empty or missing)
  	fileMigrations, err := loadMigrationsFromDir(dir)
  	if err != nil && !strings.Contains(err.Error(), "cannot read migrations directory") {
  		return nil, fmt.Errorf("migration_status: %w", err)
  	}
  	if err != nil {
  		// Directory doesn't exist — treat as empty
  		fileMigrations = nil
  	}

  	// Build a map from version to file migration
  	fileMap := make(map[int]Migration, len(fileMigrations))
  	for _, m := range fileMigrations {
  		fileMap[m.Version] = m
  	}

  	// Load applied migrations from DB (_migrations may not exist yet)
  	dbMap, err := loadAppliedMigrations(db)
  	if err != nil {
  		return nil, fmt.Errorf("migration_status: %w", err)
  	}

  	// Merge: collect all known versions
  	allVersions := make(map[int]struct{})
  	for v := range fileMap {
  		allVersions[v] = struct{}{}
  	}
  	for v := range dbMap {
  		allVersions[v] = struct{}{}
  	}

  	// Build status list
  	var statuses []MigrationStatus
  	for v := range allVersions {
  		dbEntry, inDB := dbMap[v]
  		fileEntry, inFile := fileMap[v]

  		name := fileEntry.Name
  		if !inFile && inDB {
  			// Orphaned: applied but no file on disk
  			name = dbEntry.Name + " (orphaned)"
  		}

  		statuses = append(statuses, MigrationStatus{
  			Version:   v,
  			Name:      name,
  			Applied:   inDB,
  			AppliedAt: dbEntry.AppliedAt, // zero value if not in DB
  		})
  	}

  	sort.Slice(statuses, func(i, j int) bool {
  		return statuses[i].Version < statuses[j].Version
  	})

  	return statuses, nil
  }

  // migrationDBRecord holds data from the _migrations table.
  type migrationDBRecord struct {
  	Name      string
  	AppliedAt time.Time
  }

  // loadAppliedMigrations queries _migrations and returns a map of version -> record.
  // If the table does not exist, returns an empty map without error.
  func loadAppliedMigrations(db *sql.DB) (map[int]migrationDBRecord, error) {
  	result := make(map[int]migrationDBRecord)

  	rows, err := db.Query("SELECT version, name, applied_at FROM _migrations ORDER BY version ASC")
  	if err != nil {
  		// Table doesn't exist yet — return empty map
  		if strings.Contains(err.Error(), "no such table") {
  			return result, nil
  		}
  		return nil, fmt.Errorf("query _migrations: %w", err)
  	}
  	defer rows.Close()

  	for rows.Next() {
  		var version int
  		var name, appliedAtStr string
  		if err := rows.Scan(&version, &name, &appliedAtStr); err != nil {
  			return nil, fmt.Errorf("scan _migrations row: %w", err)
  		}
  		appliedAt, _ := time.Parse("2006-01-02 15:04:05", appliedAtStr)
  		result[version] = migrationDBRecord{Name: name, AppliedAt: appliedAt}
  	}
  	return result, rows.Err()
  }

params_schema:
  {"params":[{"name":"db","desc":"conexion *sql.DB abierta a la base de datos SQLite (puede no tener _migrations aun)"},{"name":"dir","desc":"path al directorio con los archivos .sql de migracion (puede no existir)"}],"output":"slice de MigrationStatus ordenado por version ascendente con Applied y AppliedAt para cada migracion"}


id: migration_up_go_infra
name: migration_up
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MigrationUp(db *sql.DB, dir string) ([]Migration, error)
tags: ["migration","database","sql","schema","sqlite","apply","up"]
uses_functions: ["migration_parse_go_infra"]
uses_types: ["Migration_go_infra"]
returns: ["Migration_go_infra"]
error_type: error_go_core
imports: ["database/sql","fmt","os","path/filepath","sort","strings"]
test_file_path: functions/infra/migration_up_test.go
file_path: functions/infra/migration_up.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: d115848cab15afce0ca52da1d29fb98c01539c2e700546e89e0770869733c636
description:
  Lee los archivos .sql del directorio, crea la tabla _migrations si no existe, y ejecuta las migraciones pendientes en orden de version. Cada migracion corre en su propia transaccion. Retorna la lista de migraciones aplicadas en esta llamada.

example:
  ```go
  db, _ := SQLiteOpen("", "apps/my_app/operations.db")
  defer db.Close()

  applied, err := MigrationUp(db, "apps/my_app/migrations")
  if err != nil {
      log.Fatalf("migration failed: %v", err)
  }
  for _, m := range applied {
      fmt.Printf("Applied: %03d_%s\n", m.Version, m.Name)
  }
  // Applied: 001_create_users
  // Applied: 002_add_roles
  ```

tests:
  ["base de datos vacia aplica todas las migraciones","migraciones ya aplicadas se omiten","migracion con SQL invalido retorna error y deja las anteriores aplicadas","directorio sin archivos sql no aplica nada"]

notes:
  Crea `_migrations` con `CREATE TABLE IF NOT EXISTS` — es idempotente. Cada migracion se ejecuta en una transaccion independiente: si falla la migracion 3, las 1 y 2 ya aplicadas permanecen. El `up_sql` y `down_sql` se guardan en `_migrations` para que el rollback funcione aunque el archivo sea modificado o eliminado posteriormente. SQLite con el driver mattn/go-sqlite3 soporta multiples sentencias en un solo `Exec`.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  	"os"
  	"path/filepath"
  	"sort"
  	"strings"
  )

  const createMigrationsTable = `
  CREATE TABLE IF NOT EXISTS _migrations (
      version    INTEGER PRIMARY KEY,
      name       TEXT    NOT NULL,
      up_sql     TEXT    NOT NULL,
      down_sql   TEXT    NOT NULL,
      applied_at TEXT    NOT NULL DEFAULT (datetime('now'))
  )`

  // MigrationUp reads all .sql migration files from dir, creates the _migrations
  // table if it does not exist, and applies any pending migrations in version order.
  // Each migration runs in its own transaction. Returns the list of applied migrations.
  // If a migration fails, execution stops and the error is returned along with any
  // migrations that were successfully applied before the failure.
  func MigrationUp(db *sql.DB, dir string) ([]Migration, error) {
  	// Ensure _migrations table exists
  	if _, err := db.Exec(createMigrationsTable); err != nil {
  		return nil, fmt.Errorf("migration_up: cannot create _migrations table: %w", err)
  	}

  	// Load files from directory
  	allMigrations, err := loadMigrationsFromDir(dir)
  	if err != nil {
  		return nil, fmt.Errorf("migration_up: %w", err)
  	}

  	// Fetch already-applied versions
  	applied, err := appliedVersions(db)
  	if err != nil {
  		return nil, fmt.Errorf("migration_up: %w", err)
  	}

  	// Filter pending migrations
  	var pending []Migration
  	for _, m := range allMigrations {
  		if !applied[m.Version] {
  			pending = append(pending, m)
  		}
  	}

  	// Apply each pending migration in its own transaction
  	var result []Migration
  	for _, m := range pending {
  		if err := applyMigration(db, m); err != nil {
  			return result, fmt.Errorf("migration_up: applying version %d (%s): %w", m.Version, m.Name, err)
  		}
  		result = append(result, m)
  	}

  	return result, nil
  }

  // loadMigrationsFromDir reads and parses all .sql migration files from dir,
  // returning them sorted by version ascending.
  func loadMigrationsFromDir(dir string) ([]Migration, error) {
  	entries, err := os.ReadDir(dir)
  	if err != nil {
  		return nil, fmt.Errorf("cannot read migrations directory %q: %w", dir, err)
  	}

  	var migrations []Migration
  	for _, e := range entries {
  		if e.IsDir() {
  			continue
  		}
  		name := e.Name()
  		if !strings.HasSuffix(strings.ToLower(name), ".sql") {
  			continue
  		}

  		path := filepath.Join(dir, name)
  		content, err := os.ReadFile(path)
  		if err != nil {
  			return nil, fmt.Errorf("cannot read %q: %w", path, err)
  		}

  		m, err := MigrationParse(name, string(content))
  		if err != nil {
  			return nil, fmt.Errorf("parse error in %q: %w", name, err)
  		}
  		migrations = append(migrations, m)
  	}

  	sort.Slice(migrations, func(i, j int) bool {
  		return migrations[i].Version < migrations[j].Version
  	})

  	return migrations, nil
  }

  // appliedVersions returns a set of version numbers already recorded in _migrations.
  func appliedVersions(db *sql.DB) (map[int]bool, error) {
  	rows, err := db.Query("SELECT version FROM _migrations")
  	if err != nil {
  		return nil, fmt.Errorf("cannot query _migrations: %w", err)
  	}
  	defer rows.Close()

  	applied := make(map[int]bool)
  	for rows.Next() {
  		var v int
  		if err := rows.Scan(&v); err != nil {
  			return nil, fmt.Errorf("scan version: %w", err)
  		}
  		applied[v] = true
  	}
  	return applied, rows.Err()
  }

  // applyMigration executes a migration's UpSQL within a transaction and records it
  // in _migrations. If UpSQL contains multiple statements, they are executed sequentially
  // using db.Exec (SQLite supports multiple statements via the C driver).
  func applyMigration(db *sql.DB, m Migration) error {
  	tx, err := db.Begin()
  	if err != nil {
  		return fmt.Errorf("begin transaction: %w", err)
  	}
  	defer tx.Rollback() //nolint:errcheck

  	// Execute the up SQL (may contain multiple statements)
  	if _, err := tx.Exec(m.UpSQL); err != nil {
  		return fmt.Errorf("exec up_sql: %w", err)
  	}

  	// Record the migration
  	const insertSQL = `INSERT INTO _migrations (version, name, up_sql, down_sql) VALUES (?, ?, ?, ?)`
  	if _, err := tx.Exec(insertSQL, m.Version, m.Name, m.UpSQL, m.DownSQL); err != nil {
  		return fmt.Errorf("record migration: %w", err)
  	}

  	return tx.Commit()
  }

params_schema:
  {"params":[{"name":"db","desc":"conexion *sql.DB abierta a la base de datos SQLite donde aplicar las migraciones"},{"name":"dir","desc":"path al directorio que contiene los archivos .sql de migracion (ej: apps/my_app/migrations)"}],"output":"slice de Migration con las migraciones que fueron aplicadas en esta llamada (puede estar vacio si todo ya estaba aplicado)"}


id: migration_validate_go_infra
name: migration_validate
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func MigrationValidate(migrations []Migration) []string
tags: ["migration","database","sql","schema","sqlite","validate"]
uses_functions: []
uses_types: ["Migration_go_infra"]
returns: []
imports: ["fmt","sort"]
test_file_path: functions/infra/migration_validate_test.go
file_path: functions/infra/migration_validate.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 263c01c608cafa9b3531239005f34aeb09bc8934d82703884115bf64d16537d4
description:
  Verifica que una secuencia de migraciones sea valida: versiones secuenciales sin huecos comenzando en 1, sin duplicados, con up_sql y nombre no vacios. Retorna lista de errores (vacia si todo OK).

example:
  ```go
  migrations := []Migration{
      {Version: 1, Name: "create_users", UpSQL: "CREATE TABLE users (...);", DownSQL: "DROP TABLE users;"},
      {Version: 3, Name: "add_roles",    UpSQL: "CREATE TABLE roles (...);", DownSQL: "DROP TABLE roles;"},
  }
  errs := MigrationValidate(migrations)
  // errs = ["gap in versions: missing 2 (have 1 then 3)"]

  valid := []Migration{
      {Version: 1, Name: "create_users", UpSQL: "CREATE TABLE users (id TEXT PRIMARY KEY);"},
      {Version: 2, Name: "add_email",    UpSQL: "ALTER TABLE users ADD COLUMN email TEXT;"},
  }
  errs = MigrationValidate(valid)
  // errs = []  (empty — no errors)
  ```

tests:
  ["secuencia valida retorna sin errores","secuencia vacia retorna sin errores","version duplicada reporta error","hueco en versiones reporta version faltante","up_sql vacio reporta error","nombre vacio reporta error","versiones que no empiezan en 1 reportan error","multiple errores se reportan todos"]

notes:
  Funcion pura — no hace I/O, no modifica el slice de entrada. Ordena internamente una copia para detectar huecos. Todos los errores encontrados se acumulan y retornan juntos (no falla al primer error). Util antes de llamar a `MigrationUp` para detectar problemas de forma anticipada.

code:
  package infra

  import (
  	"fmt"
  	"sort"
  )

  // MigrationValidate checks a slice of migrations for consistency errors.
  // It verifies that:
  //   - Versions are sequential starting from 1 with no gaps (1, 2, 3...)
  //   - No duplicate versions exist
  //   - Each migration has non-empty UpSQL
  //   - Each migration has a non-empty Name
  //
  // Returns a slice of human-readable error strings. An empty slice means all
  // migrations are valid. The function does not mutate the input slice.
  func MigrationValidate(migrations []Migration) []string {
  	var errs []string

  	if len(migrations) == 0 {
  		return errs
  	}

  	// Work on a sorted copy to detect gaps and duplicates
  	sorted := make([]Migration, len(migrations))
  	copy(sorted, migrations)
  	sort.Slice(sorted, func(i, j int) bool {
  		return sorted[i].Version < sorted[j].Version
  	})

  	// Check individual fields and collect duplicates
  	seen := make(map[int]int) // version -> count
  	for _, m := range sorted {
  		seen[m.Version]++

  		if m.Name == "" {
  			errs = append(errs, fmt.Sprintf("version %d has empty name", m.Version))
  		}
  		if m.UpSQL == "" {
  			errs = append(errs, fmt.Sprintf("version %d (%s) has empty up_sql", m.Version, m.Name))
  		}
  	}

  	// Report duplicates
  	for v, count := range seen {
  		if count > 1 {
  			errs = append(errs, fmt.Sprintf("duplicate version %d appears %d times", v, count))
  		}
  	}

  	// Check sequential numbering starting from 1 (no gaps)
  	// Build unique sorted versions
  	versions := make([]int, 0, len(seen))
  	for v := range seen {
  		versions = append(versions, v)
  	}
  	sort.Ints(versions)

  	if len(versions) > 0 && versions[0] != 1 {
  		errs = append(errs, fmt.Sprintf("versions must start at 1, got %d", versions[0]))
  	}

  	for i := 1; i < len(versions); i++ {
  		expected := versions[i-1] + 1
  		if versions[i] != expected {
  			errs = append(errs, fmt.Sprintf("gap in versions: missing %d (have %d then %d)", expected, versions[i-1], versions[i]))
  		}
  	}

  	return errs
  }

params_schema:
  {"params":[{"name":"migrations","desc":"slice de Migration a validar, puede estar desordenado"}],"output":"slice de strings con mensajes de error; slice vacio si todas las migraciones son validas"}


id: nordvpn_container_run_go_infra
name: nordvpn_container_run
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func NordVPNContainerRun(opts NordVPNContainerRunOpts) (string, error)
tags: ["vpn","nordvpn","docker","container","run","infra","network"]
uses_functions: ["docker_run_container_go_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/nordvpn_container_run.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: fb5d1b6d4bbb8307f8cda0b3bee91f7abd05cce404f3a0321613702b77713ac0
description:
  Ejecuta un container Docker cuyo trafico pasa por el gateway NordVPN usando --network=container:<gateway>. El container hereda la IP y tunel VPN del gateway.

example:
  ```go
  // Verificar IP desde VPN
  id, err := NordVPNContainerRun(NordVPNContainerRunOpts{
      Image:   "curlimages/curl",
      Cmd:     []string{"https://api.ipify.org"},
      Remove:  true,
      Gateway: "nordvpn",
  })

  // Ejecutar scraper bajo VPN
  id, err := NordVPNContainerRun(NordVPNContainerRunOpts{
      Image:   "my-scraper:latest",
      Env:     map[string]string{"TARGET_URL": "https://example.com"},
      Volumes: []string{"/tmp/output:/output"},
      Detach:  true,
      Name:    "scraper-vpn",
  })

  // Navegador headless bajo VPN
  id, err := NordVPNContainerRun(NordVPNContainerRunOpts{
      Image:   "chromedp/headless-shell",
      Detach:  true,
      Name:    "chrome-vpn",
  })
  ```

notes:
  Requiere que el container gateway NordVPN este corriendo (usar `NordVPNContainerStart` primero). El container cliente no necesita capabilities especiales — hereda la red del gateway. Con `--network=container:X` el container no puede exponer puertos propios; los puertos deben mapearse en el gateway.

code:
  package infra

  import (
  	"fmt"
  )

  // NordVPNContainerRunOpts opciones para ejecutar un container a traves del gateway NordVPN.
  type NordVPNContainerRunOpts struct {
  	Image   string            // Imagen Docker a ejecutar (obligatorio)
  	Cmd     []string          // Comando a ejecutar en el container
  	Env     map[string]string // Variables de entorno
  	Volumes []string          // Bind mounts
  	Name    string            // Nombre del container (opcional)
  	Gateway string            // Nombre del container NordVPN gateway (default: "nordvpn")
  	Detach  bool              // Ejecutar en background
  	Remove  bool              // Eliminar al terminar (--rm)
  }

  // NordVPNContainerRun ejecuta un container Docker cuyo trafico de red
  // pasa por el container gateway NordVPN usando --network=container:<gateway>.
  // Devuelve el ID del container creado.
  func NordVPNContainerRun(opts NordVPNContainerRunOpts) (string, error) {
  	if opts.Image == "" {
  		return "", fmt.Errorf("image required")
  	}
  	if opts.Gateway == "" {
  		opts.Gateway = "nordvpn"
  	}

  	id, err := DockerRunContainer(opts.Image, DockerRunOpts{
  		Name:    opts.Name,
  		Env:     opts.Env,
  		Volumes: opts.Volumes,
  		Detach:  opts.Detach,
  		Remove:  opts.Remove,
  		Network: "container:" + opts.Gateway,
  		Cmd:     opts.Cmd,
  	})
  	if err != nil {
  		return "", fmt.Errorf("nordvpn container run %s: %w", opts.Image, err)
  	}

  	return id, nil
  }

params_schema:
  {"params":[{"name":"opts","desc":"estructura NordVPNContainerRunOpts con Image, Cmd, Env, Volumes, Detach, Name, Gateway"}],"output":"ID del contenedor ejecutado bajo VPN"}


id: nordvpn_container_start_go_infra
name: nordvpn_container_start
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error)
tags: ["vpn","nordvpn","docker","container","gateway","infra","network"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","strings","time"]
tests: []
file_path: functions/infra/nordvpn_container_start.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 24548d4d425be87612deb564d9f77e20bcb1befccc2cd23bc09042367d725318
description:
  Levanta un container Docker con NordVPN como gateway de red. Otros containers pueden rutear su trafico a traves de este con --network=container:<name>. Espera hasta 30s a que el tunel este activo.

uses_functions:
  ["docker_run_container_go_infra","docker_remove_container_go_infra","docker_container_logs_go_infra"]

example:
  ```go
  id, err := NordVPNContainerStart(NordVPNContainerOpts{
      Token:   os.Getenv("NORDVPN_TOKEN"),
      Country: "Spain",
      City:    "Madrid",
  })
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println("VPN gateway:", id)

  // Ahora otros containers pueden usar la VPN:
  // docker run --network=container:nordvpn curlimages/curl https://api.ipify.org
  ```

notes:
  Usa la imagen `ghcr.io/bubuntux/nordvpn`. Requiere un token de acceso NordVPN (obtener con `nordvpn token` desde CLI o desde la web de NordVPN). Limpia containers previos con el mismo nombre automaticamente. El protocolo por defecto es NordLynx (WireGuard).

code:
  package infra

  import (
  	"fmt"
  	"strings"
  	"time"
  )

  // NordVPNContainerOpts opciones para el container gateway NordVPN.
  type NordVPNContainerOpts struct {
  	Token    string // Token de acceso NordVPN (obligatorio)
  	Country  string // Pais al que conectar (opcional, ej: "Spain")
  	City     string // Ciudad (opcional, ej: "Madrid")
  	Protocol string // "NordLynx" o "OpenVPN" (default: NordLynx)
  	Name     string // Nombre del container (default: "nordvpn")
  }

  // NordVPNContainerStart levanta un container Docker con NordVPN como gateway.
  // Otros containers pueden usar su red con --network=container:<name>.
  // Espera hasta que el tunel este activo o timeout de 30s.
  func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error) {
  	if opts.Token == "" {
  		return "", fmt.Errorf("nordvpn token required")
  	}
  	if opts.Name == "" {
  		opts.Name = "nordvpn"
  	}
  	if opts.Protocol == "" {
  		opts.Protocol = "NordLynx"
  	}

  	env := map[string]string{
  		"TOKEN":      opts.Token,
  		"TECHNOLOGY": opts.Protocol,
  	}
  	if opts.Country != "" {
  		connect := opts.Country
  		if opts.City != "" {
  			connect += " " + opts.City
  		}
  		env["CONNECT"] = connect
  	}

  	// Limpiar container previo con el mismo nombre si existe
  	_ = DockerRemoveContainer(opts.Name, true)

  	id, err := DockerRunContainer("ghcr.io/bubuntux/nordvpn", DockerRunOpts{
  		Name:   opts.Name,
  		Env:    env,
  		Detach: true,
  		CapAdd: []string{"NET_ADMIN", "NET_RAW"},
  	})
  	if err != nil {
  		return "", fmt.Errorf("nordvpn container start: %w", err)
  	}

  	// Esperar a que el tunel este activo
  	for i := 0; i < 30; i++ {
  		time.Sleep(1 * time.Second)
  		logs, logErr := DockerContainerLogs(opts.Name, 20)
  		if logErr != nil {
  			continue
  		}
  		if strings.Contains(logs, "Connected") || strings.Contains(logs, "connected") {
  			return id, nil
  		}
  		if strings.Contains(logs, "error") || strings.Contains(logs, "failed") {
  			return id, fmt.Errorf("nordvpn connection failed, check logs: docker logs %s", opts.Name)
  		}
  	}

  	return id, fmt.Errorf("nordvpn connection timeout after 30s, check logs: docker logs %s", opts.Name)
  }

params_schema:
  {"params":[{"name":"opts","desc":"estructura NordVPNContainerOpts con Token, Country, City, Protocol"}],"output":"ID del contenedor gateway NordVPN"}


id: nordvpn_container_stop_go_infra
name: nordvpn_container_stop
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func NordVPNContainerStop(gateway string, clientNames ...string) error
tags: ["vpn","nordvpn","docker","container","stop","cleanup","infra"]
uses_functions: ["docker_stop_container_go_infra","docker_remove_container_go_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/nordvpn_container_stop.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 39308369eee7a000ad54f0769a8c935ee29b656ff105ea69b0c49114f8d7a818
description:
  Detiene y elimina el container gateway NordVPN y opcionalmente los containers cliente que usan su red.

example:
  ```go
  // Parar solo el gateway
  err := NordVPNContainerStop("nordvpn")

  // Parar gateway y clientes asociados
  err := NordVPNContainerStop("nordvpn", "chrome-vpn", "scraper-vpn")
  ```

notes:
  Detiene primero los containers cliente (si se proporcionan) y luego el gateway. Importante: si los clientes usan `--network=container:nordvpn`, deben pararse antes que el gateway para evitar errores de red. Los clientes se paran con timeout de 5s, el gateway con 10s.

code:
  package infra

  import (
  	"fmt"
  )

  // NordVPNContainerStop detiene y elimina el container gateway NordVPN.
  // Tambien detiene containers que usen su red si se proporcionan.
  func NordVPNContainerStop(gateway string, clientNames ...string) error {
  	if gateway == "" {
  		gateway = "nordvpn"
  	}

  	// Primero parar los clientes que usan la red del gateway
  	for _, name := range clientNames {
  		_ = DockerStopContainer(name, 5)
  		_ = DockerRemoveContainer(name, true)
  	}

  	// Parar y eliminar el gateway
  	if err := DockerStopContainer(gateway, 10); err != nil {
  		return fmt.Errorf("nordvpn container stop: %w", err)
  	}
  	if err := DockerRemoveContainer(gateway, true); err != nil {
  		return fmt.Errorf("nordvpn container remove: %w", err)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"gateway","desc":"nombre del contenedor gateway NordVPN a detener"},{"name":"clientNames","desc":"nombres opcionales de containers cliente que usan la VPN (se detienen primero)"}],"output":"nil si se detuvieron exitosamente, error en caso contrario"}


id: parse_nordvpn_status_go_infra
name: parse_nordvpn_status
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func ParseNordVPNStatus(output string) NordVPNStatus
tags: ["vpn","nordvpn","parser","pure","infra"]
uses_functions: []
uses_types: ["NordVPNStatus_go_infra"]
returns: []
imports: ["regexp","strings"]
test_file_path: functions/infra/parse_nordvpn_status_test.go
file_path: functions/infra/parse_nordvpn_status.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 66fcea0ec30a196a937dcdbf7df69184271222baf8311dab0b8d66f815f47569
description:
  Parsea la salida de texto de nordvpn status a un struct tipado. Elimina codigos ANSI y normaliza claves.

example:
  ```go
  output := `Status: Connected
  Hostname: es42.nordvpn.com
  IP: 185.230.124.42
  Country: Spain
  City: Madrid
  Current Technology: NordLynx`

  s := ParseNordVPNStatus(output)
  // s.Connected == true
  // s.Hostname == "es42.nordvpn.com"
  // s.Country == "Spain"
  ```

tests:
  ["TestParseNordVPNStatus_Connected","TestParseNordVPNStatus_Disconnected","TestParseNordVPNStatus_WithANSI"]

notes:
  Funcion pura — no ejecuta comandos ni accede a red. Maneja codigos ANSI que NordVPN CLI emite en terminal. Campos no presentes en la salida quedan como zero value.

code:
  package infra

  import (
  	"regexp"
  	"strings"
  )

  // NordVPNStatus representa el estado parseado de nordvpn status.
  type NordVPNStatus struct {
  	Connected  bool   // true si hay conexion activa
  	Status     string // "Connected" o "Disconnected"
  	Hostname   string // ej: "es42.nordvpn.com"
  	IP         string // IP del servidor VPN
  	Country    string // ej: "Spain"
  	City       string // ej: "Madrid"
  	Technology string // ej: "NordLynx"
  	Protocol   string // ej: "nordlynx"
  	Transfer   string // ej: "1.2 MiB received, 500 KiB sent"
  	Uptime     string // ej: "5 minutes 32 seconds"
  }

  // ansiRegexp elimina codigos de escape ANSI.
  var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`)

  // ParseNordVPNStatus parsea la salida de texto de `nordvpn status`
  // a un struct tipado. Funcion pura — no ejecuta comandos.
  func ParseNordVPNStatus(output string) NordVPNStatus {
  	var s NordVPNStatus

  	lines := strings.Split(output, "\n")
  	for _, line := range lines {
  		line = ansiRegexp.ReplaceAllString(line, "")
  		line = strings.TrimSpace(line)
  		line = strings.TrimLeft(line, "- ")

  		idx := strings.Index(line, ":")
  		if idx < 0 {
  			continue
  		}

  		key := strings.ToLower(strings.TrimSpace(line[:idx]))
  		val := strings.TrimSpace(line[idx+1:])

  		switch key {
  		case "status":
  			s.Status = val
  			s.Connected = strings.EqualFold(val, "connected")
  		case "hostname", "server":
  			s.Hostname = val
  		case "ip":
  			s.IP = val
  		case "country":
  			s.Country = val
  		case "city":
  			s.City = val
  		case "current technology":
  			s.Technology = val
  		case "current protocol":
  			s.Protocol = val
  		case "transfer":
  			s.Transfer = val
  		case "uptime":
  			s.Uptime = val
  		}
  	}

  	return s
  }

params_schema:
  {"params":[{"name":"output","desc":"salida de texto de nordvpn status"}],"output":"struct NordVPNStatus con campos Connected, Hostname, IP, Country, City, Technology"}


id: pdf_simple_report_go_infra
name: pdf_simple_report
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func PdfSimpleReport(title string, sections []ReportSection, outputPath string) (string, error)
tags: ["pdf","report","go","stub","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/pdf_simple_report.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: a1abd99740640a084e80675f78aefa6e429387fe9c5946b0b19eb13cf8b5b46d
description:
  Genera un PDF simple con titulo, secciones de texto y tablas opcionales. Stub activo: requiere go-pdf/fpdf que no esta en el modulo. Para PDFs desde Go añadir la dependencia; para casos complejos usar el builder Python.

example:
  ```go
  sections := []infra.ReportSection{
      {
          Title: "Estado del Registry",
          Text:  "El registry contiene 142 funciones, 38 tipos y 12 proposals pendientes.",
      },
      {
          Title: "Funciones por Dominio",
          Table: &infra.ReportTable{
              Headers: []string{"Dominio", "Funciones", "Tipos"},
              Rows: [][]string{
                  {"core", "45", "12"},
                  {"infra", "38", "8"},
                  {"finance", "22", "6"},
              },
          },
      },
  }

  path, err := infra.PdfSimpleReport("Registry Status Report", sections, "registry_report.pdf")
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println("PDF generado:", path)
  ```

notes:
  Stub activo: retorna `fmt.Errorf("not implemented: ...")` hasta que `go-pdf/fpdf` se añada al modulo. No añadida como dependencia directa porque solo la usa esta funcion y añade peso al modulo. Para generacion de PDFs desde el registry, la solucion recomendada es el builder Python (`pdf_create_py_infra` + `pdf_add_text_py_infra` + `pdf_add_table_py_infra` + `pdf_save_py_infra`) que es mas completo y no requiere dependencias adicionales en Go.

documentation:
  ## Tipos auxiliares

  ```go
  type ReportSection struct {
      Title string
      Text  string
      Table *ReportTable  // nil = sin tabla en esta seccion
  }

  type ReportTable struct {
      Headers []string
      Rows    [][]string
  }
  ```

  ## Activar la implementacion real

  ```bash
  # Añadir dependencia al modulo Go
  go get github.com/go-pdf/fpdf

  # La funcion usa la API de go-pdf/fpdf:
  # pdf := fpdf.New("P", "mm", "A4", "")
  # pdf.AddPage()
  # pdf.SetFont("Arial", "B", 16)
  # pdf.Cell(0, 10, title)
  # ... para cada seccion y tabla
  # pdf.OutputFileAndClose(outputPath)
  ```

code:
  package infra

  import "fmt"

  // ReportSection es una seccion de un reporte PDF simple.
  // Contiene un titulo, texto descriptivo y una tabla opcional.
  type ReportSection struct {
  	Title string
  	Text  string
  	Table *ReportTable
  }

  // ReportTable es una tabla opcional dentro de una seccion de reporte.
  type ReportTable struct {
  	Headers []string
  	Rows    [][]string
  }

  // PdfSimpleReport genera un PDF simple con titulo, secciones y tablas opcionales.
  //
  // Stub: requiere go-pdf/fpdf que no esta en el modulo. Para generar PDFs
  // desde Go usar la alternativa Python (pdf_create_py_infra + pdf_add_table_py_infra
  // + pdf_save_py_infra) o añadir go-pdf/fpdf al go.mod.
  //
  // Para usar la implementacion real:
  //
  //	go get github.com/go-pdf/fpdf
  //
  // y reemplazar el cuerpo de esta funcion con la implementacion completa.
  func PdfSimpleReport(title string, sections []ReportSection, outputPath string) (string, error) {
  	return "", fmt.Errorf(
  		"not implemented: pdf_simple_report requires go-pdf/fpdf. "+
  			"Add it with: go get github.com/go-pdf/fpdf. "+
  			"Alternatively use pdf_create_py_infra + pdf_add_table_py_infra + pdf_save_py_infra for Python",
  	)
  }

params_schema:
  {"params":[{"name":"title","desc":"titulo principal del reporte PDF, aparece en la cabecera del documento"},{"name":"sections","desc":"lista de ReportSection, cada una con Title (string), Text (string) y Table opcional (*ReportTable con Headers y Rows)"},{"name":"outputPath","desc":"ruta del archivo PDF a generar en disco"}],"output":"outputPath con el PDF generado, o string vacio + error si la dependencia no esta disponible"}


id: postgres_open_go_infra
name: postgres_open
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["database","postgres","postgresql","connection","sql"]
uses_functions: []
uses_types: ["db_config_go_infra"]
returns: []
error_type: error_go_core
imports: ["database/sql","github.com/jackc/pgx/v5/stdlib"]
tests: []
file_path: functions/infra/postgres_open.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5f7fabadeb4da54bd153b0e5650385a6b17ee0c35c0b237fdbc5b17ddd190c01
signature:
  func PostgresOpen(host string, port int, user, password, dbname string, sslmode string) (*sql.DB, error)

description:
  Conecta a PostgreSQL construyendo el DSN desde parametros individuales. sslmode por defecto 'disable' si vacio.

example:
  ```go
  db, err := PostgresOpen("localhost", 5432, "user", "secret", "mydb", "disable")
  if err != nil {
      log.Fatal(err)
  }
  defer DBClose(db)

  rows, err := DBQuery(db, "SELECT id, name FROM users WHERE active = $1", true)
  ```

notes:
  Usa el driver `github.com/jackc/pgx/v5/stdlib` registrado como "pgx". Construye DSN con los parametros separados para mayor legibilidad. Para produccion usar `sslmode=require` o `sslmode=verify-full`. Hace ping al abrir para verificar conectividad.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"

  	_ "github.com/jackc/pgx/v5/stdlib"
  )

  // PostgresOpen connects to a PostgreSQL server and returns a *sql.DB.
  // sslmode defaults to "disable" when empty.
  // Constructs a DSN of the form:
  //
  //	host=<host> port=<port> user=<user> password=<password> dbname=<dbname> sslmode=<sslmode>
  func PostgresOpen(host string, port int, user, password, dbname string, sslmode string) (*sql.DB, error) {
  	if sslmode == "" {
  		sslmode = "disable"
  	}
  	dsn := fmt.Sprintf(
  		"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
  		host, port, user, password, dbname, sslmode,
  	)
  	db, err := sql.Open("pgx", dsn)
  	if err != nil {
  		return nil, fmt.Errorf("postgres_open: open: %w", err)
  	}
  	if err := db.Ping(); err != nil {
  		db.Close()
  		return nil, fmt.Errorf("postgres_open: ping %s:%d/%s: %w", host, port, dbname, err)
  	}
  	return db, nil
  }

params_schema:
  {"params":[{"name":"host","desc":"hostname del servidor PostgreSQL"},{"name":"port","desc":"puerto del servidor (5432 por defecto)"},{"name":"user","desc":"nombre de usuario para autenticacion"},{"name":"password","desc":"contraseña para autenticacion"},{"name":"dbname","desc":"nombre de la base de datos"},{"name":"sslmode","desc":"modo SSL (disable, require, verify-full o vacio para disable)"}],"output":"conexion sql.DB abierta a PostgreSQL"}


id: process_kill_go_infra
name: process_kill
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func ProcessKill(handle *ProcessHandle, graceSec int) error
tags: ["process","subprocess","kill","signal","sigterm","sigkill","infra"]
uses_functions: []
uses_types: ["process_handle_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","syscall","time"]
tests: ["kill process"]
test_file_path: functions/infra/process_spawn_test.go
file_path: functions/infra/process_kill.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
props: []
emits: []
variant: []
content_hash: 98f0c1be85c00223abb7006ccd401f3439289a791cf540e2c03993a746ce5075
description:
  Termina un subproceso enviando SIGTERM al process group. Espera hasta graceSec segundos a que el proceso muera voluntariamente. Si sigue vivo, envia SIGKILL. Retorna error solo si la senal no pudo entregarse.

example:
  ```go
  h, err := ProcessSpawn("sleep 300", "", nil, "")
  if err != nil {
      log.Fatal(err)
  }
  // Dar 3 segundos de gracia antes de SIGKILL
  if err := ProcessKill(h, 3); err != nil {
      log.Printf("kill failed: %v", err)
  }
  ```

notes:
  Funcion impura: envia senales al sistema operativo. Usa -handle.Pid (negativo) para direccionar el process group completo, matando tanto al proceso principal como a sus hijos. ESRCH se ignora porque significa que el proceso ya murio, lo cual es el objetivo deseado. Comprueba si el proceso sigue vivo con signal 0 (kill -0) cada 100ms durante el grace period.

code:
  package infra

  import (
  	"fmt"
  	"syscall"
  	"time"
  )

  // ProcessKill sends SIGTERM to the process group of handle, then waits up to
  // graceSec seconds for the process to exit. If it is still alive after the
  // grace period, SIGKILL is sent. Returns an error only if the signal could not
  // be delivered (e.g. the process group does not exist).
  func ProcessKill(handle *ProcessHandle, graceSec int) error {
  	// Send SIGTERM to the process group (negative pid targets the group).
  	if err := syscall.Kill(-handle.Pid, syscall.SIGTERM); err != nil {
  		// ESRCH means the process is already gone — not an error from our view.
  		if err != syscall.ESRCH {
  			return fmt.Errorf("process_kill: sigterm: %w", err)
  		}
  		return nil
  	}

  	// Poll until the process exits or the grace period expires.
  	deadline := time.Now().Add(time.Duration(graceSec) * time.Second)
  	for time.Now().Before(deadline) {
  		// Check if process has exited by sending signal 0 (no-op).
  		err := syscall.Kill(-handle.Pid, 0)
  		if err == syscall.ESRCH {
  			// Process group is gone.
  			return nil
  		}
  		time.Sleep(100 * time.Millisecond)
  	}

  	// Still alive after grace period — escalate to SIGKILL.
  	if err := syscall.Kill(-handle.Pid, syscall.SIGKILL); err != nil {
  		if err != syscall.ESRCH {
  			return fmt.Errorf("process_kill: sigkill: %w", err)
  		}
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"handle","desc":"handle del proceso lanzado por ProcessSpawn"},{"name":"graceSec","desc":"segundos de gracia entre SIGTERM y SIGKILL; 0 envia SIGKILL inmediatamente"}],"output":"nil si el proceso fue terminado correctamente; error si la senal no pudo entregarse"}


id: process_spawn_go_infra
name: process_spawn
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func ProcessSpawn(command string, dir string, env []string, shell string) (*ProcessHandle, error)
tags: ["process","subprocess","spawn","exec","shell","infra"]
uses_functions: []
uses_types: ["process_handle_go_infra"]
returns: ["process_handle_go_infra"]
error_type: error_go_core
imports: ["fmt","os","os/exec","strings","syscall","time"]
test_file_path: functions/infra/process_spawn_test.go
file_path: functions/infra/process_spawn.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
props: []
emits: []
variant: []
content_hash: 6cdf1b1a7aef933452182f6090e2e1e10c935fda7e2563518b9169d2c4e33fa1
description:
  Lanza un subproceso usando el shell indicado. Si shell esta vacio usa 'sh'. Comandos con newlines se tratan como scripts multilinea (se escriben a un archivo temporal). Configura un process group propio (Setpgid) para poder matar todos los hijos con ProcessKill. Captura stdout y stderr via pipes.

example:
  ```go
  h, err := ProcessSpawn("echo hello", "", nil, "")
  if err != nil {
      log.Fatal(err)
  }
  res, err := ProcessWait(h, 10)
  fmt.Println(res.Stdout) // "hello\n"
  ```

tests:
  ["spawn and wait echo","spawn with timeout kills","spawn with env","spawn script","spawn with working dir","kill process"]

notes:
  Funcion impura: hace I/O (crea archivo temporal para scripts, lanza proceso). El process group (Setpgid=true) permite a ProcessKill enviar senales al grupo completo con -Pid, afectando a todos los hijos del proceso lanzado. Para scripts multilinea el archivo temporal queda en el directorio temporal del OS y no se limpia automaticamente.

code:
  package infra

  import (
  	"bytes"
  	"fmt"
  	"os"
  	"os/exec"
  	"strings"
  	"syscall"
  	"time"
  )

  // ProcessSpawn launches a subprocess using the given shell.
  // If shell is empty, "sh" is used. If command contains newlines it is treated
  // as a multi-line script: the content is written to a temp file and executed
  // with `shell <tempfile>`. Otherwise it is executed with `shell -c <command>`.
  // dir sets the working directory (empty = inherit). env sets the environment
  // (nil = inherit parent env). The process group is created with Setpgid so
  // that ProcessKill can target the whole group.
  func ProcessSpawn(command string, dir string, env []string, shell string) (*ProcessHandle, error) {
  	if shell == "" {
  		shell = "sh"
  	}

  	var cmd *exec.Cmd

  	if strings.Contains(command, "\n") {
  		// Multi-line script: write to a temp file and execute it.
  		tmp, err := os.CreateTemp("", "fn-proc-*.sh")
  		if err != nil {
  			return nil, fmt.Errorf("process_spawn: create temp file: %w", err)
  		}
  		if _, err := tmp.WriteString(command); err != nil {
  			_ = os.Remove(tmp.Name())
  			return nil, fmt.Errorf("process_spawn: write temp file: %w", err)
  		}
  		if err := tmp.Close(); err != nil {
  			_ = os.Remove(tmp.Name())
  			return nil, fmt.Errorf("process_spawn: close temp file: %w", err)
  		}
  		cmd = exec.Command(shell, tmp.Name())
  	} else {
  		cmd = exec.Command(shell, "-c", command)
  	}

  	if dir != "" {
  		cmd.Dir = dir
  	}
  	if len(env) > 0 {
  		cmd.Env = env
  	}

  	// New process group so we can kill all children as a group.
  	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

  	// Use buffers instead of pipes to avoid race between Wait() and ReadAll().
  	var stdoutBuf, stderrBuf bytes.Buffer
  	cmd.Stdout = &stdoutBuf
  	cmd.Stderr = &stderrBuf

  	start := time.Now()
  	if err := cmd.Start(); err != nil {
  		return nil, fmt.Errorf("process_spawn: start: %w", err)
  	}

  	return &ProcessHandle{
  		Cmd:       cmd,
  		Pid:       cmd.Process.Pid,
  		StartTime: start,
  		Dir:       dir,
  		stdout:    &stdoutBuf,
  		stderr:    &stderrBuf,
  	}, nil
  }

params_schema:
  {"params":[{"name":"command","desc":"comando shell a ejecutar; si contiene newlines se trata como script multilinea"},{"name":"dir","desc":"directorio de trabajo del proceso hijo; vacio hereda el del proceso padre"},{"name":"env","desc":"variables de entorno en formato KEY=VALUE; nil hereda el entorno del proceso padre"},{"name":"shell","desc":"interprete shell a usar (sh, bash, zsh); vacio usa 'sh'"}],"output":"handle del proceso lanzado con Cmd, Pid, StartTime, Dir y los pipes de I/O"}


id: process_wait_go_infra
name: process_wait
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func ProcessWait(handle *ProcessHandle, timeoutSec int) (ProcessResult, error)
tags: ["process","subprocess","wait","timeout","exec","infra"]
uses_functions: ["process_kill_go_infra"]
uses_types: ["process_handle_go_infra","process_result_go_infra"]
returns: ["process_result_go_infra"]
error_type: error_go_core
imports: ["fmt","io","time"]
test_file_path: functions/infra/process_spawn_test.go
file_path: functions/infra/process_wait.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
props: []
emits: []
variant: []
content_hash: e6730640f1fad474b59cbaffc963a7842dd5f8dcfb033fe97488031986b2dbe5
description:
  Espera a que un subproceso termine y recopila su salida. Lee stdout y stderr completos en goroutines para evitar deadlocks en pipes. Si timeoutSec > 0 y el proceso no termina en ese tiempo, llama a ProcessKill y marca el resultado con Killed=true. Retorna el exit code, salida completa y duracion total.

example:
  ```go
  h, err := ProcessSpawn("sleep 60", "", nil, "")
  if err != nil {
      log.Fatal(err)
  }
  res, err := ProcessWait(h, 5) // timeout de 5 segundos
  if res.Killed {
      fmt.Println("proceso terminado por timeout")
  }
  ```

tests:
  ["spawn and wait echo","spawn with timeout kills","spawn with env","spawn script","spawn with working dir"]

notes:
  Funcion impura: bloquea esperando I/O y posiblemente llama a ProcessKill. Lee stdout y stderr en goroutines separadas antes de llamar a cmd.Wait() para evitar el deadlock clasico donde cmd.Wait() bloquea porque los pipes estan llenos y nadie los lee. El exit code -1 indica que ProcessState no estaba disponible (proceso matado antes de registrar estado).

code:
  package infra

  import (
  	"time"
  )

  // ProcessWait waits for a subprocess to finish and collects its output.
  // If timeoutSec > 0 and the process has not exited by then, ProcessKill is
  // called with graceSec=5 and the result is marked Killed=true.
  func ProcessWait(handle *ProcessHandle, timeoutSec int) (ProcessResult, error) {
  	// Wait for the process in a goroutine.
  	waitCh := make(chan error, 1)
  	go func() {
  		waitCh <- handle.Cmd.Wait()
  	}()

  	killed := false

  	if timeoutSec > 0 {
  		timer := time.NewTimer(time.Duration(timeoutSec) * time.Second)
  		defer timer.Stop()
  		select {
  		case <-timer.C:
  			// Timeout exceeded — kill the process group.
  			_ = ProcessKill(handle, 5)
  			killed = true
  			<-waitCh
  		case <-waitCh:
  		}
  	} else {
  		<-waitCh
  	}

  	// After Wait() returns, buffers are safe to read.
  	exitCode := 0
  	if handle.Cmd.ProcessState != nil {
  		exitCode = handle.Cmd.ProcessState.ExitCode()
  	} else if killed {
  		exitCode = -1
  	}

  	duration := time.Since(handle.StartTime)

  	return ProcessResult{
  		ExitCode:   exitCode,
  		Stdout:     handle.stdout.String(),
  		Stderr:     handle.stderr.String(),
  		DurationMs: duration.Milliseconds(),
  		Killed:     killed,
  	}, nil
  }

params_schema:
  {"params":[{"name":"handle","desc":"handle del proceso lanzado por ProcessSpawn"},{"name":"timeoutSec","desc":"segundos maximos de espera; 0 o negativo espera indefinidamente"}],"output":"resultado con exit code, stdout, stderr, duracion en ms y flag de killed"}


id: rate_limit_headers_go_infra
name: rate_limit_headers
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func RateLimitHeaders(result RateLimitResult, limit int) http.Header
tags: ["rate_limit","http","headers","middleware","infra"]
uses_functions: []
uses_types: ["RateLimitResult_go_infra"]
returns: []
imports: ["math","net/http","strconv"]
test_file_path: functions/infra/rate_limit_headers_test.go
file_path: functions/infra/rate_limit_headers.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 44247eece4baec8a93f87058e47849a2b6bbd5dcd0cf209b83d53e3586eef90a
description:
  Construye los headers IETF estandar de rate limiting (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset y Retry-After cuando se rechaza). Funcion pura, solo formatea.

example:
  ```go
  result := RateLimiterCheck(rl, "192.168.1.1")
  headers := RateLimitHeaders(result, 20)
  for k, v := range headers {
      w.Header()[k] = v
  }
  ```

tests:
  ["setea X-RateLimit-Limit con el burst","setea X-RateLimit-Remaining con result.Remaining","setea X-RateLimit-Reset como timestamp unix","incluye Retry-After cuando no esta permitido","no incluye Retry-After cuando esta permitido"]

notes:
  Funcion pura — sin I/O, sin estado, deterministica para una misma entrada. Sigue `draft-ietf-httpapi-ratelimit-headers`. `Retry-After` se redondea hacia arriba en segundos enteros (RFC 7231) y nunca se setea por debajo de 1 (sino el cliente reintenta inmediatamente). El timestamp en `X-RateLimit-Reset` es Unix epoch en segundos.

code:
  package infra

  import (
  	"math"
  	"net/http"
  	"strconv"
  )

  // RateLimitHeaders construye los headers IETF estandar de rate limiting a partir de un resultado.
  // Setea X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset y, si no esta permitido, Retry-After.
  // limit es la capacidad total (burst) que se anuncia al cliente.
  // Funcion pura — solo formatea, no hace I/O.
  func RateLimitHeaders(result RateLimitResult, limit int) http.Header {
  	h := http.Header{}
  	h.Set("X-RateLimit-Limit", strconv.Itoa(limit))
  	h.Set("X-RateLimit-Remaining", strconv.Itoa(result.Remaining))
  	h.Set("X-RateLimit-Reset", strconv.FormatInt(result.ResetAt.Unix(), 10))
  	if !result.Allowed {
  		// Retry-After segun RFC 7231: numero entero de segundos.
  		retryAfter := int(math.Ceil(result.RetryAfter))
  		if retryAfter < 1 {
  			retryAfter = 1
  		}
  		h.Set("Retry-After", strconv.Itoa(retryAfter))
  	}
  	return h
  }

params_schema:
  {"params":[{"name":"result","desc":"RateLimitResult devuelto por RateLimiterCheck"},{"name":"limit","desc":"capacidad total (burst) anunciada al cliente en X-RateLimit-Limit"}],"output":"http.Header con los 3 headers X-RateLimit-* y, cuando result.Allowed es false, Retry-After (segundos enteros)"}


id: rate_limit_middleware_go_infra
name: rate_limit_middleware
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func RateLimitMiddleware(rl *RateLimiter) Middleware
tags: ["rate_limit","http","middleware","ip","server","infra"]
uses_functions: ["rate_limiter_by_key_go_infra"]
uses_types: ["RateLimiter_go_infra","Middleware_go_infra"]
returns: ["Middleware_go_infra"]
error_type: error_go_core
imports: ["net","net/http","strings"]
test_file_path: functions/infra/rate_limit_middleware_test.go
file_path: functions/infra/rate_limit_middleware.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 66160fdeae78a2621a56326691c22bd4dc704165ebd9151eecc307a171a83c49
description:
  Middleware HTTP que aplica rate limiting por IP del cliente. Extrae IP de X-Forwarded-For, X-Real-IP o RemoteAddr. Responde 429 con headers Retry-After y X-RateLimit-* cuando se supera el limite.

example:
  ```go
  rl := RateLimiterNew(10, 20)
  chain := HTTPMiddlewareChain(
      HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST"}),
      RateLimitMiddleware(rl),
      HTTPLoggerMiddleware(os.Stderr),
  )
  http.ListenAndServe(":8080", chain(mux))
  ```

tests:
  ["permite request bajo el limite","responde 429 cuando se excede el limite","IPs distintas tienen buckets independientes","X-Forwarded-For tiene prioridad sobre RemoteAddr","responde con Retry-After header en 429"]

notes:
  Funcion impura — el middleware producido muta el estado del limiter en cada request. Internamente delega en `RateLimiterByKey` con un keyFunc que extrae la IP segun cabeceras estandar de proxy. La extraccion de IP confia en X-Forwarded-For: para uso publico real habria que validar contra trusted proxies para evitar spoofing. Para entornos internos (deploy con Traefik/Nginx delante) es seguro.

code:
  package infra

  import (
  	"net"
  	"net/http"
  	"strings"
  )

  // rateLimitClientIP extrae la IP del cliente del request.
  // Prioridad: X-Forwarded-For (primer valor) > X-Real-IP > RemoteAddr.
  // Para X-Forwarded-For multi-hop solo se usa el primer IP (cliente original).
  func rateLimitClientIP(r *http.Request) string {
  	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
  		parts := strings.Split(xff, ",")
  		ip := strings.TrimSpace(parts[0])
  		if ip != "" {
  			return ip
  		}
  	}
  	if xri := r.Header.Get("X-Real-IP"); xri != "" {
  		return strings.TrimSpace(xri)
  	}
  	host, _, err := net.SplitHostPort(r.RemoteAddr)
  	if err == nil {
  		return host
  	}
  	return r.RemoteAddr
  }

  // RateLimitMiddleware retorna un Middleware que aplica rate limiting por IP del cliente.
  // Si el request supera el limite responde 429 con headers Retry-After y X-RateLimit-*.
  // La IP se extrae con prioridad X-Forwarded-For > X-Real-IP > RemoteAddr.
  func RateLimitMiddleware(rl *RateLimiter) Middleware {
  	return RateLimiterByKey(rl, rateLimitClientIP)
  }

params_schema:
  {"params":[{"name":"rl","desc":"puntero al RateLimiter compartido entre todos los requests"}],"output":"Middleware que limita por IP del cliente y responde 429 cuando se excede"}


id: rate_limiter_by_key_go_infra
name: rate_limiter_by_key
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func RateLimiterByKey(rl *RateLimiter, keyFunc func(r *http.Request) string) Middleware
tags: ["rate_limit","http","middleware","custom","key","server","infra"]
uses_functions: ["rate_limiter_check_go_infra","rate_limit_headers_go_infra","http_error_response_go_infra"]
uses_types: ["RateLimiter_go_infra","Middleware_go_infra","HTTPError_go_infra"]
returns: ["Middleware_go_infra"]
error_type: error_go_core
imports: ["net/http"]
test_file_path: functions/infra/rate_limiter_by_key_test.go
file_path: functions/infra/rate_limiter_by_key.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 1902fd34118d755a219232e2cebd8ac08e1fa90f80fe69b4b68fb9ac3f941409
description:
  Middleware HTTP configurable que aplica rate limiting con un extractor de clave custom. Permite limitar por API key, user ID, header arbitrario, etc. Si keyFunc devuelve cadena vacia el request pasa sin limit.

example:
  ```go
  // Limit por API key
  rl := RateLimiterNew(100, 200)
  mw := RateLimiterByKey(rl, func(r *http.Request) string {
      return r.Header.Get("X-API-Key")
  })

  // Limit por user ID extraido del JWT (suponiendo middleware previo que lo setea)
  mwUser := RateLimiterByKey(rl, func(r *http.Request) string {
      return r.Context().Value("user_id").(string)
  })
  ```

tests:
  ["aplica limit por la clave devuelta por keyFunc","key vacia salta el limit","responde 429 con body JSON al exceder","headers X-RateLimit-* siempre presentes en respuesta"]

notes:
  Funcion impura — el middleware muta el estado del limiter. Reutiliza `RateLimiterCheck`, `RateLimitHeaders` y `HTTPErrorResponse` del registry. Si el keyFunc devuelve "" se interpreta como "sin clave identificable" y se deja pasar el request: util para endpoints publicos donde solo se quiere limitar requests autenticados. La respuesta 429 sigue el formato JSON estandar `{"status":429,"code":"rate_limited","message":"too many requests"}`.

code:
  package infra

  import (
  	"net/http"
  )

  // RateLimiterByKey retorna un Middleware que aplica rate limiting usando keyFunc para extraer la clave del request.
  // Permite limitar por API key, user ID, o cualquier dimension custom.
  // Cuando keyFunc devuelve "" no se aplica limit (request pasa sin tocar el bucket).
  // Cuando se supera el limite responde 429 con headers X-RateLimit-* y Retry-After.
  func RateLimiterByKey(rl *RateLimiter, keyFunc func(r *http.Request) string) Middleware {
  	return func(next http.Handler) http.Handler {
  		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			key := keyFunc(r)
  			if key == "" {
  				// Sin clave no se aplica limit.
  				next.ServeHTTP(w, r)
  				return
  			}

  			result := RateLimiterCheck(rl, key)
  			headers := RateLimitHeaders(result, rl.burst)
  			for k, v := range headers {
  				w.Header()[k] = v
  			}

  			if !result.Allowed {
  				HTTPErrorResponse(w, HTTPError{
  					Status:  http.StatusTooManyRequests,
  					Code:    "rate_limited",
  					Message: "too many requests",
  				})
  				return
  			}

  			next.ServeHTTP(w, r)
  		})
  	}
  }

params_schema:
  {"params":[{"name":"rl","desc":"puntero al RateLimiter compartido entre todos los requests"},{"name":"keyFunc","desc":"funcion que extrae la clave del request (API key, user ID, etc.). Cadena vacia salta el limit"}],"output":"Middleware que aplica rate limit segun keyFunc, responde 429 con HTTPError JSON al exceder"}


id: rate_limiter_check_go_infra
name: rate_limiter_check
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func RateLimiterCheck(rl *RateLimiter, key string) RateLimitResult
tags: ["rate_limit","http","middleware","token_bucket","check","infra"]
uses_functions: []
uses_types: ["RateLimiter_go_infra","RateLimitResult_go_infra"]
returns: ["RateLimitResult_go_infra"]
error_type: error_go_core
imports: ["math","time"]
test_file_path: functions/infra/rate_limiter_check_test.go
file_path: functions/infra/rate_limiter_check.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 7a8cc528e778a572cc8095c318f913623b1d8f6758621e936001f1191f157f87
description:
  Evalua un request del cliente identificado por key contra el token bucket. Recarga tokens segun el tiempo transcurrido y consume uno si esta permitido. Seguro para uso concurrente (mutex interno).

example:
  ```go
  rl := RateLimiterNew(10, 20)

  result := RateLimiterCheck(rl, "192.168.1.1")
  if !result.Allowed {
      // sleep(result.RetryAfter * time.Second) o 429
  }
  ```

tests:
  ["primer request siempre allowed con burst-1 remaining","consumir todos los tokens bloquea siguiente request","los tokens se recargan con el paso del tiempo","retryAfter es positivo cuando se rechaza","keys distintas tienen buckets independientes"]

notes:
  Funcion impura porque depende de tiempo wall-clock (`time.Now`) y muta el estado del limiter. Algoritmo token bucket clasico: cada bucket se recarga linealmente a `rate` tokens/segundo hasta `burst`. Un request consume 1 token. La primera vez que aparece una key se crea el bucket lleno (descontando ya el primer token). El mutex asegura uso seguro desde multiples goroutines.

code:
  package infra

  import (
  	"math"
  	"time"
  )

  // RateLimiterCheck evalua si un request identificado por key puede consumir un token.
  // Recarga tokens segun el tiempo transcurrido desde el ultimo check (token bucket).
  // Retorna RateLimitResult con Allowed, Remaining, ResetAt y RetryAfter.
  // Es seguro para uso concurrente.
  func RateLimiterCheck(rl *RateLimiter, key string) RateLimitResult {
  	rl.mu.Lock()
  	defer rl.mu.Unlock()

  	now := time.Now()
  	client, ok := rl.clients[key]
  	if !ok {
  		// Primera vez para esta key: bucket lleno menos el token consumido.
  		client = &rateLimiterClient{tokens: float64(rl.burst), lastSeen: now}
  		rl.clients[key] = client
  	} else {
  		// Recargar tokens segun tiempo transcurrido.
  		elapsed := now.Sub(client.lastSeen).Seconds()
  		client.tokens = math.Min(float64(rl.burst), client.tokens+elapsed*rl.rate)
  		client.lastSeen = now
  	}

  	if client.tokens >= 1 {
  		client.tokens -= 1
  		remaining := int(math.Floor(client.tokens))
  		// ResetAt: cuando volvera a estar lleno.
  		missing := float64(rl.burst) - client.tokens
  		secondsToFull := missing / rl.rate
  		return RateLimitResult{
  			Allowed:    true,
  			Remaining:  remaining,
  			ResetAt:    now.Add(time.Duration(secondsToFull * float64(time.Second))),
  			RetryAfter: 0,
  		}
  	}

  	// Sin tokens suficientes — calcular cuanto esperar.
  	needed := 1 - client.tokens
  	retryAfter := needed / rl.rate
  	missing := float64(rl.burst) - client.tokens
  	secondsToFull := missing / rl.rate
  	return RateLimitResult{
  		Allowed:    false,
  		Remaining:  0,
  		ResetAt:    now.Add(time.Duration(secondsToFull * float64(time.Second))),
  		RetryAfter: retryAfter,
  	}
  }

params_schema:
  {"params":[{"name":"rl","desc":"puntero al RateLimiter creado con RateLimiterNew"},{"name":"key","desc":"identificador del cliente (IP, API key, user ID, etc.)"}],"output":"RateLimitResult con Allowed, Remaining, ResetAt y RetryAfter calculados a partir del estado del bucket"}


id: rate_limiter_cleanup_go_infra
name: rate_limiter_cleanup
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func RateLimiterCleanup(rl *RateLimiter, maxAge time.Duration, interval time.Duration) func()
tags: ["rate_limit","http","middleware","cleanup","gc","goroutine","infra"]
uses_functions: []
uses_types: ["RateLimiter_go_infra"]
returns: []
error_type: error_go_core
imports: ["time"]
test_file_path: functions/infra/rate_limiter_cleanup_test.go
file_path: functions/infra/rate_limiter_cleanup.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: a00461aceebf1e89347d29c51bbaea93fb3c39a3d1a7913246f4d79485c20166
description:
  Arranca una goroutine que purga periodicamente las entries del RateLimiter sin actividad reciente. Retorna una funcion para detener el GC sin leaks de goroutine.

example:
  ```go
  rl := RateLimiterNew(10, 20)
  stop := RateLimiterCleanup(rl, 10*time.Minute, 5*time.Minute)
  defer stop()

  // Servir requests... el GC limpia entries inactivas en background.
  ```

tests:
  ["purga entries con lastSeen mas antiguo que maxAge","no purga entries recientes","stop detiene la goroutine sin panic","stop es idempotente"]

notes:
  Funcion impura — arranca una goroutine, depende de tiempo wall-clock. La goroutine se cierra al invocar la funcion retornada (cierra un channel interno). Sin invocar stop la goroutine vive hasta que el proceso termina (leak). Valores <= 0 para interval o maxAge se sustituyen por defaults seguros (1 min / 10 min). El barrido toma el mutex del limiter — un interval muy corto puede causar contencion en limiters con muchas keys.

code:
  package infra

  import "time"

  // RateLimiterCleanup arranca una goroutine que purga periodicamente las entries
  // del limiter que no han tenido actividad en maxAge.
  // Retorna una funcion que detiene la goroutine cuando se invoca.
  // interval controla la frecuencia del barrido. maxAge es la edad maxima sin actividad.
  func RateLimiterCleanup(rl *RateLimiter, maxAge time.Duration, interval time.Duration) func() {
  	if interval <= 0 {
  		interval = time.Minute
  	}
  	if maxAge <= 0 {
  		maxAge = 10 * time.Minute
  	}

  	stop := make(chan struct{})
  	go func() {
  		ticker := time.NewTicker(interval)
  		defer ticker.Stop()
  		for {
  			select {
  			case <-stop:
  				return
  			case now := <-ticker.C:
  				rl.mu.Lock()
  				for key, client := range rl.clients {
  					if now.Sub(client.lastSeen) > maxAge {
  						delete(rl.clients, key)
  					}
  				}
  				rl.mu.Unlock()
  			}
  		}
  	}()

  	var stopped bool
  	return func() {
  		if stopped {
  			return
  		}
  		stopped = true
  		close(stop)
  	}
  }

params_schema:
  {"params":[{"name":"rl","desc":"puntero al RateLimiter cuyas entries se purgaran"},{"name":"maxAge","desc":"edad maxima sin actividad antes de purgar una entry (ej: 10*time.Minute)"},{"name":"interval","desc":"frecuencia del barrido de purga (ej: 5*time.Minute). 0 usa default 1 minuto"}],"output":"funcion stop sin argumentos que detiene la goroutine de cleanup. Idempotente — llamarla varias veces es seguro"}


id: rate_limiter_new_go_infra
name: rate_limiter_new
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func RateLimiterNew(rate float64, burst int) *RateLimiter
tags: ["rate_limit","http","middleware","token_bucket","infra"]
uses_functions: []
uses_types: ["RateLimiter_go_infra"]
returns: ["RateLimiter_go_infra"]
error_type: error_go_core
imports: []
test_file_path: functions/infra/rate_limiter_new_test.go
file_path: functions/infra/rate_limiter_new.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 401e610cb1e146b9317025a0b18c5fc204f8d2cdef0e0fe6d3bd7e543c7d14bd
description:
  Crea un RateLimiter token-bucket in-memory vacio. rate son los tokens recargados por segundo, burst es la capacidad maxima del bucket. Valores <= 0 se sustituyen por 1 como minimo seguro.

example:
  ```go
  // 10 req/s sostenidos, hasta 20 en rafaga
  rl := RateLimiterNew(10, 20)

  // 1 req/s, sin rafaga
  strict := RateLimiterNew(1, 1)
  ```

tests:
  ["crea limiter con rate y burst configurados","valores cero se sustituyen por 1","el mapa de clientes empieza vacio"]

notes:
  Funcion impura porque retorna un valor con estado mutable (mapa interno). No hace I/O ni network. La validacion de rate y burst evita panics por configuracion invalida (division por cero, buckets imposibles). Para iniciar el GC de entries inactivas usar `RateLimiterCleanup`.

code:
  package infra

  // RateLimiterNew crea un RateLimiter token-bucket vacio con la tasa y rafaga indicadas.
  // rate son los tokens recargados por segundo (sostenido).
  // burst es la capacidad maxima del bucket (rafaga puntual).
  // Si rate o burst son <= 0 se usa 1 como minimo seguro.
  func RateLimiterNew(rate float64, burst int) *RateLimiter {
  	if rate <= 0 {
  		rate = 1
  	}
  	if burst <= 0 {
  		burst = 1
  	}
  	return &RateLimiter{
  		rate:    rate,
  		burst:   burst,
  		clients: make(map[string]*rateLimiterClient),
  	}
  }

params_schema:
  {"params":[{"name":"rate","desc":"tokens recargados por segundo (tasa sostenida, ej: 10 = 10 req/s)"},{"name":"burst","desc":"capacidad maxima del bucket (rafaga maxima permitida en un instante, ej: 20)"}],"output":"puntero a RateLimiter inicializado con mapa vacio de clientes, listo para usar con RateLimiterCheck"}


id: s3_download_go_infra
name: s3_download
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func S3Download(cfg S3Config, key string, dst io.Writer) error
tags: ["s3","download","storage","cloud","stub","infra"]
uses_functions: []
uses_types: ["S3Config_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","io"]
tests: []
file_path: functions/infra/s3_download.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 4fc78ef72fd6a664027a6710b5b37f07a3d76cbc5a2b25c72d99f57d4f7cccfc
description:
  STUB. Descarga el objeto key desde un bucket S3-compatible y escribe su contenido en dst. Permite streaming directo a disco o a un HTTP response. Requiere github.com/aws/aws-sdk-go-v2.

example:
  ```go
  f, _ := os.Create("./local/photo.png")
  defer f.Close()
  err := S3Download(cfg, "images/photo.png", f)
  ```

notes:
  Soporta streaming — el `dst` puede ser un `os.File`, `http.ResponseWriter`, `bytes.Buffer`, o cualquier `io.Writer`. Para archivos grandes, escribir directamente al cliente HTTP evita cargarlo todo en memoria.

documentation:
  ## Estado

  **Stub**. Para activar la implementacion real:

  1. Añadir las mismas dependencias que `s3_upload`.
  2. Reemplazar el cuerpo del stub por:
     ```go
     func S3Download(cfg S3Config, key string, dst io.Writer) error {
         ctx := context.Background()
         awsCfg, err := awscfg.LoadDefaultConfig(ctx,
             awscfg.WithRegion(cfg.Region),
             awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
                 cfg.AccessKey, cfg.SecretKey, "")),
         )
         if err != nil {
             return fmt.Errorf("s3_download: aws config: %w", err)
         }
         client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
             o.UsePathStyle = true
             if cfg.Endpoint != "" {
                 scheme := "http"
                 if cfg.UseSSL {
                     scheme = "https"
                 }
                 o.BaseEndpoint = aws.String(fmt.Sprintf("%s://%s", scheme, cfg.Endpoint))
             }
         })
         out, err := client.GetObject(ctx, &s3.GetObjectInput{
             Bucket: aws.String(cfg.Bucket),
             Key:    aws.String(key),
         })
         if err != nil {
             return fmt.Errorf("s3_download: get object: %w", err)
         }
         defer out.Body.Close()
         _, err = io.Copy(dst, out.Body)
         return err
     }
     ```

code:
  package infra

  import (
  	"fmt"
  	"io"
  )

  // S3Download descarga el objeto identificado por key desde el bucket S3-compatible
  // y escribe su contenido en dst.
  //
  // STUB: la implementacion real requiere github.com/aws/aws-sdk-go-v2.
  func S3Download(cfg S3Config, key string, dst io.Writer) error {
  	_ = cfg
  	_ = key
  	_ = dst
  	return fmt.Errorf("s3_download: not implemented (requiere github.com/aws/aws-sdk-go-v2)")
  }

params_schema:
  {"params":[{"name":"cfg","desc":"S3Config con endpoint, bucket, credenciales y region"},{"name":"key","desc":"key del objeto a descargar (ej: \"images/foto.png\")"},{"name":"dst","desc":"writer donde se escribe el contenido (ej: os.File, http.ResponseWriter, bytes.Buffer)"}],"output":"nil si la descarga fue exitosa, error si fallo. STUB actual retorna siempre error \"not implemented\""}


id: s3_presign_url_go_infra
name: s3_presign_url
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func S3PresignURL(cfg S3Config, key string, expiry time.Duration) (string, error)
tags: ["s3","presign","url","storage","cloud","stub","infra"]
uses_functions: []
uses_types: ["S3Config_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","time"]
tests: []
file_path: functions/infra/s3_presign_url.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 4aa6d8a88b188fa73377cb7cf60403f6154fe079aa55fa6053861215ff1d79c2
description:
  STUB. Genera una URL presignada para download (GET) del objeto key en un bucket S3-compatible, valida durante expiry. Util para descargas directas sin pasar por el servidor. Requiere github.com/aws/aws-sdk-go-v2.

example:
  ```go
  url, err := S3PresignURL(cfg, "images/foto.png", time.Hour)
  // url es valida 1 hora; el cliente puede descargar sin credenciales
  fmt.Println(url)
  ```

notes:
  Para uploads directos (PUT/POST presigned), se usaria `psClient.PresignPutObject` analogamente. Las URLs presignadas heredan los permisos de las credenciales que las generaron — no incrementan privilegios.

documentation:
  ## Estado

  **Stub**. Para activar la implementacion real:

  1. Añadir las mismas dependencias que `s3_upload`.
  2. Reemplazar el cuerpo del stub por:
     ```go
     func S3PresignURL(cfg S3Config, key string, expiry time.Duration) (string, error) {
         ctx := context.Background()
         awsCfg, err := awscfg.LoadDefaultConfig(ctx,
             awscfg.WithRegion(cfg.Region),
             awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
                 cfg.AccessKey, cfg.SecretKey, "")),
         )
         if err != nil {
             return "", fmt.Errorf("s3_presign_url: aws config: %w", err)
         }
         client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
             o.UsePathStyle = true
             if cfg.Endpoint != "" {
                 scheme := "http"
                 if cfg.UseSSL {
                     scheme = "https"
                 }
                 o.BaseEndpoint = aws.String(fmt.Sprintf("%s://%s", scheme, cfg.Endpoint))
             }
         })
         psClient := s3.NewPresignClient(client)
         req, err := psClient.PresignGetObject(ctx, &s3.GetObjectInput{
             Bucket: aws.String(cfg.Bucket),
             Key:    aws.String(key),
         }, s3.WithPresignExpires(expiry))
         if err != nil {
             return "", fmt.Errorf("s3_presign_url: presign: %w", err)
         }
         return req.URL, nil
     }
     ```

code:
  package infra

  import (
  	"fmt"
  	"time"
  )

  // S3PresignURL genera una URL presignada para download (GET) del objeto key,
  // valida durante expiry.
  //
  // STUB: la implementacion real requiere github.com/aws/aws-sdk-go-v2 (s3.PresignClient).
  func S3PresignURL(cfg S3Config, key string, expiry time.Duration) (string, error) {
  	_ = cfg
  	_ = key
  	_ = expiry
  	return "", fmt.Errorf("s3_presign_url: not implemented (requiere github.com/aws/aws-sdk-go-v2)")
  }

params_schema:
  {"params":[{"name":"cfg","desc":"S3Config con endpoint, bucket, credenciales y region"},{"name":"key","desc":"key del objeto en el bucket (ej: \"images/foto.png\")"},{"name":"expiry","desc":"duracion de validez de la URL (ej: time.Hour)"}],"output":"URL presignada como string. Empty + error si falla la generacion. STUB actual retorna siempre error \"not implemented\""}


id: s3_upload_go_infra
name: s3_upload
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func S3Upload(cfg S3Config, key string, data io.Reader, contentType string) error
tags: ["s3","upload","storage","cloud","stub","infra"]
uses_functions: []
uses_types: ["S3Config_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","io","time"]
tests: []
file_path: functions/infra/s3_upload.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 70577b233094caca876240e86b9c6d566869f5ed6c94fd808ee4f2818f44e33c
description:
  STUB. Sube data a un bucket S3-compatible (AWS S3, MinIO, etc) bajo la key indicada con el Content-Type dado. La implementacion real requiere github.com/aws/aws-sdk-go-v2.

example:
  ```go
  cfg := S3Config{
      Endpoint:  "s3.amazonaws.com",
      Bucket:    "mi-bucket",
      AccessKey: os.Getenv("S3_ACCESS_KEY"),
      SecretKey: os.Getenv("S3_SECRET_KEY"),
      Region:    "us-east-1",
      UseSSL:    true,
  }
  f, _ := os.Open("./uploads/photo.png")
  defer f.Close()
  err := S3Upload(cfg, "images/photo.png", f, "image/png")
  ```

notes:
  Compatible con AWS S3, MinIO, Wasabi y otros S3-compatible. El campo `UsePathStyle = true` es necesario para MinIO y para algunos endpoints custom; AWS S3 nativo soporta tanto path-style como virtual-hosted-style.

documentation:
  ## Estado

  **Stub**. Para activar la implementacion real:

  1. Anadir dependencias:
     ```bash
     go get github.com/aws/aws-sdk-go-v2/aws
     go get github.com/aws/aws-sdk-go-v2/config
     go get github.com/aws/aws-sdk-go-v2/credentials
     go get github.com/aws/aws-sdk-go-v2/service/s3
     ```
  2. Reemplazar el cuerpo del stub por:
     ```go
     import (
         "context"
         "github.com/aws/aws-sdk-go-v2/aws"
         awscfg "github.com/aws/aws-sdk-go-v2/config"
         "github.com/aws/aws-sdk-go-v2/credentials"
         "github.com/aws/aws-sdk-go-v2/service/s3"
     )

     func S3Upload(cfg S3Config, key string, data io.Reader, contentType string) error {
         ctx := context.Background()
         awsCfg, err := awscfg.LoadDefaultConfig(ctx,
             awscfg.WithRegion(cfg.Region),
             awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
                 cfg.AccessKey, cfg.SecretKey, "")),
         )
         if err != nil {
             return fmt.Errorf("s3_upload: aws config: %w", err)
         }
         client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
             o.UsePathStyle = true
             if cfg.Endpoint != "" {
                 scheme := "http"
                 if cfg.UseSSL {
                     scheme = "https"
                 }
                 o.BaseEndpoint = aws.String(fmt.Sprintf("%s://%s", scheme, cfg.Endpoint))
             }
         })
         _, err = client.PutObject(ctx, &s3.PutObjectInput{
             Bucket:      aws.String(cfg.Bucket),
             Key:         aws.String(key),
             Body:        data,
             ContentType: aws.String(contentType),
         })
         return err
     }
     ```

code:
  package infra

  import (
  	"fmt"
  	"io"
  )

  // S3Upload sube data a un bucket S3-compatible bajo la key dada, con el
  // Content-Type indicado.
  //
  // STUB: la implementacion real requiere github.com/aws/aws-sdk-go-v2 (S3 client +
  // credentials provider). Ver el .md para el codigo completo a habilitar cuando se
  // añada la dependencia.
  func S3Upload(cfg S3Config, key string, data io.Reader, contentType string) error {
  	_ = cfg
  	_ = key
  	_ = data
  	_ = contentType
  	return fmt.Errorf("s3_upload: not implemented (requiere github.com/aws/aws-sdk-go-v2)")
  }

params_schema:
  {"params":[{"name":"cfg","desc":"S3Config con endpoint, bucket, credenciales y region"},{"name":"key","desc":"key del objeto en el bucket (ej: \"images/foto.png\")"},{"name":"data","desc":"reader con el contenido binario a subir"},{"name":"contentType","desc":"MIME type del objeto (ej: \"image/png\")"}],"output":"nil si la subida fue exitosa, error si fallo. STUB actual retorna siempre error \"not implemented\""}


id: scaffold_wails_app_go_infra
name: scaffold_wails_app
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: ScaffoldWailsApp(ctx context.Context, cfg ScaffoldWailsAppConfig) error
tags: ["wails","scaffold","desktop","project","generator"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os","os/exec","path/filepath","text/template","fmt","strings","context"]
tests: []
file_path: functions/infra/scaffold_wails_app.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 2afe52916ed12a7a8a4c391df2fc2591c9bfeb90a4da8edffb623a33013f124d
description:
  Genera proyecto Wails completo: main.go con embed, app.go con bindings base, wails.json, go.mod, y frontend vinculado a Frontend_Library.

example:
  ```go
  ScaffoldWailsApp(ctx, ScaffoldWailsAppConfig{
      Name:   "my-dashboard",
      Dir:    "/home/user/projects/my-dashboard",
      Title:  "My Dashboard",
      Width:  1280,
      Height: 800,
      Author: "Lucas",
  })
  ```

notes:
  Pipeline que compone: generación de templates Go + creación de frontend via create-project.sh de Frontend_Library + go mod tidy. Incluye GenerateAppBinding para crear métodos de binding programáticamente.

code:
  package infra

  import (
  	"context"
  	"fmt"
  	"os"
  	"os/exec"
  	"path/filepath"
  	"strings"
  	"text/template"
  )

  // ScaffoldWailsAppConfig configura la generación del proyecto Wails.
  type ScaffoldWailsAppConfig struct {
  	Name        string // Nombre del proyecto
  	Dir         string // Directorio destino
  	Title       string // Título de la ventana
  	Width       int    // Ancho de la ventana (default 1024)
  	Height      int    // Alto de la ventana (default 768)
  	Author      string // Nombre del autor
  	FrontendLib string // Path a la frontend library (default ~/.local_agentes/frontend/frontend)
  }

  const mainGoTpl = `package main

  import (
  	"embed"

  	"github.com/wailsapp/wails/v2"
  	"github.com/wailsapp/wails/v2/pkg/options"
  	"github.com/wailsapp/wails/v2/pkg/options/assetserver"
  )

  //go:embed all:frontend/dist
  var assets embed.FS

  func main() {
  	app := NewApp()

  	err := wails.Run(&options.App{
  		Title:  "{{.Title}}",
  		Width:  {{.Width}},
  		Height: {{.Height}},
  		AssetServer: &assetserver.Options{
  			Assets: assets,
  		},
  		BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
  		OnStartup:        app.startup,
  		Bind: []interface{}{
  			app,
  		},
  	})

  	if err != nil {
  		println("Error:", err.Error())
  	}
  }
  `

  const appGoTpl = `package main

  import (
  	"context"

  	"github.com/wailsapp/wails/v2/pkg/runtime"
  )

  // App struct — cada método público se expone como binding IPC al frontend.
  type App struct {
  	ctx context.Context
  }

  func NewApp() *App {
  	return &App{}
  }

  func (a *App) startup(ctx context.Context) {
  	a.ctx = ctx
  }

  // EmitEvent emite un evento al frontend.
  func (a *App) EmitEvent(eventName string, data interface{}) {
  	runtime.EventsEmit(a.ctx, eventName, data)
  }

  // Ping verifica que el IPC funciona.
  func (a *App) Ping() string {
  	return "pong"
  }
  `

  const wailsJSONTpl = `{
    "$schema": "https://wails.io/schemas/config.v2.json",
    "name": "{{.Name}}",
    "outputfilename": "{{.Name}}",
    "frontend:dir": "./frontend",
    "frontend:install": "pnpm install",
    "frontend:build": "pnpm run build",
    "frontend:dev:watcher": "pnpm run dev",
    "frontend:dev:serverUrl": "auto",
    "wailsjsdir": "./frontend/src/wailsjs",
    "author": {
      "name": "{{.Author}}"
    }
  }
  `

  const goModTpl = `module {{.Name}}

  go 1.23

  require github.com/wailsapp/wails/v2 v2.11.0
  `

  // ScaffoldWailsApp genera la estructura base de un proyecto Wails con frontend vinculado.
  func ScaffoldWailsApp(ctx context.Context, cfg ScaffoldWailsAppConfig) error {
  	if cfg.Name == "" {
  		return fmt.Errorf("name is required")
  	}
  	if cfg.Dir == "" {
  		cfg.Dir = cfg.Name
  	}
  	if cfg.Title == "" {
  		cfg.Title = cfg.Name
  	}
  	if cfg.Width == 0 {
  		cfg.Width = 1024
  	}
  	if cfg.Height == 0 {
  		cfg.Height = 768
  	}
  	if cfg.Author == "" {
  		cfg.Author = "Agent"
  	}
  	if cfg.FrontendLib == "" {
  		home, _ := os.UserHomeDir()
  		cfg.FrontendLib = filepath.Join(home, ".local_agentes", "frontend", "frontend")
  	}

  	// Crear directorio
  	if err := os.MkdirAll(cfg.Dir, 0755); err != nil {
  		return fmt.Errorf("creating dir: %w", err)
  	}

  	// Generar archivos Go
  	files := map[string]string{
  		"main.go":    mainGoTpl,
  		"app.go":     appGoTpl,
  		"wails.json": wailsJSONTpl,
  		"go.mod":     goModTpl,
  	}

  	for name, tpl := range files {
  		path := filepath.Join(cfg.Dir, name)
  		t, err := template.New(name).Parse(tpl)
  		if err != nil {
  			return fmt.Errorf("parsing template %s: %w", name, err)
  		}
  		f, err := os.Create(path)
  		if err != nil {
  			return fmt.Errorf("creating %s: %w", name, err)
  		}
  		if err := t.Execute(f, cfg); err != nil {
  			f.Close()
  			return fmt.Errorf("executing template %s: %w", name, err)
  		}
  		f.Close()
  	}

  	// Crear frontend como link simbólico o copiar template
  	frontendDir := filepath.Join(cfg.Dir, "frontend")
  	createProjectScript := filepath.Join(filepath.Dir(cfg.FrontendLib), "..", "scripts", "create-project.sh")

  	if _, err := os.Stat(createProjectScript); err == nil {
  		// Usar el script de Frontend_Library
  		cmd := exec.CommandContext(ctx, "bash", createProjectScript, cfg.Name, frontendDir, "--wails")
  		cmd.Stdout = os.Stdout
  		cmd.Stderr = os.Stderr
  		if err := cmd.Run(); err != nil {
  			return fmt.Errorf("creating frontend: %w", err)
  		}
  	} else {
  		// Fallback: crear frontend mínimo
  		if err := os.MkdirAll(filepath.Join(frontendDir, "src"), 0755); err != nil {
  			return fmt.Errorf("creating frontend dir: %w", err)
  		}
  		pkgJSON := fmt.Sprintf(`{"name":"%s-frontend","private":true,"scripts":{"dev":"vite","build":"vite build"}}`, cfg.Name)
  		os.WriteFile(filepath.Join(frontendDir, "package.json"), []byte(pkgJSON), 0644)
  	}

  	// go mod tidy
  	cmd := exec.CommandContext(ctx, "go", "mod", "tidy")
  	cmd.Dir = cfg.Dir
  	cmd.Stdout = os.Stdout
  	cmd.Stderr = os.Stderr
  	_ = cmd.Run() // No fatal si falla

  	fmt.Printf("Wails app scaffolded at %s\n", cfg.Dir)
  	return nil
  }

  // GenerateAppBinding genera el código Go para un método de binding.
  func GenerateAppBinding(name string, params []string, returnType string, body string) string {
  	var sb strings.Builder
  	sb.WriteString(fmt.Sprintf("func (a *App) %s(", name))
  	sb.WriteString(strings.Join(params, ", "))
  	sb.WriteString(")")
  	if returnType != "" {
  		sb.WriteString(" " + returnType)
  	}
  	sb.WriteString(" {\n")
  	sb.WriteString("\t" + body + "\n")
  	sb.WriteString("}\n")
  	return sb.String()
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context para cancelacion"},{"name":"cfg","desc":"estructura ScaffoldWailsAppConfig con Name, Dir, Title, Width, Height, Author"}],"output":"nil si el scaffold fue exitoso, error en caso contrario"}


id: setup_vps_app_go_infra
name: setup_vps_app
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SetupVPSApp(conn SSHConn, cfg DeployConfig) error
tags: ["deploy","vps","setup","systemd","ssh","pipeline","infra"]
uses_types: ["ssh_conn_go_infra","DeployConfig_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/setup_vps_app.go
created_at: 2026-04-12T12:48:38Z
updated_at: 2026-04-12T12:48:38Z
props: []
emits: []
variant: []
content_hash: bb775514bc7ed1fc674cbca3ee681796eb7c3c85eb6400867b246ca998f8196c
description:
  Orquesta el setup inicial de una app en un VPS remoto: verifica SSH, crea dirs y usuario, sube binario, instala systemd unit y hace health check.

uses_functions:
  ["ssh_check_go_infra","vps_setup_app_go_infra","ssh_upload_go_infra","ssh_exec_go_infra","systemd_generate_unit_go_infra","systemd_install_go_infra","health_check_http_go_infra"]

example:
  ```go
  conn := SSHConn{Host: "185.x.x.x", User: "root"}
  cfg := DeployConfig{
      AppName:     "dag_engine",
      LocalDir:    "apps/dag_engine",
      RemoteDir:   "/opt/apps/dag_engine",
      BinaryName:  "dag_engine",
      ServiceUser: "deploy",
      Port:        8080,
      HealthPath:  "/api/health",
      Env:         map[string]string{"PORT": "8080"},
  }
  err := SetupVPSApp(conn, cfg)
  ```

notes:
  Pipeline de 6 pasos para primera instalación. Después del setup inicial, usar `deploy_app_remote` para deploys continuos (no regenera dirs ni systemd unit). El health check espera hasta 30 segundos con polling cada 2s.

code:
  package infra

  import "fmt"

  // SetupVPSApp orquesta el setup inicial de una app en un VPS remoto.
  // Pasos: verificar SSH → crear dirs/usuario → rsync código → generar unit → instalar systemd → health check.
  func SetupVPSApp(conn SSHConn, cfg DeployConfig) error {
  	// 1. Verificar conectividad SSH
  	if err := SSHCheck(conn); err != nil {
  		return fmt.Errorf("setup_vps_app: ssh check: %w", err)
  	}

  	// 2. Preparar directorios y usuario en el VPS
  	if err := VPSSetupApp(conn, cfg.AppName, cfg.RemoteDir, cfg.ServiceUser); err != nil {
  		return fmt.Errorf("setup_vps_app: vps setup: %w", err)
  	}

  	// 3. Subir binario o archivos de la app
  	if err := uploadAppFiles(conn, cfg); err != nil {
  		return fmt.Errorf("setup_vps_app: upload: %w", err)
  	}

  	// 4. Generar unit de systemd
  	execStart := fmt.Sprintf("%s/%s", cfg.RemoteDir, cfg.BinaryName)
  	unit := SystemdGenerateUnit(cfg.AppName, execStart, cfg.RemoteDir, cfg.ServiceUser, cfg.Env)

  	// 5. Instalar y arrancar servicio
  	if err := SystemdInstall(conn, cfg.AppName, unit); err != nil {
  		return fmt.Errorf("setup_vps_app: systemd install: %w", err)
  	}

  	// 6. Health check (si hay endpoint configurado)
  	if cfg.HealthPath != "" && cfg.Port > 0 {
  		url := fmt.Sprintf("http://%s:%d%s", conn.Host, cfg.Port, cfg.HealthPath)
  		if err := HealthCheckHTTP(url, 30, 2000); err != nil {
  			return fmt.Errorf("setup_vps_app: health check: %w", err)
  		}
  	}

  	return nil
  }

  // uploadAppFiles sube el binario compilado al VPS via SCP.
  func uploadAppFiles(conn SSHConn, cfg DeployConfig) error {
  	localBinary := fmt.Sprintf("%s/%s", cfg.LocalDir, cfg.BinaryName)
  	remoteBinary := fmt.Sprintf("%s/%s", cfg.RemoteDir, cfg.BinaryName)

  	if err := SSHUpload(conn, localBinary, remoteBinary); err != nil {
  		return err
  	}

  	// Hacer ejecutable
  	_, stderr, code, err := SSHExec(conn, fmt.Sprintf("chmod +x %s", remoteBinary))
  	if err != nil {
  		return err
  	}
  	if code != 0 {
  		return fmt.Errorf("chmod: %s", stderr)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al VPS destino"},{"name":"cfg","desc":"configuración de deploy con nombre, rutas, build, puerto, env vars"}],"output":"nil si el setup completo fue exitoso"}


id: smtp_connect_go_infra
name: smtp_connect
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SMTPConnect(cfg SMTPConfig) (*smtp.Client, error)
tags: ["email","smtp","connection","tls","starttls","auth"]
uses_functions: []
uses_types: ["SMTPConfig_go_infra"]
returns: []
error_type: error_go_core
imports: ["crypto/tls","fmt","net","net/smtp"]
tests: ["conecta sin cifrado a servidor mock","error si el servidor no existe"]
test_file_path: functions/infra/smtp_connect_test.go
file_path: functions/infra/smtp_connect.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: 8d43fd85fb2d0f291b4ea536dc228a7441bbe34f2f5edba92b72a0f52b2e7935
description:
  Establece una conexion SMTP autenticada. TLSMode 'tls' usa TLS directo (port 465), 'starttls' hace upgrade STARTTLS (port 587), '' sin cifrado (port 25). Retorna un *smtp.Client listo para usar con SMTPSend.

example:
  ```go
  cfg := SMTPConfig{Host: "smtp.gmail.com", Port: 587, Username: "u@gmail.com", Password: "app-pw", TLSMode: "starttls"}
  client, err := SMTPConnect(cfg)
  if err != nil {
      log.Fatal(err)
  }
  defer client.Quit()
  // pasar client a SMTPSend(...)
  ```

notes:
  Funcion impura — abre conexion TCP real. El caller es responsable de cerrar el cliente con `client.Quit()`. Para test unitario usar un listener TCP local como mock. `SMTPSend` acepta el `*smtp.Client` retornado por esta funcion.

code:
  package infra

  import (
  	"crypto/tls"
  	"fmt"
  	"net"
  	"net/smtp"
  )

  // SMTPConnect establishes an authenticated SMTP connection using the given config.
  // TLSMode controls encryption:
  //   - "tls"      → direct TLS (typical port 465)
  //   - "starttls" → plain connection upgraded with STARTTLS (typical port 587)
  //   - ""         → no encryption (typical port 25)
  //
  // Returns an *smtp.Client ready to use with SMTPSend.
  // The caller is responsible for calling client.Quit() when done.
  func SMTPConnect(cfg SMTPConfig) (*smtp.Client, error) {
  	addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)

  	switch cfg.TLSMode {
  	case "tls":
  		tlsCfg := &tls.Config{ServerName: cfg.Host}
  		conn, err := tls.Dial("tcp", addr, tlsCfg)
  		if err != nil {
  			return nil, fmt.Errorf("smtp_connect: tls dial %s: %w", addr, err)
  		}
  		client, err := smtp.NewClient(conn, cfg.Host)
  		if err != nil {
  			conn.Close()
  			return nil, fmt.Errorf("smtp_connect: new client: %w", err)
  		}
  		if cfg.Username != "" {
  			auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
  			if err := client.Auth(auth); err != nil {
  				client.Close()
  				return nil, fmt.Errorf("smtp_connect: auth: %w", err)
  			}
  		}
  		return client, nil

  	case "starttls":
  		conn, err := net.Dial("tcp", addr)
  		if err != nil {
  			return nil, fmt.Errorf("smtp_connect: dial %s: %w", addr, err)
  		}
  		client, err := smtp.NewClient(conn, cfg.Host)
  		if err != nil {
  			conn.Close()
  			return nil, fmt.Errorf("smtp_connect: new client: %w", err)
  		}
  		tlsCfg := &tls.Config{ServerName: cfg.Host}
  		if err := client.StartTLS(tlsCfg); err != nil {
  			client.Close()
  			return nil, fmt.Errorf("smtp_connect: starttls: %w", err)
  		}
  		if cfg.Username != "" {
  			auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
  			if err := client.Auth(auth); err != nil {
  				client.Close()
  				return nil, fmt.Errorf("smtp_connect: auth: %w", err)
  			}
  		}
  		return client, nil

  	default:
  		// no encryption
  		client, err := smtp.Dial(addr)
  		if err != nil {
  			return nil, fmt.Errorf("smtp_connect: dial %s: %w", addr, err)
  		}
  		if cfg.Username != "" {
  			auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
  			if err := client.Auth(auth); err != nil {
  				client.Close()
  				return nil, fmt.Errorf("smtp_connect: auth: %w", err)
  			}
  		}
  		return client, nil
  	}
  }

params_schema:
  {"params":[{"name":"cfg","desc":"configuracion SMTP: host, port, usuario, password y modo TLS"}],"output":"*smtp.Client autenticado y listo para enviar mensajes; el caller debe llamar client.Quit() al terminar"}


id: smtp_send_go_infra
name: smtp_send
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SMTPSend(client *smtp.Client, msg EmailMessage) error
tags: ["email","smtp","send","mime","multipart"]
uses_functions: ["smtp_connect_go_infra"]
uses_types: ["EmailMessage_go_infra"]
returns: []
error_type: error_go_core
test_file_path: functions/infra/smtp_send_test.go
file_path: functions/infra/smtp_send.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: ec1bc52f76d305a2d241e80170f5ebd532aa6948e65c8ff3eda42e9095fc4c6f
description:
  Serializa un EmailMessage a MIME y lo envia usando el smtp.Client dado. Soporta texto plano, HTML, multipart/alternative y adjuntos (multipart/mixed).

imports:
  ["bytes","encoding/base64","fmt","mime","mime/multipart","net/smtp","net/textproto","strings","time"]

example:
  ```go
  cfg := SMTPConfig{Host: "smtp.gmail.com", Port: 587, Username: "u@gmail.com", Password: "pw", TLSMode: "starttls"}
  client, err := SMTPConnect(cfg)
  if err != nil { log.Fatal(err) }
  defer client.Quit()

  msg := EmailBuildHTML("u@gmail.com", []string{"dest@example.com"}, "Asunto", "<b>Hola</b>")
  if err := SMTPSend(client, msg); err != nil { log.Fatal(err) }
  ```

tests:
  ["envia mensaje texto plano via mock smtp","envia mensaje html via mock smtp","envia con adjunto via mock smtp"]

notes:
  Funcion impura — envia datos por red. El cliente debe estar autenticado (obtenido de `SMTPConnect`). BCC se incluye en RCPT TO pero no en las cabeceras MIME visibles. Los adjuntos se codifican en base64. Para HTML+texto usa `multipart/alternative`; con adjuntos usa `multipart/mixed`.

code:
  package infra

  import (
  	"bytes"
  	"encoding/base64"
  	"fmt"
  	"mime"
  	"mime/multipart"
  	"net/smtp"
  	"net/textproto"
  	"strings"
  	"time"
  )

  // SMTPSend serializes msg to MIME and sends it using the given smtp.Client.
  // Supports plain text, HTML, and mixed (with attachments) content types.
  // The client must be authenticated (obtained from SMTPConnect).
  func SMTPSend(client *smtp.Client, msg EmailMessage) error {
  	if err := client.Mail(extractAddr(msg.From)); err != nil {
  		return fmt.Errorf("smtp_send: MAIL FROM: %w", err)
  	}

  	allTo := append(append([]string{}, msg.To...), msg.CC...)
  	allTo = append(allTo, msg.BCC...)
  	for _, addr := range allTo {
  		if err := client.Rcpt(extractAddr(addr)); err != nil {
  			return fmt.Errorf("smtp_send: RCPT TO %s: %w", addr, err)
  		}
  	}

  	w, err := client.Data()
  	if err != nil {
  		return fmt.Errorf("smtp_send: DATA: %w", err)
  	}
  	defer w.Close()

  	body, err := buildMIME(msg)
  	if err != nil {
  		return fmt.Errorf("smtp_send: build mime: %w", err)
  	}

  	if _, err := w.Write(body); err != nil {
  		return fmt.Errorf("smtp_send: write: %w", err)
  	}
  	return nil
  }

  // extractAddr extracts the bare email address from a possibly formatted string
  // like "Alice <alice@example.com>" → "alice@example.com".
  func extractAddr(addr string) string {
  	if i := strings.Index(addr, "<"); i >= 0 {
  		if j := strings.Index(addr[i:], ">"); j >= 0 {
  			return addr[i+1 : i+j]
  		}
  	}
  	return strings.TrimSpace(addr)
  }

  // buildMIME constructs the full MIME email bytes.
  func buildMIME(msg EmailMessage) ([]byte, error) {
  	var buf bytes.Buffer

  	// Headers
  	buf.WriteString("Date: " + time.Now().UTC().Format(time.RFC1123Z) + "\r\n")
  	buf.WriteString("From: " + msg.From + "\r\n")
  	buf.WriteString("To: " + strings.Join(msg.To, ", ") + "\r\n")
  	if len(msg.CC) > 0 {
  		buf.WriteString("Cc: " + strings.Join(msg.CC, ", ") + "\r\n")
  	}
  	buf.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", msg.Subject) + "\r\n")
  	buf.WriteString("MIME-Version: 1.0\r\n")
  	for k, v := range msg.Headers {
  		buf.WriteString(k + ": " + v + "\r\n")
  	}

  	hasText := msg.BodyText != ""
  	hasHTML := msg.BodyHTML != ""
  	hasAtts := len(msg.Attachments) > 0

  	switch {
  	case hasAtts:
  		// multipart/mixed: text or html parts + attachments
  		mw := multipart.NewWriter(&buf)
  		buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mw.Boundary() + "\"\r\n\r\n")

  		if hasText && hasHTML {
  			// nested multipart/alternative inside mixed
  			var altBuf bytes.Buffer
  			aw := multipart.NewWriter(&altBuf)
  			writeTextPart(aw, msg.BodyText)
  			writeHTMLPart(aw, msg.BodyHTML)
  			aw.Close()
  			altHeader := textproto.MIMEHeader{}
  			altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+aw.Boundary()+"\"")
  			pw, _ := mw.CreatePart(altHeader)
  			pw.Write(altBuf.Bytes())
  		} else if hasHTML {
  			writeHTMLPart(mw, msg.BodyHTML)
  		} else {
  			writeTextPart(mw, msg.BodyText)
  		}

  		for _, att := range msg.Attachments {
  			writeAttachment(mw, att)
  		}
  		mw.Close()

  	case hasText && hasHTML:
  		mw := multipart.NewWriter(&buf)
  		buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + mw.Boundary() + "\"\r\n\r\n")
  		writeTextPart(mw, msg.BodyText)
  		writeHTMLPart(mw, msg.BodyHTML)
  		mw.Close()

  	case hasHTML:
  		buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
  		buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
  		buf.WriteString(msg.BodyHTML)

  	default:
  		buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
  		buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
  		buf.WriteString(msg.BodyText)
  	}

  	return buf.Bytes(), nil
  }

  func writeTextPart(mw *multipart.Writer, body string) {
  	h := textproto.MIMEHeader{}
  	h.Set("Content-Type", "text/plain; charset=utf-8")
  	h.Set("Content-Transfer-Encoding", "quoted-printable")
  	pw, _ := mw.CreatePart(h)
  	pw.Write([]byte(body))
  }

  func writeHTMLPart(mw *multipart.Writer, body string) {
  	h := textproto.MIMEHeader{}
  	h.Set("Content-Type", "text/html; charset=utf-8")
  	h.Set("Content-Transfer-Encoding", "quoted-printable")
  	pw, _ := mw.CreatePart(h)
  	pw.Write([]byte(body))
  }

  func writeAttachment(mw *multipart.Writer, att EmailAttachment) {
  	h := textproto.MIMEHeader{}
  	h.Set("Content-Type", att.ContentType)
  	h.Set("Content-Transfer-Encoding", "base64")
  	h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename))
  	pw, _ := mw.CreatePart(h)
  	enc := base64.NewEncoder(base64.StdEncoding, pw)
  	enc.Write(att.Data)
  	enc.Close()
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente SMTP autenticado obtenido de SMTPConnect"},{"name":"msg","desc":"mensaje email con From, To, Subject y al menos un body (HTML o text)"}],"output":"nil si el envio fue exitoso; error con contexto si falla MAIL FROM, RCPT TO, DATA o la escritura MIME"}


id: sqlite_open_go_infra
name: sqlite_open
kind: function
lang: go
domain: infra
version: 1.1.0
purity: impure
signature: func SQLiteOpen(path string, basePath string) (*sql.DB, error)
tags: ["database","sqlite","connection","sql"]
uses_functions: []
uses_types: ["db_config_go_infra"]
returns: []
error_type: error_go_core
imports: ["database/sql","path/filepath","github.com/mattn/go-sqlite3"]
tests: []
file_path: functions/infra/sqlite_open.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b5a71fb204400c6c90673bbb58b9c93a9648128ba52eac07bb097ad941862f34
description:
  Abre (o crea) una base de datos SQLite con WAL mode y foreign keys habilitados. Hace ping para verificar la conexion. Si basePath es no-vacio y path es relativo, resuelve el path como filepath.Join(basePath, path).

example:
  ```go
  // Path absoluto o relativo al cwd
  db, err := SQLiteOpen("/data/myapp.db", "")
  if err != nil {
      log.Fatal(err)
  }
  defer DBClose(db)

  // Path relativo al directorio del archivo YAML de configuracion
  configDir := filepath.Dir(configPath)
  db, err := SQLiteOpen(cfg.DatabasePath, configDir)
  if err != nil {
      log.Fatal(err)
  }

  rows, err := DBQuery(db, "SELECT * FROM users WHERE active = ?", 1)
  ```

notes:
  Usa el driver `github.com/mattn/go-sqlite3` (CGO). El DSN incluye `_journal_mode=WAL` para mejor concurrencia y `_foreign_keys=on`. Acepta `:memory:` para base de datos en memoria (basePath se ignora en este caso). Hace ping al abrir para detectar errores temprano.

  El parametro `basePath` resuelve el problema de paths relativos en configs YAML: cuando el binario corre desde un directorio distinto al del archivo de config, el path relativo se interpreta incorrectamente. Pasar `filepath.Dir(configPath)` como basePath corrige esto.

  Para mantener el comportamiento anterior (resolver vs cwd), pasar `basePath = ""`.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  	"path/filepath"

  	_ "github.com/mattn/go-sqlite3"
  )

  // SQLiteOpen opens (or creates) a SQLite database file with WAL mode and
  // foreign key support enabled. Returns a ready-to-use *sql.DB or an error.
  // Pass ":memory:" for an in-memory database.
  //
  // If basePath is non-empty and path is relative, the path is resolved as
  // filepath.Join(basePath, path). This is useful when the path comes from a
  // config file and must be interpreted relative to that file's directory rather
  // than the process working directory.
  func SQLiteOpen(path string, basePath string) (*sql.DB, error) {
  	if path == "" {
  		return nil, fmt.Errorf("sqlite_open: path must not be empty (use ':memory:' for in-memory)")
  	}
  	resolved := path
  	if basePath != "" && path != ":memory:" && !filepath.IsAbs(path) {
  		resolved = filepath.Join(basePath, path)
  	}
  	dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on", resolved)
  	db, err := sql.Open("sqlite3", dsn)
  	if err != nil {
  		return nil, fmt.Errorf("sqlite_open: open %q: %w", resolved, err)
  	}
  	if err := db.Ping(); err != nil {
  		db.Close()
  		return nil, fmt.Errorf("sqlite_open: ping %q: %w", resolved, err)
  	}
  	return db, nil
  }

params_schema:
  {"params":[{"name":"path","desc":"ruta del archivo SQLite (:memory: para base en memoria)"},{"name":"basePath","desc":"ruta base para resolver paths relativos (vacio = relativo al cwd)"}],"output":"conexion sql.DB abierta a SQLite con WAL mode y foreign keys"}


id: sse_handler_go_infra
name: sse_handler
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSEHandler(events <-chan SSEEvent) http.HandlerFunc
tags: ["sse","server-sent-events","http","handler","server","infra","realtime"]
uses_functions: ["sse_send_go_infra"]
uses_types: ["SSEEvent_go_infra"]
returns: []
error_type: error_go_core
imports: ["net/http"]
test_file_path: functions/infra/sse_test.go
file_path: functions/infra/sse_handler.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: 182c3e9aee05fb340eb59fb87da23df14d192d0156d23b362d24c692c3039256
description:
  Retorna un http.HandlerFunc que setea los headers SSE (Content-Type, Cache-Control, Connection, X-Accel-Buffering), consume eventos del canal y los envia con flush a cada cliente conectado. Cierra limpiamente si el cliente se desconecta (context cancelado) o si el canal de eventos se cierra.

example:
  ```go
  events := make(chan SSEEvent, 100)

  routes := []Route{
      {Method: "GET", Path: "/events", Handler: SSEHandler(events)},
  }

  go func() {
      for i := 0; ; i++ {
          events <- SSEEvent{
              Event: "tick",
              ID:    fmt.Sprintf("%d", i),
              Data:  fmt.Sprintf(`{"n":%d}`, i),
          }
          time.Sleep(time.Second)
      }
  }()

  mux := HTTPRouter(routes)
  HTTPServe(":8080", mux, ctx)
  ```

tests:
  ["setea headers SSE correctos","envia eventos del canal al writer","termina si el contexto del request se cancela","termina si el canal de eventos se cierra"]

notes:
  Importante: este handler asume **un canal compartido entre todos los clientes** o un canal por request. Si se quiere broadcast a multiples clientes desde una sola fuente, montar un fan-out por encima (ej: un hub similar a `WSHub` pero para SSE). Para uso tipico (1 cliente = 1 stream) basta con crear el canal dentro del handler externo y pasarlo.

  `X-Accel-Buffering: no` deshabilita el buffering en nginx (sin esto los eventos se acumulan hasta que nginx flushea su buffer, anulando el real-time). En Caddy/Traefik no es necesario pero no estorba.

  El handler **no monta keepalives** automaticamente. Si la conexion va a estar idle mas de ~30s, lanzar `SSEKeepalive` en una goroutine paralela compartiendo el mismo writer.

code:
  package infra

  import "net/http"

  // SSEHandler retorna un http.HandlerFunc que setea los headers SSE,
  // consume eventos del canal y los envia con flush. Cierra limpiamente
  // si el cliente se desconecta (context cancelado) o si el canal se cierra.
  //
  // Headers seteados:
  //   Content-Type: text/event-stream
  //   Cache-Control: no-cache
  //   Connection: keep-alive
  //   X-Accel-Buffering: no   (deshabilita buffering en nginx)
  func SSEHandler(events <-chan SSEEvent) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		flusher, ok := w.(http.Flusher)
  		if !ok {
  			http.Error(w, "streaming unsupported", http.StatusInternalServerError)
  			return
  		}

  		w.Header().Set("Content-Type", "text/event-stream")
  		w.Header().Set("Cache-Control", "no-cache")
  		w.Header().Set("Connection", "keep-alive")
  		w.Header().Set("X-Accel-Buffering", "no")
  		w.WriteHeader(http.StatusOK)
  		flusher.Flush()

  		ctx := r.Context()
  		for {
  			select {
  			case <-ctx.Done():
  				return
  			case ev, ok := <-events:
  				if !ok {
  					return
  				}
  				if err := SSESend(w, ev); err != nil {
  					return
  				}
  			}
  		}
  	}
  }

params_schema:
  {"params":[{"name":"events","desc":"canal de SSEEvent del que se consumen eventos para enviar al cliente. Cerrar el canal termina el handler limpiamente."}],"output":"http.HandlerFunc lista para montarse en una ruta. Sirve un solo cliente por invocacion (cada request abre su propio stream)."}


id: sse_keepalive_go_infra
name: sse_keepalive
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSEKeepalive(w http.ResponseWriter, interval time.Duration, done <-chan struct{})
tags: ["sse","keepalive","server-sent-events","http","server","infra","realtime"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["net/http","time"]
test_file_path: functions/infra/sse_test.go
file_path: functions/infra/sse_keepalive.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: c1565faa4a1dc62c808a590eb55add257cad6f9e7e7943e8d0d97d4988e22d17
description:
  Envia comentarios SSE periodicos (`: keepalive\n\n`) al writer hasta que done se cierre. Sirve para evitar que proxies o load balancers cierren conexiones inactivas. Bloqueante: lanzar como goroutine. Si w implementa http.Flusher hace flush tras cada keepalive.

example:
  ```go
  http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
      w.Header().Set("Content-Type", "text/event-stream")
      w.Header().Set("Cache-Control", "no-cache")
      w.Header().Set("Connection", "keep-alive")

      done := make(chan struct{})
      defer close(done)

      go SSEKeepalive(w, 15*time.Second, done)

      // Bucle principal de envio de eventos...
  })
  ```

tests:
  ["escribe comentario keepalive periodicamente","termina cuando done se cierra","termina cuando la escritura falla"]

notes:
  Comentario SSE: la spec dice que cualquier linea que empiece con `:` es un comentario y el cliente la ignora. Mantienen viva la conexion sin generar eventos espurios. Recomendado intervalo entre 15-30s para infraestructuras tipicas (nginx default = 60s timeout idle).

  Si la escritura falla (conexion cerrada) la funcion retorna sin error, asumiendo que el handler principal ya detectara la desconexion via `r.Context().Done()`.

code:
  package infra

  import (
  	"net/http"
  	"time"
  )

  // SSEKeepalive envia comentarios SSE periodicos (": keepalive\n\n") al writer
  // hasta que done se cierre. Bloqueante: lanzar como goroutine.
  // Sirve para evitar que proxies/load balancers cierren conexiones inactivas.
  // Si w implementa http.Flusher hace flush tras cada keepalive.
  func SSEKeepalive(w http.ResponseWriter, interval time.Duration, done <-chan struct{}) {
  	if interval <= 0 {
  		interval = 30 * time.Second
  	}
  	ticker := time.NewTicker(interval)
  	defer ticker.Stop()

  	flusher, _ := w.(http.Flusher)

  	for {
  		select {
  		case <-done:
  			return
  		case <-ticker.C:
  			if _, err := w.Write([]byte(": keepalive\n\n")); err != nil {
  				return
  			}
  			if flusher != nil {
  				flusher.Flush()
  			}
  		}
  	}
  }

params_schema:
  {"params":[{"name":"w","desc":"http.ResponseWriter destino del stream SSE. Si implementa http.Flusher se hace flush tras cada keepalive."},{"name":"interval","desc":"intervalo entre keepalives (recomendado 15-30s). Si \u003c= 0 se usa 30s por defecto."},{"name":"done","desc":"canal de cierre. Cuando se cierre, la goroutine retorna inmediatamente."}],"output":"ningun retorno; la funcion retorna cuando done se cierra o cuando una escritura falla (cliente desconectado)"}


id: sse_send_go_infra
name: sse_send
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSESend(w http.ResponseWriter, event SSEEvent) error
tags: ["sse","server-sent-events","http","server","infra","realtime"]
uses_functions: []
uses_types: ["SSEEvent_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","net/http","strings"]
test_file_path: functions/infra/sse_test.go
file_path: functions/infra/sse_send.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: 01fd61430dd7797f13a9ca0b330d7cdc8d200ae5bea0097a2c4c690050ea0d24
description:
  Escribe un evento Server-Sent Events formateado al ResponseWriter segun la spec W3C y hace flush si el writer implementa http.Flusher. Campos opcionales (event, id, retry) solo se incluyen si tienen valor. Data con saltos de linea se traduce a multiples lineas data: segun la spec.

example:
  ```go
  http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
      w.Header().Set("Content-Type", "text/event-stream")
      w.Header().Set("Cache-Control", "no-cache")
      w.Header().Set("Connection", "keep-alive")

      SSESend(w, SSEEvent{
          Event: "metric",
          ID:    "1",
          Data:  `{"cpu": 45.2}`,
      })
  })
  ```

tests:
  ["serializa data simple sin event ni id","incluye event cuando esta presente","incluye id cuando esta presente","incluye retry cuando es positivo","data multilinea genera multiples lineas data:","hace flush si el writer es Flusher"]

notes:
  Funcion atomica: solo formatea y flushea un evento. La gestion del ciclo de vida (headers, loop de eventos, deteccion de desconexion) se hace en `sse_handler`.

  Sigue estrictamente la spec W3C de Server-Sent Events. El doble salto de linea final separa eventos. Para enviar un comentario keepalive (no un evento) escribir `: keepalive\n\n` directamente — no usar esta funcion.

code:
  package infra

  import (
  	"fmt"
  	"net/http"
  	"strings"
  )

  // SSESend escribe un evento SSE formateado al ResponseWriter y hace flush si es posible.
  // Sigue la spec W3C: campos opcionales (event, id, retry) solo se incluyen si tienen valor.
  // Data con saltos de linea se traduce a multiples lineas "data:" segun la spec.
  // Retorna error si la escritura falla.
  func SSESend(w http.ResponseWriter, event SSEEvent) error {
  	var b strings.Builder
  	if event.Event != "" {
  		b.WriteString("event: ")
  		b.WriteString(event.Event)
  		b.WriteByte('\n')
  	}
  	if event.ID != "" {
  		b.WriteString("id: ")
  		b.WriteString(event.ID)
  		b.WriteByte('\n')
  	}
  	if event.Retry > 0 {
  		fmt.Fprintf(&b, "retry: %d\n", event.Retry)
  	}
  	// Data: cada salto de linea genera una nueva linea "data:"
  	for _, line := range strings.Split(event.Data, "\n") {
  		b.WriteString("data: ")
  		b.WriteString(line)
  		b.WriteByte('\n')
  	}
  	b.WriteByte('\n') // separador de eventos

  	if _, err := w.Write([]byte(b.String())); err != nil {
  		return fmt.Errorf("sse send: %w", err)
  	}
  	if flusher, ok := w.(http.Flusher); ok {
  		flusher.Flush()
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"w","desc":"http.ResponseWriter destino. Debe tener headers SSE ya seteados (Content-Type: text/event-stream). Si implementa http.Flusher se hace flush tras la escritura."},{"name":"event","desc":"SSEEvent a serializar. Campos opcionales se omiten si estan vacios. Data multilinea se trocea en varias lineas data: segun la spec."}],"output":"error si la escritura al ResponseWriter falla, nil si el evento se envio y se hizo flush correctamente"}


id: ssh_check_go_infra
name: ssh_check
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHCheck(conn SSHConn) error
description: Verifica conectividad SSH ejecutando un comando noop en el host remoto. Timeout de 5 segundos.
tags: ["ssh","connection","check","remote"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os/exec","strings"]
tests: ["conecta a organic-machine","falla con host inexistente"]
test_file_path: functions/infra/ssh_check_test.go
file_path: functions/infra/ssh_check.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 7e034f12abb02abd36bd9afc59f361c0c966d8774ac72b12654b7a3c3227b6ef
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  if err := SSHCheck(conn); err != nil {
      log.Fatal("no se puede conectar:", err)
  }
  ```

notes:
  Usa BatchMode=yes para fallar inmediatamente si requiere input interactivo (password prompt). ConnectTimeout=5 para no bloquear si el host no responde. StrictHostKeyChecking=accept-new acepta hosts nuevos automaticamente.

code:
  package infra

  import (
  	"fmt"
  	"os/exec"
  	"strings"
  )

  // SSHCheck verifica la conectividad SSH ejecutando un comando noop en el host remoto.
  // Retorna nil si la conexion fue exitosa, o error con el detalle del fallo.
  func SSHCheck(conn SSHConn) error {
  	args := conn.sshArgs()
  	args = append(args, "-o", "ConnectTimeout=5")
  	args = append(args, "-o", "BatchMode=yes")
  	args = append(args, conn.destination(), "exit", "0")

  	out, err := exec.Command("ssh", args...).CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("ssh check %s: %s", conn.destination(), strings.TrimSpace(string(out)))
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"}],"output":"nil si la conectividad SSH es exitosa, error en caso contrario"}


id: ssh_config_add_entry_go_infra
name: ssh_config_add_entry
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigAddEntry(entries []SSHConfigEntry, entry SSHConfigEntry) ([]SSHConfigEntry, error)
description: Añade un nuevo SSHConfigEntry a la lista. Error si el alias ya existe.
tags: ["ssh","config","add","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: ["fmt"]
tests: ["añade entry a lista vacia","añade entry a lista existente","error si alias duplicado"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_add_entry.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
notes: No muta el slice original — crea una copia nueva. La validacion de unicidad es por alias exacto.
content_hash: 408d6a6a828cfd716cd7ed46fffd195f54c37a8f75690affd6f72d7cb6879369
example:
  ```go
  newEntry := SSHConfigEntry{Alias: "staging", HostName: "10.0.0.2", User: "deploy"}
  updated, err := SSHConfigAddEntry(entries, newEntry)
  ```

code:
  package infra

  import "fmt"

  // SSHConfigAddEntry añade un nuevo entry a la lista. Retorna error si el alias
  // ya existe. No muta el slice original.
  func SSHConfigAddEntry(entries []SSHConfigEntry, entry SSHConfigEntry) ([]SSHConfigEntry, error) {
  	for _, e := range entries {
  		if e.Alias == entry.Alias {
  			return nil, fmt.Errorf("alias %q already exists", entry.Alias)
  		}
  	}
  	result := make([]SSHConfigEntry, len(entries), len(entries)+1)
  	copy(result, entries)
  	return append(result, entry), nil
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista actual de SSHConfigEntry"},{"name":"entry","desc":"nuevo entry a añadir con alias unico"}],"output":"nueva lista con el entry añadido al final"}


id: ssh_config_find_go_infra
name: ssh_config_find
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigFind(entries []SSHConfigEntry, alias string) (SSHConfigEntry, bool)
description: Busca un entry por alias en la lista de SSHConfigEntry.
tags: ["ssh","config","search","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: []
tests: ["encuentra entry existente","retorna false para alias inexistente"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_find.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: 15d325b1845644bdb2f4db5a28866db5fa6ee0f787ddbe562b6bfb92d8cb0737
example:
  ```go
  entry, ok := SSHConfigFind(entries, "myserver")
  if ok {
      conn := entry.ToSSHConn()
      SSHCheck(conn)
  }
  ```

notes:
  Busqueda lineal por coincidencia exacta del alias. Para pocos entries (tipico en un SSH config personal) es suficiente.

code:
  package infra

  // SSHConfigFind busca un entry por alias en la lista. Retorna el entry y true
  // si lo encuentra, o un SSHConfigEntry vacio y false si no existe.
  func SSHConfigFind(entries []SSHConfigEntry, alias string) (SSHConfigEntry, bool) {
  	for _, e := range entries {
  		if e.Alias == alias {
  			return e, true
  		}
  	}
  	return SSHConfigEntry{}, false
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista de SSHConfigEntry donde buscar"},{"name":"alias","desc":"nombre del host a buscar (case-sensitive)"}],"output":"el SSHConfigEntry encontrado y true, o entry vacio y false si no existe"}


id: ssh_config_parse_go_infra
name: ssh_config_parse
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigParse(content string) []SSHConfigEntry
description: Parsea el contenido de un archivo ~/.ssh/config y retorna una lista de SSHConfigEntry.
tags: ["ssh","config","parser","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: ["bufio","strconv","strings"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_parse.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: 71a1e099fe017854a19d9de99d8948b74c4bf9624fa336dc8c83466c436992ad
example:
  ```go
  content := `Host myserver
      HostName 192.168.1.100
      User deploy
      Port 2222
      IdentityFile ~/.ssh/id_ed25519`

  entries := SSHConfigParse(content)
  // entries[0].Alias == "myserver"
  // entries[0].HostName == "192.168.1.100"
  ```

tests:
  ["parsea config con multiples hosts","ignora comentarios y lineas vacias","ignora hosts con wildcards","parsea opciones extra en Options map"]

notes:
  Ignora bloques `Host *` y `Host 192.168.?.*` (wildcards). Soporta directivas con separador espacio o `=`. Las directivas no reconocidas se almacenan en el map `Options`.

code:
  package infra

  import (
  	"bufio"
  	"strconv"
  	"strings"
  )

  // SSHConfigParse parsea el contenido de un archivo ~/.ssh/config y retorna
  // una lista de SSHConfigEntry. Ignora comentarios y lineas vacias.
  // Los bloques Host con wildcards (* o ?) se ignoran.
  func SSHConfigParse(content string) []SSHConfigEntry {
  	var entries []SSHConfigEntry
  	var current *SSHConfigEntry

  	scanner := bufio.NewScanner(strings.NewReader(content))
  	for scanner.Scan() {
  		line := strings.TrimSpace(scanner.Text())
  		if line == "" || strings.HasPrefix(line, "#") {
  			continue
  		}

  		key, value := splitDirective(line)
  		if key == "" {
  			continue
  		}

  		if strings.EqualFold(key, "Host") {
  			if current != nil && current.Alias != "" {
  				entries = append(entries, *current)
  			}
  			if strings.ContainsAny(value, "*?") {
  				current = nil
  				continue
  			}
  			current = &SSHConfigEntry{
  				Alias:   value,
  				Options: make(map[string]string),
  			}
  			continue
  		}

  		if current == nil {
  			continue
  		}

  		switch strings.ToLower(key) {
  		case "hostname":
  			current.HostName = value
  		case "user":
  			current.User = value
  		case "port":
  			if p, err := strconv.Atoi(value); err == nil {
  				current.Port = p
  			}
  		case "identityfile":
  			current.IdentityFile = value
  		default:
  			current.Options[key] = value
  		}
  	}

  	if current != nil && current.Alias != "" {
  		entries = append(entries, *current)
  	}
  	return entries
  }

  // splitDirective separa una linea "Key Value" o "Key=Value" en clave y valor.
  func splitDirective(line string) (string, string) {
  	// Intentar separar por =
  	if idx := strings.IndexByte(line, '='); idx >= 0 {
  		return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
  	}
  	// Separar por primer espacio/tab
  	fields := strings.SplitN(line, " ", 2)
  	if len(fields) < 2 {
  		fields = strings.SplitN(line, "\t", 2)
  	}
  	if len(fields) < 2 {
  		return "", ""
  	}
  	return strings.TrimSpace(fields[0]), strings.TrimSpace(fields[1])
  }

params_schema:
  {"params":[{"name":"content","desc":"texto completo del archivo ~/.ssh/config"}],"output":"lista de SSHConfigEntry, uno por cada bloque Host (sin wildcards)"}


id: ssh_config_read_go_infra
name: ssh_config_read
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHConfigRead() ([]SSHConfigEntry, error)
description: Lee y parsea ~/.ssh/config. Retorna lista vacia si el archivo no existe.
tags: ["ssh","config","read","remote"]
uses_functions: ["ssh_config_parse_go_infra"]
uses_types: ["ssh_config_entry_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath"]
tests: []
file_path: functions/infra/ssh_config_read.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: 9901646f0309183d802c801b89db4e71356d8e4f76d3c3946486436ba1926fdd
params_schema: {"output":"lista de SSHConfigEntry parseados del archivo ~/.ssh/config"}
example:
  ```go
  entries, err := SSHConfigRead()
  if err != nil {
      log.Fatal(err)
  }
  for _, e := range entries {
      fmt.Printf("%s -> %s@%s\n", e.Alias, e.User, e.HostName)
  }
  ```

notes:
  Si `~/.ssh/config` no existe, retorna lista vacia y nil (no es error — el usuario simplemente no tiene config aun). Usa `SSHConfigParse` internamente.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  )

  // SSHConfigRead lee y parsea el archivo ~/.ssh/config. Si el archivo no existe,
  // retorna una lista vacia sin error (config todavia no creado).
  func SSHConfigRead() ([]SSHConfigEntry, error) {
  	path := filepath.Join(os.Getenv("HOME"), ".ssh", "config")
  	data, err := os.ReadFile(path)
  	if err != nil {
  		if os.IsNotExist(err) {
  			return nil, nil
  		}
  		return nil, fmt.Errorf("ssh config read: %w", err)
  	}
  	return SSHConfigParse(string(data)), nil
  }


id: ssh_config_remove_entry_go_infra
name: ssh_config_remove_entry
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigRemoveEntry(entries []SSHConfigEntry, alias string) ([]SSHConfigEntry, error)
description: Elimina un entry por alias de la lista. Error si el alias no existe.
tags: ["ssh","config","remove","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: ["fmt"]
example:
  ```go
  updated, err := SSHConfigRemoveEntry(entries, "old-server")
  ```

tests: ["elimina entry existente","error si alias no existe"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_remove_entry.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
notes: No muta el slice original — crea una copia nueva sin el entry eliminado.
content_hash: 76bbba25eefd7f9b17fbd3c33b04f05e7c2438e7349a055ef7dbcf7757a48653
code:
  package infra

  import "fmt"

  // SSHConfigRemoveEntry elimina el entry con el alias dado. Retorna error si
  // el alias no existe. No muta el slice original.
  func SSHConfigRemoveEntry(entries []SSHConfigEntry, alias string) ([]SSHConfigEntry, error) {
  	idx := -1
  	for i, e := range entries {
  		if e.Alias == alias {
  			idx = i
  			break
  		}
  	}
  	if idx < 0 {
  		return nil, fmt.Errorf("alias %q not found", alias)
  	}
  	result := make([]SSHConfigEntry, 0, len(entries)-1)
  	result = append(result, entries[:idx]...)
  	result = append(result, entries[idx+1:]...)
  	return result, nil
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista actual de SSHConfigEntry"},{"name":"alias","desc":"alias del host a eliminar"}],"output":"nueva lista sin el entry eliminado"}


id: ssh_config_render_go_infra
name: ssh_config_render
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigRender(entries []SSHConfigEntry) string
description: Convierte una lista de SSHConfigEntry al formato texto de ~/.ssh/config.
tags: ["ssh","config","render","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: ["fmt","sort","strings"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_render.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: 5b8dd99fbfbb4063320170ad95a94b38a7aaf7c5f81b532eadf5390b8f427782
example:
  ```go
  entries := []SSHConfigEntry{{
      Alias: "prod", HostName: "10.0.0.1", User: "admin", Port: 22,
  }}
  text := SSHConfigRender(entries)
  // "Host prod\n    HostName 10.0.0.1\n    User admin\n    Port 22\n"
  ```

tests:
  ["renderiza entry completo","renderiza entry minimo solo con alias","separa bloques con linea en blanco","ordena opciones extra alfabeticamente"]

notes:
  Campos vacios o Port=0 se omiten. Las opciones extra se renderizan en orden alfabetico para determinismo.

code:
  package infra

  import (
  	"fmt"
  	"sort"
  	"strings"
  )

  // SSHConfigRender convierte una lista de SSHConfigEntry al formato texto
  // de ~/.ssh/config. Cada bloque se separa con una linea en blanco.
  func SSHConfigRender(entries []SSHConfigEntry) string {
  	var sb strings.Builder
  	for i, e := range entries {
  		if i > 0 {
  			sb.WriteString("\n")
  		}
  		sb.WriteString(fmt.Sprintf("Host %s\n", e.Alias))
  		if e.HostName != "" {
  			sb.WriteString(fmt.Sprintf("    HostName %s\n", e.HostName))
  		}
  		if e.User != "" {
  			sb.WriteString(fmt.Sprintf("    User %s\n", e.User))
  		}
  		if e.Port != 0 {
  			sb.WriteString(fmt.Sprintf("    Port %d\n", e.Port))
  		}
  		if e.IdentityFile != "" {
  			sb.WriteString(fmt.Sprintf("    IdentityFile %s\n", e.IdentityFile))
  		}
  		if len(e.Options) > 0 {
  			keys := make([]string, 0, len(e.Options))
  			for k := range e.Options {
  				keys = append(keys, k)
  			}
  			sort.Strings(keys)
  			for _, k := range keys {
  				sb.WriteString(fmt.Sprintf("    %s %s\n", k, e.Options[k]))
  			}
  		}
  	}
  	return sb.String()
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista de SSHConfigEntry a renderizar"}],"output":"texto en formato ~/.ssh/config con bloques Host separados por linea en blanco"}


id: ssh_config_write_go_infra
name: ssh_config_write
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHConfigWrite(entries []SSHConfigEntry) error
description: Escribe entries a ~/.ssh/config con backup automatico del archivo previo.
tags: ["ssh","config","write","remote"]
uses_functions: ["ssh_config_render_go_infra"]
uses_types: ["ssh_config_entry_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath"]
tests: []
file_path: functions/infra/ssh_config_write.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: df8c99a8122ce5c2109d4b549aeb3f98c3803a04ca1f03800a15c57bd534cc23
example:
  ```go
  entries, _ := SSHConfigRead()
  entries, _ = SSHConfigAddEntry(entries, SSHConfigEntry{
      Alias: "prod", HostName: "10.0.0.1", User: "deploy",
  })
  if err := SSHConfigWrite(entries); err != nil {
      log.Fatal(err)
  }
  ```

notes:
  Crea `~/.ssh/` con permisos 0700 si no existe. Hace backup a `~/.ssh/config.bak` antes de sobrescribir. El archivo resultante tiene permisos 0600 (solo lectura/escritura para el owner).

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  )

  // SSHConfigWrite escribe la lista de entries al archivo ~/.ssh/config.
  // Crea un backup (.config.bak) antes de sobrescribir si el archivo ya existe.
  // Crea el directorio ~/.ssh si no existe.
  func SSHConfigWrite(entries []SSHConfigEntry) error {
  	sshDir := filepath.Join(os.Getenv("HOME"), ".ssh")
  	configPath := filepath.Join(sshDir, "config")
  	backupPath := filepath.Join(sshDir, "config.bak")

  	if err := os.MkdirAll(sshDir, 0700); err != nil {
  		return fmt.Errorf("ssh config write: create dir: %w", err)
  	}

  	// Backup si existe
  	if data, err := os.ReadFile(configPath); err == nil {
  		if err := os.WriteFile(backupPath, data, 0600); err != nil {
  			return fmt.Errorf("ssh config write: backup: %w", err)
  		}
  	}

  	content := SSHConfigRender(entries)
  	if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
  		return fmt.Errorf("ssh config write: %w", err)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista de SSHConfigEntry a escribir en ~/.ssh/config"}],"output":"nil si la escritura fue exitosa"}


id: ssh_download_go_infra
name: ssh_download
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHDownload(conn SSHConn, remotePath, localPath string) error
description: Descarga un archivo del host remoto al filesystem local via scp.
tags: ["ssh","scp","download","file","transfer","remote"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os/exec","strings"]
tests: ["upload y download roundtrip"]
test_file_path: functions/infra/ssh_transfer_test.go
file_path: functions/infra/ssh_download.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 0f1671ca287737d3d4c12e1f8cb0da0996f9fd57535af64e3ee18097fdf3dce9
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  err := SSHDownload(conn, "/var/log/app.log", "./app.log")
  ```

notes:
  Descarga un archivo remoto al path local indicado. Para descargar directorios, usar SSHExec con tar/rsync como alternativa.

code:
  package infra

  import (
  	"fmt"
  	"os/exec"
  	"strings"
  )

  // SSHDownload descarga un archivo del host remoto al filesystem local via scp.
  func SSHDownload(conn SSHConn, remotePath, localPath string) error {
  	args := conn.scpArgs()
  	src := fmt.Sprintf("%s:%s", conn.destination(), remotePath)
  	args = append(args, src, localPath)

  	out, err := exec.Command("scp", args...).CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("scp download from %s: %s", conn.destination(), strings.TrimSpace(string(out)))
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"},{"name":"remotePath","desc":"ruta del archivo en el host remoto"},{"name":"localPath","desc":"ruta de destino en el filesystem local"}],"output":"nil si la descarga fue exitosa, error en caso contrario"}


id: ssh_exec_go_infra
name: ssh_exec
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHExec(conn SSHConn, command string) (string, string, int, error)
description: Ejecuta un comando en el host remoto via SSH. Retorna stdout, stderr y exit code separados.
tags: ["ssh","exec","remote","command"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["os/exec"]
tests: ["echo simple","captura stderr","exit code no cero","comando multilinea"]
test_file_path: functions/infra/ssh_exec_test.go
file_path: functions/infra/ssh_exec.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 54a83a717fe225fbd8da6973409db833096cafa910663c21f043c0b4111b2236
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  stdout, stderr, code, err := SSHExec(conn, "df -h /")
  if err != nil {
      log.Fatal(err)
  }
  fmt.Printf("exit=%d\nstdout:\n%s\nstderr:\n%s\n", code, stdout, stderr)
  ```

notes:
  Retorna stdout y stderr como strings separados mas el exit code. Si el comando remoto falla (exit code != 0), NO retorna error — el exit code indica el fallo. Solo retorna error si ssh no pudo conectar o ejecutar. Usa BatchMode=yes para evitar prompts interactivos.

code:
  package infra

  import (
  	"os/exec"
  )

  // SSHExec ejecuta un comando en el host remoto via SSH y retorna stdout, stderr y exit code.
  func SSHExec(conn SSHConn, command string) (string, string, int, error) {
  	args := conn.sshArgs()
  	args = append(args, "-o", "BatchMode=yes")
  	args = append(args, conn.destination(), command)

  	cmd := exec.Command("ssh", args...)
  	var stdout, stderr []byte
  	cmd.Stdout = writerFunc(func(p []byte) (int, error) {
  		stdout = append(stdout, p...)
  		return len(p), nil
  	})
  	cmd.Stderr = writerFunc(func(p []byte) (int, error) {
  		stderr = append(stderr, p...)
  		return len(p), nil
  	})

  	err := cmd.Run()
  	exitCode := 0
  	if err != nil {
  		if exitErr, ok := err.(*exec.ExitError); ok {
  			exitCode = exitErr.ExitCode()
  		} else {
  			return "", "", -1, err
  		}
  	}
  	return string(stdout), string(stderr), exitCode, nil
  }

  // writerFunc adapta una funcion a io.Writer.
  type writerFunc func([]byte) (int, error)

  func (f writerFunc) Write(p []byte) (int, error) { return f(p) }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"},{"name":"command","desc":"comando a ejecutar en el host remoto"}],"output":"tupla (stdout, stderr, exit_code, error) del comando ejecutado"}


id: ssh_tunnel_close_go_infra
name: ssh_tunnel_close
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHTunnelClose(pid int) error
description: Cierra un tunel SSH enviando SIGTERM al proceso por PID.
tags: ["ssh","tunnel","close","remote"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","syscall"]
tests: ["abre tunel y lo cierra"]
test_file_path: functions/infra/ssh_tunnel_test.go
file_path: functions/infra/ssh_tunnel_close.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: c3a586c7b9c474d95545f696d8e5525ef32e69bf3f26a0579ea9bcbd0dec32f1
example:
  ```go
  // Abrir tunel
  pid, _ := SSHTunnelOpen(conn, 5432, "db-server", 5432)
  // ... usar el tunel ...
  // Cerrar
  err := SSHTunnelClose(pid)
  ```

notes:
  Envia SIGTERM (cierre limpio) al proceso ssh. El PID viene de SSHTunnelOpen. Si el proceso ya termino, retorna error.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"syscall"
  )

  // SSHTunnelClose cierra un tunel SSH enviando SIGTERM al proceso por PID.
  func SSHTunnelClose(pid int) error {
  	proc, err := os.FindProcess(pid)
  	if err != nil {
  		return fmt.Errorf("ssh tunnel close: process %d not found: %w", pid, err)
  	}

  	err = proc.Signal(syscall.SIGTERM)
  	if err != nil {
  		return fmt.Errorf("ssh tunnel close: cannot signal PID %d: %w", pid, err)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"pid","desc":"ID del proceso ssh del tunel abierto (obtenido de ssh_tunnel_open)"}],"output":"nil si el tunel se cerro exitosamente, error en caso contrario"}


id: ssh_tunnel_open_go_infra
name: ssh_tunnel_open
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHTunnelOpen(conn SSHConn, localPort int, remoteHost string, remotePort int) (int, error)
tags: ["ssh","tunnel","port-forwarding","remote"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os/exec","strings","time"]
tests: ["abre tunel y lo cierra"]
test_file_path: functions/infra/ssh_tunnel_test.go
file_path: functions/infra/ssh_tunnel_open.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 61704714519f542eaf357bc408e5ff197a4eb5a9cc18d9cf9bf32e22689b117c
description:
  Abre un tunel SSH (local port forwarding) en background. Retorna el PID del proceso para cerrarlo despues.

example:
  ```go
  conn := SSHConn{Host: "bastion.example.com", User: "deploy"}
  // Tunel: localhost:5432 -> db-server:5432 via bastion
  pid, err := SSHTunnelOpen(conn, 5432, "db-server", 5432)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println("tunnel PID:", pid)
  // Usar localhost:5432 para conectar a la BD remota
  // Cerrar con SSHTunnelClose(pid)
  ```

notes:
  Usa ssh -N -f -L para crear el tunel en background. ExitOnForwardFailure=yes falla inmediatamente si el puerto local esta ocupado. remoteHost vacio se interpreta como "localhost" (el servidor SSH mismo). El PID se obtiene buscando el proceso ssh en la tabla de procesos.

code:
  package infra

  import (
  	"fmt"
  	"os/exec"
  	"strings"
  	"time"
  )

  // SSHTunnelOpen abre un tunel SSH (local port forwarding) en background.
  // Mapea localPort en la maquina local a remoteHost:remotePort a traves del servidor SSH.
  // Retorna el PID del proceso ssh para cerrarlo despues con SSHTunnelClose.
  func SSHTunnelOpen(conn SSHConn, localPort int, remoteHost string, remotePort int) (int, error) {
  	if remoteHost == "" {
  		remoteHost = "localhost"
  	}

  	forward := fmt.Sprintf("%d:%s:%d", localPort, remoteHost, remotePort)
  	args := conn.sshArgs()
  	args = append(args, "-o", "BatchMode=yes")
  	args = append(args, "-o", "ExitOnForwardFailure=yes")
  	args = append(args, "-N", "-f", "-L", forward, conn.destination())

  	cmd := exec.Command("ssh", args...)
  	out, err := cmd.CombinedOutput()
  	if err != nil {
  		return 0, fmt.Errorf("ssh tunnel %s: %s", forward, strings.TrimSpace(string(out)))
  	}

  	// ssh -f se detach, buscar el PID del proceso
  	time.Sleep(200 * time.Millisecond)
  	pidCmd := exec.Command("sh", "-c",
  		fmt.Sprintf("ps aux | grep '[s]sh.*-L %s' | awk '{print $2}' | head -1", forward))
  	pidOut, err := pidCmd.Output()
  	if err != nil || strings.TrimSpace(string(pidOut)) == "" {
  		return 0, fmt.Errorf("ssh tunnel started but could not find PID")
  	}

  	var pid int
  	_, err = fmt.Sscanf(strings.TrimSpace(string(pidOut)), "%d", &pid)
  	if err != nil {
  		return 0, fmt.Errorf("ssh tunnel: invalid PID %q", strings.TrimSpace(string(pidOut)))
  	}

  	return pid, nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"},{"name":"localPort","desc":"puerto local donde escuchar conexiones"},{"name":"remoteHost","desc":"host remoto accesible desde el servidor SSH"},{"name":"remotePort","desc":"puerto en el host remoto"}],"output":"PID del proceso ssh del tunel para cerrarlo despues"}


id: ssh_upload_go_infra
name: ssh_upload
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHUpload(conn SSHConn, localPath, remotePath string) error
description: Sube un archivo local al host remoto via scp.
tags: ["ssh","scp","upload","file","transfer","remote"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os/exec","strings"]
tests: ["upload y download roundtrip"]
test_file_path: functions/infra/ssh_transfer_test.go
file_path: functions/infra/ssh_upload.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: ab011740b2cf010a97a9c0ac15ec185c48ed355c3c3819c1755294bb7ccc0260
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy", KeyPath: "~/.ssh/id_ed25519"}
  err := SSHUpload(conn, "./config.yaml", "/home/deploy/app/config.yaml")
  ```

notes:
  Usa scp con -P para el puerto (distinto a ssh que usa -p). Para subir directorios, usar SSHExec con tar/rsync como alternativa.

code:
  package infra

  import (
  	"fmt"
  	"os/exec"
  	"strings"
  )

  // SSHUpload sube un archivo local al host remoto via scp.
  func SSHUpload(conn SSHConn, localPath, remotePath string) error {
  	args := conn.scpArgs()
  	dest := fmt.Sprintf("%s:%s", conn.destination(), remotePath)
  	args = append(args, localPath, dest)

  	out, err := exec.Command("scp", args...).CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("scp upload to %s: %s", conn.destination(), strings.TrimSpace(string(out)))
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"},{"name":"localPath","desc":"ruta del archivo local a subir"},{"name":"remotePath","desc":"ruta de destino en el host remoto"}],"output":"nil si la subida fue exitosa, error en caso contrario"}


id: stop_app_go_infra
name: stop_app
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func StopApp(containerName string, removeImage bool) error
tags: ["docker","stop","remove","deploy","pipeline","infra","container"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/stop_app.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: c9f3114f4f02fd26b57eea8a0f4a52153be78503602cdbeb08a3a9fdcd9bef53
description:
  Para y elimina el contenedor de una app desplegada. Si removeImage es true elimina también la imagen Docker. containerName debe coincidir con el imageName usado en deploy_app.

uses_functions:
  ["docker_stop_container_go_infra","docker_remove_container_go_infra","docker_remove_image_go_infra"]

example:
  ```go
  // Parar contenedor sin eliminar la imagen (para relanzar rapido)
  err := StopApp("myapp", false)

  // Parar y limpiar todo
  err = StopApp("myapp", true)
  if err != nil {
      log.Fatal(err)
  }
  ```

notes:
  Inverso de deploy_app. El contenedor se detiene con 10 segundos de gracia antes de SIGKILL. La imagen se busca como containerName:latest (convencion de deploy_app). Si solo se quiere parar sin limpiar, usar removeImage=false para conservar la imagen en cache local y acelerar el siguiente deploy.

code:
  package infra

  import (
  	"fmt"
  )

  // StopApp para y elimina el contenedor de una app desplegada.
  // Si removeImage es true, elimina también la imagen Docker asociada.
  // containerName debe coincidir con el nombre usado en DeployApp (= imageName).
  func StopApp(containerName string, removeImage bool) error {
  	// 1. Detener el contenedor (timeout 10s)
  	if err := DockerStopContainer(containerName, 10); err != nil {
  		return fmt.Errorf("stop_app: detener contenedor %s: %w", containerName, err)
  	}

  	// 2. Eliminar el contenedor
  	if err := DockerRemoveContainer(containerName, false); err != nil {
  		return fmt.Errorf("stop_app: eliminar contenedor %s: %w", containerName, err)
  	}

  	// 3. Eliminar la imagen si se solicita
  	if removeImage {
  		imageName := containerName + ":latest"
  		if err := DockerRemoveImage(imageName, false); err != nil {
  			return fmt.Errorf("stop_app: eliminar imagen %s: %w", imageName, err)
  		}
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"containerName","desc":"nombre del contenedor Docker a detener (coincide con imageName de deploy_app)"},{"name":"removeImage","desc":"si true elimina tambien la imagen Docker"}],"output":"nil si la app se detuvo exitosamente, error en caso contrario"}


id: systemd_generate_unit_go_infra
name: systemd_generate_unit
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SystemdGenerateUnit(name, execStart, workDir, user string, env map[string]string) string
tags: ["systemd","unit","service","generate","deploy"]
uses_functions: []
uses_types: []
returns: []
imports: ["fmt","sort","strings"]
tests: []
file_path: functions/infra/systemd_generate_unit.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: 3a6d3577d85f9f239166371c3e7c720e87911f5a9dfbf3424f3f8aa642221c24
description:
  Genera el texto de un archivo .service de systemd para una app. Incluye restart automático y env vars en orden determinista.

example:
  ```go
  unit := SystemdGenerateUnit(
      "dag_engine",
      "/opt/apps/dag_engine/dag_engine",
      "/opt/apps/dag_engine",
      "deploy",
      map[string]string{"PORT": "8080", "DB_PATH": "/opt/apps/dag_engine/data/ops.db"},
  )
  fmt.Println(unit)
  ```

notes:
  Función pura sin I/O. Las env vars se ordenan alfabéticamente para output determinista. Genera un unit con Restart=on-failure y RestartSec=5.

code:
  package infra

  import (
  	"fmt"
  	"sort"
  	"strings"
  )

  // SystemdGenerateUnit genera el texto de un archivo .service de systemd.
  func SystemdGenerateUnit(name, execStart, workDir, user string, env map[string]string) string {
  	var b strings.Builder

  	b.WriteString("[Unit]\n")
  	b.WriteString(fmt.Sprintf("Description=%s\n", name))
  	b.WriteString("After=network.target\n\n")

  	b.WriteString("[Service]\n")
  	b.WriteString("Type=simple\n")
  	b.WriteString(fmt.Sprintf("ExecStart=%s\n", execStart))
  	if workDir != "" {
  		b.WriteString(fmt.Sprintf("WorkingDirectory=%s\n", workDir))
  	}
  	if user != "" {
  		b.WriteString(fmt.Sprintf("User=%s\n", user))
  	}

  	// Environment vars en orden determinista
  	if len(env) > 0 {
  		keys := make([]string, 0, len(env))
  		for k := range env {
  			keys = append(keys, k)
  		}
  		sort.Strings(keys)
  		for _, k := range keys {
  			b.WriteString(fmt.Sprintf("Environment=%s=%s\n", k, env[k]))
  		}
  	}

  	b.WriteString("Restart=on-failure\n")
  	b.WriteString("RestartSec=5\n\n")

  	b.WriteString("[Install]\n")
  	b.WriteString("WantedBy=multi-user.target\n")

  	return b.String()
  }

params_schema:
  {"params":[{"name":"name","desc":"nombre del servicio (aparece en Description)"},{"name":"execStart","desc":"comando completo para arrancar la app (ruta absoluta al binario + args)"},{"name":"workDir","desc":"directorio de trabajo del servicio (vacío para omitir)"},{"name":"user","desc":"usuario del sistema bajo el que corre el servicio (vacío para omitir)"},{"name":"env","desc":"variables de entorno key=value para el servicio"}],"output":"texto completo del archivo .service listo para escribir a /etc/systemd/system/"}


id: systemd_install_go_infra
name: systemd_install
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SystemdInstall(conn SSHConn, unitName, unitContent string) error
tags: ["systemd","install","deploy","service","remote"]
uses_functions: ["ssh_exec_go_infra"]
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/systemd_install.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: 69cb9675e2df296cc03b40a220fe781924fae866e8b22dd4a5ac59d5515f3118
description:
  Sube un unit file al host remoto, hace daemon-reload, enable y restart. Idempotente: reemplaza si el unit ya existe.

example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  unit := SystemdGenerateUnit("dag_engine", "/opt/apps/dag_engine/dag_engine", "/opt/apps/dag_engine", "deploy", nil)
  err := SystemdInstall(conn, "dag_engine", unit)
  ```

notes:
  Escribe el unit a un archivo temporal en /tmp y lo mueve con sudo a /etc/systemd/system/. Requiere que el usuario SSH tenga permisos sudo sin password para systemctl y mv a /etc/systemd/system/.

code:
  package infra

  import "fmt"

  // SystemdInstall sube un unit file al host remoto, hace daemon-reload, enable y restart.
  // Idempotente: si el unit ya existe, lo reemplaza.
  func SystemdInstall(conn SSHConn, unitName, unitContent string) error {
  	// Escribir a archivo temporal y mover a /etc/systemd/system/
  	tmpPath := fmt.Sprintf("/tmp/%s.service", unitName)
  	destPath := fmt.Sprintf("/etc/systemd/system/%s.service", unitName)

  	writeCmd := fmt.Sprintf("cat > %s << 'UNIT_EOF'\n%sUNIT_EOF", tmpPath, unitContent)
  	_, stderr, code, err := SSHExec(conn, writeCmd)
  	if err != nil {
  		return fmt.Errorf("systemd_install: ssh write: %w", err)
  	}
  	if code != 0 {
  		return fmt.Errorf("systemd_install: write unit file: %s", stderr)
  	}

  	// Mover a systemd y aplicar
  	cmds := fmt.Sprintf("sudo mv %s %s && sudo systemctl daemon-reload && sudo systemctl enable %s && sudo systemctl restart %s",
  		tmpPath, destPath, unitName, unitName)

  	_, stderr, code, err = SSHExec(conn, cmds)
  	if err != nil {
  		return fmt.Errorf("systemd_install: ssh exec: %w", err)
  	}
  	if code != 0 {
  		return fmt.Errorf("systemd_install: %s", stderr)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al host remoto"},{"name":"unitName","desc":"nombre del unit sin extensión (ej: dag_engine)"},{"name":"unitContent","desc":"contenido completo del archivo .service (generado por SystemdGenerateUnit)"}],"output":"nil si el unit se instaló y arrancó correctamente"}


id: systemd_restart_go_infra
name: systemd_restart
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SystemdRestart(conn SSHConn, unitName string) error
description: Reinicia un servicio systemd en un host remoto via SSH.
tags: ["systemd","restart","service","remote"]
uses_functions: ["ssh_exec_go_infra"]
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/systemd_restart.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: 17487dcbac3f36656123000e7807663b88a9d678dc93fc7091f144738f1b363f
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  if err := SystemdRestart(conn, "dag_engine"); err != nil {
      log.Fatal(err)
  }
  ```

notes:
  Usa `sudo systemctl restart`. Requiere que el usuario SSH tenga permisos sudo. Si el servicio no existe, systemctl retorna error.

code:
  package infra

  import "fmt"

  // SystemdRestart reinicia un servicio systemd en un host remoto.
  func SystemdRestart(conn SSHConn, unitName string) error {
  	_, stderr, code, err := SSHExec(conn, fmt.Sprintf("sudo systemctl restart %s", unitName))
  	if err != nil {
  		return fmt.Errorf("systemd_restart: ssh exec: %w", err)
  	}
  	if code != 0 {
  		return fmt.Errorf("systemd_restart %s: %s", unitName, stderr)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al host remoto"},{"name":"unitName","desc":"nombre del unit systemd a reiniciar (sin .service)"}],"output":"nil si el reinicio fue exitoso"}


id: systemd_status_go_infra
name: systemd_status
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SystemdStatus(conn SSHConn, unitName string, logLines int) (SystemdServiceStatus, error)
tags: ["systemd","status","monitor","service","remote"]
uses_functions: ["ssh_exec_go_infra"]
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","strings"]
tests: []
file_path: functions/infra/systemd_status.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: e90aa707b3e837e3f3df22a17a4c6341e82b25abb43edae721dfc49b367952a6
description:
  Consulta el estado de un servicio systemd en un host remoto. Retorna estado activo, sub-estado, PID y logs recientes.

example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  s, err := SystemdStatus(conn, "dag_engine", 20)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Printf("State: %s/%s  PID: %s\n", s.Active, s.SubState, s.MainPID)
  fmt.Println(s.Logs)
  ```

notes:
  Usa `systemctl show` para obtener propiedades sin formato humano. Los logs se obtienen con journalctl y son opcionales (logLines=0 los omite). Si journalctl falla (ej: permisos), los logs quedan vacíos sin error.

code:
  package infra

  import (
  	"fmt"
  	"strings"
  )

  // SystemdServiceStatus resultado de consultar el estado de un servicio systemd.
  type SystemdServiceStatus struct {
  	Unit     string
  	Active   string // "active", "inactive", "failed"
  	SubState string // "running", "dead", "failed"
  	MainPID  string
  	Logs     string
  }

  // SystemdStatus consulta el estado de un servicio systemd en un host remoto.
  func SystemdStatus(conn SSHConn, unitName string, logLines int) (SystemdServiceStatus, error) {
  	status := SystemdServiceStatus{Unit: unitName}

  	// Obtener propiedades del servicio
  	cmd := fmt.Sprintf("systemctl show %s --property=ActiveState,SubState,MainPID --no-pager", unitName)
  	stdout, stderr, code, err := SSHExec(conn, cmd)
  	if err != nil {
  		return status, fmt.Errorf("systemd_status: ssh exec: %w", err)
  	}
  	if code != 0 {
  		return status, fmt.Errorf("systemd_status: systemctl show: %s", stderr)
  	}

  	// Parsear propiedades key=value
  	for _, line := range strings.Split(stdout, "\n") {
  		parts := strings.SplitN(line, "=", 2)
  		if len(parts) != 2 {
  			continue
  		}
  		switch parts[0] {
  		case "ActiveState":
  			status.Active = parts[1]
  		case "SubState":
  			status.SubState = parts[1]
  		case "MainPID":
  			status.MainPID = parts[1]
  		}
  	}

  	// Obtener logs recientes
  	if logLines > 0 {
  		logCmd := fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || true", unitName, logLines)
  		logOut, _, _, logErr := SSHExec(conn, logCmd)
  		if logErr == nil {
  			status.Logs = strings.TrimSpace(logOut)
  		}
  	}

  	return status, nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al host remoto"},{"name":"unitName","desc":"nombre del unit systemd a consultar (sin .service)"},{"name":"logLines","desc":"número de líneas de journalctl a incluir (0 para no incluir logs)"}],"output":"SystemdServiceStatus con Active, SubState, MainPID y Logs del servicio"}


id: thumbnail_generate_go_infra
name: thumbnail_generate
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func ThumbnailGenerate(srcPath string, dstPath string, maxWidth int, maxHeight int) error
tags: ["image","thumbnail","resize","file","upload","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","image","image/jpeg","image/png","os","path/filepath","strings"]
test_file_path: functions/infra/thumbnail_generate_test.go
file_path: functions/infra/thumbnail_generate.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: c7d7a287a57903c812a29bf81f8bb9a2b06991f11b974e927098293309bd9e62
description:
  Lee una imagen JPEG o PNG, la redimensiona manteniendo aspect ratio para que quepa en (maxWidth, maxHeight) y la guarda en dstPath. Solo soporta entrada/salida JPEG y PNG.

example:
  ```go
  err := ThumbnailGenerate("./uploads/photo.jpg", "./uploads/thumbs/photo.jpg", 200, 200)
  if err != nil {
      log.Fatal(err)
  }
  ```

tests:
  ["genera thumbnail JPEG mas pequeno que el original","preserva aspect ratio","rechaza extension de salida no soportada","no agranda imagenes ya pequenas"]

notes:
  Implementacion solo con stdlib (`image`, `image/jpeg`, `image/png`). Resize nearest-neighbor: rapido y sin dependencias, pero baja calidad. Para apps que necesiten thumbnails de alta calidad usar `golang.org/x/image/draw` con `BiLinear` o `CatmullRom`.

  NO soporta WebP, AVIF, HEIC ni GIF. Si la imagen original ya es mas pequena que (maxWidth, maxHeight), se guarda sin redimensionar (no se agranda).

code:
  package infra

  import (
  	"fmt"
  	"image"
  	"image/jpeg"
  	"image/png"
  	"os"
  	"path/filepath"
  	"strings"
  )

  // ThumbnailGenerate lee una imagen JPEG o PNG desde srcPath, la redimensiona
  // manteniendo aspect ratio para que quepa dentro de (maxWidth, maxHeight) y guarda
  // el resultado en dstPath con el formato inferido por la extension de dstPath.
  //
  // Solo soporta entrada/salida JPEG y PNG (image stdlib). Formatos modernos como
  // WebP, AVIF o HEIC no estan soportados — retornan error explicito.
  //
  // El algoritmo de resize es nearest-neighbor (sin filtro) para mantener cero
  // dependencias externas. Para apps que necesiten calidad alta usar una libreria
  // como `golang.org/x/image/draw` con BiLinear o CatmullRom.
  func ThumbnailGenerate(srcPath string, dstPath string, maxWidth int, maxHeight int) error {
  	if maxWidth <= 0 || maxHeight <= 0 {
  		return fmt.Errorf("thumbnail_generate: maxWidth y maxHeight deben ser > 0")
  	}

  	srcF, err := os.Open(srcPath)
  	if err != nil {
  		return fmt.Errorf("thumbnail_generate: open src %s: %w", srcPath, err)
  	}
  	defer srcF.Close()

  	srcImg, _, err := image.Decode(srcF)
  	if err != nil {
  		return fmt.Errorf("thumbnail_generate: decode %s: %w", srcPath, err)
  	}

  	thumb := resizeNearest(srcImg, maxWidth, maxHeight)

  	if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
  		return fmt.Errorf("thumbnail_generate: mkdir dst: %w", err)
  	}

  	dstF, err := os.Create(dstPath)
  	if err != nil {
  		return fmt.Errorf("thumbnail_generate: create dst %s: %w", dstPath, err)
  	}
  	defer dstF.Close()

  	ext := strings.ToLower(filepath.Ext(dstPath))
  	switch ext {
  	case ".jpg", ".jpeg":
  		return jpeg.Encode(dstF, thumb, &jpeg.Options{Quality: 85})
  	case ".png":
  		return png.Encode(dstF, thumb)
  	default:
  		return fmt.Errorf("thumbnail_generate: extension %q no soportada (solo jpg/jpeg/png)", ext)
  	}
  }

  // resizeNearest redimensiona la imagen al maximo (maxW, maxH) manteniendo aspect
  // ratio. Usa interpolacion nearest-neighbor (rapida pero baja calidad).
  func resizeNearest(src image.Image, maxW, maxH int) image.Image {
  	srcBounds := src.Bounds()
  	srcW := srcBounds.Dx()
  	srcH := srcBounds.Dy()

  	if srcW == 0 || srcH == 0 {
  		return src
  	}

  	// Calcular escala manteniendo aspect ratio
  	scaleW := float64(maxW) / float64(srcW)
  	scaleH := float64(maxH) / float64(srcH)
  	scale := scaleW
  	if scaleH < scaleW {
  		scale = scaleH
  	}
  	if scale >= 1.0 {
  		// Imagen ya cabe, no agrandar
  		return src
  	}

  	dstW := int(float64(srcW) * scale)
  	dstH := int(float64(srcH) * scale)
  	if dstW < 1 {
  		dstW = 1
  	}
  	if dstH < 1 {
  		dstH = 1
  	}

  	dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
  	for y := 0; y < dstH; y++ {
  		srcY := int(float64(y) / scale)
  		for x := 0; x < dstW; x++ {
  			srcX := int(float64(x) / scale)
  			dst.Set(x, y, src.At(srcBounds.Min.X+srcX, srcBounds.Min.Y+srcY))
  		}
  	}
  	return dst
  }

params_schema:
  {"params":[{"name":"srcPath","desc":"ruta de la imagen original (JPEG o PNG)"},{"name":"dstPath","desc":"ruta destino del thumbnail (extension determina formato de salida: .jpg/.jpeg/.png)"},{"name":"maxWidth","desc":"ancho maximo del thumbnail en pixeles (\u003e0)"},{"name":"maxHeight","desc":"alto maximo del thumbnail en pixeles (\u003e0)"}],"output":"nil si el thumbnail se genero correctamente, error si falla la lectura, decode, encode o si la extension no es soportada"}


id: upload_handler_go_infra
name: upload_handler
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func UploadHandler(cfg StorageConfig) http.HandlerFunc
tags: ["http","upload","multipart","handler","pipeline","infra"]
uses_types: ["StorageConfig_go_infra","UploadedFile_go_infra","HTTPError_go_infra"]
returns: []
error_type: error_go_core
imports: ["net/http"]
test_file_path: functions/infra/upload_handler_test.go
file_path: functions/infra/upload_handler.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 22b452893d494f1fd8be1e9d2dc39e62e5d0da074547248b36441f76a6d2f464
description:
  HTTP handler completo para multipart upload. Compone UploadParse + FileValidateType + FileSaveDisk segun StorageConfig. Responde JSON con los UploadedFile guardados o un HTTPError estructurado en caso de fallo.

uses_functions:
  ["upload_parse_go_infra","file_validate_type_go_infra","file_save_disk_go_infra","http_json_response_go_infra","http_error_response_go_infra"]

example:
  ```go
  cfg := StorageConfig{
      BaseDir:      "./uploads",
      MaxFileSize:  10 << 20,
      AllowedTypes: []string{"image/png", "image/jpeg", "application/pdf"},
  }

  mux := HTTPRouter([]Route{
      {Method: "POST", Path: "/api/upload", Handler: UploadHandler(cfg)},
  })
  http.ListenAndServe(":8080", mux)
  ```

tests:
  ["acepta upload con multiple imagenes y responde JSON","rechaza tipo no permitido con 415","rechaza body que excede MaxFileSize con 400"]

notes:
  Pipeline de 5 funciones — compone parse + validate + save + json/error responses. El campo `ContentType` del `UploadedFile` retornado se sobreescribe con el MIME REAL detectado por magic bytes (ignorando el Content-Type del request), no con el inferido por extension.

  Si un archivo del batch falla, el handler responde error y NO continua. Las funciones que ya se guardaron en disco quedan ahi (no hay rollback transaccional).

code:
  package infra

  import (
  	"net/http"
  )

  // UploadHandler retorna un http.HandlerFunc completo para multipart upload. Compone
  // internamente UploadParse + FileValidateType + FileSaveDisk segun cfg.
  //
  // Comportamiento:
  //   - Parsea el multipart con maxSize = cfg.MaxFileSize.
  //   - Para cada archivo: valida tipo por magic bytes contra cfg.AllowedTypes,
  //     guarda en cfg.BaseDir con FileSaveDisk, sobreescribe ContentType con el
  //     MIME real detectado.
  //   - Responde 200 con JSON `{"files": [UploadedFile, ...]}`.
  //   - Errores: 400 si el parse falla, 415 si algun archivo es de tipo no permitido,
  //     500 si falla el guardado.
  func UploadHandler(cfg StorageConfig) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		files, err := UploadParse(r, cfg.MaxFileSize)
  		if err != nil {
  			HTTPErrorResponse(w, HTTPError{
  				Status:  http.StatusBadRequest,
  				Code:    "parse_error",
  				Message: err.Error(),
  			})
  			return
  		}

  		var saved []UploadedFile
  		for _, pf := range files {
  			mime, ok := FileValidateType(pf.Header, cfg.AllowedTypes)
  			if !ok {
  				HTTPErrorResponse(w, HTTPError{
  					Status:  http.StatusUnsupportedMediaType,
  					Code:    "invalid_type",
  					Message: "tipo no permitido: " + mime,
  				})
  				return
  			}

  			uploaded, err := FileSaveDisk(cfg.BaseDir, pf.Filename, pf.Content)
  			if err != nil {
  				HTTPErrorResponse(w, HTTPError{
  					Status:  http.StatusInternalServerError,
  					Code:    "save_error",
  					Message: err.Error(),
  				})
  				return
  			}
  			uploaded.ContentType = mime
  			saved = append(saved, uploaded)
  		}

  		HTTPJSONResponse(w, http.StatusOK, map[string]any{"files": saved})
  	}
  }

params_schema:
  {"params":[{"name":"cfg","desc":"StorageConfig con BaseDir, MaxFileSize y AllowedTypes"}],"output":"http.HandlerFunc lista para montar como ruta. Responde 200 con {\"files\":[UploadedFile,...]}, 400 parse_error, 415 invalid_type o 500 save_error"}


id: upload_parse_go_infra
name: upload_parse
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func UploadParse(r *http.Request, maxSize int64) ([]ParsedFile, error)
tags: ["http","upload","multipart","parse","form","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["bytes","fmt","io","mime/multipart","net/http","strings"]
test_file_path: functions/infra/upload_parse_test.go
file_path: functions/infra/upload_parse.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
props: []
emits: []
variant: []
content_hash: 9f44d4e9fa47aa422129591754653fbde9e3e327b7c9fe325ae2f6198d336c91
description:
  Parsea un request multipart/form-data y extrae todos los archivos adjuntos. Aplica http.MaxBytesReader para limitar el tamano. Carga el contenido en memoria como bytes.Reader.

example:
  ```go
  files, err := UploadParse(r, 10<<20) // 10 MB
  if err != nil {
      HTTPErrorResponse(w, HTTPError{Status: 400, Code: "parse_error", Message: err.Error()})
      return
  }
  for _, f := range files {
      mime, ok := FileValidateType(f.Header, []string{"image/png", "image/jpeg"})
      if !ok {
          continue
      }
      uploaded, _ := FileSaveDisk("./uploads", f.Filename, f.Content)
      log.Println(uploaded.Path, mime)
  }
  ```

tests:
  ["extrae un archivo del multipart","extrae multiples archivos","rechaza content-type no multipart","respeta maxSize"]

notes:
  Esta implementacion carga TODO el contenido en memoria. Adecuada para archivos pequenos/medianos (<100MB). Para uploads enormes, usar `multipart.NewReader(r.Body, boundary)` directamente y stream-ear cada parte a disco/S3. `MIMEHint` viene del header del cliente y NO debe confiarse — usar `FileValidateType(f.Header, allowed)` para verificacion real.

code:
  package infra

  import (
  	"bytes"
  	"fmt"
  	"io"
  	"mime/multipart"
  	"net/http"
  	"strings"
  )

  // ParsedFile representa un archivo extraido de un multipart form, con su contenido
  // completo en memoria y los primeros bytes (Header) listos para validacion por
  // magic bytes.
  type ParsedFile struct {
  	FormField string // nombre del field del form (ej: "file", "image")
  	Filename  string // nombre original reportado por el cliente
  	Size      int64  // tamano en bytes
  	MIMEHint  string // Content-Type segun el cliente (NO confiar — usar FileValidateType)
  	Header    []byte // primeros 512 bytes para magic byte detection
  	Content   io.Reader
  }

  // UploadParse parsea un request multipart/form-data y extrae todos los archivos
  // adjuntos. Aplica http.MaxBytesReader para limitar el tamano total a maxSize bytes.
  //
  // Retorna un slice de ParsedFile con el contenido cargado en memoria como bytes.Reader,
  // listo para ser pasado a FileSaveDisk o S3Upload.
  //
  // Para uploads muy grandes considerar streaming con multipart.Reader directamente
  // en vez de esta funcion (que carga todo en memoria).
  func UploadParse(r *http.Request, maxSize int64) ([]ParsedFile, error) {
  	if r.Body == nil {
  		return nil, fmt.Errorf("upload_parse: request sin body")
  	}
  	ct := r.Header.Get("Content-Type")
  	if !strings.HasPrefix(ct, "multipart/") {
  		return nil, fmt.Errorf("upload_parse: content-type %q no es multipart", ct)
  	}

  	r.Body = http.MaxBytesReader(nil, r.Body, maxSize)
  	if err := r.ParseMultipartForm(maxSize); err != nil {
  		return nil, fmt.Errorf("upload_parse: parse form: %w", err)
  	}
  	if r.MultipartForm == nil {
  		return nil, fmt.Errorf("upload_parse: form vacio")
  	}

  	var out []ParsedFile
  	for field, headers := range r.MultipartForm.File {
  		for _, fh := range headers {
  			pf, err := readMultipartFile(field, fh)
  			if err != nil {
  				return nil, err
  			}
  			out = append(out, pf)
  		}
  	}
  	return out, nil
  }

  func readMultipartFile(field string, fh *multipart.FileHeader) (ParsedFile, error) {
  	f, err := fh.Open()
  	if err != nil {
  		return ParsedFile{}, fmt.Errorf("upload_parse: open %s: %w", fh.Filename, err)
  	}
  	defer f.Close()

  	buf, err := io.ReadAll(f)
  	if err != nil {
  		return ParsedFile{}, fmt.Errorf("upload_parse: read %s: %w", fh.Filename, err)
  	}

  	header := buf
  	if len(header) > 512 {
  		header = header[:512]
  	}

  	return ParsedFile{
  		FormField: field,
  		Filename:  fh.Filename,
  		Size:      int64(len(buf)),
  		MIMEHint:  fh.Header.Get("Content-Type"),
  		Header:    header,
  		Content:   bytes.NewReader(buf),
  	}, nil
  }

params_schema:
  {"params":[{"name":"r","desc":"http.Request con Content-Type multipart/form-data"},{"name":"maxSize","desc":"tamano maximo total del body en bytes (ej: 10\u003c\u003c20 para 10MB)"}],"output":"slice de ParsedFile con FormField, Filename, Size, MIMEHint, Header (primeros 512 bytes para magic detection) y Content (io.Reader). Error si el body excede maxSize, content-type no es multipart o falla el parse"}


id: vps_setup_app_go_infra
name: vps_setup_app
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func VPSSetupApp(conn SSHConn, appName, remoteDir, serviceUser string) error
tags: ["vps","setup","deploy","remote","infra"]
uses_functions: ["ssh_exec_go_infra"]
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/vps_setup_app.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: f766f9a4e8404e356cb54dfb31e7874dd4bc15069916b0fd00b59777b3735d1d
description:
  Prepara un host remoto para recibir una app: crea directorios, usuario de servicio y asigna ownership.

example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  err := VPSSetupApp(conn, "dag_engine", "/opt/apps/dag_engine", "deploy")
  ```

notes:
  Idempotente: mkdir -p no falla si el directorio existe, useradd se salta si el usuario existe. Crea subdirectorios `data/` y `logs/` dentro del remoteDir. Requiere sudo.

code:
  package infra

  import "fmt"

  // VPSSetupApp prepara un host remoto para recibir una app:
  // crea directorios, usuario de servicio si no existe, y directorio de datos.
  func VPSSetupApp(conn SSHConn, appName, remoteDir, serviceUser string) error {
  	// Crear directorio de la app y subdirectorios comunes
  	mkdirCmd := fmt.Sprintf("sudo mkdir -p %s/data %s/logs", remoteDir, remoteDir)
  	_, stderr, code, err := SSHExec(conn, mkdirCmd)
  	if err != nil {
  		return fmt.Errorf("vps_setup_app: ssh exec: %w", err)
  	}
  	if code != 0 {
  		return fmt.Errorf("vps_setup_app: mkdir: %s", stderr)
  	}

  	// Crear usuario de servicio si se especificó y no existe
  	if serviceUser != "" {
  		userCmd := fmt.Sprintf("id %s >/dev/null 2>&1 || sudo useradd -r -s /usr/sbin/nologin -d %s %s",
  			serviceUser, remoteDir, serviceUser)
  		_, stderr, code, err = SSHExec(conn, userCmd)
  		if err != nil {
  			return fmt.Errorf("vps_setup_app: ssh exec: %w", err)
  		}
  		if code != 0 {
  			return fmt.Errorf("vps_setup_app: create user: %s", stderr)
  		}

  		// Asignar ownership al usuario de servicio
  		chownCmd := fmt.Sprintf("sudo chown -R %s:%s %s", serviceUser, serviceUser, remoteDir)
  		_, stderr, code, err = SSHExec(conn, chownCmd)
  		if err != nil {
  			return fmt.Errorf("vps_setup_app: ssh exec: %w", err)
  		}
  		if code != 0 {
  			return fmt.Errorf("vps_setup_app: chown: %s", stderr)
  		}
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al host remoto"},{"name":"appName","desc":"nombre de la app (para logging)"},{"name":"remoteDir","desc":"ruta absoluta donde vivirá la app en el remoto (ej: /opt/apps/dag_engine)"},{"name":"serviceUser","desc":"usuario del sistema para correr el servicio (vacío para omitir creación de usuario)"}],"output":"nil si el setup fue exitoso"}


id: wails_bind_crud_go_infra
name: wails_bind_crud
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: GenerateWailsCRUD(spec WailsCRUDSpec) string
tags: ["wails","crud","generator","bindings","codegen","infra"]
uses_functions: []
uses_types: []
returns: []
imports: ["fmt","strings"]
tests: []
file_path: functions/infra/wails_bind_crud.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 021352757307ee8e1012c7a7e7f3ed164224bb5a41d3d56a01c00ac71d8acdf5
description:
  Genera código Go de bindings CRUD para Wails: struct + métodos List/Get/Create/Update/Delete con stubs not-implemented.

example:
  ```go
  code := GenerateWailsCRUD(WailsCRUDSpec{
      EntityName: "User",
      Fields:     []string{"Name string", "Email string", "Role string"},
      WithList:   true,
      WithGet:    true,
      WithCreate: true,
      WithUpdate: true,
      WithDelete: true,
  })
  // Genera: type User struct + ListUsers + GetUser + CreateUser + UpdateUser + DeleteUser
  ```

notes:
  Función pura — genera código como string. Los métodos son stubs con `return fmt.Errorf("not implemented")`. Un agente puede generar el código, insertarlo en app.go, y luego implementar los TODOs.

code:
  package infra

  import (
  	"fmt"
  	"strings"
  )

  // WailsCRUDSpec define la especificación para generar bindings CRUD.
  type WailsCRUDSpec struct {
  	EntityName string   // Nombre de la entidad (PascalCase, ej: "User")
  	Fields     []string // Campos de la entidad (ej: ["Name string", "Email string"])
  	WithList   bool     // Generar List method
  	WithGet    bool     // Generar Get method
  	WithCreate bool     // Generar Create method
  	WithUpdate bool     // Generar Update method
  	WithDelete bool     // Generar Delete method
  }

  // GenerateWailsCRUD genera código Go de bindings CRUD para una entidad.
  // Retorna el código Go como string — función pura.
  func GenerateWailsCRUD(spec WailsCRUDSpec) string {
  	var sb strings.Builder
  	lower := strings.ToLower(spec.EntityName)
  	plural := lower + "s"

  	// Tipo
  	sb.WriteString(fmt.Sprintf("// %s entity\n", spec.EntityName))
  	sb.WriteString(fmt.Sprintf("type %s struct {\n", spec.EntityName))
  	sb.WriteString("\tID string `json:\"id\"`\n")
  	for _, field := range spec.Fields {
  		parts := strings.SplitN(field, " ", 2)
  		if len(parts) == 2 {
  			jsonTag := strings.ToLower(parts[0])
  			sb.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n", parts[0], parts[1], jsonTag))
  		}
  	}
  	sb.WriteString("}\n\n")

  	// List
  	if spec.WithList {
  		sb.WriteString(fmt.Sprintf("// List%s retorna todos los %s.\n", spec.EntityName+"s", plural))
  		sb.WriteString(fmt.Sprintf("func (a *App) List%s() ([]%s, error) {\n", spec.EntityName+"s", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement List%s\n", spec.EntityName+"s"))
  		sb.WriteString(fmt.Sprintf("\treturn nil, fmt.Errorf(\"not implemented\")\n"))
  		sb.WriteString("}\n\n")
  	}

  	// Get
  	if spec.WithGet {
  		sb.WriteString(fmt.Sprintf("// Get%s retorna un %s por ID.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Get%s(id string) (%s, error) {\n", spec.EntityName, spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Get%s\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\treturn %s{}, fmt.Errorf(\"not implemented\")\n", spec.EntityName))
  		sb.WriteString("}\n\n")
  	}

  	// Create
  	if spec.WithCreate {
  		sb.WriteString(fmt.Sprintf("// Create%s crea un nuevo %s.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Create%s(%s %s) (%s, error) {\n", spec.EntityName, lower, spec.EntityName, spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Create%s\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\treturn %s, fmt.Errorf(\"not implemented\")\n", lower))
  		sb.WriteString("}\n\n")
  	}

  	// Update
  	if spec.WithUpdate {
  		sb.WriteString(fmt.Sprintf("// Update%s actualiza un %s existente.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Update%s(%s %s) (%s, error) {\n", spec.EntityName, lower, spec.EntityName, spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Update%s\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\treturn %s, fmt.Errorf(\"not implemented\")\n", lower))
  		sb.WriteString("}\n\n")
  	}

  	// Delete
  	if spec.WithDelete {
  		sb.WriteString(fmt.Sprintf("// Delete%s elimina un %s por ID.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Delete%s(id string) error {\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Delete%s\n", spec.EntityName))
  		sb.WriteString("\treturn fmt.Errorf(\"not implemented\")\n")
  		sb.WriteString("}\n\n")
  	}

  	return sb.String()
  }

params_schema:
  {"params":[{"name":"spec","desc":"estructura WailsCRUDSpec con EntityName, Fields, WithList/Get/Create/Update/Delete flags"}],"output":"string con codigo Go generado (struct + metodos CRUD stubs)"}


id: wails_build_go_infra
name: wails_build
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: WailsBuild(ctx context.Context, cfg WailsBuildConfig) error
tags: ["wails","build","compile","desktop","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os/exec","fmt","strings","context"]
tests: []
file_path: functions/infra/wails_build.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Requiere `wails` CLI instalado. WailsDev bloquea el proceso (es un servidor de desarrollo).
content_hash: 29867491260074c6a0c1bad048b1278cfd099a503463ac4fa26f4c0f30f3e703
description:
  Compila un proyecto Wails para linux/windows/darwin. Incluye WailsDev para modo desarrollo con hot reload.

example:
  ```go
  // Build para linux
  WailsBuild(ctx, WailsBuildConfig{
      Dir:      "/home/user/my-app",
      Platform: "linux",
  })

  // Desarrollo con hot reload + browser
  WailsDev(ctx, "/home/user/my-app", true)
  ```

code:
  package infra

  import (
  	"context"
  	"fmt"
  	"os/exec"
  	"strings"
  )

  // WailsBuildConfig configura la compilación de un proyecto Wails.
  type WailsBuildConfig struct {
  	Dir      string // Directorio del proyecto Wails
  	Platform string // linux, windows, darwin (default: current)
  	Output   string // Nombre del binario de salida
  	Debug    bool   // Build con debug info
  }

  // WailsBuild compila un proyecto Wails para la plataforma especificada.
  func WailsBuild(ctx context.Context, cfg WailsBuildConfig) error {
  	if cfg.Dir == "" {
  		return fmt.Errorf("dir is required")
  	}

  	args := []string{"build"}

  	if cfg.Platform != "" {
  		args = append(args, "-platform", cfg.Platform)
  	}
  	if cfg.Output != "" {
  		args = append(args, "-o", cfg.Output)
  	}
  	if cfg.Debug {
  		args = append(args, "-debug")
  	}

  	cmd := exec.CommandContext(ctx, "wails", args...)
  	cmd.Dir = cfg.Dir

  	output, err := cmd.CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("wails build failed: %w\n%s", err, string(output))
  	}

  	fmt.Printf("Built successfully: %s\n", strings.TrimSpace(string(output)))
  	return nil
  }

  // WailsDev lanza un proyecto Wails en modo desarrollo con hot reload.
  func WailsDev(ctx context.Context, dir string, browser bool) error {
  	if dir == "" {
  		return fmt.Errorf("dir is required")
  	}

  	args := []string{"dev"}
  	if browser {
  		args = append(args, "-browser")
  	}

  	cmd := exec.CommandContext(ctx, "wails", args...)
  	cmd.Dir = dir
  	cmd.Stdout = nil // inherit
  	cmd.Stderr = nil

  	return cmd.Run()
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context para cancelacion"},{"name":"cfg","desc":"estructura WailsBuildConfig con Dir, Platform (linux/windows/darwin)"}],"output":"nil si la compilacion fue exitosa, error en caso contrario"}


id: wails_emit_event_go_infra
name: wails_emit_event
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: WailsEmitEvent(ctx context.Context, eventName string, data interface{})
tags: ["wails","event","emit","ipc","realtime","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["context","encoding/json","fmt","time","github.com/wailsapp/wails/v2/pkg/runtime"]
tests: []
file_path: functions/infra/wails_emit_event.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 7f4a1a50609433623d66dbb19bd9ab8060876d0fa5456170cd4f4fad3a6ee74d
description:
  Emite eventos tipados de Go al frontend con timestamp automático. Incluye WailsEmitJSON para serialización explícita.

example:
  ```go
  // Emitir evento tipado
  WailsEmitEvent(ctx, "price:update", PriceData{Symbol: "BTC", Price: 67500.0})

  // El frontend lo recibe con useWailsEvent:
  // useWailsEvent({ eventName: 'price:update', onEvent: (data) => ... })
  ```

notes:
  El evento llega al frontend como WailsEventPayload con `type`, `data` y `timestamp`. Complementa `use_wails_event` del lado TS. Requiere la dependencia `github.com/wailsapp/wails/v2` en el proyecto destino.

code:
  //go:build ignore
  // NOTE: requires wails dependency in target project — use this file by copying into a Wails project

  package infra

  import (
  	"context"
  	"encoding/json"
  	"fmt"
  	"time"

  	"github.com/wailsapp/wails/v2/pkg/runtime"
  )

  // WailsEventPayload es un envelope para eventos tipados.
  type WailsEventPayload struct {
  	Type      string      `json:"type"`
  	Data      interface{} `json:"data"`
  	Timestamp time.Time   `json:"timestamp"`
  }

  // WailsEmitEvent emite un evento tipado al frontend.
  func WailsEmitEvent(ctx context.Context, eventName string, data interface{}) {
  	payload := WailsEventPayload{
  		Type:      eventName,
  		Data:      data,
  		Timestamp: time.Now(),
  	}
  	runtime.EventsEmit(ctx, eventName, payload)
  }

  // WailsEmitJSON emite datos como JSON string al frontend.
  func WailsEmitJSON(ctx context.Context, eventName string, data interface{}) error {
  	b, err := json.Marshal(data)
  	if err != nil {
  		return fmt.Errorf("marshaling event data: %w", err)
  	}
  	runtime.EventsEmit(ctx, eventName, string(b))
  	return nil
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context de Wails"},{"name":"eventName","desc":"nombre del evento a emitir (ej: price:update)"},{"name":"data","desc":"datos a serializar y enviar al frontend"}],"output":"void (sin valor de retorno, efecto es emitir el evento)"}


id: wails_stream_data_go_infra
name: wails_stream_data
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: WailsStreamData[T any](ctx context.Context, cfg WailsStreamConfig, data []T) error
tags: ["wails","stream","ipc","realtime","chunks","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["context","fmt","time","github.com/wailsapp/wails/v2/pkg/runtime"]
tests: []
file_path: functions/infra/wails_stream_data.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 9e95a95081a919411bb80efa578abead56b149c821158ad68f847377ce859ae3
description:
  Envía datos como stream Go→TS con protocolo {name}/{name}:complete/{name}:error. Incluye WailsStreamFunc para generadores.

example:
  ```go
  // Stream de slice
  WailsStreamData(ctx, WailsStreamConfig{
      StreamName: "logs",
      ChunkDelay: 10 * time.Millisecond,
  }, logLines)

  // Stream con generador
  WailsStreamFunc(ctx, "metrics", func(ctx context.Context, ch chan<- Metric) error {
      for {
          select {
          case <-ctx.Done():
              return nil
          case <-time.After(time.Second):
              ch <- collectMetric()
          }
      }
  })

  // Frontend lo recibe con useWailsStream:
  // useWailsStream({ streamName: 'metrics', autoStart: true })
  ```

notes:
  Protocolo: chunks en `{streamName}`, fin en `{streamName}:complete`, error en `{streamName}:error`. Compatible con `use_wails_stream` del lado TS. WailsStreamFunc usa goroutine + canal para datos asíncronos. Requiere Go 1.18+ (generics) y la dependencia `github.com/wailsapp/wails/v2` en el proyecto destino.

code:
  //go:build ignore
  // NOTE: requires wails dependency in target project — use this file by copying into a Wails project

  package infra

  import (
  	"context"
  	"fmt"
  	"time"

  	"github.com/wailsapp/wails/v2/pkg/runtime"
  )

  // WailsStreamConfig configura un stream de datos hacia el frontend.
  type WailsStreamConfig struct {
  	StreamName string        // Nombre del stream (evento base)
  	ChunkDelay time.Duration // Delay entre chunks (default 0)
  }

  // WailsStreamData envía un slice de datos como stream al frontend.
  // Emite cada elemento como chunk en {streamName}, y {streamName}:complete al terminar.
  func WailsStreamData[T any](ctx context.Context, cfg WailsStreamConfig, data []T) error {
  	if cfg.StreamName == "" {
  		return fmt.Errorf("stream name is required")
  	}

  	for _, chunk := range data {
  		select {
  		case <-ctx.Done():
  			runtime.EventsEmit(ctx, cfg.StreamName+":error", map[string]string{
  				"message": "stream cancelled",
  			})
  			return ctx.Err()
  		default:
  			runtime.EventsEmit(ctx, cfg.StreamName, chunk)
  			if cfg.ChunkDelay > 0 {
  				time.Sleep(cfg.ChunkDelay)
  			}
  		}
  	}

  	runtime.EventsEmit(ctx, cfg.StreamName+":complete", nil)
  	return nil
  }

  // WailsStreamFunc ejecuta una función generadora y envía resultados como stream.
  // La función generadora envía datos por el canal, y esta función los retransmite al frontend.
  func WailsStreamFunc[T any](ctx context.Context, streamName string, generator func(ctx context.Context, ch chan<- T) error) error {
  	ch := make(chan T, 100)
  	errCh := make(chan error, 1)

  	go func() {
  		defer close(ch)
  		errCh <- generator(ctx, ch)
  	}()

  	for chunk := range ch {
  		runtime.EventsEmit(ctx, streamName, chunk)
  	}

  	if err := <-errCh; err != nil {
  		runtime.EventsEmit(ctx, streamName+":error", map[string]string{
  			"message": err.Error(),
  		})
  		return err
  	}

  	runtime.EventsEmit(ctx, streamName+":complete", nil)
  	return nil
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context de Wails"},{"name":"cfg","desc":"estructura WailsStreamConfig con StreamName y ChunkDelay"},{"name":"data","desc":"slice de datos genericos a enviar en chunks"}],"output":"nil si el stream se completo exitosamente, error en caso contrario"}


id: write_dockerfile_go_infra
name: write_dockerfile
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WriteDockerfile(dir, content string) (string, error)
tags: ["docker","dockerfile","io","write","deploy","infra"]
uses_functions: ["generate_dockerfile_go_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath"]
test_file_path: functions/infra/write_dockerfile_test.go
file_path: functions/infra/write_dockerfile.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: d2b96af570f088cb1ea5aeaf933ba61a5bfee8ccb2f0c08e7b91a6cc72c4aa0d
description:
  Escribe content en dir/Dockerfile. Crea el directorio si no existe. Retorna el path absoluto del archivo escrito. Compañera impura de generate_dockerfile.

example:
  ```go
  // Patron puro+impuro: generar contenido y luego escribir
  content := GenerateDockerfile("myapp", 8080, map[string]string{"PORT": "8080"})
  path, err := WriteDockerfile("/home/user/apps/myapp", content)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println("Dockerfile escrito en:", path)
  // /home/user/apps/myapp/Dockerfile
  ```

tests:
  ["escribe Dockerfile en directorio existente","crea directorio si no existe","retorna path absoluto correcto","error si dir es path invalido"]

notes:
  Patron puro+impuro: generate_dockerfile produce el texto (pura, testeable sin I/O), write_dockerfile lo persiste (impura, efecto secundario aislado). Esto facilita testear la generacion del contenido independientemente de la escritura. Sobreescribe cualquier Dockerfile existente en el directorio.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  )

  // WriteDockerfile escribe content en dir/Dockerfile.
  // Crea el directorio si no existe. Retorna el path absoluto del archivo escrito.
  func WriteDockerfile(dir, content string) (string, error) {
  	absDir, err := filepath.Abs(dir)
  	if err != nil {
  		return "", fmt.Errorf("write_dockerfile: resolver path %s: %w", dir, err)
  	}

  	if err := os.MkdirAll(absDir, 0o755); err != nil {
  		return "", fmt.Errorf("write_dockerfile: crear directorio %s: %w", absDir, err)
  	}

  	dockerfilePath := filepath.Join(absDir, "Dockerfile")
  	if err := os.WriteFile(dockerfilePath, []byte(content), 0o644); err != nil {
  		return "", fmt.Errorf("write_dockerfile: escribir %s: %w", dockerfilePath, err)
  	}

  	return dockerfilePath, nil
  }

params_schema:
  {"params":[{"name":"dir","desc":"directorio donde crear el archivo Dockerfile (se crea si no existe)"},{"name":"content","desc":"contenido del Dockerfile como string"}],"output":"ruta absoluta del archivo Dockerfile escrito"}


id: ws_broadcast_go_infra
name: ws_broadcast
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WSBroadcast(hub *WSHub, msg []byte) error
tags: ["websocket","broadcast","server","fanout","infra","realtime"]
uses_functions: []
uses_types: ["WSHub_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
test_file_path: functions/infra/ws_test.go
file_path: functions/infra/ws_broadcast.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: d5ab099284d4a0fd14c8a093d23390de05cbe7774d1608e53473c600255ea577
description:
  Envia bytes al canal Broadcast del hub para que se entreguen a todos los clientes WebSocket conectados. La entrega real la hace el loop Run() del hub. Bloqueante hasta que el canal Broadcast tenga espacio (capacidad 256 por defecto). Retorna error si el hub es nil.

example:
  ```go
  hub := NewWSHub()
  go hub.Run()
  defer hub.Stop()

  // Notificar a todos los clientes conectados
  msg, _ := json.Marshal(WSMessage{
      Type:     "step_complete",
      Payload:  []byte(`{"step":"build","status":"ok"}`),
      SenderID: "pipeline_runner",
      Ts:       time.Now().UnixMilli(),
  })
  WSBroadcast(hub, msg)
  ```

tests:
  ["envia mensaje al canal Broadcast del hub","retorna error si hub es nil","el hub entrega el mensaje a todos los clientes registrados"]

notes:
  Bloqueante con backpressure controlado: si el canal `Broadcast` se llena (256 mensajes pendientes), la llamada se bloquea hasta que el hub procese alguno. Esto da feedback natural al productor cuando el sistema esta saturado.

  La entrega a clientes individuales es no bloqueante (clientes lentos se desconectan automaticamente). Si necesitas semantica at-least-once con retry, montar la logica encima.

code:
  package infra

  import "fmt"

  // WSBroadcast envia bytes al canal Broadcast del hub para entregar el mensaje
  // a todos los clientes conectados. La entrega real la hace el loop Run() del hub.
  // Bloqueante hasta que el canal Broadcast tenga espacio (capacidad 256 por defecto).
  func WSBroadcast(hub *WSHub, msg []byte) error {
  	if hub == nil {
  		return fmt.Errorf("ws broadcast: hub is nil")
  	}
  	hub.Broadcast <- msg
  	return nil
  }

params_schema:
  {"params":[{"name":"hub","desc":"*WSHub donde estan registrados los clientes. Si es nil retorna error."},{"name":"msg","desc":"bytes a entregar a todos los clientes. Tipicamente JSON serializado de un WSMessage."}],"output":"error si hub es nil. Nil si el mensaje se encolo en el canal Broadcast (la entrega es asincrona)."}


id: ws_handler_go_infra
name: ws_handler
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WSHandler(hub *WSHub, origins []string) http.HandlerFunc
tags: ["websocket","handler","http","server","hub","infra","realtime"]
uses_functions: ["ws_upgrader_go_infra"]
uses_types: ["WSHub_go_infra","WSClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["context","crypto/rand","encoding/hex","net/http","time","nhooyr.io/websocket"]
test_file_path: functions/infra/ws_test.go
file_path: functions/infra/ws_handler.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: ece22798c8c4cce858cfec555eba21d11d86a8850d0b4107d11a873eadaee914
description:
  Retorna un http.HandlerFunc que upgradea la conexion HTTP a WebSocket via WSUpgrader, crea un WSClient con ID hex aleatorio, lo registra en el hub y lanza readPump y writePump como goroutines. La readPump bloquea el handler para mantener la request viva. Al desconectar (error de I/O o canal cerrado) se desregistra el cliente y se cierra la conexion limpiamente.

example:
  ```go
  hub := NewWSHub()
  go hub.Run()
  defer hub.Stop()

  routes := []Route{
      {Method: "GET", Path: "/ws", Handler: WSHandler(hub, []string{"example.com"})},
  }
  mux := HTTPRouter(routes)
  HTTPServe(":8080", mux, ctx)
  ```

tests:
  ["upgradea conexion y registra cliente en hub","broadcast del hub llega al cliente conectado","desregistra cliente al desconectar","multiples clientes reciben el broadcast"]

notes:
  El handler asigna un ID hex aleatorio de 16 caracteres a cada cliente (`crypto/rand`). Si se quiere usar IDs propios (UUID, username autenticado), envolver el handler para sobreescribir `client.ID` antes del Register.

  readPump publica todos los mensajes recibidos al `hub.Broadcast` — modo chat-like por defecto. Para procesar mensajes con un callback (sin reenviar a todos), copiar el codigo y reemplazar el Broadcast por la logica deseada. Esta separacion es deliberada: el hub es un mecanismo de fan-out, no un router de mensajes.

  writePump usa write deadline de 10s — si un Write tarda mas, asume cliente muerto y termina. Esto previene goroutines colgadas si el cliente cierra TCP sin enviar Close frame.

code:
  package infra

  import (
  	"context"
  	"crypto/rand"
  	"encoding/hex"
  	"net/http"
  	"time"

  	"nhooyr.io/websocket"
  )

  // WSHandler retorna un http.HandlerFunc que upgradea la conexion HTTP a WebSocket,
  // crea un WSClient, lo registra en el hub y lanza dos goroutines:
  //   - readPump: lee mensajes del Conn y los publica al hub.Broadcast
  //   - writePump: consume del client.Send y escribe al Conn
  //
  // El cliente se desregistra del hub cuando alguna de las pumps termina (cliente
  // desconectado, error de I/O, o canal cerrado). Asigna un ID hex aleatorio si
  // no se sobreescribe externamente.
  func WSHandler(hub *WSHub, origins []string) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		conn, err := WSUpgrader(w, r, origins)
  		if err != nil {
  			return
  		}

  		client := &WSClient{
  			Hub:  hub,
  			Conn: conn,
  			Send: make(chan []byte, 64),
  			ID:   randomID(),
  		}
  		hub.Register <- client

  		// writePump
  		go wsWritePump(client)
  		// readPump (bloqueante en el handler para mantener viva la request)
  		wsReadPump(client)
  	}
  }

  // wsReadPump lee mensajes del Conn y los publica al hub.Broadcast.
  // Termina si Read retorna error (cliente desconectado o cerrado).
  // Al terminar, desregistra el cliente y cierra la conexion.
  func wsReadPump(client *WSClient) {
  	defer func() {
  		// Unregister no bloqueante: si el hub ya esta cerrado, no esperamos
  		select {
  		case client.Hub.Unregister <- client:
  		case <-client.Hub.done:
  		}
  		_ = client.Conn.Close(websocket.StatusNormalClosure, "")
  	}()

  	ctx := context.Background()
  	for {
  		_, data, err := client.Conn.Read(ctx)
  		if err != nil {
  			return
  		}
  		// Encolar al hub.Broadcast sin bloquear si esta lleno
  		select {
  		case client.Hub.Broadcast <- data:
  		default:
  			// Hub saturado: dropear mensaje del cliente para no bloquear el read
  		}
  	}
  }

  // wsWritePump consume del canal Send del cliente y escribe al Conn.
  // Termina si el canal se cierra (hub desregistro al cliente) o si Write falla.
  func wsWritePump(client *WSClient) {
  	for msg := range client.Send {
  		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  		err := client.Conn.Write(ctx, websocket.MessageText, msg)
  		cancel()
  		if err != nil {
  			return
  		}
  	}
  }

  // randomID genera un identificador hex aleatorio de 16 caracteres (8 bytes).
  // No es criptograficamente perfecto para autenticacion — solo identificacion.
  func randomID() string {
  	b := make([]byte, 8)
  	if _, err := rand.Read(b); err != nil {
  		return "anon"
  	}
  	return hex.EncodeToString(b)
  }

params_schema:
  {"params":[{"name":"hub","desc":"*WSHub donde se registran los clientes que se conecten via este handler. Debe estar corriendo (hub.Run() lanzado en goroutine)."},{"name":"origins","desc":"lista de patrones de origen permitidos para el upgrade. Pasa directamente a WSUpgrader. Para dev: `[\"*\"]`. Para prod: lista explicita."}],"output":"http.HandlerFunc lista para montarse en una ruta GET. Cada conexion entrante crea un cliente nuevo en el hub."}


id: ws_hub_go_infra
name: ws_hub
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func NewWSHub() *WSHub
tags: ["websocket","hub","server","broadcast","infra","realtime"]
uses_functions: []
uses_types: ["WSHub_go_infra","WSClient_go_infra"]
returns: ["WSHub_go_infra"]
error_type: error_go_core
imports: []
test_file_path: functions/infra/ws_test.go
file_path: functions/infra/ws_hub.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: 8725e9ed3c9352198fcd8ba3dc82fdbcb4ad6378ae98f8356db5e1562f422855
description:
  Crea un WSHub con canales Broadcast (buffereado, capacidad 256), Register y Unregister. El metodo Run() ejecuta el loop principal: select sobre Register/Unregister/Broadcast. Stop() cierra el loop y limpia todos los clientes. Patron hub clasico de Go: un solo writer al mapa de clientes, sin mutex.

example:
  ```go
  hub := NewWSHub()
  go hub.Run()
  defer hub.Stop()

  // Cualquier goroutine puede broadcastear
  WSBroadcast(hub, []byte("evento"))
  ```

tests:
  ["registra y desregistra clientes","broadcast envia mensaje a todos los clientes","cliente lento es desconectado del hub","Stop cierra todos los clientes"]

notes:
  El loop `Run()` es bloqueante y esta diseñado para correr como goroutine durante toda la vida del proceso. La unica forma de pararlo es llamar a `Stop()`.

  Un cliente lento (cuyo canal `Send` esta lleno) es desconectado automaticamente del hub durante el broadcast — esta es la garantia anti-backpressure: ningun cliente puede bloquear el broadcast a los demas.

  El hub no autentica ni autoriza — es solo un fan-out. La auth se hace en el handler antes de registrar el cliente.

code:
  package infra

  import "sync/atomic"

  // WSHub gestiona el ciclo de vida de conexiones WebSocket.
  // Mantiene un mapa de clientes activos y canales para registro,
  // desregistro y broadcast. Se ejecuta como goroutine via su Run().
  type WSHub struct {
  	Clients    map[*WSClient]bool
  	Broadcast  chan []byte
  	Register   chan *WSClient
  	Unregister chan *WSClient
  	done       chan struct{}
  	count      atomic.Int64
  }

  // ClientCount retorna el numero de clientes registrados de forma thread-safe.
  // Util para metricas, dashboards y tests.
  func (h *WSHub) ClientCount() int {
  	return int(h.count.Load())
  }

  // NewWSHub crea un WSHub vacio con canales inicializados.
  // Ejecutar Run() en una goroutine para activar el loop de eventos.
  func NewWSHub() *WSHub {
  	return &WSHub{
  		Clients:    make(map[*WSClient]bool),
  		Broadcast:  make(chan []byte, 256),
  		Register:   make(chan *WSClient),
  		Unregister: make(chan *WSClient),
  		done:       make(chan struct{}),
  	}
  }

  // Run ejecuta el loop principal del hub. Atiende registros, desregistros
  // y broadcasts en un select. Bloqueante: lanzar como goroutine.
  // Para parar, llamar a Stop().
  func (h *WSHub) Run() {
  	for {
  		select {
  		case <-h.done:
  			// Cerrar todos los Send y vaciar el mapa
  			for client := range h.Clients {
  				delete(h.Clients, client)
  				close(client.Send)
  			}
  			h.count.Store(0)
  			return
  		case client := <-h.Register:
  			h.Clients[client] = true
  			h.count.Store(int64(len(h.Clients)))
  		case client := <-h.Unregister:
  			if _, ok := h.Clients[client]; ok {
  				delete(h.Clients, client)
  				close(client.Send)
  				h.count.Store(int64(len(h.Clients)))
  			}
  		case msg := <-h.Broadcast:
  			for client := range h.Clients {
  				select {
  				case client.Send <- msg:
  				default:
  					// Cliente lento: desconectar
  					delete(h.Clients, client)
  					close(client.Send)
  				}
  			}
  			h.count.Store(int64(len(h.Clients)))
  		}
  	}
  }

  // Stop cierra el loop de Run() y limpia todos los clientes.
  // Idempotente — llamar mas de una vez no panica.
  func (h *WSHub) Stop() {
  	select {
  	case <-h.done:
  		// Ya cerrado
  	default:
  		close(h.done)
  	}
  }

params_schema:
  {"output":"*WSHub listo para usar. Llamar a Run() en una goroutine para activar el loop. Llamar a Stop() para terminar limpiamente."}


id: ws_send_go_infra
name: ws_send
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WSSend(client *WSClient, msg []byte) error
tags: ["websocket","send","server","infra","realtime"]
uses_functions: []
uses_types: ["WSClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
test_file_path: functions/infra/ws_test.go
file_path: functions/infra/ws_send.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: 25add33d81cc8ccc6d7a869cae2cca2c308bcf64f093dfab6a3c2235432bd010
description:
  Envia bytes al canal Send de un cliente WebSocket especifico de forma no bloqueante. Si el canal esta lleno o el cliente desconectado, retorna error sin bloquear al emisor. Para broadcast a todos los clientes del hub usar WSBroadcast en su lugar.

example:
  ```go
  // Enviar mensaje a un cliente especifico (unicast)
  target := findClientByID(hub, "user-42")
  err := WSSend(target, []byte(`{"type":"notification","text":"hola"}`))
  if err != nil {
      log.Printf("send failed: %v", err)
  }
  ```

tests:
  ["envia mensaje al canal Send del cliente","retorna error si client es nil","retorna error si el canal esta lleno"]

notes:
  Funcion no bloqueante: si el cliente no consume su canal `Send`, este se llena y la funcion retorna error inmediatamente en vez de bloquear al emisor. Esto previene que un cliente lento bloquee a otros productores.

  El mensaje se encola en `client.Send` — la goroutine writePump del cliente lo escribira al `websocket.Conn`. No hay garantia de orden estricto entre llamadas concurrentes a `WSSend` sobre el mismo cliente.

code:
  package infra

  import "fmt"

  // WSSend envia bytes al canal Send de un cliente especifico de forma no bloqueante.
  // Si el canal esta lleno o el cliente desconectado, retorna error sin bloquear al emisor.
  // Para broadcast a todos los clientes usar WSBroadcast.
  func WSSend(client *WSClient, msg []byte) error {
  	if client == nil {
  		return fmt.Errorf("ws send: client is nil")
  	}
  	select {
  	case client.Send <- msg:
  		return nil
  	default:
  		return fmt.Errorf("ws send: client %s send channel full or closed", client.ID)
  	}
  }

params_schema:
  {"params":[{"name":"client","desc":"*WSClient destinatario. Si es nil retorna error."},{"name":"msg","desc":"bytes a enviar. Tipicamente JSON serializado de un WSMessage. Se entregan tal cual al websocket.Conn."}],"output":"error si el canal Send esta lleno (cliente lento) o cerrado (cliente desconectado). Nil si el mensaje se encolo correctamente."}


