Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: type duplication bug #938

Merged

Conversation

Yohe-Am
Copy link
Contributor

@Yohe-Am Yohe-Am commented Dec 12, 2024

  • Fixes bug in type-deduplication impl.
  • Fixes issues with very long names generated by prisma where types.
  • Fix bug where duplicate names end up in typegraph
  • Tests to avoid type size and duplication regressions
  • Bumps version to 0.5.0-rc.8

Migration notes


  • The change comes with new or modified tests
  • Hard-to-understand functions have explanatory comments
  • End-user documentation is updated to reflect the change

Copy link

linear bot commented Dec 12, 2024

@Yohe-Am Yohe-Am marked this pull request as ready for review December 13, 2024 18:38
@luckasRanarison
Copy link
Contributor

luckasRanarison commented Dec 14, 2024

Nice, metagen has been fixed but I still get the unreachable error when trying something like this:

typegraph
import { fx, Policy, t, typegraph } from "@typegraph/sdk";
import { Auth } from "@typegraph/sdk/params";
import { KvRuntime } from "@typegraph/sdk/runtimes/kv";
import { DenoRuntime } from "@typegraph/sdk/runtimes/deno";
import { PrismaRuntime } from "@typegraph/sdk/providers/prisma";
import { S3Runtime } from "@typegraph/sdk/providers/aws";
import { dbg, rack, rootBuilder } from "./utils.js";
import { TypegraphBuilderArgs } from "@typegraph/sdk/typegraph";
import {
  Backend,
  SubstantialRuntime,
  WorkflowFile,
} from "@typegraph/sdk/runtimes/substantial";

const timestamp = () => t.integer();

