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:
+134
@@ -55,6 +55,7 @@ from server.registry_bridge import ( # noqa: E402
|
||||
update_obsidian_note,
|
||||
upsert_sentinel_block,
|
||||
)
|
||||
from server import writes # noqa: E402
|
||||
|
||||
# Tope de filas que un render vuelca en una nota (las notas no son un export).
|
||||
RENDER_MAX_ROWS = 200
|
||||
@@ -85,6 +86,71 @@ class RenderNoteBody(BaseModel):
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class PersonBody(BaseModel):
|
||||
"""Body de POST/PUT /api/person: ficha de persona multi-valor.
|
||||
|
||||
Las listas telefonos/emails/direcciones son la fuente de verdad; los
|
||||
singulares de la nota se derivan del primer elemento al materializar.
|
||||
"""
|
||||
|
||||
slug: str | None = None
|
||||
nombre: str | None = None
|
||||
aliases: list = Field(default_factory=list)
|
||||
sexo: str | None = None
|
||||
fecha_nacimiento: str | None = None
|
||||
dni: str | None = None
|
||||
pais: str | None = None
|
||||
contexto: str | None = None
|
||||
telefonos: list = Field(default_factory=list)
|
||||
emails: list = Field(default_factory=list)
|
||||
direcciones: list = Field(default_factory=list)
|
||||
tags: list = Field(default_factory=list)
|
||||
|
||||
|
||||
class ContactBody(BaseModel):
|
||||
"""Body de POST/PUT /api/contact: contacto CardDAV multi-valor."""
|
||||
|
||||
uid: str | None = None
|
||||
nombre: str | None = None
|
||||
fn: str | None = None
|
||||
collection: str | None = None
|
||||
tels: list = Field(default_factory=list)
|
||||
telefonos: list = Field(default_factory=list)
|
||||
emails: list = Field(default_factory=list)
|
||||
correos: list = Field(default_factory=list)
|
||||
direcciones: list = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventBody(BaseModel):
|
||||
"""Body de POST/PUT /api/event: evento CalDAV."""
|
||||
|
||||
uid: str | None = None
|
||||
calendar: str | None = None
|
||||
summary: str | None = None
|
||||
dtstart: str | None = None
|
||||
dtend: str | None = None
|
||||
all_day: bool = False
|
||||
location: str | None = None
|
||||
rrule: str | None = None
|
||||
|
||||
|
||||
class AddressbookBody(BaseModel):
|
||||
"""Body de POST /api/addressbook: crea libreta CardDAV."""
|
||||
|
||||
slug: str
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class CalendarBody(BaseModel):
|
||||
"""Body de POST /api/calendar: crea calendario CalDAV."""
|
||||
|
||||
slug: str
|
||||
display_name: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
def create_app(cfg: Config) -> FastAPI:
|
||||
"""Construye la app FastAPI con la configuración dada (inyectable en tests)."""
|
||||
app = FastAPI(title="osint_db", docs_url=None, redoc_url=None)
|
||||
@@ -215,6 +281,74 @@ def create_app(cfg: Config) -> FastAPI:
|
||||
"rows_rendered": result["row_count"],
|
||||
}
|
||||
|
||||
# --- Escritura estructurada (DB fuente de verdad) ---------------------
|
||||
# Todos responden 200 + {status}. La escritura DB va bajo el lock del
|
||||
# service; el push DAV y el render ocurren tras cerrar la transacción.
|
||||
|
||||
def _guard(fn) -> dict:
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@app.post("/api/person")
|
||||
def create_person(body: PersonBody) -> dict:
|
||||
if not body.slug:
|
||||
return {"status": "error", "error": "falta 'slug'"}
|
||||
return _guard(lambda: writes.upsert_person(cfg, body.slug, body.model_dump()))
|
||||
|
||||
@app.put("/api/person/{slug}")
|
||||
def update_person(slug: str, body: PersonBody) -> dict:
|
||||
return _guard(lambda: writes.upsert_person(cfg, slug, body.model_dump()))
|
||||
|
||||
@app.delete("/api/person/{slug}")
|
||||
def remove_person(slug: str) -> dict:
|
||||
return _guard(lambda: writes.delete_person(cfg, slug))
|
||||
|
||||
@app.post("/api/person/{slug}/render")
|
||||
def materialize_person(slug: str) -> dict:
|
||||
return _guard(lambda: writes.render_person(cfg, slug))
|
||||
|
||||
@app.post("/api/contact")
|
||||
def create_contact(body: ContactBody) -> dict:
|
||||
if not body.uid:
|
||||
return {"status": "error", "error": "falta 'uid'"}
|
||||
return _guard(lambda: writes.upsert_contact(cfg, body.uid, body.model_dump()))
|
||||
|
||||
@app.put("/api/contact/{uid}")
|
||||
def update_contact(uid: str, body: ContactBody) -> dict:
|
||||
return _guard(lambda: writes.upsert_contact(cfg, uid, body.model_dump()))
|
||||
|
||||
@app.delete("/api/contact/{uid}")
|
||||
def remove_contact(uid: str) -> dict:
|
||||
return _guard(lambda: writes.delete_contact(cfg, uid))
|
||||
|
||||
@app.post("/api/event")
|
||||
def create_event(body: EventBody) -> dict:
|
||||
if not body.uid:
|
||||
return {"status": "error", "error": "falta 'uid'"}
|
||||
return _guard(lambda: writes.upsert_event(cfg, body.uid, body.model_dump()))
|
||||
|
||||
@app.put("/api/event/{uid}")
|
||||
def update_event(uid: str, body: EventBody) -> dict:
|
||||
return _guard(lambda: writes.upsert_event(cfg, uid, body.model_dump()))
|
||||
|
||||
@app.delete("/api/event/{uid}")
|
||||
def remove_event(uid: str) -> dict:
|
||||
return _guard(lambda: writes.delete_event(cfg, uid))
|
||||
|
||||
@app.post("/api/addressbook")
|
||||
def create_addressbook(body: AddressbookBody) -> dict:
|
||||
return _guard(lambda: writes.make_addressbook(cfg, body.model_dump()))
|
||||
|
||||
@app.post("/api/calendar")
|
||||
def create_calendar(body: CalendarBody) -> dict:
|
||||
return _guard(lambda: writes.make_calendar(cfg, body.model_dump()))
|
||||
|
||||
@app.post("/api/push/dav")
|
||||
def push_dav() -> dict:
|
||||
return _guard(lambda: writes.push_all_dav(cfg))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user