# Capability: duckdb

Operar bases de datos DuckDB desde el registry: abrir/crear bases, consultas read-only seguras, conversion CSV -> Parquet, deduplicacion por hash y carga de series temporales. DuckDB es el motor analitico embebido del ecosistema (OLAP local, archivos `.duckdb`, lectura directa de CSV/Parquet/JSON).

Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (project `osint`): la app `osint_db` posee la DuckDB maestra y este grupo aporta las primitivas de acceso.

## Funciones

| ID | Firma | Que hace |
|---|---|---|
| `duckdb_open_go_infra` | `DuckDBOpen(path string) (*sql.DB, error)` | Abre (o crea) una base DuckDB desde Go. Path vacio o `:memory:` abre en memoria. |
| `duckdb_query_readonly_py_infra` | `duckdb_query_readonly(db_path, sql, params=None, max_rows=10000) -> dict` | Consulta read-only segura: conexion `read_only=True`, params posicionales `?`, filas como `list[dict]` con tipos normalizados a JSON (date/datetime -> isoformat, Decimal -> float, bytes -> base64). Devuelve `{status, columns, rows, row_count, truncated}` sin lanzar. |
| `duckdb_execute_py_infra` | `duckdb_execute(db_path, sql, params=None) -> dict` | Ejecuta UNA sentencia de escritura (INSERT/UPDATE/DELETE/DDL) en conexion read-write, commit, devuelve `{status, rowcount}` sin lanzar. Primitivo de escritura del grupo (complementa a `duckdb_query_readonly`). |
| `duckdb_upsert_py_infra` | `duckdb_upsert(db_path, table, rows, key_cols, update_cols=None) -> dict` | UPSERT idempotente `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET ...` actualizando SOLO `update_cols`. Excluir columnas de `update_cols` permite que un re-upsert NO las pise (ownership selectivo: la DB es la verdad). Devuelve `{status, inserted, updated}`. |
| `csv_to_parquet_duckdb_py_core` | `csv_to_parquet_duckdb(csv_path, parquet_path, column_casts=None, overwrite=False) -> bool` | Convierte CSV -> Parquet con `read_csv_auto`. `column_casts` fuerza tipos por columna. No reescribe si el parquet existe y `overwrite=False`. |
| `dedup_duckdb_table_by_hash_py_pipelines` | `dedup_duckdb_table_by_hash(duckdb_path, table, exclude_cols=None) -> dict` | Pipeline: anade columna `row_hash` (md5 de columnas de datos) idempotentemente y borra filas duplicadas conservando la primera insercion. |
| `load_ohlcv_from_duckdb_go_finance` | `LoadOHLCVFromDuckDB(dbPath, query string) ([][]float64, error)` | Carga datos OHLCV ejecutando una query SQL sobre una base DuckDB (consumo desde apps Go de finanzas). |
| `duckdb_list_tables_py_infra` | `duckdb_list_tables(db_path) -> dict` | Introspección read-only: lista las tablas (`information_schema.tables`, schema main) ordenadas. Devuelve `{status, tables}`. |
| `duckdb_table_schema_py_infra` | `duckdb_table_schema(db_path, table) -> dict` | Introspección read-only: schema de una tabla (`DESCRIBE`). Devuelve `{status, table, columns:[{name,type}]}`. Útil para mapear tipos a otro motor (p.ej. PostgreSQL). |
| `excel_to_duckdb_py_infra` | `excel_to_duckdb(xlsx_path, duckdb_path, table, sheet=None, mode='replace') -> dict` | **Puente de entrada Excel→DuckDB**: ingiere una hoja `.xlsx` a una tabla con la extensión nativa `excel` de DuckDB. `replace`/`append`. Devuelve `{status, table, row_count}`. |
| `duckdb_to_postgres_py_pipelines` | `duckdb_to_postgres(duckdb_path, table, pg_dsn, pg_table=None, mode='replace', key_cols=None, batch_size=5000) -> dict` | **Puente de salida DuckDB→Postgres**: mapea tipos, crea la tabla y sincroniza filas. Desbloquea que Metabase/Grafana/Superset (que no hablan DuckDB) lean los datos. Devuelve `{status, pg_table, rows_synced, created}`. |
| `query_osint_db_py_datascience` | `query_osint_db(sql, base_url='http://127.0.0.1:8771', timeout=30) -> dict` | **Cliente HTTP del service `osint_db`**: hace `POST {base_url}/api/query` con `{"sql": sql}` y devuelve `{status, columns, rows, row_count, truncated}` sin lanzar (mismo estilo que `duckdb_query_readonly`). Vía correcta para leer la DuckDB maestra del proyecto `osint` desde otro proceso sin abrir el archivo (respeta el single-writer). Service caído → `{status:'error', error}` claro. Solo stdlib. |

## Puentes: Excel → DuckDB → Postgres → visualización

DuckDB es el centro del stack de datos: el motor analítico embebido. Los datos entran desde Excel y salen hacia BI:

```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys
sys.path.insert(0, "python/functions")
from infra import excel_to_duckdb, duckdb_list_tables, duckdb_query_readonly
from pipelines.duckdb_to_postgres import duckdb_to_postgres

# 1. Excel -> DuckDB (extensión nativa, sin pandas)
excel_to_duckdb("/tmp/ventas.xlsx", "/tmp/datos.duckdb", "ventas", sheet="ventas")
print(duckdb_list_tables("/tmp/datos.duckdb"))

# 2. Analítica en DuckDB
print(duckdb_query_readonly("/tmp/datos.duckdb",
    "SELECT categoria, SUM(importe) AS total FROM ventas GROUP BY 1")["rows"])

# 3. DuckDB -> Postgres (para que Metabase/Grafana lo lean)
# dsn = "postgresql://captacion:<pass>@localhost:5433/trends"
# duckdb_to_postgres("/tmp/datos.duckdb", "ventas", dsn, pg_table="ventas", mode="replace")
PYEOF
```

- **Evidence.dev** lee el `.duckdb` directamente (nativo) — no necesita el puente a Postgres.
- **Metabase / Grafana / Superset** no hablan DuckDB → usa `duckdb_to_postgres` y apunta la herramienta al Postgres espejo.

## Ejemplo canonico

Consulta read-only desde cualquier sesion (la conexion se abre `read_only=True` y se cierra siempre):

```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys
sys.path.insert(0, "python/functions")
from infra import duckdb_query_readonly

res = duckdb_query_readonly(
    "projects/osint/apps/osint_db/data/osint.duckdb",
    "SELECT contexto, COUNT(*) AS n FROM persons GROUP BY contexto ORDER BY n DESC",
    max_rows=50,
)
print(res["status"], res["row_count"])
for row in res["rows"]:
    print(row)
PYEOF
```

Conversion CSV -> Parquet en una linea:

```bash
./fn run csv_to_parquet_duckdb datos.csv datos.parquet
```

## Gotchas del grupo

- **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service (`query_osint_db` para `osint_db`). Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente.
- **Version del motor**: el formato de archivo puede cambiar entre versiones mayores de DuckDB. El venv del registry lleva `duckdb` 1.5.x; no mezclar con CLIs/WASM antiguos sobre el mismo archivo.
- `read_only=True` exige que el archivo exista — no crea bases nuevas.

## Fronteras

- NO cubre SQLite (`sqlite_open_go_infra` y el grupo de operations.db van aparte).
- NO cubre el render de resultados a Markdown/notas — eso es `render_markdown_table_py_core` + `upsert_sentinel_block_py_core` (grupo `obsidian`).
- El analisis exploratorio pesado (notebooks) vive en `analysis/` con sus propios venvs.