export default function vivavox(g: TypegraphBuilderArgs) {
  using root = rootBuilder(g);

  const basicUsers = {
    // key used by the web app
    vivavox_web: "vivavox_web",
    worker: "worker",
  };

  const substantialCommon = [
    "funcs/types.ts",
    "funcs/client.ts",
    "funcs/utils.ts",
  ];
  const substantial_files = [
    WorkflowFile.deno("funcs/response.ts", [...substantialCommon])
      .import(["responseSession"])
      .build(),
    WorkflowFile.deno("funcs/ingress.ts", [...substantialCommon])
      .import(["startIngress"])
      .build(),
  ];

  const rts = {
    deno: new DenoRuntime(),
    kvCache: new KvRuntime("CACHE_REDIS_URL"),
    prisma: new PrismaRuntime("vivavox", "POSTGRES_CONN"),
    sub: new SubstantialRuntime(Backend.devMemory(), substantial_files),
    s3: new S3Runtime({
      hostSecret: "VIVAVOX_S3_HOST",
      regionSecret: "VIVAVOX_S3_REGION",
      accessKeySecret: "VIVAVOX_S3_ACCESS_KEY",
      secretKeySecret: "VIVAVOX_S3_SECRET_KEY",
      pathStyleSecret: "VIVAVOX_S3_PATH_STYLE",
    }),
  };

  g.auth(Auth.basic([basicUsers.vivavox_web, basicUsers.worker]));
  g.auth(Auth.oauth2Google("profile email openid"));

  const pol = {
    pub: Policy.public(),
    internal: Policy.internal(),
    vivavoxWebAllowAll: rts.deno.policy(
      "vivavoxWebAllowAll",
      `(_args, { context }) => context.username == "${basicUsers.vivavox_web}" || null`,
    ),
    workerAllowAll: rts.deno.policy(
      "workerAllowAll",
      `(_args, { context }) => context.username == "${basicUsers.worker}" || null`,
    ),
  };

  const commonFields = {
    // FIXME: injected fields are still required by prisma
    // create functions
    // FIXME: applying simple (non-PerEffect) injection eliminates field from Model
    // FIXME: PerEffect injections run into weird permission errors like update being
    // denied for createdAt on a create query
    // FIXME: datetime doesn't expose a orderBy clause in prisma
    createdAt: t.datetime(),
    updatedAt: t.datetime(),
    // TODO: JSONB table mirror for entity tables to collect delted items
    // this should be easily a ts function
  };

  const userRoles = t.enum_(["admin", "manager", "reviewer"]);

  // the main entities of vivavox, each
  // represented by a db table
  const tDomain = rack({
    webSession: t.struct({
      // TODO: MET-764 uuidv7??
      id: t.uuid({ asId: true, config: { auto: true } }),
      // TODO: use numbers for efficency??
      ipAddr: t.string(),
      userAgent: t.string(),

      // user_id: t.string().optional(), // TODO: connect with user on login
      // TODO: connect with oauth2, etc sessions

      expiresAt: t.datetime(),
      ...commonFields,
    }),

    // FIXME: could it be possible to type entt
    // so that we can make sure `g.ref` gets valid names?
    scenario: (/* entt */) =>
      t.struct({
        id: t.uuid({ config: ["auto"] }).id(),
        title: t.string(),
        body: t.string(),
        publishedAt: t.datetime().optional(),
        scenes: t.list(g.ref("scene")),
        links: t.list(g.ref("scenarioLink")),
        author: g.ref("user"),
        ...commonFields,
      }),

    scenarioLink: t.struct({
      id: t.uuid({ config: { auto: true } }).id(),
      // TODO: unique index on slug to accelerate lookup
      // we're doing tablescans today
      slug: t.string(),
      slugRecipe: t.json(),
      closedAt: timestamp().optional(),
      scenario: g.ref("scenario"),
      attachedSessions: t.list(g.ref("vivaSession")),
      ...commonFields,
    }),

    // use different counters for different
    // needs. By default, just go for the counter
    // under the key `default`.
    // Only make a separate counter if you need to generate
    // a lot of items for a specific usecase and
    sqidCounters: t.struct({
      key: t.string().id(),
      number: t.integer({ defaultValue: 0 }),
    }),

    scene: t.struct(
      {
        id: t.uuid({ asId: true, config: { auto: true } }),
        title: t.string(),
        description: t.string(),
        order: t.integer(),
        video: g.ref("sceneVideo").optional(),
        scenario: g.ref("scenario"),
        // FIXME: MET-762 this leads to typegraph too large
        // to even serialize
        // responseVideo: t.list(g.ref("responseVideo")),
      },
      { config: { unique: ["scenario", "order"] } },
    ),

    sceneVideo: t.struct({
      id: t.uuid({ asId: true, config: { auto: true } }),
      scene: g.ref("scene"),
      duration: t.float(),
      type: t.string(), // mime type
      width: t.integer(),
      height: t.integer(),
      recordingStartedAt: t.datetime(),
      // FIXME: consider storing the object path here
      // directly
      // filePath: t.string(),
      ...commonFields,
    }),

    vivaSession: t.struct({
      id: t.uuid({ config: { auto: true } }).id(),
      email: t.string({ format: "email" }),
      sourceScenarioLink: g.ref("scenarioLink"),
      specialLinkSlug: t.string(),
      response: g.ref("response").optional(),
      // UTC unix timestamp
      // to get access to comparision
      // operators in prisma
      expiresAtTs: timestamp(),
      ...commonFields,
    }),

    response: t.struct({
      id: t.uuid({ config: { auto: true } }).id(),
      session: g.ref("vivaSession"),
      hiddenAt: t.datetime().optional(),
      videos: t.list(g.ref("responseVideo")),
      ...commonFields,
    }),

    responseVideo: t.struct({
      id: t.uuid({ config: { auto: true } }).id(),
      response: g.ref("response"),
      // FIXME: MET-762
      // scene: g.ref("scene"),
      sceneIdPlaceholder: t.string(),
      filePath: t.string(),
      recordingStartedAt: t.datetime(),
      ...commonFields,
    }),

    user: t.struct(
      {
        id: t.uuid({ config: { auto: true } }).id(),
        name: t.string(),
        email: t.email().optional(),
        providerName: t.string(),
        providerId: t.string(),
        organizations: t.list(g.ref("userOrganization")),
        scenarios: t.list(g.ref("scenario")),
        // picture: t.uri().optional()
      },
      { config: { unique: [["providerName", "providerId"]] } },
    ),

    organization: t.struct({
      id: t.uuid({ config: { auto: true } }).id(),
      name: t.string(),
      email: t.email(),
      users: t.list(g.ref("userOrganization")),
    }),

    userOrganization: t.struct({
      id: t.integer({}, { config: { auto: true } }).id(),
      user: g.ref("user"),
      organization: g.ref("organization"),
      role: userRoles,
    }),

    invitation: t.struct({
      id: t.integer({}, { config: { auto: true } }).id(),
      recipient: t.email(),
      organization: g.ref("organization"),
      role: userRoles,
    }),

    //orgPreferences: t.struct({
    //  id: t.uuid({ config: { auto: true } }).id(),
    //  domain: t.string(),
    //  organization: g.ref("organization"),
    //}),
  });

  // utility shared types
  // must not be used in prisma
  const tUtil = rack({
    sceneMetadata: t.struct({
      title: t.string(),
      description: t.string(),
    }),
  });

  // we use a seed custom functions so that we don't
  // have to expose the prisma functions (they're internal)
  root.expose(
    {
      seedTest: rts.deno
        .import(t.struct({}), t.boolean(), {
          effect: fx.create(true),
          name: "seedTest",
          module: "./funcs/seeds.ts",
          deps: ["./funcs/fdk.ts", "./funcs/client.ts", "./funcs/utils.ts"],
        })
        .rename("seedTest")
        .withPolicy(pol.pub),
      createScenariosInternal: rts.prisma.createMany(tDomain.scenario),
    },
    pol.internal,
  );

  // endpoints for web sessions
  root.expose(
    {
      createWebSession: rts.prisma.create(tDomain.webSession),
      findWebSession: rts.prisma.findFirst(tDomain.webSession),

      webSessionCacheGet: rts.kvCache.get(),
      webSessionCacheSet: rts.kvCache.set(),
      webSessionCacheDel: rts.kvCache.delete(),
    },
    [pol.vivavoxWebAllowAll, pol.internal],
  );

  // endpoints for viva sessions
  root.expose(
    {
      findVivaSession: rts.prisma.findFirst(tDomain.vivaSession),
      createVivaSessionInternal: rts.prisma
        .create(tDomain.vivaSession)
        .withPolicy(pol.internal),
      updateVivaSessionInternal: rts.prisma
        .update(tDomain.vivaSession)
        .withPolicy(pol.internal),
      emailVivaSessionLink: rts.deno
        .import(
          t.struct({
            scenarioId: t.string(),
            address: t.email(),
            linkSlug: t.string(),
          }),
          t.boolean(),
          {
            effect: fx.create(false),
            name: "sendEmailLink",
            module: "./funcs/session.ts",
            deps: ["./funcs/fdk.ts", "./funcs/client.ts", "./funcs/utils.ts"],
            secrets: [
              "VIVA_SESSION_LIFESPAN_SECS",
              "VIVAVOX_WEB_URL",
              "MAIL_SERVICE_URL",
              "MAILIT_CREDS",
              "MAIL_SENDER_ADDR",
              "MAIL_SENDER_NAME",
              "MAIL_SUPPORT_ADDR",
            ],
          },
        )
        .rename("emailVivaSessionLink"),
    },
    [pol.vivavoxWebAllowAll, pol.internal],
  );

  // endpoints for responses
  root.expose(
    {
      findResponse: rts.prisma.findFirst(tDomain.response),
      findResponses: rts.prisma.findMany(tDomain.response),
      hideResponse: rts.prisma.update(tDomain.response).reduce({
        where: g.inherit(),
        data: {
          hiddenAt: g.inherit(),
        },
      }),
    },
    pol.pub,
  );

  root.expose(
    {
      createSceneInternal: rts.prisma.create(tDomain.scene),
      aggregateScenesInternal: rts.prisma.aggregate(tDomain.scene).apply({
        where: { scenario: { id: g.asArg("scenarioId") } },
      }),
      // setSceneOrderInternal: rts.prisma.update(tDomain.scene)
      //   .apply({
      //     where: { id: g.asArg("sceneId") },
      //     data: { order: g.asArg("order") }
      //   }),
    },
    pol.internal,
  );

  root.expose(
    {
      publishScenario: rts.deno.import(
        t.struct({
          scenarioId: t.string(),
        }),
        t.struct({
          scenarioId: t.string(),
          slug: t.string(),
          /* scenario: rts.prisma.findFirst(tDomain.scenario).reduce({
            where: {
              id: g.inherit().fromParent("scenarioId"),
            },
          }), */
        }),
        {
          effect: fx.update(true),
          name: "publishScenario",
          module: "./funcs/scenario.ts",
          deps: ["./funcs/fdk.ts", "./funcs/client.ts", "./funcs/utils.ts"],
        },
      ),
      updateShortLinkInternal: rts.prisma
        .update(tDomain.scenarioLink)
        .withPolicy(pol.internal),
      createScenario: rts.prisma.create(tDomain.scenario),
      deleteScenario: rts.prisma.delete(tDomain.scenario),
      updateScenario: rts.prisma.update(tDomain.scenario),
      findAllScenarios: rts.prisma.findMany(tDomain.scenario),
      // createScene: rts.prisma.create(tDomain.scene),
      findAllScenes: rts.prisma.findMany(tDomain.scene),
      // deleteScenario: rts.prisma.delete(tDomain.scenario).apply({
      //   where: { id: g.asArg("scenarioId") },
      // }),
      createScene: rts.deno
        .import(
          t.struct({
            scenarioId: t.uuid(),
            scene: tUtil.sceneMetadata,
          }),
          t.uuid(),
          {
            effect: fx.create(false),
            name: "createScene",
            module: "./funcs/scene.ts",
            deps: ["./funcs/fdk.ts", "./funcs/client.ts"],
          },
        )
        .rename("createScene"),
      updateScene: rts.prisma.update(tDomain.scene),
      deleteScene: rts.prisma.delete(tDomain.scene),
      createUser: rts.prisma.create(tDomain.user),
      findUser: rts.prisma.findFirst(tDomain.user),
      // updateScene: rts.deno
      //   .import(tDomain.scene_metadata, t.uuid(), {
      //     effect: fx.create(false),
      //     name: "updateScene",
      //     module: "./funcs/scene.ts",
      //     deps: ["./funcs/mdk.ts", "./funcs/client.ts"],
      //   }),
      // setSceneOrder: rts.deno
      //   .import(t.struct({ sceneId: t.uuid(), order: t.integer() }), t.uuid(), {
      //     effect: fx.create(false),
      //     name: "setSceneOrder",
      //     module: "./funcs/scene.ts",
      //     deps: ["./funcs/mdk.ts", "./funcs/client.ts"],
      //   }),
    },
    // FIXME authenticated user?
    pol.pub,
  );

  root.expose(
    {
      createOrganization: rts.prisma.create(tDomain.organization),
      findOrganization: rts.prisma.findFirst(tDomain.organization),
      findManyOrganization: rts.prisma.findMany(tDomain.organization),
      addUserToOrganization: rts.prisma.create(tDomain.userOrganization),
      removeUserFromOrganization: rts.prisma.delete(tDomain.userOrganization),
    },
    pol.pub, // FIXME
  );

  // endpoints for scenarios
  root.expose(
    {
      findScenario: rts.prisma.findFirst(tDomain.scenario),
      findScene: rts.prisma.findFirst(tDomain.scene),
      createVideo: rts.prisma.create(tDomain.sceneVideo),
      deleteVideo: rts.prisma.delete(tDomain.sceneVideo),

      // endpoint for video files
      signUploadUrl: rts.s3.presignPut({ bucket: "vivavox" }),
      getDownloadUrl: rts.s3.presignGet({
        bucket: "vivavox",
        expirySecs: 60 * 60,
      }),
    },
    // FIXME authenticated user?
    [pol.vivavoxWebAllowAll, pol.internal],
  );

  root.expose(
    {
      startIngress: rts.sub
        .start(
          t.struct({
            webSessionId: t.string(),
            roomName: t.string(),
            fileName: t.string(),
          }),
          {
            secrets: [
              "LIVEKIT_HOST",
              "LIVEKIT_KEY",
              "LIVEKIT_SECRET",
              "WORKER_REDIS_URL",
            ],
          },
        )
        .reduce({
          name: "startIngress",
        }),
    },
    [pol.vivavoxWebAllowAll],
  );

  // endpoints for responses
  root.expose(
    {
      createResponseInternal: rts.prisma
        .create(tDomain.response)
        .withPolicy(pol.internal),
      createResponse: rts.prisma.create(tDomain.response),

      startResponseSession: rts.sub
        .start(
          t.struct({
            sessionId: t.string(),
            timeoutSec: t.integer().optional(),
          }),
        )
        .reduce({
          name: "responseSession",
        }),

      sendDevice: rts.sub
        .send(
          t.struct({
            audio: t.boolean(),
            video: t.boolean(),
            issues: t.string({}, { config: { format: "json" } }).optional(),
          }),
        )
        .reduce({
          event: { name: "device" },
        }),

      sendAnswer: rts.sub
        .send(
          t.struct({
            sceneId: t.string(),
            filePath: t.string(),
            recordingStartedAt: t.datetime(),
          }),
        )
        .reduce({
          event: { name: "answer" },
        }),

      submitResponse: rts.sub.send(t.boolean()).reduce({
        event: { name: "submitResponse" },
      }),

      results: rts.sub.queryResultsRaw().reduce({ name: "responseSession" }),

      abortRun: rts.sub.stop(),
    },
    pol.vivavoxWebAllowAll,
  );
  root.expose(
    {
      getSqidNumber: rts.prisma.upsert(tDomain.sqidCounters).apply({
        where: {
          // TODO: default values for asArg
          key: g.asArg("key"),
        },
        create: {
          // FIXME: allow injecting multiplle leaf apply leaf nodes
          // from the same arg
          key: g.asArg("key_again"),
          number: g.set(0),
        },
        update: {},
      }),
    },
    [pol.internal],
  );
}

