Published

Sub-approach · I·2b — los nueve nacimientos por supabase directo

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...

Sub-approach · I·2b — los nueve nacimientos por supabase directo

Documento de ejecución (2026-07-30). Sigue a index-i2a-approach.md, que cerró los cinco cuerpos duplicados y dejó el trinquete de G·0 en 9. Estos nueve son la otra mitad: nacimientos por supabase.from('datasets').insert().

El reconocimiento reformó la fase, y es la tercera vez seguida que pasa. No es «llamar a la función que ya existe».


0 · Lo que el reconocimiento corrigió

0.1 · El bloqueante que parecía un muro, y era una creencia rancia

createDatasetArtifact recibe un PoolClient de pg. Los nueve sitios usan supabase-js. Nueve de nueve. Ese desajuste parece decidir la fase entera — y el propio repo lo declara imposible:

app/api/datasets/parse/route.ts:17-18«Uses supabaseAdmin (PostgREST) for row inserts since API routes don't have access to the workerDb pool».

Es falso. Hay un pool alcanzable desde rutas API —getRuntimePool() (lib/pipelines/runtime.ts:57)— y tres rutas ya lo usan para llamar a la puerta: manual-table:138-139, media-set-output, model-output. No es teoría: son nacimientos gobernados que corren hoy en producción desde app/api/.

El comentario no está mintiendo a propósito: dice workerDb, y workerDb efectivamente no está. Lo que hay es otro pool. Pero leído como está, cierra la puerta a la fase entera. Cuarta vez esta línea de trabajo que un comentario del repo describe un límite que el runtime no tiene.

⚠️ Un matiz que sí hay que respetar: getRuntimePool() conecta con PIPELINE_RUNTIME_DB_URL — el rol acotado node_transform_runtime, con fallback a SUPABASE_DB_URL. Que manual-table cree datasets por ahí en producción demuestra empíricamente que el rol puede, pero es una dependencia de permisos que hay que nombrar, no un detalle.

0.2 · La puerta no tiene la forma del objeto gobernado — y lo que le falta dice qué es

Los nueve no divergen sólo en el cliente. Divergen en columnas, y al ordenarlas sale sola la distinción que este pilar lleva persiguiendo:

Lo que los sitios estampan y la puerta NO tiene¿Qué es?
display_name (¡distinto de name! la puerta lo hardcodea igual), credential_id, content_type, storage_backendDel objeto gobernado. Entran en la puerta.
type del satélite (dataset · media_set · unstructured_dataset — medidos 90 · 4 · 3 en producción; la puerta hardcodea 'dataset') y su source_id (hardcodeado '')Del objeto gobernado. Entran.
row_count, size_bytes, last_sync_at🔴 ANDAMIO. Son literalmente §4 de warehouse-index.md: copias de lo que el catálogo ya sabe, y de lo que el reconciliador de N·2c ya reclama la autoridad.

Y de ahí sale el criterio, que es mejor que cualquier lista: la puerta acepta lo que hace que un objeto sea un objeto, y rechaza los hechos que el catálogo cuenta. Un escritor que necesite estampar row_count tendrá que hacerlo en un UPDATE aparte —visible, greppable, retirable— en vez de colarlo dentro del nacimiento. La firma de la puerta se convierte en la definición ejecutable de «objeto gobernado», y lo que rechaza es exactamente la lista que W·1 retira.

0.3 · El nacimiento no es atómico hoy, y hay huella

Con supabase-js, satélite y datasets son dos round-trips sin transacción. Si el segundo falla, queda basura. Medido en la BD viva:

  • 3 satélites huérfanos (project_files type='dataset' sin ninguna fila de datasets apuntando), todos de marzo de 2026.
  • 1 file_id colgando (un dataset apunta a un satélite que no existe).

Calibración honesta: 4 filas sobre 106, y viejas. No es un incendio — es una clase de fallo que hoy no tiene quien la impida, y la puerta la cierra de paso porque envuelve las dos inserciones en una transacción.

⚠️ Y este argumento NO aplica a los tres nacimientos sin satélite: ahí sólo hay un INSERT, así que no hay atomicidad que ganar. Para ésos el motivo es otro —el carrier y la tenencia— y conviene no venderlo como lo que no es.

⚠️ Nota de método, porque casi cuela un número falso. La primera medición de «file_id colgando» dio 11, y era un artefacto: la sonda filtraba project_files por type='dataset', así que contaba como inexistentes los satélites que existen con otro type (media_set, file, unstructured_dataset). Al quitar el filtro: 1, que coincide con el censo de I·3. El bug de la sonda fue el que reveló que hay cuatro tipos de satélite — el hallazgo de §0.2 salió de perseguir el descuadre.


1 · Los nueve, agrupados por FORMA (no por fichero)

