feat: DuckDB como fuente de verdad (multi-valor, ownership selectivo, escritura, libretas)

F1 — migraciones: 002_multivalue (persons +telefonos/emails/direcciones/extra_fm JSON,
backfill desde singulares con to_json) + 003_addressbooks (tabla addressbooks + seed
idempotente de la libreta por defecto). Conteos intactos (697/1065/98).

F2 — ingest_vault selectivo (anti-pisado): personas que ya existen en DB solo actualizan
note_path + extra_fm vía duckdb_upsert(update_cols=...), NO pisan los campos OWNED por la
DB; personas nuevas = bootstrap completo. _link_contacts enlaza por listas telefonos[]/
emails[] además del singular. ingest_dav itera todas las libretas de la tabla addressbooks.

F3 — escritura estructurada (server/writes.py + endpoints en main.py): CRUD
/api/person|contact|event, /api/addressbook, /api/calendar, /api/person/{slug}/render
(DB→nota preservando la prosa del cuerpo), /api/push/dav (reconcilia DB→Xandikos). El push
DAV y el render ocurren fuera de la transacción de escritura para no bloquear la DB con
latencia de red. registry_bridge.py importa las funciones nuevas; app.md actualizado.

Verificado: 18 tests verdes; ownership probado sobre datos reales (un centinela DB-owned
sobrevivió a POST /api/ingest/vault sobre las 697 fichas); person CRUD + materialización
de la ficha .md en vivo, con cleanup sin residuo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 00:44:02 +02:00
parent 2716edd5a0
commit 63f37257cd
8 changed files with 1447 additions and 81 deletions
+26 -3
View File
@@ -13,9 +13,18 @@ uses_functions:
- slugify_obsidian_name_py_obsidian
- dav_get_collection_py_infra
- dav_list_calendars_py_infra
- dav_list_addressbooks_py_infra
- dav_collection_ctag_py_infra
- carddav_put_vcard_py_infra
- caldav_put_event_py_infra
- dav_delete_resource_py_infra
- dav_make_addressbook_py_infra
- dav_make_calendar_py_infra
- pass_get_secret_py_infra
- duckdb_query_readonly_py_infra
- duckdb_execute_py_infra
- duckdb_upsert_py_infra
- build_vcard_py_core
- render_markdown_table_py_core
- upsert_sentinel_block_py_core
uses_types: []
@@ -59,7 +68,14 @@ en el body (el plugin parsea el body, no el código HTTP).
completo del vault) + `persons`, `organizations`, `domains`, `cases`,
`places` (fichas de nivel-1 de cada carpeta de entidades, excluyendo las
notas con prefijo `_`). Cada una lleva `note_path`: el path relativo de la
nota dentro del vault.
nota dentro del vault. **`persons` es dueña de sus campos estructurados**
(multi-valor `telefonos`/`emails`/`direcciones` JSON + singulares de compat
`telefono`/`email`/`direccion`): la API los edita y los materializa a la
nota. El ingest del vault es **selectivo** para `persons` — una ficha que ya
existe en la DB solo refresca `note_path` + `extra_fm` (el frontmatter
no-owned), conservando los campos OWNED; una ficha nueva se inserta completa
(bootstrap desde el frontmatter). `addressbooks` (schema `main`) registra
las libretas CardDAV: el ingest DAV las recorre todas (no solo la fija).
2. **Maestras DAV** (schema `main`): `contacts` y `events` importados de
Xandikos — fuente de verdad del lado agenda/calendario. `contacts.note_path`
se enlaza contra `persons` matcheando por UID `osint-<slug>`, por el
@@ -104,9 +120,16 @@ Health check: `curl http://127.0.0.1:8771/api/health`.
| POST | `/api/query` | `{sql, params, max_rows}` → respuesta exacta de `duckdb_query_readonly` (solo lectura) |
| GET | `/api/queries` | catálogo de queries con nombre (`server/named_queries.py`) |
| POST | `/api/query/named` | `{name, max_rows}` → misma shape que `/api/query` |
| POST | `/api/ingest/vault` | escanea el vault completo y reconstruye notes + entidades + derivadas |
| POST | `/api/ingest/dav` | baja Xandikos (CardDAV + cada calendario CalDAV), reconstruye contacts/events, enlaza y reconstruye derivadas |
| POST | `/api/ingest/vault` | escanea el vault completo; notes y entidades de espejo puro se reemplazan, persons se ingesta SELECTIVO (existentes solo `note_path`+`extra_fm`, nuevas bootstrap completo) |
| POST | `/api/ingest/dav` | baja TODAS las libretas registradas en `addressbooks` + cada calendario CalDAV, reconstruye contacts/events, enlaza y reconstruye derivadas |
| POST | `/api/render/note` | `{note_path, block_id, sql\|query, title?}` → tabla Markdown upsertada como bloque sentinel `osintdb` en la nota (la crea si no existe) |
| POST/PUT/DELETE | `/api/person[/{slug}]` | CRUD de personas multi-valor (`telefonos`/`emails`/`direcciones` listas). Tras escribir la DB, materializa la ficha DB→nota (singulares = `lista[0]`) sin tocar la prosa |
| POST | `/api/person/{slug}/render` | re-materializa la ficha DB→nota (frontmatter OWNED + merge `extra_fm`, preserva el body) |
| POST/PUT/DELETE | `/api/contact[/{uid}]` | CRUD de contactos CardDAV (`tels`/`emails` listas). Tras la DB, push DB→Xandikos (`build_vcard`+`carddav_put_vcard`, o `dav_delete_resource` en delete) fuera de la transacción |
| POST/PUT/DELETE | `/api/event[/{uid}]` | CRUD de eventos CalDAV. Push `caldav_put_event`/`dav_delete_resource` |
| POST | `/api/addressbook` | `{slug, display_name?, description?, color?}``dav_make_addressbook` + INSERT en `addressbooks` |
| POST | `/api/calendar` | `{slug, display_name?, color?}``dav_make_calendar` (paridad) |
| POST | `/api/push/dav` | reconcilia en bloque: recorre `contacts` y `events` de la DB y los empuja a Xandikos (PUT, sin borrar). Útil tras la migración |
Queries con nombre incluidas: `personas_por_contexto`, `personas_recientes`,
`eventos_proximos`, `contactos_sin_nota`, `stats_personas`,