From bf987d867c39010a0bd04661fc3e1fbabcbe12ed Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 3 Dec 2025 19:28:07 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/page.tsx | 22 +- src/app/layout.tsx | 30 +- src/components/ClientResourcesTable.tsx | 6 +- .../CreateInternalResourceDialog.tsx | 167 ++++------ src/components/EditInternalResourceDialog.tsx | 287 ++++++++++-------- src/components/TanstackQueryProvider.tsx | 12 +- src/lib/queries.ts | 106 ++++++- src/types/tanstack-query.d.ts | 13 + 8 files changed, 370 insertions(+), 273 deletions(-) create mode 100644 src/types/tanstack-query.d.ts diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index c883f038..fc806acc 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,17 +1,15 @@ -import { verifySession } from "@app/lib/auth/verifySession"; -import UserProvider from "@app/providers/UserProvider"; -import { cache } from "react"; -import MemberResourcesPortal from "../../components/MemberResourcesPortal"; -import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; -import { internal } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { redirect } from "next/navigation"; import { Layout } from "@app/components/Layout"; -import { ListUserOrgsResponse } from "@server/routers/org"; +import MemberResourcesPortal from "@app/components/MemberResourcesPortal"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { verifySession } from "@app/lib/auth/verifySession"; import { pullEnv } from "@app/lib/pullEnv"; -import EnvProvider from "@app/providers/EnvProvider"; -import { orgLangingNavItems } from "@app/app/navigation"; +import UserProvider from "@app/providers/UserProvider"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; type OrgPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 460cace0..bdd6c3fe 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -95,16 +95,16 @@ export default async function RootLayout({ strategy="afterInteractive" /> )} - - - - - + + + + + @@ -124,11 +124,11 @@ export default async function RootLayout({ - - - - - + + + + + ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5d96d1d2..53998e2e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -55,7 +55,7 @@ import CreateInternalResourceDialog from "@app/components/CreateInternalResource import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; -import { siteQueries } from "@app/lib/queries"; +import { orgQueries, siteQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; export type TargetHealth = { @@ -135,9 +135,7 @@ export default function ClientResourcesTable({ useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const { data: sites = [] } = useQuery( - siteQueries.listPerOrg({ orgId, api }) - ); + const { data: sites = [] } = useQuery(orgQueries.sites({ orgId, api })); const [internalSorting, setInternalSorting] = useState( defaultSort ? [defaultSort] : [] diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 44ed12a5..9db438a8 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -1,15 +1,16 @@ "use client"; -import { useEffect, useState } from "react"; -import { Button } from "@app/components/ui/button"; -import { Input } from "@app/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Button } from "@app/components/ui/button"; import { Command, CommandEmpty, @@ -18,15 +19,6 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { Check, ChevronsUpDown } from "lucide-react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; import { Form, FormControl, @@ -36,29 +28,36 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { toast } from "@app/hooks/useToast"; -import { useTranslations } from "next-intl"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { ListSitesResponse } from "@server/routers/site"; -import { ListRolesResponse } from "@server/routers/role"; -import { ListUsersResponse } from "@server/routers/user"; -import { ListClientsResponse } from "@server/routers/client/listClients"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { Separator } from "@app/components/ui/separator"; -import { AxiosResponse } from "axios"; +import { orgQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ListClientsResponse } from "@server/routers/client/listClients"; +import { ListSitesResponse } from "@server/routers/site"; +import { ListUsersResponse } from "@server/routers/user"; import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; type Site = ListSitesResponse["sites"][0]; @@ -167,15 +166,38 @@ export default function CreateInternalResourceDialog({ type FormData = z.infer; - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( - [] + const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId })); + const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId })); + const { data: clientsResponse = [] } = useQuery( + orgQueries.clients({ + orgId, + filters: { + filter: "machine" + } + }) ); - const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( - [] - ); - const [allClients, setAllClients] = useState< - { id: string; text: string }[] - >([]); + + const allRoles = rolesResponse + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"); + + const allUsers = usersResponse.map((user) => ({ + id: user.id.toString(), + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })); + + const allClients = clientsResponse + .filter((client) => !client.userId) + .map((client) => ({ + id: client.clientId.toString(), + text: client.name + })); + + const hasMachineClients = allClients.length > 0; + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); @@ -185,7 +207,6 @@ export default function CreateInternalResourceDialog({ const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< number | null >(null); - const [hasMachineClients, setHasMachineClients] = useState(false); const availableSites = sites.filter( (site) => site.type === "newt" && site.subnet @@ -228,60 +249,6 @@ export default function CreateInternalResourceDialog({ } }, [open]); - useEffect(() => { - const fetchRolesUsersAndClients = async () => { - try { - const [rolesResponse, usersResponse, clientsResponse] = - await Promise.all([ - api.get>( - `/org/${orgId}/roles` - ), - api.get>( - `/org/${orgId}/users` - ), - api.get>( - `/org/${orgId}/clients?filter=machine&limit=1000` - ) - ]); - - setAllRoles( - rolesResponse.data.data.roles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin") - ); - - setAllUsers( - usersResponse.data.data.users.map((user) => ({ - id: user.id.toString(), - text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })) - ); - - const machineClients = clientsResponse.data.data.clients - .filter((client) => !client.userId) - .map((client) => ({ - id: client.clientId.toString(), - text: client.name - })); - - setAllClients(machineClients); - setHasMachineClients(machineClients.length > 0); - } catch (error) { - console.error( - "Error fetching roles, users, and clients:", - error - ); - } - }; - - if (open) { - fetchRolesUsersAndClients(); - } - }, [open, orgId]); - const handleSubmit = async (data: FormData) => { setIsSubmitting(true); try { diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 2eea4bb0..231940cd 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -45,6 +45,8 @@ import { ListClientsResponse } from "@server/routers/client/listClients"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { AxiosResponse } from "axios"; import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; type InternalResourceData = { id: number; @@ -155,15 +157,43 @@ export default function EditInternalResourceDialog({ type FormData = z.infer; - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( - [] + const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId })); + + const allRoles = rolesResponse + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"); + + const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId })); + const { data: existingClients = [] } = useQuery( + resourceQueries.resourceUsers({ resourceId: resource.id }) ); - const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( - [] + const { data: clientsResponse = [] } = useQuery( + orgQueries.clients({ + orgId, + filters: { + filter: "machine" + } + }) ); - const [allClients, setAllClients] = useState< - { id: string; text: string }[] - >([]); + + const allUsers = usersResponse.map((user) => ({ + id: user.id.toString(), + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })); + + const allClients = clientsResponse + .filter((client) => !client.userId) + .map((client) => ({ + id: client.clientId.toString(), + text: client.name + })); + + const hasMachineClients = + allClients.length > 0 || existingClients.length > 0; + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); @@ -174,7 +204,6 @@ export default function EditInternalResourceDialog({ number | null >(null); const [loadingRolesUsers, setLoadingRolesUsers] = useState(false); - const [hasMachineClients, setHasMachineClients] = useState(false); const form = useForm({ resolver: zodResolver(formSchema), @@ -194,136 +223,116 @@ export default function EditInternalResourceDialog({ const mode = form.watch("mode"); - const fetchRolesAndUsers = async () => { - setLoadingRolesUsers(true); - try { - const [ - rolesResponse, - resourceRolesResponse, - usersResponse, - resourceUsersResponse, - clientsResponse - ] = await Promise.all([ - api.get>( - `/org/${orgId}/roles` - ), - api.get>( - `/site-resource/${resource.id}/roles` - ), - api.get>( - `/org/${orgId}/users` - ), - api.get>( - `/site-resource/${resource.id}/users` - ), - api.get>( - `/org/${orgId}/clients?filter=machine&limit=1000` - ) - ]); + // const fetchRolesAndUsers = async () => { + // setLoadingRolesUsers(true); + // try { + // const [ + // // rolesResponse, + // resourceRolesResponse, + // usersResponse, + // resourceUsersResponse, + // clientsResponse + // ] = await Promise.all([ + // // api.get>( + // // `/org/${orgId}/roles` + // // ), + // api.get>( + // `/site-resource/${resource.id}/roles` + // ), + // api.get>( + // `/org/${orgId}/users` + // ), + // api.get>( + // `/site-resource/${resource.id}/users` + // ), + // api.get>( + // `/org/${orgId}/clients?filter=machine&limit=1000` + // ) + // ]); - let resourceClientsResponse: AxiosResponse< - AxiosResponse - >; - try { - resourceClientsResponse = await api.get< - AxiosResponse - >(`/site-resource/${resource.id}/clients`); - } catch { - resourceClientsResponse = { - data: { - data: { - clients: [] - } - }, - status: 200, - statusText: "OK", - headers: {} as any, - config: {} as any - } as any; - } + // let resourceClientsResponse: AxiosResponse< + // AxiosResponse + // >; + // try { + // resourceClientsResponse = await api.get< + // AxiosResponse + // >(`/site-resource/${resource.id}/clients`); + // } catch { + // resourceClientsResponse = { + // data: { + // data: { + // clients: [] + // } + // }, + // status: 200, + // statusText: "OK", + // headers: {} as any, + // config: {} as any + // } as any; + // } - setAllRoles( - rolesResponse.data.data.roles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin") - ); + // // setAllRoles( + // // rolesResponse.data.data.roles + // // .map((role) => ({ + // // id: role.roleId.toString(), + // // text: role.name + // // })) + // // .filter((role) => role.text !== "Admin") + // // ); - form.setValue( - "roles", - resourceRolesResponse.data.data.roles - .map((i) => ({ - id: i.roleId.toString(), - text: i.name - })) - .filter((role) => role.text !== "Admin") - ); + // form.setValue( + // "roles", + // resourceRolesResponse.data.data.roles + // .map((i) => ({ + // id: i.roleId.toString(), + // text: i.name + // })) + // .filter((role) => role.text !== "Admin") + // ); - setAllUsers( - usersResponse.data.data.users.map((user) => ({ - id: user.id.toString(), - text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })) - ); + // form.setValue( + // "users", + // resourceUsersResponse.data.data.users.map((i) => ({ + // id: i.userId.toString(), + // text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` + // })) + // ); - form.setValue( - "users", - resourceUsersResponse.data.data.users.map((i) => ({ - id: i.userId.toString(), - text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` - })) - ); + // const machineClients = clientsResponse.data.data.clients + // .filter((client) => !client.userId) + // .map((client) => ({ + // id: client.clientId.toString(), + // text: client.name + // })); - const machineClients = clientsResponse.data.data.clients - .filter((client) => !client.userId) - .map((client) => ({ - id: client.clientId.toString(), - text: client.name - })); + // setAllClients(machineClients); - setAllClients(machineClients); + // const existingClients = + // resourceClientsResponse.data.data.clients.map( + // (c: { clientId: number; name: string }) => ({ + // id: c.clientId.toString(), + // text: c.name + // }) + // ); - const existingClients = - resourceClientsResponse.data.data.clients.map( - (c: { clientId: number; name: string }) => ({ - id: c.clientId.toString(), - text: c.name - }) - ); + // form.setValue("clients", existingClients); - form.setValue("clients", existingClients); + // // Show clients tag input if there are machine clients OR existing client access + // setHasMachineClients( + // machineClients.length > 0 || existingClients.length > 0 + // ); + // } catch (error) { + // console.error("Error fetching roles, users, and clients:", error); + // } finally { + // setLoadingRolesUsers(false); + // } + // }; - // Show clients tag input if there are machine clients OR existing client access - setHasMachineClients( - machineClients.length > 0 || existingClients.length > 0 - ); - } catch (error) { - console.error("Error fetching roles, users, and clients:", error); - } finally { - setLoadingRolesUsers(false); - } - }; - - useEffect(() => { - if (open) { - form.reset({ - name: resource.name, - mode: resource.mode || "host", - // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, - // proxyPort: resource.proxyPort ?? undefined, - destination: resource.destination || "", - // destinationPort: resource.destinationPort ?? undefined, - alias: resource.alias ?? null, - roles: [], - users: [], - clients: [] - }); - fetchRolesAndUsers(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, resource]); + // useEffect(() => { + // if (open) { + // fetchRolesAndUsers(); + // } + // }, [open, resource]); const handleSubmit = async (data: FormData) => { setIsSubmitting(true); @@ -391,7 +400,27 @@ export default function EditInternalResourceDialog({ }; return ( - + { + if (!open) { + // reset only on close + form.reset({ + name: resource.name, + mode: resource.mode || "host", + // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, + // proxyPort: resource.proxyPort ?? undefined, + destination: resource.destination || "", + // destinationPort: resource.destinationPort ?? undefined, + alias: resource.alias ?? null, + roles: [], + users: [], + clients: [] + }); + } + setOpen(open); + }} + > diff --git a/src/components/TanstackQueryProvider.tsx b/src/components/TanstackQueryProvider.tsx index 3d9f62e1..b052ce96 100644 --- a/src/components/TanstackQueryProvider.tsx +++ b/src/components/TanstackQueryProvider.tsx @@ -3,19 +3,29 @@ import * as React from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { QueryClient } from "@tanstack/react-query"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { durationToMs } from "@app/lib/durationToMs"; export type ReactQueryProviderProps = { children: React.ReactNode; }; export function TanstackQueryProvider({ children }: ReactQueryProviderProps) { + const api = createApiClient(useEnvContext()); const [queryClient] = React.useState( () => new QueryClient({ defaultOptions: { queries: { retry: 2, // retry twice by default - staleTime: 5 * 60 * 1_000 // 5 minutes + staleTime: durationToMs(5, "minutes"), + meta: { + api + } + }, + mutations: { + meta: { api } } } }) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index da6f435c..260209b2 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,10 +1,18 @@ -import { keepPreviousData, queryOptions } from "@tanstack/react-query"; -import { durationToMs } from "./durationToMs"; import { build } from "@server/build"; -import { remote } from "./api"; -import type ResponseT from "@server/types/Response"; +import type { ListClientsResponse } from "@server/routers/client"; +import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; -import type { AxiosInstance, AxiosResponse } from "axios"; +import type { + ListSiteResourceRolesResponse, + ListSiteResourceUsersResponse +} from "@server/routers/siteResource"; +import type { ListUsersResponse } from "@server/routers/user"; +import type ResponseT from "@server/types/Response"; +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import z from "zod"; +import { remote } from "./api"; +import { durationToMs } from "./durationToMs"; export type ProductUpdate = { link: string | null; @@ -68,15 +76,89 @@ export const productUpdatesQueries = { }) }; -export const siteQueries = { - listPerOrg: ({ orgId, api }: { orgId: string; api: AxiosInstance }) => +export const clientFilterSchema = z.object({ + filter: z.enum(["machine", "user"]), + limit: z.int().prefault(1000).optional() +}); + +export const orgQueries = { + clients: ({ + orgId, + filters + }: { + orgId: string; + filters: z.infer; + }) => queryOptions({ - queryKey: ["SITE_PER_ORG", orgId] as const, - queryFn: async ({ signal }) => { - const res = await api.get>( - `/org/${orgId}/sites` - ); + queryKey: ["ORG", orgId, "CLIENTS", filters] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + ...filters, + limit: (filters.limit ?? 1000).toString() + }); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/clients?${sp.toString()}`, { signal }); + + return res.data.data.clients; + } + }), + users: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "USERS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/users`, { signal }); + + return res.data.data.users; + } + }), + roles: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "ROLES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/roles`, { signal }); + + return res.data.data.roles; + } + }), + + sites: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "SITES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/sites`, { signal }); return res.data.data.sites; } }) }; + +export const resourceQueries = { + resourceUsers: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "USERS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/site-resource/${resourceId}/users`, { signal }); + return res.data.data.users; + } + }), + resourceRoles: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "ROLES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/site-resource/${resourceId}/roles`, { signal }); + + return res.data.data.roles; + } + }) +}; diff --git a/src/types/tanstack-query.d.ts b/src/types/tanstack-query.d.ts new file mode 100644 index 00000000..b93f5c2c --- /dev/null +++ b/src/types/tanstack-query.d.ts @@ -0,0 +1,13 @@ +import "@tanstack/react-query"; +import type { AxiosInstance } from "axios"; + +interface Meta extends Record { + api: AxiosInstance; +} + +declare module "@tanstack/react-query" { + interface Register { + queryMeta: Meta; + mutationMeta: Meta; + } +}