GrupoSitiosQué necesitaRiesgo
B · sin satélite (3) ✅ HECHOdatasets/route.ts:386 · notebooks/…/upload:197 · sql-editor/execute:406 (CREATE VIEW)satellite:false (ya existe, I·2a.2) + displayName/credentialId/contentType/storageBackend/viewDefinitionbajo — filas del paradigma nuevo, pocos consumidores
A · satélite canónico (3) ✅ HECHOdatasets/route.ts:435 · datasets/ingest:137 · datasets/parse:361lo mismo, con satélite type='dataset', satelliteSourceId y el modo adoptarmedio
C · forma propia (3) — 2/3 ✅fileBackedDatasetUpload ×2 ✅ (satélite media_set, source_id=datasetId, 5 columnas extra) · dataset-writer:1513 🔓 (el sink del polling)satelliteType + satelliteSourceId + el UPDATE de estadísticaaltodataset-writer es el más caliente del plan

Orden: B → A → C, y dataset-writer el último de todos. Es la misma regla que ordenó W·2: primero donde tocar arregla algo, después donde tocar puede romper algo.

Por qué B primero, y no porque sea fácil: los tres nacen sin carrier de gobernanza ningunofile_id NULL y nadie escribía datasets.metadata—, así que hoy son objetos del Warehouse literalmente sin registro en Index. Migrarlos no es mover código: es que empiecen a tener gobernanza. Y uno de ellos es el CREATE VIEW del SQL Editor, que es el caso de I·3c: sus fuentes están en su propio view_definition y hoy se tiran.


2 · La guarda

TrinqueteCómo se comprueba
B9 → 6npm run check:dataset-births; las entradas de notebooks y sql-editor desaparecieron, datasets/route.ts bajó 2 → 1
A6 → 3idem
C 2/33 → 1idem
C 3/3 · dataset-writer1 (a propósito)la ruta por la puerta está escrita y su paridad MEDIDA; el trinquete no baja hasta que el camino legacy se borre

La aserción que prueba que B sirvió: un CREATE VIEW deja una fila con datasets.metadata poblada. Hoy la deja vacía. Re-medible con scripts/dataspaces/i3-carrier-census.ts, que hoy dice 0 de 101.


2.1 · Lo que el grupo B destapó al hacerse: omitir ≠ pasar NULL

Ampliar la puerta con content_type y storage_backend casi rompe a todos sus llamantes actuales, y el fallo habría sido silencioso.

Medido: los 28 datasets nacidos por esta puerta tienen content_type='table' y storage_backend='postgres' — y la puerta no los escribe nunca. Son DEFAULTs de columna. La primera implementación pasaba input.contentType ?? null, y un NULL explícito ANULA el default: los objetos habrían nacido sin forma física declarada.

La puerta construye ahora la sentencia con los opcionales que vienen; el que falta no se menciona y manda la base de datos. Un null explícito del llamante sí se transmite — es una intención, no una ausencia.

Es el mismo patrón que ya mordió tres veces en esta línea de trabajo (el namespace asumido, el .env.local rancio, el comentario del pool): una ausencia y un valor vacío no son lo mismo, y tratarlos igual no da error — miente.


2.2 · Lo que el grupo A destapó: un TERCER modo de satélite

La puerta tenía dos (crear / ninguno). /api/datasets/parse no encaja en ninguno: su satélite ya existe —es el fichero que el usuario subió— y el dataset llega después, al parsearlo. No es «con satélite» ni «sin satélite»: es adoptar.

Se modela con fileId, que gana sobre satellite. Y trae consigo una decisión que no es obvia: el carrier de un satélite adoptado va al canónico, no al satélite. Su metadata es del fichero (nombre, tamaño, parseo) y la escribió otro; sobrescribirla sería pisar información ajena.

Y aquí sí se gana atomicidad, que en el grupo B no. Los dos sitios con satélite propio traían una compensación a manodelete project_files si el segundo INSERT fallaba—: el sustituto artesanal de una transacción, y que sólo cubría el error del INSERT, no una caída del proceso entre los dos. De ahí salen los 3 satélites huérfanos de §0.3. La puerta los envuelve en BEGIN/COMMIT.


2.3 · Grupo C, 2 de 3 — y una comprobación que había que hacer antes de tocar

fileBackedDatasetUpload escribía el carrier a los DOS sitios a la vez: project_files.metadata y datasets.metadata. La puerta lo prohíbe (una copia, nunca dos), así que migrarlo significa que para un artefacto satelitado datasets.metadata pasa a ser NULL donde hoy está poblado. Eso podía romper a un lector, y había que mirarlo antes, no después.

Medido: datasets.metadata tiene exactamente dos lectoresresolveCarrier (que funde las dos columnas desde I·3a) y fileBackedDatasetUpload.ts:556, que sólo entra en la rama else, cuando file_id es NULL. Es decir: en el caso satelitado nadie lo lee. La regla de un solo carrier se sostiene sin excepción.

Y un detalle pequeño que dice algo: la creación de un media set no necesita UPDATE de estadística, porque nace vacío y row_count/size_bytes valen 0, que es el default. Que no haga falta es la señal de que esas columnas no pintaban nada en el nacimiento.


