Base de datos
Schema, migraciones, convenciones y workflow de la BD.
Stack
- PostgreSQL 15 (Supabase local en dev, Supabase managed en prod)
- Drizzle ORM como capa de acceso (single schema file)
- drizzle-kit para generación de migraciones
- postgres.js como driver (rápido, soporta Supabase pooler)
Schema
Single file en src/lib/server/db/schema.ts. ~25 tablas mapeadas a los 9 módulos del MVP.
Ver docs/MODULES.md para el mapeo módulo → tablas.
Convenciones
- Nombres de tabla:
snake_caseplural (patients,meal_plan_items). - Nombres de columna:
snake_case(full_name,created_at). - PKs:
uuidcondefaultRandom()exceptoprofiles.idque viene deauth.users. - FKs:
references()cononDeleteexplícito:cascadepara dependencias fuertes (mediciones → paciente).restrictpara datos financieros/históricos (pagos → paciente).set nullpara referencias opcionales.
- Timestamps:
timestamp({ withTimezone: true })siempre. - Montos:
decimal(precision, scale)con escala explícita. CLP comointeger(no hay centavos). - Enums:
pgEnumpara campos con valores fijos. - Índices: en FKs y en columnas de búsqueda frecuente. Definidos inline en el
pgTable(...). - No usar
mode: 'string'para timestamps.
Patrones de modelado
Profile que extiende auth.users:
export const profiles = pgTable('profiles', {
id: uuid('id').primaryKey(), // = auth.users.id (no FK porque Supabase la maneja)
role: userRoleEnum('role').notNull(),
email: text('email').notNull().unique(),
fullName: text('full_name').notNull(),
// ...
});
Relación 1:1 con extensión:
export const patients = pgTable('patients', {
id: uuid('id').primaryKey().defaultRandom(),
profileId: uuid('profile_id')
.notNull()
.unique()
.references(() => profiles.id, { onDelete: 'cascade' }),
nutritionistId: uuid('nutritionist_id')
.notNull()
.references(() => profiles.id, { onDelete: 'restrict' }),
// ...
});
Relación many-to-many con tabla de unión:
export const patientAchievements = pgTable(
'patient_achievements',
{
patientId: uuid('patient_id').notNull().references(() => patients.id, { onDelete: 'cascade' }),
achievementId: uuid('achievement_id').notNull().references(() => achievements.id, { onDelete: 'cascade' }),
unlockedAt: timestamp('unlocked_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [primaryKey({ columns: [t.patientId, t.achievementId] })]
);
JSONB para datos semi-estructurados:
services: jsonb('services').$type<Array<{ name: string; priceClp: number; description: string }>>()
Usar solo cuando la forma varía (ej. servicios del profesional). Para datos estructurados, crear tablas.
Migraciones
Workflow en dev
# 1. Editar src/lib/server/db/schema.ts
# 2. Generar la migración
pnpm db:generate
# Esto crea un archivo .sql en /drizzle/
# 3. Aplicar a la BD local
pnpm db:migrate
# O más rápido para iterar:
pnpm db:push
db:push aplica cambios directamente sin generar migration files — solo para dev.
db:migrate aplica las migrations generadas — usar antes de commit y en prod.
Workflow para deploy
# 1. Local: db:generate, revisar SQL generado, commitear
# 2. Prod: db:migrate (con DATABASE_URL apuntando a Supabase prod)
Naming de migrations
Drizzle usa timestamps: 0000_white_silver_surfer.sql. No renombrar a mano.
RLS (Row Level Security)
Por ahora NO definidas. Las queries pasan por Drizzle con service-role connection, así que RLS no bloquea el server code. Pero:
- Defensa en profundidad: si en el futuro habilitamos queries desde el browser via Supabase client, RLS ya debería estar.
- Auth-based queries: si en algún momento un paciente hace queries al server con su sesión, RLS en
patientsdebería limitar anutritionist_id = auth.uid().
Cuando agreguemos RLS, lo hacemos en supabase/migrations/ (no en Drizzle migrations, porque RLS es concepto de Postgres/Supabase, no del ORM):
-- supabase/migrations/20260101120000_rls_patients.sql
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Patients can read their own record"
ON patients FOR SELECT
USING (profile_id = auth.uid());
CREATE POLICY "Nutritionists can read their patients"
ON patients FOR SELECT
USING (nutritionist_id = auth.uid());
Seed
supabase/seed.sql se ejecuta automáticamente con pnpm supabase:reset. Tiene:
- Categorías de alimentos del Cono Sur
- Achievements por defecto (gamificación)
Cliente Drizzle
Single client en src/lib/server/db/index.ts. Se importa así:
import { db, tables } from '$server/db';
// o equivalentemente:
import { db } from '$server/db';
import * as tables from '$server/db/schema'; // menos limpio, preferí el barrel
db está tipado, las queries heredan tipos del schema.
Tipos inferidos
Para cada tabla, Drizzle infiere:
type Patient = typeof tables.patients.$inferSelect;
type NewPatient = typeof tables.patients.$inferInsert;
Están exportados en schema.ts. Usar estos en services, no any.
Queries comunes (cheat sheet)
import { db, tables } from '$server/db';
import { eq, and, desc, asc, like, sql, gte, lte, inArray } from 'drizzle-orm';
// Select con where
const myPatients = await db
.select()
.from(tables.patients)
.where(eq(tables.patients.nutritionistId, userId))
.orderBy(desc(tables.patients.createdAt));
// Select con join + where compuesto
const results = await db
.select({ patient: tables.patients, profile: tables.profiles })
.from(tables.patients)
.innerJoin(tables.profiles, eq(tables.profiles.id, tables.patients.profileId))
.where(and(eq(tables.patients.nutritionistId, userId), eq(tables.patients.isActive, true)));
// Insert
const [newPatient] = await db
.insert(tables.patients)
.values({ profileId, nutritionistId, birthDate })
.returning();
// Update
await db
.update(tables.patients)
.set({ isActive: false })
.where(eq(tables.patients.id, patientId));
// Delete
await db.delete(tables.appointments).where(eq(tables.appointments.id, apptId));
// Transacción
await db.transaction(async (tx) => {
await tx.insert(tables.patients).values(...);
await tx.insert(tables.profiles).values(...);
});
Limits Always Free (Supabase free tier)
| Recurso | Límite Free |
|---|---|
| Database size | 500 MB |
| Storage | 1 GB |
| Bandwidth | 2 GB |
| Auth MAUs | 50,000 |
| Edge function invocations | 500,000/mes |
Para un MVP chico sobra. Cuando crezca, migrar a Pro de Supabase o a Postgres managed en Oracle Cloud.
Backups
Drizzle migrations son el "schema backup". Para data backups:
- Dev:
supabase db dumpexporta todo. - Prod: configurar backups automáticos en el dashboard de Supabase (Pro plan) o vía pg_dump cron en Oracle Cloud.
Performance
- Índices: definidos en
pgTable(...)para FKs y queries frecuentes. Si una query es lenta, agregar índice. - N+1: usar
innerJoin/leftJoinen vez de loops. - Paginación: usar
.limit(N).offset(M)por ahora; migrar a cursor-based cuando haya >10K rows por tabla. - Aggregations: usar SQL crudo con
sqltemplate tag si Drizzle no tiene helper.
Limitaciones actuales
- No hay soft delete. Borrar es hard delete (excepto payments que están protegidos con
restrict). - No hay auditoría. No guardamos quién modificó qué. Agregar tabla
audit_logpost-MVP. - No hay multi-tenancy. Un nutricionista no puede compartir pacientes con otros (salvo admin).
- No hay multi-currency. Todo en CLP.