🌲 Warehouse · W1 — Árbol re-enraizado + Catalog/Schema creables (Approach)
Approach de la Fase W1. Padre: warehouse-reorg.md. Substrato W0 (aplicado):
catalogs·schemas·datasets.schema_idsembradas (../../supabase/migrations/20261231_warehouse_catalog_hierarchy.sql).Objetivo W1: que el Warehouse se vea y se opere como un catálogo real — el árbol pasa de
My organization → project → dataset(re-proyección de carpetas) aCatalog → Schema → Dataset/Volumeleído del modelo nuevo, con acciones Create catalog / Create schema. Los media sets dejan de esconderse y aparecen como Volumes.
1. El corte de riesgo — LÓGICO ahora, FÍSICO después
La bala W1 del doc padre decía "namespace Iceberg → catalog.schema". Eso se parte en dos y solo la mitad entra en W1:
- ✅ W1 = re-enraizado LÓGICO.
catalog.schemaes una jerarquía en PG que ordena y gobierna. El árbol, los breadcrumbs y las CRUD leen/escriben este modelo. - ⛔ DIFERIDO (W1.5) = re-enraizado FÍSICO. Mover las tablas Iceberg vivas de
datasets.ds_{id}a{catalog}.{schema}.ds_{id}en Lakekeeper es invasivo (renombra namespaces con snapshots en vuelo, toca los routers de lectura/escritura). Se hace aparte, gated y coordinado con el write-path nativo (fase 2d). En W1 el físico siguedatasets.ds_{id}; elschema_ides overlay lógico.
Fuera de W1 también: el swap del picker "output folder" → "catalog.schema" en Data Connection (W3), el modelo GRANT (W5), credentials/connections (W3).
2. Lectura — re-enraizar GET /api/dataspace/catalog
Hoy (app/api/dataspace/catalog/route.ts) agrupa datasets por project_id sobre user_projects y devuelve { projects, orphans, shares }. Auth: withRLSContext + requirePermission('view') + supabaseRLS.
W1 lee del modelo nuevo y devuelve una jerarquía de 3 niveles, separando Dataset (tabla) de Volume (service='media_set'):
{
"workspaceId": "…",
"catalogs": [{
"id": "…", "name": "main", "comment": "…",
"schemas": [{
"id": "…", "name": "Ventas", "comment": "…",
"datasets": [{ "id","name","display_name","status","row_count","column_count","size_bytes","file_id","updated_at" }],
"volumes": [{ "id","name","display_name","status","size_bytes","file_id","updated_at" }]
}]
}],
"shares": [ /* sin cambios: dataspace_transfers direction='in' */ ]
}
Query (3 fetches workspace-scoped, mismo patrón):
catalogs(id, name, comment) del workspace,order by name.schemas(id, name, comment, catalog_id) del workspace,order by name.datasets(+schema_id,service) del workspace.
Ensamblado:
- Split:
service='media_set'→volumes; resto →datasets. - Group by
schema_id→ cuelga de su schema; group schemas bycatalog_id. - Catálogos/schemas vacíos SE MUESTRAN (como Databricks: un
Create schemaproduce un nodo visible aunque no tenga datos aún). ← quita el.filter(datasets.length>0)actual. - Huérfanos (
schema_id NULL): fallback defensivo — si tienenproject_id, resolver porschemas.legacy_project_id; si no, al schemadefaultdelmain(ver §5, el trigger normalmente ya lo evita).
Consumidor único: WarehouseTab. Se cambian ruta y componente en el mismo PR (pre-release, sin flag). El shape viejo (projects/orphans) se retira.
3. Escritura — que el modelo se auto-mantenga (invariantes por trigger)
El riesgo real de W1 no es la lectura, es la deriva: workspaces/projects/datasets creados después de la siembra W0 no tendrían catalog/schema/schema_id. En vez de parchear cada create-path en la app, se mantienen los invariantes en el borde de datos (triggers) — robusto y con mínimo churn de código. Migración auxiliar 20261232_warehouse_w1_invariants.sql:
| # | Trigger | Efecto |
|---|---|---|
| T1 | AFTER INSERT ON workspaces | crea el catalog main + schema default del nuevo workspace |
| T2 | AFTER INSERT ON user_projects | crea un schema (en el main del workspace) con legacy_project_id = NEW.id |
| T3 | BEFORE INSERT OR UPDATE ON datasets | si schema_id IS NULL: lo deriva de project_id vía schemas.legacy_project_id; si no hay project → schema default |
Más, en la misma migración: seed del schema default para los main existentes + backfill de datasets huérfanos (schema_id NULL) → su default.
Notas de RLS/seguridad: T1 corre en el contexto del webhook de Clerk (service_role, bypassa RLS). T2/T3 corren como el usuario autenticado, que es miembro del workspace → las policies … IN (SELECT user_workspace_ids()) pasan sin SECURITY DEFINER. T3 solo hace un SELECT de resolución + set de columna (sin insert cruzado).
Trade-off: los triggers son "magia implícita". Son deliberados para la fase-puente; se retiran cuando el picker se vuelve explícito (catalog.schema en W3) y schema_id se setea en el create-path.
Create catalog / Create schema (rutas nuevas)
Mismo patrón que el read (requirePermission de mutación + supabaseRLS; la RLS de W0 hace el resto):
POST /api/dataspace/catalogs { name, comment? } → 201 { id, name, comment }
POST /api/dataspace/schemas { catalogId, name, comment? } → 201 { id, name, comment }
- Gate: el mismo de los creates workspace-scoped existentes (verificar el string en
lib/graphs/auth— probablementerequirePermission('edit')). workspace_idse deriva del ctx (no del body). Paraschemas,workspace_id= el del catalog (validar que elcatalogIdes del workspace del ctx antes de insertar).- Rename/Delete de catalog/schema → W1.1 (no bloqueante para "se ve como Databricks").
Decisión de routing: las rutas nuevas se quedan bajo
/api/dataspace/*por consistencia concatalog/item/sample(Capa B). Un rename cosmético del folder a/api/warehouse/*es un pase aparte de bajo valor; no en W1.
4. UI — WarehouseTab gana el nivel Catalog
Hoy: My organization → project → dataset. W1:
▾ main (Catalog) ← icono catálogo
▾ Ventas (Schema) ← icono database
▦ transacciones (Dataset) ← icono table
▦ clientes (Dataset)
▾ Volumes ← agrupación (o inline con icono volume)
▣ media_reviews (Volume) ← service='media_set'
▾ Shares received (sin cambios)
- Interfaces:
Catalog { id, name, comment, schemas: Schema[] },Schema { id, name, comment, datasets: DsItem[], volumes: DsItem[] }. Se retiranCatalogProject/orphans. - Expand/colapso a 3 niveles (hoy 2). Auto-expandir el primer catalog + su primer schema.
- Botón
+"Create" (como el menú Create de Databricks): "Create catalog" / "Create schema" → POST nuevos → refetch. (Add data / Upload to volume / Create credential → placeholders para fases futuras.) - Iconos: catalog
cloud-server/database, schemadatabase/folder, datasetth, volumefolder-close/media. - Breadcrumb del detalle:
catalog › schema › dataset(hoyCatalog Explorer › namespace › item). - Detalle (ItemDetail): sin cambios de fondo (sigue por
datasetIdvía/api/dataspace/item); solo el breadcrumb y, para volumes, ocultar tabs no aplicables (Schema/Sample siguen; History/Quality según aplique).
5. Migración auxiliar 20261232_warehouse_w1_invariants.sql
Contenido (idempotente + rollback en header):
- Schema
defaultpor cada catalogmainexistente (ON CONFLICT (catalog_id,name) DO NOTHING). - Backfill huérfanos:
UPDATE datasets SET schema_id = <default del main del workspace> WHERE schema_id IS NULL. - T1/T2/T3 (funciones
plpgsql+ triggers) de §3. NOTIFY pgrst, 'reload schema';
Aditiva e inerte respecto a la lógica de negocio; solo garantiza invariantes. Se puede aplicar antes que el código de §2–§4 sin romper nada (los triggers empiezan a mantener el modelo; el read viejo lo ignora).
6. Riesgos y mitigaciones
| Riesgo | Mitigación |
|---|---|
Datasets nuevos post-W0 sin schema_id → caen a "huérfano" | T3 los deriva de project_id en el borde; el read tiene fallback por legacy_project_id. |
| Workspaces nuevos sin catalog | T1 siembra main+default en el insert. |
| Nombres de schema duplicados (dos projects "Sales") | Ya desambiguado en W0; T2 replica la desambiguación. |
| Media sets aparecen dos veces (Dataset y Volume) | Split explícito por service='media_set'; excluir del array datasets. |
| Confundir logical vs físico | Namespace Iceberg no se toca en W1 (§1); documentado. |
| Permiso de mutación incorrecto en las CRUD | Verificar el gate contra un create workspace-scoped existente antes de mergear. |
7. Desglose de tareas (orden)
- ✅ Migración
20261232(T1/T2/T3 + default schema + backfill) — escrita, pendiente aplicar. (DB) - ✅ Read path —
catalog/route.tsal shape v2 (catalogs→schemas→datasets/volumes; muestra vacíos; split media; fallback huérfanos→default). (API) - ✅ CRUD —
POST /api/dataspace/catalogsy/schemas(validación de tenancy + 409 en duplicado). (API) - ✅
WarehouseTab— interfaces nuevas, árbol de 3 niveles, create inline (catalog/schema), iconos (database/folder/th/box), breadcrumbcatalog › schema › item. (UI) - ⏳ Verificación (§8) — pendiente (typecheck + preview autenticado).
- (W1.1 opcional) rename/delete de catalog/schema — no hecho.
8. Verificación
- El árbol muestra
main → schemas → datasets/volumes; schemas/catalogs vacíos visibles. - "Create catalog" y "Create schema" crean el nodo y persisten (aparece tras refetch; visible solo dentro del workspace por RLS).
- Un dataset sincronizado nuevo (Data Connection) aparece bajo el schema de su project (T3), no en huérfanos.
- Un media set aparece como Volume, no como Dataset, y no duplicado.
preview_*: cargar el Warehouse, expandir a 3 niveles, screenshot;preview_networksobre/api/dataspace/catalog(shape v2) y sobre los POST de create.
9. Decisiones abiertas (W1)
- Nodo raíz del árbol: ¿"My organization" (workspace) como raíz con catalogs debajo, o los catalogs directos? (Databricks: catalogs directos bajo la conexión.)
- Volumes en el árbol: ¿sub-grupo "Volumes" por schema, o inline con icono distinto?
- ¿T2 para siempre? El acople project→schema vía trigger es puente; decidir cuándo los projects dejan de crear schemas (cuando el picker sea
catalog.schema, W3).