3 · Riesgos

  • El rol acotado. getRuntimePool() usa node_transform_runtime. Empíricamente basta (manual-table crea datasets así en prod), pero si una migración futura le quita INSERT sobre datasets, se rompen a la vez todos los sitios migrados. Es una dependencia nueva para seis rutas que hoy no la tienen.
  • Un pool más en el camino de una request. Las rutas de B hacen hoy un INSERT por PostgREST; pasarán a tomar una conexión del pool (max: 4 por defecto). Para un nacimiento —operación rara, del camino de CONTROL— es exactamente donde index-frontier.md dice que Index debe estar. No es el camino caliente.
  • RLS. Las rutas validan con requirePermission / supabaseRLS antes; el pool escribe sin RLS. Es el patrón que manual-table ya usa: comprobar con el cliente RLS, escribir con el pool. No se relaja ninguna comprobación — se conserva la que hay.

2.4 · dataset-writer — el único con canario, y por qué

Es el sink del polling: por aquí pasa cada sincronización. Un cambio de comportamiento aquí no se manifiesta como un error sino como datasets que nacen sutilmente distintos, y eso se descubre semanas después cuando algo aguas abajo no cuadra. Los ocho nacimientos anteriores no tenían esa propiedad.

Las dos rutas conviven tras INDEX_BIRTH_DOOR_POLLING (por defecto off), y el trinquete se queda en 1 a propósito: la superficie no está cerrada mientras el camino viejo pueda ejecutarse. Borrar ese bloque es el último paso de I·2b, y va después de que el flag lleve tiempo encendido.

Lo que se conserva entero, y es lo más delicado del sitio: la recuperación de carrera de coordenada. El índice parcial idx_datasets_source_coordinate rechaza un (workspace, schema_id, name) duplicado con 23505, y la respuesta correcta no es fallar la sincronización sino adoptar al ganador. Con pg el código viaja en err.code igual que venía en el de supabase. Con la transacción, además, el ROLLBACK se lleva el satélite — así que la limpieza a mano que hacía el camino viejo deja de ser necesaria en vez de tener que replicarse.

El canario: scripts/dataspaces/i2b-polling-birth-parity.ts. No razona la equivalencia — escribe por las dos rutas y compara las filas columna a columna, en datasets y en project_files, con la única excepción de ids y timestamps. Resultado: 0 diferencias en las dos tablas.

⚠️ Y la primera corrida salió en ROJO, por culpa del propio canario. El carrier real embebe dataset_name, y las dos filas necesitan nombres distintos (la coordenada es única), así que el comparador estaba comparando dos payloads diferentes. El canario funcionó —cazó algo— y lo que cazó era suyo. Queda escrito en el fichero: un comparador que compara payloads distintos no mide la puerta, mide su propio ruido.


4 · Decisiones

  1. ¿La puerta rechaza row_count/size_bytes/last_sync_at?SÍ, aplicado. Convierte la firma en la definición ejecutable de «objeto gobernado» y deja el andamio a la vista, en UPDATEs que W·1 podrá retirar de un barrido.

4·1 · ¿La puerta VALIDA TENENCIA o sólo registra? — ✅ VALIDA

Es la decisión que la convierte de chokepoint en frontera, y la razón no es de seguridad abstracta: warehouse-index.md §5.2 enumera siete piezas de gobernanza que se leen en la UI y no impiden nada, y cierra con «Index hoy sabe quién debería poder ver qué, y no lo impide». Una puerta de escritura que sólo registrase sería la octava.

assertTenancy comprueba las tres referencias que un nacimiento cruza, y las tres pueden venir de fuera:

ReferenciaSe comprueba contra
el proyecto donde se anclauser_projects.workspace_id
el satélite que adopta (modo fileId)project_files.workspace_id
la coordenada del Warehouse (schema_id)schemas.workspace_id

Cuatro propiedades que valía la pena fijar con test, porque las cuatro son formas de fallar en silencio:

  • niega ANTES de escribir — ni satélite ni fila; no hay estado a medias que compensar;
  • la ausencia no es permiso — un proyecto que no existe se rechaza igual que uno ajeno;
  • es el simétrico del rechazo cross-workspace que runQuery ya hace en lectura;
  • vive sólo en el camino de control (raro, caro por operación, autoritativo), nunca en el de datos — que es literalmente donde index-frontier.md dice que Index debe estar.

Coste: tres SELECT por nacimiento. Un nacimiento es una operación rara; un scan no. 2. ¿getRuntimePool o un pool propio de Index? Recomendación: reusar el que hay ahora, y anotar que el día que Index tenga puerta de escritura de verdad (N·3) querrá el suyo. Abrir un pool nuevo hoy es infraestructura sin usuario. 3. ¿dataset-writer entra en esta fase? Recomendación: no. Es el sink del polling, corre en workers (que tienen workerDb, así que ni siquiera comparte el bloqueante de §0.1) y merece su propio diff.


Cruza con: index-i2-i3-approach.md (el tramo) · index-i2a-approach.md (la mitad anterior) · warehouse-index.md §4 (lo que la puerta debe rechazar) · index-handoff.md.