INFORMATION MODEL — Memora
D1 schema. Convención: snake_case, PK id UUID v4 (TEXT), timestamps created_at / updated_at (INTEGER unix epoch ms).
Diagrama de entidades
user ─┐
├─< project ─┬─< project_member >─ user (coautores)
│ ├─< period ─< chapter ─< chapter_entry >─ entry
│ │ └─< witness_invitation >─ external_witness
│ ├─< entry ─< asset
│ │ └─< entry_person >─ person
│ ├─< chronicle_session ─< entry (solo si mode='chronicle')
│ ├─< person
│ ├─< external_witness
│ └─< publication (libro/vídeo/podcast)
└─< user_settings
Regla clave: casi TODO cuelga de project, no de user directamente. Un usuario puede tener N proyectos (Diario 2026, Crónica con Pep, Diario embarazo). Personas y testigos se duplican por proyecto a propósito (privacidad y contexto). En V2 se podrán linkar entre proyectos.
Tablas
user
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | UUID |
| TEXT UNIQUE | login Google | |
| display_name | TEXT | |
| avatar_url | TEXT | de Google |
| locale | TEXT | es, en, ca, eu, gl, pt |
| timezone | TEXT | IANA |
| plan | TEXT | free, plus, family (default free) |
| created_at | INTEGER |
project 🆕 ⭐ entidad raíz de contenido
Un proyecto agrupa todo el contenido bajo un mismo modo de uso.
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | UUID |
| owner_user_id | TEXT FK user | creador |
| mode | TEXT | journal | chronicle — inmutable tras crear |
| title | TEXT | "Diario 2026", "Años de coches con Pep" |
| description | TEXT | |
| cover_asset_id | TEXT FK asset | |
| color_hex | TEXT | identidad visual rápida |
| era_starts_year | INTEGER | solo Crónica, aproximado |
| era_ends_year | INTEGER | solo Crónica, NULL si en curso |
| default_intimacy | TEXT | hereda de user_settings, override por proyecto |
| weekly_assistant_enabled | INTEGER | solo aplica si mode='journal' |
| created_at | INTEGER | |
| archived_at | INTEGER |
project_member 🆕
Usuarios con acceso a un proyecto. El owner se replica con rol owner.
| col | tipo | notas |
|---|---|---|
| project_id | TEXT FK project | |
| user_id | TEXT FK user | |
| role | TEXT | owner | coauthor | reader |
| joined_at | INTEGER | |
| invited_by | TEXT FK user | |
| color_hex | TEXT | color del avatar en timeline merge |
Crónica: típicamente 2-8 coauthors. Diario: típicamente solo el owner; en V2 "diario de pareja" añade 1 coauthor.
period
Bloque temporal alto nivel ("Embarazo Lola", "Año en Berlín", "2024", "Verano 2008 en Cádiz").
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | |
| project_id | TEXT FK | 🆕 reemplaza user_id |
| title | TEXT | |
| description | TEXT | |
| starts_on | TEXT | ISO date YYYY-MM-DD, opcional |
| ends_on | TEXT | opcional, NULL = en curso |
| is_approximate | INTEGER | 🆕 0/1 — típicamente 1 en Crónica ("sobre 2008") |
| cover_asset_id | TEXT FK asset | |
| created_at | INTEGER |
entry ⭐ entidad central
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | |
| project_id | TEXT FK | 🆕 reemplaza user_id, scope principal |
| author_user_id | TEXT FK user | quién escribió (en multi-autor Crónica importa) |
| period_id | TEXT FK | nullable, IA puede asignar después |
| occurred_on | TEXT | ISO date del evento (no fecha de captura) |
| occurred_at | INTEGER | epoch ms preciso si se sabe hora |
| occurred_precision | TEXT | 🆕 exact | day | month | year | era — crítico en Crónica |
| captured_at | INTEGER | cuándo se subió |
| body_md | TEXT | contenido markdown |
| body_plain | TEXT | sin formato, para FTS |
| location_text | TEXT | "Cádiz, plaza San Antonio" |
| location_lat | REAL | |
| location_lng | REAL | |
| intimacy_level | TEXT | private | intimate | circle | public (default private) |
| author_type | TEXT | owner | coauthor | witness |
| author_witness_id | TEXT FK | NULL salvo si author_type='witness' |
| source | TEXT | 🆕 manual | voice | photo | chronicle_session | assistant_recall | witness_response |
| chronicle_session_id | TEXT FK | 🆕 NULL salvo si nació de una sesión de Crónica |
| status | TEXT | published | pending_review | archived |
| assisted_recall | INTEGER | 0/1 — generada por asistente semanal (Diario) |
| created_at | INTEGER | |
| updated_at | INTEGER |
Reglas de visibilidad (centralizadas en helper canSee(viewer, entry)):
private→ soloentry.author_user_id(en Crónica multi-autor: NUNCA visible para otros coautores)intimate→ autor + miembros con rolcoauthordel proyectocircle→ todos losproject_member+ visualizadores invitados a capítulospublic→ cualquiera con enlace público / incluido en libros públicos
chronicle_session 🆕
Una sesión grabada de reconstrucción de pasado (Flow 6b). Solo aplica a proyectos mode='chronicle'.
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | |
| project_id | TEXT FK | |
| started_by_user_id | TEXT FK user | |
| topic | TEXT | tema sugerido o elegido ("el año en Berlín", "primer encuentro con Marta") |
| audio_asset_id | TEXT FK asset | grabación completa |
| transcript_full | TEXT | transcripción raw Whisper, con timestamps |
| ai_questions_json | TEXT | JSON array de preguntas que la IA fue inyectando con timestamp |
| participants_user_ids | TEXT | JSON de coautores que participaron |
| participant_witness_ids | TEXT | JSON de testigos externos invitados a la sesión (V2) |
| duration_ms | INTEGER | |
| split_into_entries | INTEGER | count de entries derivadas |
| status | TEXT | recording | processing | ready | archived |
| started_at | INTEGER | |
| ended_at | INTEGER |
asset
Archivos físicos (audio, foto, vídeo) en R2.
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | |
| project_id | TEXT FK | 🆕 |
| uploader_user_id | TEXT FK user | |
| entry_id | TEXT FK | nullable (cover de proyecto/período, audio de chronicle_session) |
| kind | TEXT | audio | photo | video | doc |
| r2_key | TEXT | ej. audio/{project}/{uuid}.webm |
| mime | TEXT | |
| size_bytes | INTEGER | |
| duration_ms | INTEGER | audio/video |
| width / height | INTEGER | foto/vídeo |
| transcript | TEXT | si audio (Whisper) |
| caption | TEXT | si foto (Groq Vision) |
| ocr_text | TEXT | si foto con texto |
| exif_json | TEXT | EXIF crudo |
| guessed_year | INTEGER | 🆕 año aproximado si EXIF vacío y usuario lo indicó (Crónica) |
| created_at | INTEGER |
person
Personas del círculo del usuario, mencionadas en entries. Scope por proyecto.
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | |
| project_id | TEXT FK | 🆕 |
| display_name | TEXT | |
| relation | TEXT | "padre", "hija", "amiga universidad" |
| birth_year | INTEGER | opcional |
| avatar_asset_id | TEXT FK | |
| linked_witness_id | TEXT FK | nullable, conecta con external_witness si compartiste con él |
entry_person
Relación N:N entry↔person.
| col | tipo |
|---|---|
| entry_id | TEXT FK |
| person_id | TEXT FK |
| mention_text | TEXT (palabras detectadas por NER) |
external_witness 🆕
Personas reales SIN cuenta Memora, candidatas a recibir invitaciones a capítulos. Scope por proyecto.
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | |
| project_id | TEXT FK | 🆕 |
| TEXT | requerido | |
| display_name | TEXT | |
| relation | TEXT | "tío", "ex-jefe", etc. |
| linked_person_id | TEXT FK | bidireccional con person |
| last_invited_at | INTEGER | |
| total_responses | INTEGER | counter |
chapter
Capítulo curado de un período.
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | |
| project_id | TEXT FK | 🆕 |
| period_id | TEXT FK | |
| title | TEXT | |
| intro_md | TEXT | |
| cover_asset_id | TEXT FK | |
| status | TEXT | draft | published |
| public_slug | TEXT | nullable, si público con URL |
| created_at | INTEGER |
chapter_entry
Selección ordenada de entries dentro del capítulo. | chapter_id | TEXT FK | | entry_id | TEXT FK | | sort_order | INTEGER | | editor_note | TEXT | nota curatorial opcional |
witness_invitation 🆕
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | |
| chapter_id | TEXT FK | |
| witness_id | TEXT FK external_witness | |
| token | TEXT UNIQUE | UUID, en URL /w/{token} |
| sent_at | INTEGER | |
| expires_at | INTEGER | +30d |
| viewed_at | INTEGER | |
| responded_at | INTEGER | |
| response_entry_id | TEXT FK entry | nullable, la entry creada por el testigo |
| owner_decision | TEXT | pending | approved | rejected | edited |
| personal_note | TEXT | nota del autor al testigo |
publication (V1.5)
| col | tipo | notas |
|---|---|---|
| id | TEXT PK | |
| project_id | TEXT FK | 🆕 |
| period_id | TEXT FK | |
| kind | TEXT | pdf_book | epub | mp4_video | mp3_podcast |
| min_intimacy | TEXT | piso de intimidad incluido (default circle) |
| r2_key | TEXT | artefacto generado |
| status | TEXT | queued | rendering | ready | failed |
| created_at | INTEGER |
user_settings
| user_id | TEXT PK FK |
| weekly_assistant_enabled | INTEGER | |
| weekly_assistant_dow | INTEGER | 0=dom |
| weekly_assistant_hour | INTEGER | local |
| email_notifications | INTEGER | |
| default_intimacy | TEXT | default private |
Vectorize
Índice memora-entries (Cloudflare Vectorize) — mismo patrón que codex-knowledge.
- Modelo:
@cf/baai/bge-m3(Workers AI) - Dimensiones: 1024 (NO 1536 — no usamos OpenAI)
- Métrica: cosine
- Plan free: 200K vectores
- Vector ID:
{entry_id}o{entry_id}_chunk{n}si la entry > 1500 chars
Metadata por vector:
{
"project_id": "...",
"project_mode": "journal",
"author_user_id": "...",
"entry_id": "...",
"occurred_on": "2024-08-12",
"intimacy_level": "circle",
"period_id": "...",
"person_ids": ["...", "..."],
"lang": "es"
}
Filtrado obligatorio: toda query incluye filter: { project_id: <activo> } (o lista si búsqueda global) más intimacy_level permitido por rol del viewer.
FTS
Tabla virtual entry_fts (D1 FTS5) sobre body_plain + transcript + caption + location_text para búsqueda exacta complementaria a la semántica.
Migraciones planeadas
| # | Sprint | Contenido |
|---|---|---|
| 0001 | S1 | user, project, project_member, user_settings |
| 0002 | S1 | period, entry (con intimacy_level, occurred_precision, source), asset |
| 0003 | S1 | person, entry_person |
| 0004 | S1 | external_witness, chapter, chapter_entry, witness_invitation |
| 0005 | S1 | chronicle_session (clave para Crónica desde día 1) |
| 0006 | S1 | entry_fts (FTS5) |
| 0007 | S2 | publication |
| 0008 | S4 | legacy_contact |