Published

A1 · Modelo de objetos canónico del Warehouse (v1)

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

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, trigger warehouse_set_catalog_slug), comment?, created_by?, created_at, updated_at.
  • Invariantes: UNIQUE(workspace_id, name) y UNIQUE(workspace_id, slug); semilla 'main' por workspace; RLS por workspace_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 (trigger warehouse_set_schema_slug), comment?, created_by?, timestamps.
  • Puente: legacy_project_id (puntero al user_project migrado) — se retira en D5.
  • Invariantes: UNIQUE(catalog_id, name) y UNIQUE(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 datasets con view_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 nullable 20261231:97), tier? (medallion, NULL=untiered, 20261242:23-27), iceberg_namespace (canónico {catalog.slug}.{schema.slug}; hoy NULL=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 en 20261249).
  • 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 datasets con view_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 a schemas: vive bajo un Schema, coordenada catalog.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 (Intent de ficheros), no dataset_rows.

2 · La coordenada (addressing canónico)

  • Clave interna: datasetId (Table/View) / volumeId (Volume) — estable, nunca cambia.
  • Coordenada de display/addressing: catalog.schema.nameWarehouseCoordinate {catalog, schema, prefix} (qualified-name.ts:33-52). Derivada del embed schemas(name, catalogs(name)).
  • Regla: la coordenada es autoritativa en v1 (todo átomo colocado en la jerarquía → schema_id NOT NULL, D2). El FQN físico Iceberg = {catalog.slug}.{schema.slug}.ds_<uuid>.

3 · El tridente como respaldo (por átomo)

ÁtomoBytes (R2)Punteros (catálogo Iceberg)Metadata + gobernanza (PG)
TableParquet en s3://lakehouse/warehousetabla {ns}.ds_<uuid> (writer.py:416-419)fila datasets + project_files.metadata
Viewfila datasets (view_definition)
Volumeprefijo 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)

  1. Coordenada única y autoritativa. Todo átomo tiene catalog.schema.name; schema_id NOT 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.
  2. Un respaldo por Table. Exactamente un triple del tridente; identidad reservada.
  3. Una puerta. Todo acceso por loadItemForConsumption; nada toca dataset_rows/R2 fuera de ella (salvo control-plane documentado).
  4. Una serialización de escritura. CAS del puntero de metadata en el catálogo.
  5. Gobernanza engine-agnostic. Tenencia, tier, procedencia viajan en el DatasetHandle.

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.

#DeltaDe → ARiesgo / nota
D1Versionar el contrato de datasetstabla base no versionada (20261231:24-26) → migración que declara las columnas canónicas + comentariosBajo (declarativo). Da estatus de contrato a la Table/View.
D2schema_id NOT NULLnullable + siembra W0 inerte (20261231:97,21-22) → backfill total + SET NOT NULLBackfill antes de la constraint (patrón tier). Todo dataset colocado.
D3Namespace físico {catalog.slug}.{schema.slug}iceberg_namespace NULL=legacy 'datasets' → re-catalogar las ~82 tablasEl más delicado: job que re-registra cada ds_<uuid> en el nuevo namespace (canary, por dataset); retira 'datasets'.
D4Introducir Volumekind file-backed → tabla public.volumes + migración de los media/unstructuredNueva entidad; migrar punteros; ruta de acceso por la puerta.
D5Retirar el bridge de siembrasolo schemas.legacy_project_id (el puntero de la siembra W0 schema→project) → drenar cuando ya no se useNO incluye datasets.project_id/file_id — esos son first-class (project & files), no se tocan.
D6Unicidad de coordenada globaldatasets_view_coordinate_unique solo-vistas → índice sobre Table ∪ View ∪ VolumeDetecta 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 ✅ escritosupabase/migrations/20261245_datasets_contract_v1.sql: versiona el contrato de datasets vía COMMENT (declarativo, cero cambio de datos). NOTA: D1 comentó project_id/file_id como "legacy" por error → corregido en 20261249 (son first-class: paradigma project & files, no se retiran).
  • D2 ✅ escritosupabase/migrations/20261246_datasets_schema_id_not_null.sql: re-seed main/default defensivo + endurece el trigger warehouse_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_id es 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 0 schema_id NULL → aplicó limpio; verificado post-apply (attnotnull=true).
  • D4 ✅ escritosupabase/migrations/20261247_warehouse_volumes.sql: introduce la tabla volumes (átomo Volume) bajo Schema — slug identifier-safe (trigger + warehouse_slugify), unicidad (schema_id, lower(name)) y (schema_id, slug), RLS por workspace, FK schema_id RESTRICT. Estructura only; la migración de los datasets file-backed (kind='media_set') a volumes + 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 ✅ escritosupabase/migrations/20261248_warehouse_coordinate_namespace.sql: guarda cross-átomo Volume ↔ Table/View (trigger en volumes) + auditoría no-bloqueante de colisiones de datasets (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 del id — la clave estable datasetId no se toca); (2) política de nombre en la capa de datos — trigger datasets_name_unique (BEFORE INSERT/UPDATE, corre tras datasets_derive_schema) que resuelve una colisión al próximo base (N) libre en la coordenada → una puerta, cubre las ~10 rutas de INSERT sin tocarlas; (3) índice UNIQUE 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).

Nota a la invariante 1 (§4): ✅ la unicidad de coordenada global (Table ∪ View) está
completa y aplicada (índice datasets_coordinate_unique + trigger de nombre). El lado
datasets↔volume de la guarda llega con D4-b (cuando se poblen volumes).

  • D3 → partido (iceberg_namespace SÍ se consume en el hot path: facade item-consumption.ts:162, writer iceberg-native-write.ts:133 — cambiarlo sin re-catalogar rompe reads/writes):
    • D3.0 ✅ hecho — resolución resiliente en el ml-runner (resolve_existing_identifier en writer.py + los 4 single-shot writers + read_service.py los 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 con catalog_lock) + endpoint POST /lakehouse/migrate-namespace + driver canary scripts/migrate-warehouse-namespace.ts (computa {cat.slug}.{sch.slug} de los slugs PG, salta migrados, dry-run por defecto, --apply/--limit/--dataset, estampa iceberg_namespace tras 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.

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.