A1 · Modelo de objetos canónico del Warehouse (v1)
Entregable A1 del Punto 1 (
warehouse-compact-unit.md):
la definición versionada y autoritativa de los átomos del Warehouse. Es "atomizar la
estructura": nombrar cada átomo, sus campos canónicos y sus invariantes, y declarar los
deltas de migración que llevan la estructura-puente de hoy al modelo canónico.
Cotejado en código (fichero:línea). Versión del modelo:warehouse-object-model/v1.Decisiones incorporadas (2026-07-08): Volume = átomo de primera clase; namespace
físico: migrar las ~82 tablas a{catalog.slug}.{schema.slug}.
1 · Los 5 átomos
Notación: canónico = parte del modelo v1; puente = existe hoy pero se retira; a introducir = no existe aún.
1.1 · Catalog — contenedor top por workspace
- Respaldo:
public.catalogs(20261231:44-56,20261233:36). - Campos canónicos:
id,workspace_id,name(humano, 1–128),slug(namespace-safe, triggerwarehouse_set_catalog_slug),comment?,created_by?,created_at,updated_at. - Invariantes:
UNIQUE(workspace_id, name)yUNIQUE(workspace_id, slug); semilla'main'por workspace; RLS porworkspace_id. - Coordenada: es el primer segmento (
catalog).
1.2 · Schema — namespace dentro de un Catalog (= Iceberg namespace)
- Respaldo:
public.schemas(20261231:69-83,20261233:37). - Campos canónicos:
id,catalog_id,workspace_id(denormalizado para RLS),name,slug(triggerwarehouse_set_schema_slug),comment?,created_by?, timestamps. - Puente:
legacy_project_id(puntero aluser_projectmigrado) — se retira en D5. - Invariantes:
UNIQUE(catalog_id, name)yUNIQUE(catalog_id, slug); semilla'default'. - Coordenada: segundo segmento (
schema).{catalog.slug}.{schema.slug}= el namespace Iceberg físico (objetivo canónico).
1.3 · Table — datos tabulares (un átomo del data plane)
- Respaldo: fila
datasetsconview_definition IS NULL+ el tridente (§3). - Campos canónicos (de
datasets):id(=datasetId, clave estable),workspace_id,name,display_name?,service,schema(jsonb, SSOT del esquema),row_count?,current_transaction_id?,schema_id(→ coordenada; canónico NOT NULL, hoy nullable20261231:97),tier?(medallion,NULL=untiered,20261242:23-27),iceberg_namespace(canónico{catalog.slug}.{schema.slug}; hoyNULL=legacy'datasets',20261233:38),description?. - First-class (NO legacy):
project_id,file_id— el paradigma project & files es una feature de primera clase de la plataforma, ortogonal a la coordenada del Warehouse (schema_id): conviven, no se reemplazan. No se retiran (corregido en20261249). - Invariantes: exactamente un triple del tridente; columnas de identidad reservadas
(
__row_index/__row_id/__created_at).
1.4 · View — SELECT almacenado (sin respaldo físico)
- Respaldo: fila
datasetsconview_definition IS NOT NULL(20261243:24). - Campos canónicos: los de un dataset +
view_definition(el SQL). Sin tridente. - Invariantes: hereda coordenada/tenencia; se expande a CTE en tiempo de query;
datasets_view_coordinate_unique(20261243:32-34) — extendida en D6 a todo el espacio de nombres (una View no colisiona con una Table/Volume).
1.5 · Volume — datos no tabulares (a introducir)
- Respaldo objetivo: tabla nueva
public.volumes(D4), análoga aschemas: vive bajo un Schema, coordenadacatalog.schema.volume, respaldada por un prefijo en R2 (bytes), sin tabla Iceberg. - Campos propuestos:
id,schema_id(NOT NULL),workspace_id,name,slug,comment?,storage_prefix(ruta R2),created_by?, timestamps. - Migra desde: los
kind='media_set'|'unstructured_dataset'(consumption-contract.ts:153) y manifiestos file-backed de hoy. - Invariantes: comparte el espacio de nombres del Schema con Table/View (D6); acceso por la
misma puerta (
Intentde ficheros), nodataset_rows.
2 · La coordenada (addressing canónico)
- Clave interna:
datasetId(Table/View) /volumeId(Volume) — estable, nunca cambia. - Coordenada de display/addressing:
catalog.schema.name—WarehouseCoordinate {catalog, schema, prefix}(qualified-name.ts:33-52). Derivada del embedschemas(name, catalogs(name)). - Regla: la coordenada es autoritativa en v1 (todo átomo colocado en la jerarquía →
schema_idNOT NULL, D2). El FQN físico Iceberg ={catalog.slug}.{schema.slug}.ds_<uuid>.
3 · El tridente como respaldo (por átomo)
| Átomo | Bytes (R2) | Punteros (catálogo Iceberg) | Metadata + gobernanza (PG) |
|---|---|---|---|
| Table | Parquet en s3://lakehouse/warehouse | tabla {ns}.ds_<uuid> (writer.py:416-419) | fila datasets + project_files.metadata |
| View | — | — | fila datasets (view_definition) |
| Volume | prefijo R2 (ficheros) | — | fila volumes + metadata |
Serialización de escritura (invariante 4): toda escritura de Table commitea por el
CAS atómico del catálogo (prod: Lakekeeper Iceberg REST, type=rest; SqlCatalog
retirado, P2.5). Fail-loud, sin fallback silencioso (write-router.ts:11-17).
4 · Invariantes del modelo (v1)
- Coordenada única y autoritativa. Todo átomo tiene
catalog.schema.name;schema_idNOT NULL.UNIQUE(workspace_id, schema_id, lower(name))sobre Table ∪ View ∪ Volume (D6) — no hay colisión de nombre entre tipos de átomo en un Schema. - Un respaldo por Table. Exactamente un triple del tridente; identidad reservada.
- Una puerta. Todo acceso por
loadItemForConsumption; nada tocadataset_rows/R2 fuera de ella (salvo control-plane documentado). - Una serialización de escritura. CAS del puntero de metadata en el catálogo.
- Gobernanza engine-agnostic. Tenencia,
tier, procedencia viajan en elDatasetHandle.
5 · Deltas de migración (de la estructura-puente al modelo canónico)
Cada delta es una migración + (donde toca prod) un rollout canary por dataset.
| # | Delta | De → A | Riesgo / nota |
|---|---|---|---|
| D1 | Versionar el contrato de datasets | tabla base no versionada (20261231:24-26) → migración que declara las columnas canónicas + comentarios | Bajo (declarativo). Da estatus de contrato a la Table/View. |
| D2 | schema_id NOT NULL | nullable + siembra W0 inerte (20261231:97,21-22) → backfill total + SET NOT NULL | Backfill antes de la constraint (patrón tier). Todo dataset colocado. |
| D3 | Namespace físico {catalog.slug}.{schema.slug} | iceberg_namespace NULL=legacy 'datasets' → re-catalogar las ~82 tablas | El más delicado: job que re-registra cada ds_<uuid> en el nuevo namespace (canary, por dataset); retira 'datasets'. |
| D4 | Introducir Volume | kind file-backed → tabla public.volumes + migración de los media/unstructured | Nueva entidad; migrar punteros; ruta de acceso por la puerta. |
| D5 | Retirar el bridge de siembra | solo schemas.legacy_project_id (el puntero de la siembra W0 schema→project) → drenar cuando ya no se use | NO incluye datasets.project_id/file_id — esos son first-class (project & files), no se tocan. |
| D6 | Unicidad de coordenada global | datasets_view_coordinate_unique solo-vistas → índice sobre Table ∪ View ∪ Volume | Detecta colisiones existentes antes de aplicar. |
Orden sugerido: D1 → D2 → (D6, D4 en paralelo) → D3 → D5. D3 es el trabajo pesado sobre prod y se hace último, ya con la coordenada autoritativa.
Estado:
- D1 ✅ escrito —
supabase/migrations/20261245_datasets_contract_v1.sql: versiona el contrato dedatasetsvía COMMENT (declarativo, cero cambio de datos). NOTA: D1 comentóproject_id/file_idcomo "legacy" por error → corregido en20261249(son first-class: paradigma project & files, no se retiran). - D2 ✅ escrito —
supabase/migrations/20261246_datasets_schema_id_not_null.sql: re-seedmain/defaultdefensivo + endurece el triggerwarehouse_dataset_schema(nunca deja NULL)- re-backfill + guarda fail-loud + FK
ON DELETE RESTRICT+schema_id SET NOT NULL. Verificado sin regresión:datasets.workspace_ides NO ACTION (borrar workspace ya exige borrar datasets antes → el RESTRICT nunca choca con el cascade), y no hay path de borrado directo de schema. Idempotente; rollback en la cabecera. ✅ APLICADO a prod (2026-07-08) — prod tenía 0schema_idNULL → aplicó limpio; verificado post-apply (attnotnull=true).
- re-backfill + guarda fail-loud + FK
- D4 ✅ escrito —
supabase/migrations/20261247_warehouse_volumes.sql: introduce la tablavolumes(átomo Volume) bajo Schema — slug identifier-safe (trigger +warehouse_slugify), unicidad(schema_id, lower(name))y(schema_id, slug), RLS por workspace, FKschema_idRESTRICT. Estructura only; la migración de los datasets file-backed (kind='media_set') avolumes+ re-cableado de readers es D4-b (Punto 3). - D6 → partido por la realidad del código (unicidad hoy es PARCIAL; ad-hoc/pipeline reusan
nombres por diseño):
- D6a ✅ escrito —
supabase/migrations/20261248_warehouse_coordinate_namespace.sql: guarda cross-átomoVolume ↔ Table/View(trigger envolumes) + auditoría no-bloqueante de colisiones dedatasets(cuantifica D6b). Lado datasets→volume diferido a D4-b. - D6b ✅ HECHO + APLICADO a prod (2026-07-08) —
supabase/migrations/20261250_datasets_coordinate_unique.sql: (1) dedup de las 23 filas ad-hoc duplicadas (credential_id NULL; conserva la más antigua por grupo, sufija el resto con fragmento delid— la clave establedatasetIdno se toca); (2) política de nombre en la capa de datos — triggerdatasets_name_unique(BEFORE INSERT/UPDATE, corre trasdatasets_derive_schema) que resuelve una colisión al próximobase (N)libre en la coordenada → una puerta, cubre las ~10 rutas de INSERT sin tocarlas; (3) índiceUNIQUE datasets_coordinate_unique (workspace_id, schema_id, lower(name))(backstop del invariante); (4) drop de los parciales subsumidos (idx_datasets_source_coordinate,datasets_view_coordinate_unique). Medido: 7 grupos → 0. Trigger verificado end-to-end (colisión forzada →… (2), rollback).
- D6a ✅ escrito —
Nota a la invariante 1 (§4): ✅ la unicidad de coordenada global (Table ∪ View) está
completa y aplicada (índicedatasets_coordinate_unique+ trigger de nombre). El lado
datasets↔volumede la guarda llega con D4-b (cuando se poblenvolumes).
- D3 → partido (
iceberg_namespaceSÍ se consume en el hot path: facadeitem-consumption.ts:162, writericeberg-native-write.ts:133— cambiarlo sin re-catalogar rompe reads/writes):- D3.0 ✅ hecho — resolución resiliente en el ml-runner (
resolve_existing_identifierenwriter.py+ los 4 single-shot writers +read_service.pylos 2 reads): resuelve al namespace pedido y, si la tabla no está, cae al legacy'datasets'→ reads no dan 404 y single-shot writes no forkean durante la ventana del rename. Verificado: py_compile + tests verdes (9 passed); comportamiento idéntico en estado estable. El path chunked (init/ stage/commit) se deja intacto; D3.1 debe saltar datasets con ingest chunked activo. - D3.1 ✅ construido (no ejecutado en prod) — el job de catálogo: servicio
migrate_native_namespace(app/lakehouse/namespace_migration_service.py: idempotente, dry-run, verify fail-loud, thread-safe concatalog_lock) + endpointPOST /lakehouse/migrate-namespace+ driver canaryscripts/migrate-warehouse-namespace.ts(computa{cat.slug}.{sch.slug}de los slugs PG, salta migrados, dry-run por defecto,--apply/--limit/--dataset, estampaiceberg_namespacetras el rename). Verificado: py_compile + 4 tests verdes (rename/idempotencia/dry-run/not-found). Ejecución contra las 82 tablas prod = por el ml-runner desplegado (py3.12 + creds), canary — no desde el dev box (Python 3.14 sin pyiceberg, sin creds de catálogo). Precondición: saltar datasets con ingest chunked activo. - D3.2 ⏳ — retirar el fallback
'datasets'cuando las 82 estén migradas.
- D3.0 ✅ hecho — resolución resiliente en el ml-runner (
6 · El contrato de tipo (sketch TS canónico)
Para congelar el modelo en código (complementa A2). Un discriminado por átomo:
// lib/warehouse/object-model.ts (v1)
export type WarehouseAtom = 'catalog' | 'schema' | 'table' | 'view' | 'volume';
export interface WarehouseCoordinate { catalog: string; schema: string; name: string; }
export interface CatalogObj { atom: 'catalog'; id: string; workspaceId: string; name: string; slug: string; }
export interface SchemaObj { atom: 'schema'; id: string; catalogId: string; workspaceId: string; name: string; slug: string; }
export interface TableObj { atom: 'table'; id: string; coord: WarehouseCoordinate; schema: ItemSchemaColumn[]; tier: DatasetTier | null; icebergNamespace: string; }
export interface ViewObj { atom: 'view'; id: string; coord: WarehouseCoordinate; viewDefinition: string; }
export interface VolumeObj { atom: 'volume'; id: string; coord: WarehouseCoordinate; storagePrefix: string; }
export type WarehouseObject = CatalogObj | SchemaObj | TableObj | ViewObj | VolumeObj;
(Reusa ItemSchemaColumn y DatasetTier del contrato de la puerta; no duplica el esquema
SSOT — solo tipa la identidad + coordenada del átomo.)
7 · No-goals (A1)
No implementa las migraciones (las declara); no expone REST/vending (Punto 2); no congela aún el runtime del tipo (A2 lo hace); no migra puertos (Punto 3). A1 entrega la definición: los 5 átomos, sus invariantes y los deltas — el mapa para volver la estructura una unidad compacta.