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

18 KiB

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.

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:

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:

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.

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