metis-desk/docs/arquitectura/proveedor-de-relaciones-tipadas.md
2026-10-20 13:20:22 +02:00

312 lines
18 KiB
Markdown

# Proveedor de relaciones tipadas v1 · Paso 2.3
El primer proveedor real adapta `assets:directory-membership` a los contratos de
lectura de `@metis/relationship-contracts`. Consulta las pertenencias del paso 2.2;
no crea otra tabla de relaciones, no reconstruye agregados ni cambia comandos,
revisiones o identidad del dominio Windows. No modifica puestos, perfiles,
AccessWorkspace, SMB ni las relaciones anteriores entre activos.
## Registro y composición
`apps/server/src/infrastructure/http/relationship-runtime.ts` construye el registro
estático durante `createIdentityRuntime`, registra la definición exportada por
Assets e inyecta directamente servicios propietarios y autorización. El resultado
se expone como `runtime.relationships`. No hay estado global, Service Locator,
búsqueda dinámica de implementaciones ni dependencias circulares.
```ts
createRelationshipRegistry(
["assets", "resources"],
[DIRECTORY_MEMBERSHIP_DEFINITION],
["directory:domain"],
);
```
La definición conserva propietario Assets, origen `assets:asset`, destino
`resources:resource` con capacidad `directory:domain`, dirección dirigida,
cardinalidad vigente de un dominio por activo y varios activos por dominio,
representación `record`, historial `revisions`, etiquetas directa e inversa
traducibles y escritura `owner-command`.
El registro valida formas, duplicados y proveedores al arrancar y congela sus
copias internas. Su tercer argumento declara capacidades semánticas cuyo namespace
no corresponde a un módulo: **no se registra un proveedor ficticio `directory`**.
Se conserva la aceptación anterior de capacidades del namespace de un proveedor
registrado. Los duplicados o identificadores inválidos del catálogo de capacidades
producen `INVALID_REGISTRY`; referencias a proveedores/capacidades no declarados
producen `UNREGISTERED_PROVIDER`.
Servicios incorporados:
| Propietario | Implementación | Composición pública |
| --- | --- | --- |
| Assets | `DirectoryMembershipRelationshipQuery` | `assets.services.relationshipReads(registry, access, domainSummaries).memberships` |
| Assets | `AssetEntitySummaryResolver` | mismo resultado, `.summaries` |
| Resources | `DirectoryDomainSummaryResolver` | `resources.services.directorySummaries(access)` |
| Identity | validación de sesión existente | `identity.services.sessions.isAuthenticated` |
| Companies | proyección de existencia | `companies.existence.exists` |
Las clases propietarias permanecen privadas; los módulos exponen sus instancias
mediante composición. El paquete común no depende de Sequelize, Express ni de
módulos funcionales. Las nuevas dependencias son enlaces workspace a contratos y,
en el servidor, al `Result` compartido existente. No se añade una biblioteca externa.
## Contexto y seguridad
`SessionRelationshipReadAccess.createContext(session, companyId, requestId)` recibe
la sesión del servidor, valida su usuario activo y `authVersion` mediante Identity
y comprueba la existencia de la compañía mediante Companies. Emite un contexto
inmutable y conserva su asociación con la sesión en un WeakMap privado.
Cada consulta o lote vuelve a autorizar **antes** de leer datos propietarios. Una
copia de sus campos, un contexto inventado o uno emitido por otra instancia no son
credenciales válidas. Una desactivación o cambio de `authVersion` invalida lecturas
posteriores. El consumidor debe usar contextos de petición y no almacenarlos como
una autorización permanente.
El modelo actual permite a los operadores autenticados acceder a las compañías
atendidas en general; no dispone de ACL granular por compañía ni por entidad.
La validación de existencia no crea esa ACL ni exige compañía activa. La compañía
atendida tampoco es un tenant del operador. La autorización compartida cubre ambos
extremos en este modelo; si aparecen permisos más finos, los propietarios deberán
aplicarlos también antes de paginar y resolver nombres.
Todas las consultas SQL filtran por compañía. Una referencia que declara otra
compañía produce `FORBIDDEN` en el proveedor. Un UUID ajeno presentado dentro del
ámbito local produce el mismo `UNAVAILABLE` que uno inexistente. Los resolutores
producen `unavailable` sin nombres ni motivos para entidades ajenas, inexistentes,
proveedores/tipos incompatibles o recursos sin capacidad de dominio. Una referencia
estructuralmente inválida o un lote mayor de 100 produce `INVALID_QUERY`.
No se acepta un operador desde HTTP: este paso no añade rutas. La fábrica de
contextos es una API interna del servidor, no una credencial transportable del
navegador. Los fallos técnicos de infraestructura se propagan al diagnóstico del
servidor; no se convierten silenciosamente en listas vacías o entidades ausentes.
## Consultas directas e inversas
El consumidor obtiene el contexto con el objeto de sesión del servidor y consulta
`runtime.relationships.memberships.list(query, context)`. `query` contiene:
- `typeId: "assets:directory-membership"`.
- `anchor`: referencia UUID de un activo o recurso de dominio de la compañía.
- `direction`: `outgoing` desde activo, `incoming` desde dominio o `either` desde
cualquiera. Un sentido incompatible produce `INVALID_QUERY`.
- `includeEnded`: `false` limita a vigentes; `true` incluye periodos finalizados.
- `pageSize`: entero entre 1 y 100; `cursor` opcional para continuar.
La consulta inversa conserva `source = activo`, `target = recurso de dominio`.
`origin.kind = record` y `origin.id` es el UUID de la pertenencia existente.
`stateCode` vale `active` o `ended`; no depende de que el recurso siga activo.
El UUID y rol de un periodo finalizado permanecen consultables. Un traslado devuelve
el nuevo periodo al consultar vigentes y ambos al incluir finalizadas; cambiar rol
mantiene el UUID, como en los comandos actuales.
El proveedor valida existencia del activo en Assets. Para el ancla inversa utiliza
el resolutor público de Resources, que exige una proyección estructurada de dominio.
No importa repositorios privados ni consulta tablas privadas de Resources. Las FK
compuestas del paso 2.2 garantizan compañía, existencia y capacidad del destino de
las filas devueltas desde un activo.
## Decisión sobre el rol
El contrato común no tenía atributos de relación. Usar solamente la consulta
especializada anterior de Assets obligaría al consumidor común a volver a consultar
las pertenencias para conocer su rol, con dos lecturas potencialmente desfasadas.
Añadir un campo exclusivo a un subtipo también haría que el schema común estricto
rechazara sus respuestas.
Se añade **`roleCode?: string`**, un código semántico validado como texto no vacío,
siguiendo el criterio de `stateCode`. Las vistas anteriores sin rol siguen siendo
válidas; no se admiten objetos arbitrarios de metadatos. Assets publica
`DirectoryMembershipRelationshipSchema` y su página, que restringen tipo,
representación, extremos, estado y `roleCode` al vocabulario existente
`member | domain-controller`. La raíz persistida sigue llamando a su campo `role`;
este cambio solo adapta lectura.
El resultado satisface `RelationshipQueryService` y pasa tanto el schema común de
página como el especializado. Un consumidor genérico conserva `roleCode`; uno que
necesite la unión cerrada valida con el schema público de Assets. Ningún consumidor
obtiene permisos de escritura por disponer de estos contratos.
## Paginación SQL e índices
La raíz se consulta con `WHERE companyId = ...` y `assetId = ...` o
`domainResourceId = ...`, `endedAt IS NULL` cuando corresponde,
`id > :afterId` en páginas sucesivas, `ORDER BY id ASC LIMIT :pageSizePlusOne`.
Un JOIN con Assets conserva el ámbito del origen. Solo se retira la fila centinela
en memoria; nunca se carga el conjunto completo ni sus revisiones.
Se utiliza `directory_membership_asset(companyId, assetId, id)` para la consulta
directa. El índice previo del dominio antepone `role, endedAt`, por lo que no ofrece
el mismo orden al consultar todos los roles. La migración explícita e idempotente
`directory-membership-read-index.ts` añade
`directory_membership_domain_page(companyId, domainResourceId, id)`, sin cambiar
columnas, restricciones, comandos ni persistencia de dominios. El índice previo se
conserva para los filtros especializados existentes. La migración se ejecuta después
de inicializar las tablas de pertenencias.
El cursor usa HMAC SHA-256 con una clave aleatoria privada de la instancia del
proveedor. Incluye última identidad y versión de formato; queda ligado al operador,
compañía, tipo, proveedor/tipo/UUID del ancla, sentido, filtro histórico y tamaño de
página. Se valida forma, firma y coincidencia de ámbito antes de consultar filas.
Un cursor inválido o reutilizado en otra consulta produce `INVALID_QUERY`.
El identificador de petición puede cambiar entre páginas.
La firma protege integridad, no confidencialidad: su contenido solo contiene IDs y
opciones ya conocidos por el consumidor. No contiene nombres, totales ni filas
ocultas. Los cursores caducan al reiniciar y no se comparten entre instancias del
servidor; habría que acordar una clave compartida si se despliega con varios nodos.
El recorrido no es una instantánea transaccional: cambios concurrentes pueden
cambiar el conjunto vigente, aunque el orden por UUID se mantiene estable.
## Resolución por lotes
`assetSummaries.resolveMany` proyecta `id, name, status, templateName` de Assets;
la plantilla es el subtítulo y el estado conserva su código y clave de traducción.
`domainSummaries.resolveMany` proyecta nombre, estado y DNS normalizado de Resources
mediante JOIN con su propia proyección de dominios. No carga inventarios completos,
características ni revisiones.
Cada resolutor acepta hasta 100 referencias, valida forma y ámbito, deduplica IDs
para una sola consulta SQL y reconstruye resultados en el orden de entrada,
incluyendo repeticiones. Un lote vacío o sin referencias elegibles no consulta
entidades. La revalidación de sesión y compañía tiene un coste fijo por operación,
independiente del número de referencias. No se cachean nombres entre compañías.
## HTTP, segundo consumidor y ownership
No se añaden endpoints: la integración interna comprueba el contrato real sin
adelantar la UI del paso 2.4 ni crear una API universal de grafos. Los endpoints
especializados del paso 2.2 permanecen intactos.
Un segundo consumidor interno recibe explícitamente el servicio de consultas y los
resolutores que necesite. Para la UI del paso 2.4 deberá añadirse un adaptador HTTP
acotado que obtenga sesión y requestId del servidor, valide el ámbito y parámetros
con Zod y traduzca estos errores a HTTP. El navegador no enviará contextos ni actores.
La disposición de pantallas todavía no está implementada.
Un segundo **proveedor** deberá aportar su definición estática, adaptador de lectura,
resolutores y composición explícita. Sus tablas, identidad, reglas de capacidad,
autorización, transacciones, cardinalidades, revisiones, traducciones y comandos
siguen siendo responsabilidad del propietario. El registro no ejecuta servicios ni
escribe relaciones; añadir una definición no implementa esas responsabilidades.
## Pruebas y alcance verificado
`typed-relationships.integration.test.ts` utiliza exclusivamente `metis_desk_test`,
crea datos con UUID propios mediante los casos de uso reales y los limpia por ámbito.
Verifica ambos sentidos, roles, ausencia de duplicados, cambios de rol, traslado,
finalización, historial, cursores ligados a consulta, límites, lotes, orden,
referencias no disponibles y revocación de contextos. Un hook SQL de la propia
prueba cuenta consultas de proyección y comprueba LIMIT y keyset reales.
La inspección EXPLAIN usa los predicados y ordenación de la raíz para ambos extremos,
con y sin finalizadas. Se conserva la decisión libre del optimizador y se comprueba
por separado cada índice compuesto con FORCE INDEX solo en EXPLAIN; las consultas
de producción no fuerzan índices. La muestra es funcional y pequeña, no una prueba
de carga ni una medida de rendimiento. Los resultados observados y los comandos
ejecutados se registran a continuación.
Pruebas adicionales: contratos comunes, definición real, schemas propietarios,
firma y vinculación del cursor y autorización de contexto; regresiones completas
de Assets y Resources y las integraciones de los pasos 2.1 y 2.2. Las pruebas no
cambian las expectativas de funcionalidades ajenas.
### Resultado de verificación del 20 de octubre de 2026
- Typecheck de contratos, Assets, Resources, Identity, Companies y servidor: correcto.
El typecheck global también se intentó: falla en el fichero vendor sin modificar
`packages/shadcn-ui/src/components/calendar.tsx:165`, por incompatibilidad de
`locale` con `exactOptionalPropertyTypes` (TS2375). No se modifica vendor.
- Build del servidor, Biome y ESLint dirigidos, y `git diff --check`: correctos.
- Contratos comunes: **13/13**.
- Suite completa de Assets: **119/119**, incluyendo tres pruebas nuevas del proveedor.
- Suite completa de Resources: **53/53**.
- Suite completa de Identity: **10/10**; contexto de lectura del servidor: **1/1**.
- MariaDB, pasos 2.1, 2.2 y 2.3: **10/10**, incluyendo siete subpruebas del proveedor
y su prueba contenedora. Migraciones y fixtures solo sobre `metis_desk_test`.
- Companies: **169/171**. Se reproducen los dos fallos ya conocidos en
`modules/companies/src/web/companies/shared/companies-page.test.tsx`, líneas 180
y 210: expectativas de texto «Personas (1)» y «Esta persona no tiene acceso a
Metis Desk». No se modifican estas pruebas ni la UI para resolverlos.
En los cuatro EXPLAIN libres de la muestra funcional, MariaDB eligió `PRIMARY`,
acceso `range`, con `Using where`. Al comprobar expresamente cada índice compuesto,
aparecen `directory_membership_asset` y `directory_membership_domain_page` con
`Using index condition`, además de `Using where` cuando se filtran finalizadas;
ninguno requiere `filesort`. Estos resultados no predicen el plan con volúmenes
reales ni justifican forzar índices en el proveedor.
Comandos reproducibles desde la raíz, usando las versiones fijadas del proyecto:
```bash
npm_config_verify_deps_before_run=false pnpm --config.verify-deps-before-run=false exec turbo run typecheck --env-mode=loose
pnpm --config.verify-deps-before-run=false --filter @metis/relationship-contracts --filter @metis/assets --filter @metis/resources --filter @metis/identity --filter @metis/companies --filter @metis/server typecheck
pnpm --config.verify-deps-before-run=false --filter @metis/relationship-contracts test
pnpm --config.verify-deps-before-run=false --filter @metis/server test:relationships
pnpm --config.verify-deps-before-run=false --filter @metis/server build
git diff --check
```
El intento inicial del comando global mediante PNPM/Turbo tropezó antes con la
comprobación automática de dependencias de los procesos hijos y su store SQLite.
La variable de configuración y `--env-mode=loose` permitieron ejecutar los
compiladores, que identificaron el error vendor indicado. No se cambia la
configuración del proyecto ni se relajan sus tipos.
Las suites de Assets, Resources, Identity y Companies ejecutaron **todos** los
ficheros de sus scripts `test`, añadiendo `--test-isolation=none` al runner nativo
para enumerar los casos reales, sin quitar pruebas. El contrato nuevo y la prueba
de autorización también se ejecutaron directamente con `node --import tsx --test
--test-isolation=none`.
La integración conjunta se ejecutó desde `apps/server`:
```bash
DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx \
--test --test-isolation=none --test-concurrency=1 \
src/directory-domains.integration.test.ts \
src/asset-directory-memberships.integration.test.ts \
src/typed-relationships.integration.test.ts
```
Biome y ESLint se aplicaron a los 24 ficheros TypeScript de este paso y Biome a los
manifiestos modificados. La comprobación del grafo workspace recorre 13 paquetes sin
ciclos. No se ejecuta la suite global del monorepo ni se afirma que esté verde.
## Archivos de este paso
Creados:
- `apps/server/src/infrastructure/http/relationship-runtime.ts`.
- `apps/server/src/infrastructure/http/relationship-read-access.ts` y su prueba.
- `apps/server/src/typed-relationships.integration.test.ts`.
- `modules/assets/src/common/directory-membership-relationship.ts`.
- En `modules/assets/src/api/infrastructure/persistence/`:
`directory-membership-relationship-query.ts`, `directory-relationship-cursor.ts`,
`asset-entity-summary-resolver.ts`, `directory-membership-read-index.ts` y
`directory-membership-provider.test.ts`.
- `modules/resources/src/api/infrastructure/persistence/directory-domain-summary-resolver.ts`.
- `modules/companies/src/api/infrastructure/persistence/company-existence-reader.ts`.
- Este documento.
Modificados:
- Schemas, interfaces, registro, exports y pruebas de `packages/relationship-contracts`.
- Composition roots de Assets, Resources, Identity y Companies.
- `apps/server/src/infrastructure/http/identity-runtime.ts` y el fixture compartido
de pertenencias para reutilizar la composición real en integración.
- Exports comunes de Assets, scripts/manifiestos de Assets, Resources y servidor,
y `pnpm-lock.yaml` con enlaces workspace, sin paquetes externos nuevos.
- [Documentación del contrato](contratos-de-relaciones-tipadas.md).
Los cambios pendientes de los pasos anteriores permanecen en el árbol de trabajo;
este paso no los revierte ni los presenta como modificaciones nuevas.
## Consumidor visual incorporado en el paso 2.4
El [adaptador HTTP y las fichas de dominios/activos](../integracion-visual-de-dominios-windows.md)
consumen ya estos servicios. Esta incorporación no cambia el contrato común, la
persistencia ni la estrategia HMAC descritos para el paso 2.3.