chore: auto-commit (57 archivos)

- frontend/functions/core/format_datetime_short.md
- frontend/functions/core/format_datetime_short.test.ts
- frontend/functions/core/format_datetime_short.ts
- frontend/functions/core/format_duration.md
- frontend/functions/core/format_duration.test.ts
- frontend/functions/core/format_duration.ts
- frontend/functions/core/month_grid.md
- frontend/functions/core/month_grid.test.ts
- frontend/functions/core/month_grid.ts
- frontend/functions/core/string_hash_palette.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 03:41:58 +02:00
parent cd50e790ca
commit 03568c88e3
58 changed files with 2923 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
package browser
// CdpSetCookie establece una cookie en el browser via Network.setCookie.
// Util para autenticar tests e2e contra apps con sesion HttpOnly: hacer login
// HTTP en el test, capturar el Set-Cookie, y propagar al browser antes de navegar.
//
// name/value/domain son obligatorios. path por defecto "/". httpOnly true por
// defecto (replica HttpOnly de la app). secure y sameSite opcionales.
func CdpSetCookie(c *CDPConn, name, value, domain, path string, httpOnly bool) error {
if path == "" {
path = "/"
}
params := map[string]any{
"name": name,
"value": value,
"domain": domain,
"path": path,
"httpOnly": httpOnly,
}
_, err := c.sendCDP("Network.setCookie", params)
return err
}
+49
View File
@@ -0,0 +1,49 @@
---
id: cdp_set_cookie_go_browser
name: cdp_set_cookie
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Establece una cookie en el browser via Network.setCookie del protocolo CDP. Soporta cookies HttpOnly. Util para tests e2e que necesitan autenticar el browser sin pasar por la UI de login."
tags: [cdp, browser, cookie, e2e, auth]
signature: "func CdpSetCookie(c *CDPConn, name, value, domain, path string, httpOnly bool) error"
uses_functions: []
uses_types:
- cdp_conn_go_browser
returns: ""
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_set_cookie.go"
example: |
conn, _ := browser.CdpConnect(9222)
defer browser.CdpClose(conn, 0)
// Tras hacer login HTTP en el test:
if err := browser.CdpSetCookie(conn, "session", token, "localhost", "/", true); err != nil {
log.Fatal(err)
}
browser.CdpNavigate(conn, "http://localhost:8080/dashboard")
params:
- name: c
desc: Conexion CDP abierta (de CdpConnect)
- name: name
desc: Nombre de la cookie
- name: value
desc: Valor de la cookie (token de sesion, etc.)
- name: domain
desc: Dominio sin protocolo (ej. "localhost", "example.com")
- name: path
desc: Path scope. Vacio se trata como "/"
- name: httpOnly
desc: Si true, cookie HttpOnly (no accesible desde JS)
output: "error si Network.setCookie falla; nil en exito"
---
## Notas
- Network.setCookie es un comando CDP nativo, no requiere JS evaluate.
- Permite cookies HttpOnly que `document.cookie` no puede setear desde JS.
- Necesita que el dominio coincida con la URL a la que se navegara despues.
+26
View File
@@ -0,0 +1,26 @@
package core
import "time"
// ParseDateOrDefault parses s as a date/datetime and returns the result.
// Accepted formats: "2006-01-02" (YYYY-MM-DD) and time.RFC3339Nano.
// Returns dflt when s is empty or does not match either format.
// When endOfDay is true and the input matched YYYY-MM-DD, adds
// 24*time.Hour - time.Nanosecond so the result is the last nanosecond of
// that day (useful for inclusive end-of-range queries).
// RFC3339Nano inputs are never adjusted regardless of endOfDay.
func ParseDateOrDefault(s string, dflt time.Time, endOfDay bool) time.Time {
if s == "" {
return dflt
}
if t, err := time.Parse("2006-01-02", s); err == nil {
if endOfDay {
return t.Add(24*time.Hour - time.Nanosecond)
}
return t
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
return dflt
}
+64
View File
@@ -0,0 +1,64 @@
---
name: parse_date_or_default
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func ParseDateOrDefault(s string, dflt time.Time, endOfDay bool) time.Time"
description: "Parsea s como fecha (YYYY-MM-DD) o datetime (RFC3339Nano). Retorna dflt si s esta vacio o no encaja en ninguno de los dos formatos. Si endOfDay es true y el formato fue YYYY-MM-DD, ajusta al ultimo nanosegundo del dia (util para rangos de fechas inclusivos por el final)."
tags: [date, parse, time, default, range, YYYY-MM-DD, RFC3339Nano]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports:
- time
params:
- name: s
desc: "Cadena a parsear. Formatos aceptados: '2006-01-02' (YYYY-MM-DD) o RFC3339Nano. String vacio activa el valor por defecto."
- name: dflt
desc: "Valor time.Time a retornar cuando s esta vacio o no coincide con ningun formato soportado."
- name: endOfDay
desc: "Si es true y el input fue YYYY-MM-DD, suma 24h-1ns para apuntar al ultimo nanosegundo del dia. No tiene efecto sobre RFC3339Nano ni sobre el retorno de dflt."
output: "time.Time parseado desde s, ajustado si endOfDay=true y formato YYYY-MM-DD; o dflt si s esta vacio o es invalido."
tested: true
tests:
- "string vacio retorna dflt"
- "formato invalido retorna dflt"
- "YYYY-MM-DD sin endOfDay retorna inicio de dia"
- "YYYY-MM-DD con endOfDay retorna ultimo nanosegundo del dia"
- "RFC3339Nano sin endOfDay retorna timestamp exacto"
- "RFC3339Nano con endOfDay no se ajusta"
- "endOfDay false con dflt no lo modifica"
test_file_path: "functions/core/parse_date_or_default_test.go"
file_path: "functions/core/parse_date_or_default.go"
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
source_license: "private"
source_file: "apps/kanban/backend/metrics.go"
---
## Ejemplo
```go
dflt := time.Now().UTC()
// Inicio de rango (incluye el dia completo desde las 00:00)
from := ParseDateOrDefault("2024-01-01", dflt, false)
// Fin de rango (incluye hasta el ultimo nanosegundo del dia)
to := ParseDateOrDefault("2024-01-31", dflt, true)
// Timestamp exacto desde RFC3339Nano (no se ajusta aunque endOfDay=true)
ts := ParseDateOrDefault("2024-01-15T12:00:00Z", dflt, true)
// Valor invalido → dflt
fallback := ParseDateOrDefault("no-es-fecha", dflt, false)
```
## Notas
Unifica las dos funciones originales `parseDateOrDefault` y `parseEndDateOrDefault`
de `apps/kanban/backend/metrics.go:132-156` en una sola funcion parametrica.
`time.Parse` es deterministico, por lo que la funcion es pura aunque use el paquete `time`.
@@ -0,0 +1,65 @@
package core
import (
"testing"
"time"
)
func TestParseDateOrDefault(t *testing.T) {
dflt := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
t.Run("string vacio retorna dflt", func(t *testing.T) {
got := ParseDateOrDefault("", dflt, false)
if !got.Equal(dflt) {
t.Errorf("got %v, want %v", got, dflt)
}
})
t.Run("formato invalido retorna dflt", func(t *testing.T) {
got := ParseDateOrDefault("no-es-fecha", dflt, false)
if !got.Equal(dflt) {
t.Errorf("got %v, want %v", got, dflt)
}
})
t.Run("YYYY-MM-DD sin endOfDay retorna inicio de dia", func(t *testing.T) {
got := ParseDateOrDefault("2024-03-15", dflt, false)
want := time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("YYYY-MM-DD con endOfDay retorna ultimo nanosegundo del dia", func(t *testing.T) {
got := ParseDateOrDefault("2024-03-15", dflt, true)
want := time.Date(2024, 3, 15, 23, 59, 59, 999999999, time.UTC)
if !got.Equal(want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("RFC3339Nano sin endOfDay retorna timestamp exacto", func(t *testing.T) {
input := "2024-03-15T14:30:00.123456789Z"
got := ParseDateOrDefault(input, dflt, false)
want, _ := time.Parse(time.RFC3339Nano, input)
if !got.Equal(want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("RFC3339Nano con endOfDay no se ajusta", func(t *testing.T) {
input := "2024-03-15T00:00:00Z"
got := ParseDateOrDefault(input, dflt, true)
want, _ := time.Parse(time.RFC3339Nano, input)
if !got.Equal(want) {
t.Errorf("RFC3339Nano should not be adjusted; got %v, want %v", got, want)
}
})
t.Run("endOfDay false con dflt no lo modifica", func(t *testing.T) {
got := ParseDateOrDefault("", dflt, true)
if !got.Equal(dflt) {
t.Errorf("got %v, want %v", got, dflt)
}
})
}
+10
View File
@@ -0,0 +1,10 @@
package datascience
// DurationStats holds descriptive statistics for a set of durations in milliseconds.
type DurationStats struct {
N int `json:"n"`
AvgMs int64 `json:"avg_ms"`
P50Ms int64 `json:"p50_ms"`
P90Ms int64 `json:"p90_ms"`
P99Ms int64 `json:"p99_ms"`
}
+27
View File
@@ -0,0 +1,27 @@
package datascience
import "sort"
// DurationStatsFrom computes descriptive statistics for a slice of durations in milliseconds.
// It sorts a local copy so the original slice is not mutated.
// Returns a zero-value DurationStats when the input is empty.
func DurationStatsFrom(durations []int64) DurationStats {
n := len(durations)
if n == 0 {
return DurationStats{}
}
cp := make([]int64, n)
copy(cp, durations)
sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] })
var sum int64
for _, d := range cp {
sum += d
}
return DurationStats{
N: n,
AvgMs: sum / int64(n),
P50Ms: Percentile(cp, 0.5),
P90Ms: Percentile(cp, 0.9),
P99Ms: Percentile(cp, 0.99),
}
}
+55
View File
@@ -0,0 +1,55 @@
---
name: duration_stats
kind: function
lang: go
domain: datascience
version: "1.0.0"
purity: pure
signature: "func DurationStatsFrom(durations []int64) DurationStats"
description: "Calcula estadisticas descriptivas (N, media, P50/P90/P99) de un slice de duraciones en milisegundos. Ordena una copia local sin mutar el input. Retorna DurationStats{} para slice vacio."
tags: [statistics, duration, percentile, metrics, int64]
uses_functions:
- percentile_int64_go_datascience
uses_types:
- DurationStats_go_datascience
returns:
- DurationStats_go_datascience
returns_optional: false
error_type: ""
imports:
- sort
params:
- name: durations
desc: "Slice de duraciones en milisegundos. No necesita estar ordenado; se ordena internamente sobre una copia."
output: "DurationStats con N, AvgMs, P50Ms, P90Ms y P99Ms calculados. DurationStats{} si el slice esta vacio."
tested: true
tests:
- "slice vacio retorna estadisticas cero"
- "un solo elemento produce estadisticas identicas"
- "cinco elementos calcula media y percentiles correctos"
- "input original no se muta"
- "diez elementos p90 usa idx truncado"
test_file_path: "functions/datascience/duration_stats_test.go"
file_path: "functions/datascience/duration_stats.go"
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
source_license: "private"
source_file: "apps/kanban/backend/metrics.go"
---
## Ejemplo
```go
durations := []int64{50, 10, 30, 40, 20}
stats := DurationStatsFrom(durations)
// stats.N = 5
// stats.AvgMs = 30
// stats.P50Ms = 30
// stats.P90Ms = 50
// stats.P99Ms = 50
```
## Notas
Ordena una copia con `sort.Slice` para no mutar el slice original.
Compone `Percentile` (`percentile_int64_go_datascience`) para los calculos de P50/P90/P99.
Extraido y generalizado desde `apps/kanban/backend/metrics.go:113-130`.
@@ -0,0 +1,69 @@
package datascience
import "testing"
func TestDurationStatsFrom(t *testing.T) {
t.Run("slice vacio retorna estadisticas cero", func(t *testing.T) {
got := DurationStatsFrom([]int64{})
if got.N != 0 || got.AvgMs != 0 || got.P50Ms != 0 || got.P90Ms != 0 || got.P99Ms != 0 {
t.Errorf("got %+v, want zero DurationStats", got)
}
})
t.Run("un solo elemento produce estadisticas identicas", func(t *testing.T) {
got := DurationStatsFrom([]int64{100})
if got.N != 1 || got.AvgMs != 100 || got.P50Ms != 100 || got.P90Ms != 100 || got.P99Ms != 100 {
t.Errorf("got %+v, want all=100", got)
}
})
t.Run("cinco elementos calcula media y percentiles correctos", func(t *testing.T) {
// sorted: [10,20,30,40,50], n=5
// P50: idx=int(4*0.5)=2 → 30
// P90: idx=int(4*0.9)=int(3.6)=3 → 40
// P99: idx=int(4*0.99)=int(3.96)=3 → 40
got := DurationStatsFrom([]int64{50, 10, 30, 40, 20})
if got.N != 5 {
t.Errorf("N: got %v, want 5", got.N)
}
if got.AvgMs != 30 {
t.Errorf("AvgMs: got %v, want 30", got.AvgMs)
}
if got.P50Ms != 30 {
t.Errorf("P50Ms: got %v, want 30", got.P50Ms)
}
if got.P90Ms != 40 {
t.Errorf("P90Ms: got %v, want 40", got.P90Ms)
}
if got.P99Ms != 40 {
t.Errorf("P99Ms: got %v, want 40", got.P99Ms)
}
})
t.Run("input original no se muta", func(t *testing.T) {
input := []int64{50, 10, 30}
_ = DurationStatsFrom(input)
if input[0] != 50 || input[1] != 10 || input[2] != 30 {
t.Errorf("input mutated: got %v", input)
}
})
t.Run("diez elementos p90 usa idx truncado", func(t *testing.T) {
// sorted: [10..100], n=10
// P50: idx=int(9*0.5)=4 → 50
// P90: idx=int(9*0.9)=int(8.1)=8 → 90
got := DurationStatsFrom([]int64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100})
if got.N != 10 {
t.Errorf("N: got %v, want 10", got.N)
}
if got.AvgMs != 55 {
t.Errorf("AvgMs: got %v, want 55", got.AvgMs)
}
if got.P50Ms != 50 {
t.Errorf("P50Ms: got %v, want 50", got.P50Ms)
}
if got.P90Ms != 90 {
t.Errorf("P90Ms: got %v, want 90", got.P90Ms)
}
})
}
+19
View File
@@ -0,0 +1,19 @@
package datascience
// Percentile returns the value at percentile p (0.01.0) from a pre-sorted
// ascending slice of int64 values.
// Returns 0 for an empty slice.
// idx is computed as int(float64(len-1)*p), clamped to [0, len-1].
func Percentile(sorted []int64, p float64) int64 {
if len(sorted) == 0 {
return 0
}
idx := int(float64(len(sorted)-1) * p)
if idx < 0 {
idx = 0
}
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
+52
View File
@@ -0,0 +1,52 @@
---
name: percentile_int64
kind: function
lang: go
domain: datascience
version: "1.0.0"
purity: pure
signature: "func Percentile(sorted []int64, p float64) int64"
description: "Calcula el percentil p (0.0-1.0) de un slice de int64 pre-ordenado ascendente. Retorna 0 para slice vacio. idx = int(float64(len-1)*p), clamped a [0, len-1]."
tags: [statistics, percentile, quantile, int64, sorted]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: sorted
desc: "Slice de int64 pre-ordenado en orden ascendente. No se reordena internamente."
- name: p
desc: "Percentil a calcular, en rango [0.0, 1.0]. 0.5 = mediana, 0.9 = P90, 0.99 = P99."
output: "El valor en la posicion del percentil p dentro del slice. Retorna 0 si el slice esta vacio."
tested: true
tests:
- "slice vacio retorna cero"
- "un solo elemento retorna ese elemento"
- "p0 retorna minimo"
- "p100 retorna maximo"
- "p50 retorna mediana de cinco elementos"
- "p90 de diez elementos usa idx int truncado"
- "p99 de slice pequeno usa idx truncado a cero"
test_file_path: "functions/datascience/percentile_int64_test.go"
file_path: "functions/datascience/percentile_int64.go"
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
source_license: "private"
source_file: "apps/kanban/backend/metrics.go"
---
## Ejemplo
```go
sorted := []int64{10, 20, 30, 40, 50}
p50 := Percentile(sorted, 0.5) // 30
p90 := Percentile(sorted, 0.9) // 50
p99 := Percentile(sorted, 0.99) // 50
```
## Notas
El input debe estar ordenado ascendente antes de llamar a esta funcion.
`DurationStatsFrom` se encarga de ordenar antes de llamarla.
Extraido de `apps/kanban/backend/metrics.go:99-111`.
@@ -0,0 +1,56 @@
package datascience
import "testing"
func TestPercentile(t *testing.T) {
t.Run("slice vacio retorna cero", func(t *testing.T) {
got := Percentile([]int64{}, 0.5)
if got != 0 {
t.Errorf("got %v, want 0", got)
}
})
t.Run("un solo elemento retorna ese elemento", func(t *testing.T) {
got := Percentile([]int64{42}, 0.5)
if got != 42 {
t.Errorf("got %v, want 42", got)
}
})
t.Run("p0 retorna minimo", func(t *testing.T) {
got := Percentile([]int64{10, 20, 30, 40, 50}, 0.0)
if got != 10 {
t.Errorf("got %v, want 10", got)
}
})
t.Run("p100 retorna maximo", func(t *testing.T) {
got := Percentile([]int64{10, 20, 30, 40, 50}, 1.0)
if got != 50 {
t.Errorf("got %v, want 50", got)
}
})
t.Run("p50 retorna mediana de cinco elementos", func(t *testing.T) {
got := Percentile([]int64{10, 20, 30, 40, 50}, 0.5)
if got != 30 {
t.Errorf("got %v, want 30", got)
}
})
t.Run("p90 de diez elementos usa idx int truncado", func(t *testing.T) {
// idx = int(9 * 0.9) = int(8.1) = 8 → sorted[8] = 9
got := Percentile([]int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0.9)
if got != 9 {
t.Errorf("got %v, want 9", got)
}
})
t.Run("p99 de slice pequeno usa idx truncado a cero", func(t *testing.T) {
// idx = int(1 * 0.99) = int(0.99) = 0 → sorted[0] = 100
got := Percentile([]int64{100, 200}, 0.99)
if got != 100 {
t.Errorf("got %v, want 100", got)
}
})
}
@@ -0,0 +1,17 @@
package infra
import "net/http"
// SessionCookieClear invalidates the named session cookie by setting
// MaxAge=-1. The browser removes the cookie immediately on receipt.
// It does not return an error because http.SetCookie never fails at runtime.
func SessionCookieClear(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
@@ -0,0 +1,49 @@
---
name: http_session_cookie_clear
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SessionCookieClear(w http.ResponseWriter, name string)"
description: "Invalida la cookie de sesion en el browser fijando MaxAge=-1. Path='/', HttpOnly=true, SameSite=Lax. No retorna error porque http.SetCookie no falla en runtime."
tags: [http, session, cookie, auth, logout, response]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["net/http"]
params:
- name: w
desc: "ResponseWriter donde se escribe el header Set-Cookie"
- name: name
desc: "nombre de la cookie a invalidar (debe coincidir con el nombre usado al crearla)"
output: "escribe el header Set-Cookie con MaxAge=-1 en w; sin valor de retorno"
tested: true
tests:
- "cookie clear setea MaxAge negativo"
- "cookie clear valor es vacio"
- "header Set-Cookie contiene HttpOnly y SameSite=Lax"
test_file_path: "functions/infra/http_session_cookie_clear_test.go"
file_path: "functions/infra/http_session_cookie_clear.go"
---
## Ejemplo
```go
func handleLogout(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := infra.SessionTokenExtract(r, "my_session")
if token != "" {
_ = infra.SessionDelete(db.conn, token)
}
infra.SessionCookieClear(w, "my_session")
w.WriteHeader(http.StatusNoContent)
}
}
```
## Notas
Extraido de apps/kanban/backend/auth.go. MaxAge=-1 hace que el browser elimine la cookie inmediatamente independientemente de la fecha Expires original. La funcion no retorna error porque `http.SetCookie` escribe directamente en los headers y nunca falla. Complemento de `http_session_cookie_set_go_infra`.
@@ -0,0 +1,50 @@
package infra
import (
"net/http/httptest"
"strings"
"testing"
)
func TestSessionCookieClear(t *testing.T) {
t.Run("cookie clear setea MaxAge negativo", func(t *testing.T) {
w := httptest.NewRecorder()
SessionCookieClear(w, "my_session")
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "my_session" {
t.Errorf("Name: got %q, want %q", c.Name, "my_session")
}
if c.MaxAge >= 0 {
t.Errorf("MaxAge: got %d, want negative", c.MaxAge)
}
})
t.Run("cookie clear valor es vacio", func(t *testing.T) {
w := httptest.NewRecorder()
SessionCookieClear(w, "sess")
cookies := w.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("no cookie set")
}
if cookies[0].Value != "" {
t.Errorf("Value: got %q, want empty", cookies[0].Value)
}
})
t.Run("header Set-Cookie contiene HttpOnly y SameSite=Lax", func(t *testing.T) {
w := httptest.NewRecorder()
SessionCookieClear(w, "sess")
header := w.Header().Get("Set-Cookie")
if !strings.Contains(header, "HttpOnly") {
t.Errorf("Set-Cookie header missing HttpOnly: %s", header)
}
if !strings.Contains(header, "SameSite=Lax") {
t.Errorf("Set-Cookie header missing SameSite=Lax: %s", header)
}
})
}
@@ -0,0 +1,21 @@
package infra
import (
"net/http"
"time"
)
// SessionCookieSet writes a session cookie to the response.
// The cookie is HttpOnly, Path="/", SameSite=Lax and expires at the
// Unix timestamp expiresAt (seconds). It does not return an error
// because http.SetCookie never fails at runtime.
func SessionCookieSet(w http.ResponseWriter, name, token string, expiresAt int64) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expiresAt, 0),
})
}
@@ -0,0 +1,48 @@
---
name: http_session_cookie_set
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SessionCookieSet(w http.ResponseWriter, name, token string, expiresAt int64)"
description: "Escribe una cookie de sesion HttpOnly en la respuesta HTTP. Path='/', SameSite=Lax, Expires=time.Unix(expiresAt,0). No retorna error porque http.SetCookie no falla en runtime."
tags: [http, session, cookie, auth, response]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["net/http", "time"]
params:
- name: w
desc: "ResponseWriter donde se escribe el header Set-Cookie"
- name: name
desc: "nombre de la cookie (p.ej. 'kanban_session')"
- name: token
desc: "valor del token de sesion"
- name: expiresAt
desc: "timestamp Unix (segundos) de expiracion de la cookie"
output: "escribe el header Set-Cookie en w; sin valor de retorno"
tested: true
tests:
- "cookie set con nombre y token correctos"
- "header Set-Cookie contiene HttpOnly y SameSite=Lax"
test_file_path: "functions/infra/http_session_cookie_set_test.go"
file_path: "functions/infra/http_session_cookie_set.go"
---
## Ejemplo
```go
sess, err := infra.SessionCreate(db, userID, 7*24*time.Hour, nil)
if err != nil {
http.Error(w, "internal error", 500)
return
}
infra.SessionCookieSet(w, "my_session", sess.Token, sess.ExpiresAt)
```
## Notas
Extraido de apps/kanban/backend/auth.go. La funcion no retorna error porque `http.SetCookie` escribe directamente en los headers del ResponseWriter y nunca falla. El campo `error_type` se omite porque la firma no tiene retorno de error — hay precedente en el registry (componentes C++ y otros helpers HTTP impuros sin error_type).
@@ -0,0 +1,46 @@
package infra
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSessionCookieSet(t *testing.T) {
t.Run("cookie set con nombre y token correctos", func(t *testing.T) {
w := httptest.NewRecorder()
expires := time.Now().Add(24 * time.Hour).Unix()
SessionCookieSet(w, "my_session", "tok_abc", expires)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "my_session" {
t.Errorf("Name: got %q, want %q", c.Name, "my_session")
}
if c.Value != "tok_abc" {
t.Errorf("Value: got %q, want %q", c.Value, "tok_abc")
}
if c.Path != "/" {
t.Errorf("Path: got %q, want %q", c.Path, "/")
}
if !c.HttpOnly {
t.Errorf("expected HttpOnly=true")
}
})
t.Run("header Set-Cookie contiene HttpOnly y SameSite=Lax", func(t *testing.T) {
w := httptest.NewRecorder()
SessionCookieSet(w, "s", "v", time.Now().Add(time.Hour).Unix())
header := w.Header().Get("Set-Cookie")
if !strings.Contains(header, "HttpOnly") {
t.Errorf("Set-Cookie header missing HttpOnly: %s", header)
}
if !strings.Contains(header, "SameSite=Lax") {
t.Errorf("Set-Cookie header missing SameSite=Lax: %s", header)
}
})
}
@@ -0,0 +1,19 @@
package infra
import "net/http"
// SessionTokenExtract extracts a session token from the request.
// It checks the cookie named cookieName first; if present and non-empty,
// that value is returned. Otherwise it checks the Authorization header
// for a "Bearer <token>" prefix and returns the token part.
// Returns "" if no token is found in either source.
func SessionTokenExtract(r *http.Request, cookieName string) string {
if c, err := r.Cookie(cookieName); err == nil && c.Value != "" {
return c.Value
}
auth := r.Header.Get("Authorization")
if len(auth) > 7 && auth[:7] == "Bearer " {
return auth[7:]
}
return ""
}
@@ -0,0 +1,51 @@
---
name: http_session_token_extract
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func SessionTokenExtract(r *http.Request, cookieName string) string"
description: "Extrae el token de sesion de un request HTTP. Comprueba primero la cookie con el nombre indicado; si no esta, parsea el header Authorization 'Bearer <token>'. Retorna cadena vacia si no hay token."
tags: [http, session, cookie, bearer, auth, token]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["net/http"]
params:
- name: r
desc: "request HTTP entrante"
- name: cookieName
desc: "nombre de la cookie de sesion a buscar (p.ej. 'kanban_session')"
output: "token extraido de la cookie o del header Authorization; cadena vacia si no hay token en ninguna fuente"
tested: true
tests:
- "cookie present retorna token de cookie"
- "bearer header retorna token de header"
- "cookie gana sobre bearer header"
- "sin token retorna cadena vacia"
test_file_path: "functions/infra/http_session_token_extract_test.go"
file_path: "functions/infra/http_session_token_extract.go"
---
## Ejemplo
```go
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := infra.SessionTokenExtract(r, "my_session")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// validate token...
next.ServeHTTP(w, r)
})
}
```
## Notas
Extraido de apps/kanban/backend/auth.go. Funcion pura: solo lee el request, no muta estado. La cookie tiene precedencia sobre el header Authorization para mantener consistencia con el comportamiento del browser (la cookie es el canal primario; el header es para clientes API que no gestionan cookies).
@@ -0,0 +1,46 @@
package infra
import (
"net/http"
"testing"
)
func TestSessionTokenExtract(t *testing.T) {
const cookieName = "test_session"
t.Run("cookie present retorna token de cookie", func(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{Name: cookieName, Value: "tok_cookie"})
got := SessionTokenExtract(r, cookieName)
if got != "tok_cookie" {
t.Errorf("got %q, want %q", got, "tok_cookie")
}
})
t.Run("bearer header retorna token de header", func(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer tok_bearer")
got := SessionTokenExtract(r, cookieName)
if got != "tok_bearer" {
t.Errorf("got %q, want %q", got, "tok_bearer")
}
})
t.Run("cookie gana sobre bearer header", func(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{Name: cookieName, Value: "tok_cookie"})
r.Header.Set("Authorization", "Bearer tok_bearer")
got := SessionTokenExtract(r, cookieName)
if got != "tok_cookie" {
t.Errorf("got %q, want %q (cookie should win)", got, "tok_cookie")
}
})
t.Run("sin token retorna cadena vacia", func(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
got := SessionTokenExtract(r, cookieName)
if got != "" {
t.Errorf("got %q, want empty string", got)
}
})
}
@@ -0,0 +1,88 @@
package infra
import (
"database/sql"
"fmt"
"io/fs"
"sort"
"strings"
)
// ApplyMigrations reads SQL files matching glob from fsys, sorts them
// lexicographically (NNN_name.sql order), and executes every statement
// found in each file against conn.
//
// If glob is empty, it defaults to "migrations/*.sql".
//
// Each statement is executed individually. Errors that look like
// idempotent SQLite errors (duplicate column, already exists) are
// silently ignored so that migrations can be replayed safely against
// a database that was partially migrated.
//
// NOTE: the internal statement parser splits on ";" at the end of a
// trimmed line. This is intentionally simple and will break if SQL
// strings contain a literal semicolon at end-of-line. Avoid using
// such patterns in migration files.
func ApplyMigrations(conn *sql.DB, fsys fs.FS, glob string) error {
if glob == "" {
glob = "migrations/*.sql"
}
files, err := fs.Glob(fsys, glob)
if err != nil {
return err
}
sort.Strings(files)
for _, f := range files {
b, err := fs.ReadFile(fsys, f)
if err != nil {
return err
}
for _, stmt := range splitSQLStatements(string(b)) {
s := strings.TrimSpace(stmt)
if s == "" {
continue
}
if _, err := conn.Exec(s); err != nil {
if isIdempotentMigrationError(err) {
continue
}
return fmt.Errorf("%s: %w", f, err)
}
}
}
return nil
}
// splitSQLStatements splits a SQL script into individual statements.
// Lines starting with "--" (comments) and blank lines are skipped.
// A statement ends when a trimmed line ends with ";".
func splitSQLStatements(s string) []string {
out := []string{}
cur := strings.Builder{}
for _, line := range strings.Split(s, "\n") {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "--") || trim == "" {
continue
}
cur.WriteString(line)
cur.WriteString("\n")
if strings.HasSuffix(trim, ";") {
out = append(out, cur.String())
cur.Reset()
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// isIdempotentMigrationError returns true for SQLite errors that arise
// from re-applying a migration that was already applied (duplicate
// column, table/index already exists).
func isIdempotentMigrationError(err error) bool {
msg := err.Error()
return strings.Contains(msg, "duplicate column") ||
strings.Contains(msg, "already exists")
}
@@ -0,0 +1,58 @@
---
name: sqlite_apply_migrations
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ApplyMigrations(conn *sql.DB, fsys fs.FS, glob string) error"
description: "Aplica migraciones SQL desde un fs.FS en orden lexicografico. Lee archivos con glob (default 'migrations/*.sql'), divide por sentencias y ejecuta cada una contra conn. Errores idempotentes (duplicate column, already exists) se ignoran."
tags: [database, sqlite, migration, schema, embed, fs]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["database/sql", "fmt", "io/fs", "sort", "strings"]
params:
- name: conn
desc: "conexion *sql.DB donde se ejecutan las migraciones"
- name: fsys
desc: "sistema de archivos con los .sql (tipicamente embed.FS del caller)"
- name: glob
desc: "patron glob para seleccionar archivos (vacio = 'migrations/*.sql')"
output: "nil si todas las migraciones se aplicaron correctamente; error del primer fallo no idempotente con nombre de archivo incluido"
tested: true
tests:
- "una migracion se aplica correctamente"
- "multiples migraciones en orden"
- "error real se propaga"
- "ALTER TABLE ADD COLUMN duplicado se ignora"
test_file_path: "functions/infra/sqlite_apply_migrations_test.go"
file_path: "functions/infra/sqlite_apply_migrations.go"
---
## Ejemplo
```go
//go:embed migrations/*.sql
var migrationsFS embed.FS
func openDB(path string) (*sql.DB, error) {
db, err := infra.SQLiteOpen(path, "")
if err != nil {
return nil, err
}
if err := infra.ApplyMigrations(db, migrationsFS, ""); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
```
## Notas
Extraido de apps/kanban/backend/db.go. El parser de sentencias SQL es intencionalmente simple: separa por `;` al final de una linea (ignorando comentarios `--` y lineas vacias). Esta logica falla si el SQL contiene un `;` dentro de un string literal al final de linea — evitar ese patron en los archivos de migracion.
Los errores idempotentes (`duplicate column`, `already exists`) se ignoran para que las migraciones sean re-ejecutables contra DBs que ya tenian parte del schema. Esto permite un flujo sin tabla `_migrations` para proyectos pequenos; para proyectos con muchas migraciones y rollback, usar `migration_up_go_infra` / `migration_down_go_infra`.
@@ -0,0 +1,98 @@
package infra
import (
"database/sql"
"testing"
"testing/fstest"
_ "github.com/mattn/go-sqlite3"
)
// makeFS builds a simple in-memory FS with the given path→content pairs.
func makeFS(files map[string]string) fstest.MapFS {
m := make(fstest.MapFS, len(files))
for path, content := range files {
m[path] = &fstest.MapFile{Data: []byte(content)}
}
return m
}
func TestApplyMigrations(t *testing.T) {
t.Run("una migracion se aplica correctamente", func(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
fsys := makeFS(map[string]string{
"migrations/001_init.sql": `CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT);`,
})
if err := ApplyMigrations(db, fsys, ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify table exists.
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("table 'items' not created; count=%d", count)
}
})
t.Run("multiples migraciones en orden", func(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
fsys := makeFS(map[string]string{
"migrations/001_init.sql": `CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY);`,
"migrations/002_add_column.sql": `ALTER TABLE t ADD COLUMN val TEXT;`,
})
if err := ApplyMigrations(db, fsys, ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Insert using the new column.
if _, err := db.Exec(`INSERT INTO t (id, val) VALUES (1, 'hello')`); err != nil {
t.Fatalf("insert failed (column may be missing): %v", err)
}
})
t.Run("error real se propaga", func(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
fsys := makeFS(map[string]string{
"migrations/001_bad.sql": `THIS IS NOT VALID SQL;`,
})
if err := ApplyMigrations(db, fsys, ""); err == nil {
t.Errorf("expected error for invalid SQL, got nil")
}
})
t.Run("ALTER TABLE ADD COLUMN duplicado se ignora", func(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
setup := `CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, val TEXT);`
if _, err := db.Exec(setup); err != nil {
t.Fatal(err)
}
// Run ALTER that would fail with "duplicate column".
fsys := makeFS(map[string]string{
"migrations/001_dup.sql": `ALTER TABLE t ADD COLUMN val TEXT;`,
})
if err := ApplyMigrations(db, fsys, ""); err != nil {
t.Errorf("duplicate column error should be ignored, got: %v", err)
}
})
}
+30
View File
@@ -0,0 +1,30 @@
package infra
import (
"database/sql"
"fmt"
)
// ColumnExists reports whether the named column exists in the given table
// by querying PRAGMA table_info. Returns false if the table does not exist.
func ColumnExists(conn *sql.DB, table, name string) (bool, error) {
rows, err := conn.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid int
var colName, ctype string
var notnull int
var dflt sql.NullString
var pk int
if err := rows.Scan(&cid, &colName, &ctype, &notnull, &dflt, &pk); err != nil {
return false, err
}
if colName == name {
return true, nil
}
}
return false, rows.Err()
}
+48
View File
@@ -0,0 +1,48 @@
---
name: sqlite_column_exists
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ColumnExists(conn *sql.DB, table, name string) (bool, error)"
description: "Comprueba si una columna existe en una tabla SQLite consultando PRAGMA table_info. Retorna false sin error si la tabla no existe."
tags: [database, sqlite, schema, pragma, column, migration]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["database/sql", "fmt"]
params:
- name: conn
desc: "conexion SQLite abierta"
- name: table
desc: "nombre de la tabla a inspeccionar"
- name: name
desc: "nombre de la columna a buscar"
output: "true si la columna existe, false si no existe o la tabla no existe; error si la query falla"
tested: true
tests:
- "columna existente retorna true"
- "columna inexistente retorna false"
- "tabla inexistente retorna false sin error"
test_file_path: "functions/infra/sqlite_column_exists_test.go"
file_path: "functions/infra/sqlite_column_exists.go"
---
## Ejemplo
```go
exists, err := ColumnExists(db, "cards", "assignee_id")
if err != nil {
return err
}
if !exists {
_, err = db.Exec(`ALTER TABLE cards ADD COLUMN assignee_id TEXT`)
}
```
## Notas
Extraido de apps/kanban/backend/db.go. Util como comprobacion previa a ALTER TABLE ADD COLUMN en scripts de migracion que necesitan ser idempotentes. PRAGMA table_info retorna cero filas si la tabla no existe, por lo que la funcion retorna false sin error en ese caso.
@@ -0,0 +1,59 @@
package infra
import (
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func openMemDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open :memory: db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestColumnExists(t *testing.T) {
t.Run("columna existente retorna true", func(t *testing.T) {
db := openMemDB(t)
if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)`); err != nil {
t.Fatal(err)
}
got, err := ColumnExists(db, "t", "name")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got {
t.Errorf("expected true for existing column 'name'")
}
})
t.Run("columna inexistente retorna false", func(t *testing.T) {
db := openMemDB(t)
if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY)`); err != nil {
t.Fatal(err)
}
got, err := ColumnExists(db, "t", "missing")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got {
t.Errorf("expected false for missing column")
}
})
t.Run("tabla inexistente retorna false sin error", func(t *testing.T) {
db := openMemDB(t)
got, err := ColumnExists(db, "no_such_table", "col")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got {
t.Errorf("expected false for non-existent table")
}
})
}