const isMainModule = import.meta.url.endsWith(process.argv[1]);
// const isMainModule = import.meta.url === Deno.mainModule;

if (isMainModule) {
  await typegraph(
    {
      name: "vivavox",
      cors: { allowOrigin: ["https://metatype.dev", "http://localhost:3000"] },
    },
    vivavox,
  );
}

When removing the user and scenario relation it works

Copy link

codecov bot commented Dec 15, 2024

Codecov Report

Attention: Patch coverage is 44.00000% with 14 lines in your changes missing coverage. Please review.

Project coverage is 77.76%. Comparing base (64ed210) to head (a5f34f5).
Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
tools/deps.ts 30.00% 7 Missing ⚠️
src/typegate/src/config.ts 50.00% 3 Missing ⚠️
tools/utils.ts 0.00% 3 Missing ⚠️
src/typegraph/deno/src/typegraph.ts 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #938      +/-   ##
==========================================
- Coverage   77.81%   77.76%   -0.06%     
==========================================
  Files         153      153              
  Lines       18960    18974      +14     
  Branches     1894     1894              
==========================================
+ Hits        14754    14755       +1     
- Misses       4182     4195      +13     
  Partials       24       24              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Natoandro
Natoandro previously approved these changes Dec 16, 2024
Copy link
Contributor

@Natoandro Natoandro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments...

src/common/src/typegraph/visitor2.rs Show resolved Hide resolved
src/typegraph/core/src/global_store.rs Outdated Show resolved Hide resolved
tools/tree-view-web.ts Outdated Show resolved Hide resolved
@Yohe-Am Yohe-Am enabled auto-merge (squash) December 16, 2024 08:24
@Yohe-Am Yohe-Am requested a review from a team December 16, 2024 08:24
@Yohe-Am Yohe-Am merged commit ea8711f into main Dec 16, 2024
11 of 13 checks passed
@Yohe-Am Yohe-Am deleted the met-762-recursive-type-duplication-on-3-way-prisma-relationships branch December 16, 2024 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants