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:
@@ -8,6 +8,7 @@ ingest del vault, que re-enlaza.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
@@ -354,3 +355,189 @@ def test_render_note_valida_inputs(client):
|
||||
).json()
|
||||
assert r["status"] == "error"
|
||||
assert "fuera del vault" in r["error"]
|
||||
|
||||
|
||||
# --- F1: migraciones multi-valor + addressbooks ----------------------------
|
||||
|
||||
|
||||
def test_migracion_multivalue_y_addressbooks(client, cfg):
|
||||
"""002 añade columnas multi-valor a persons; 003 crea+seed addressbooks."""
|
||||
r = client.get("/api/tables").json()
|
||||
by_name = {(t["schema"], t["name"]): t for t in r["tables"]}
|
||||
persons_cols = {c["name"] for c in by_name[("main", "persons")]["columns"]}
|
||||
assert {"telefonos", "emails", "direcciones", "extra_fm"} <= persons_cols
|
||||
# Las singulares siguen existiendo (compat).
|
||||
assert {"telefono", "email", "direccion"} <= persons_cols
|
||||
# La libreta por defecto quedó sembrada.
|
||||
assert ("main", "addressbooks") in by_name
|
||||
ab = client.post(
|
||||
"/api/query", json={"sql": "SELECT slug, collection_path FROM addressbooks"}
|
||||
).json()
|
||||
rows = {row["slug"]: row["collection_path"] for row in ab["rows"]}
|
||||
assert rows["addressbook"] == "/enmanuel/contacts/addressbook/"
|
||||
|
||||
|
||||
# --- F2: ingest selectivo (la DB es dueña de los campos OWNED) --------------
|
||||
|
||||
|
||||
def test_ingest_vault_no_pisa_campo_owned(client, cfg):
|
||||
"""Un valor escrito por la API persiste tras re-ingestar el vault.
|
||||
|
||||
Simula la escritura de un teléfono por la futura API con un UPDATE directo
|
||||
a la DB; el re-ingest del vault NO debe pisarlo con el frontmatter viejo de
|
||||
la nota (que tenía '+34 600 111 222').
|
||||
"""
|
||||
client.post("/api/ingest/vault")
|
||||
# La API escribe un teléfono nuevo (multi-valor) en la DB.
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE persons SET telefonos = ?, telefono = ? WHERE slug = ?",
|
||||
['["+34 999 888 777"]', "+34 999 888 777", "ana-garcia-perez"],
|
||||
)
|
||||
# Re-ingest del vault: la ficha de Ana YA existe -> solo refresca
|
||||
# note_path + extra_fm, NO los campos OWNED.
|
||||
r = client.post("/api/ingest/vault").json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["persons"] == 2
|
||||
assert r["persons_updated"] == 2 # ambas fichas ya existían
|
||||
assert r["persons_inserted"] == 0
|
||||
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={
|
||||
"sql": "SELECT telefono, telefonos FROM persons WHERE slug = 'ana-garcia-perez'"
|
||||
},
|
||||
).json()
|
||||
assert q["rows"][0]["telefono"] == "+34 999 888 777" # el valor de la API, NO el del FM
|
||||
assert "+34 999 888 777" in q["rows"][0]["telefonos"]
|
||||
|
||||
|
||||
def test_ingest_vault_bootstrapea_ficha_nueva(client, cfg):
|
||||
"""Una ficha cuyo slug no está en la DB se inserta completa desde el FM."""
|
||||
# Primer ingest: solo las dos fichas del fixture.
|
||||
client.post("/api/ingest/vault")
|
||||
# Añadimos una ficha nueva al vault con teléfono singular en el FM.
|
||||
nueva = (
|
||||
"---\n"
|
||||
"tipo: persona\n"
|
||||
'nombre: "Marta Ruiz"\n'
|
||||
"slug: marta-ruiz\n"
|
||||
'telefono: "+34 611 000 111"\n'
|
||||
"contexto: trabajo\n"
|
||||
"campo_libre: valor_raro\n"
|
||||
"tags: [persona]\n"
|
||||
"---\n\n## Notas\n"
|
||||
)
|
||||
with open(
|
||||
os.path.join(cfg.vault_dir, "personas", "marta-ruiz.md"), "w", encoding="utf-8"
|
||||
) as fh:
|
||||
fh.write(nueva)
|
||||
|
||||
r = client.post("/api/ingest/vault").json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["persons"] == 3
|
||||
assert r["persons_inserted"] == 1 # marta-ruiz nueva
|
||||
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={
|
||||
"sql": "SELECT telefono, telefonos, contexto, extra_fm "
|
||||
"FROM persons WHERE slug = 'marta-ruiz'"
|
||||
},
|
||||
).json()
|
||||
row = q["rows"][0]
|
||||
assert row["telefono"] == "+34 611 000 111" # singular derivado de la lista
|
||||
assert "+34 611 000 111" in row["telefonos"] # lista poblada desde el singular
|
||||
assert row["contexto"] == "trabajo"
|
||||
# extra_fm captura el frontmatter no-owned (campo_libre), no los OWNED.
|
||||
extra = json.loads(row["extra_fm"])
|
||||
assert extra.get("campo_libre") == "valor_raro"
|
||||
assert "telefono" not in extra and "contexto" not in extra
|
||||
|
||||
|
||||
# --- F3: endpoints de escritura estructurada (persons, sin red) ------------
|
||||
|
||||
|
||||
def test_api_person_crud_y_materializa(client, cfg):
|
||||
"""POST /api/person con 2 teléfonos -> fila en DB + nota .md materializada."""
|
||||
client.post("/api/ingest/vault")
|
||||
body = {
|
||||
"slug": "nuevo-contacto",
|
||||
"nombre": "Nuevo Contacto",
|
||||
"telefonos": ["+34 600 000 001", "+34 600 000 002"],
|
||||
"emails": ["nc@example.com"],
|
||||
"contexto": "trabajo",
|
||||
"tags": ["persona"],
|
||||
}
|
||||
r = client.post("/api/person", json=body).json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["inserted"] == 1
|
||||
assert r["materialized"] is True
|
||||
|
||||
# Fila en DB: telefonos como lista, singular = primer elemento.
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={
|
||||
"sql": "SELECT telefono, telefonos, emails, email FROM persons "
|
||||
"WHERE slug = 'nuevo-contacto'"
|
||||
},
|
||||
).json()
|
||||
row = q["rows"][0]
|
||||
assert json.loads(row["telefonos"]) == ["+34 600 000 001", "+34 600 000 002"]
|
||||
assert row["telefono"] == "+34 600 000 001"
|
||||
assert row["email"] == "nc@example.com"
|
||||
|
||||
# Nota .md materializada con la lista telefonos.
|
||||
note_file = os.path.join(cfg.vault_dir, "personas", "nuevo-contacto.md")
|
||||
assert os.path.exists(note_file)
|
||||
content = open(note_file, encoding="utf-8").read()
|
||||
assert "telefonos:" in content
|
||||
assert "+34 600 000 001" in content
|
||||
assert "+34 600 000 002" in content
|
||||
|
||||
# PUT actualiza (un solo teléfono ahora).
|
||||
r = client.put(
|
||||
"/api/person/nuevo-contacto",
|
||||
json={"slug": "nuevo-contacto", "nombre": "NC", "telefonos": ["+34 600 000 009"]},
|
||||
).json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["updated"] == 1
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={"sql": "SELECT telefono FROM persons WHERE slug = 'nuevo-contacto'"},
|
||||
).json()
|
||||
assert q["rows"][0]["telefono"] == "+34 600 000 009"
|
||||
|
||||
# DELETE quita la fila de la DB.
|
||||
r = client.request("DELETE", "/api/person/nuevo-contacto").json()
|
||||
assert r["status"] == "ok"
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={"sql": "SELECT COUNT(*) AS n FROM persons WHERE slug = 'nuevo-contacto'"},
|
||||
).json()
|
||||
assert q["rows"][0]["n"] == 0
|
||||
|
||||
|
||||
def test_api_person_render_preserva_prosa(client, cfg):
|
||||
"""POST /api/person/{slug}/render reescribe el frontmatter SIN tocar la prosa."""
|
||||
client.post("/api/ingest/vault")
|
||||
# Ana ya tiene cuerpo "## Notas\nFicha de prueba." en el fixture.
|
||||
note_file = os.path.join(cfg.vault_dir, "personas", "ana-garcia-perez.md")
|
||||
before = open(note_file, encoding="utf-8").read()
|
||||
assert "Ficha de prueba." in before
|
||||
|
||||
# Cambiamos el teléfono por la API y re-materializamos.
|
||||
client.put(
|
||||
"/api/person/ana-garcia-perez",
|
||||
json={
|
||||
"slug": "ana-garcia-perez",
|
||||
"nombre": "Ana García Pérez",
|
||||
"telefonos": ["+34 622 333 444"],
|
||||
},
|
||||
)
|
||||
r = client.post("/api/person/ana-garcia-perez/render").json()
|
||||
assert r["status"] == "ok"
|
||||
|
||||
after = open(note_file, encoding="utf-8").read()
|
||||
assert "Ficha de prueba." in after # prosa preservada
|
||||
assert "+34 622 333 444" in after # frontmatter actualizado
|
||||
|
||||
Reference in New Issue
Block a user