diff --git a/infra/dev/postgres/init.sql b/infra/dev/postgres/init.sql index 406cf8185..ea9e4f485 100644 --- a/infra/dev/postgres/init.sql +++ b/infra/dev/postgres/init.sql @@ -7,3 +7,9 @@ CREATE DATABASE "test"; -- Create a twenty user CREATE USER twenty PASSWORD 'twenty'; ALTER USER twenty CREATEDB; + +-- Connect to the "default" database +\c "default"; + +-- Create the metadata schema if it doesn't exist +CREATE SCHEMA IF NOT EXISTS "metadata"; diff --git a/server/package.json b/server/package.json index cc4432fff..8d7243705 100644 --- a/server/package.json +++ b/server/package.json @@ -23,10 +23,10 @@ "prisma:generate-gql-select": "node scripts/generate-model-select-map.js", "prisma:generate-nest-graphql": "npx prisma generate --generator nestgraphql", "prisma:generate": "yarn prisma:generate-client && yarn prisma:generate-gql-select && yarn prisma:generate-nest-graphql", - "prisma:migrate": "npx prisma migrate deploy", + "prisma:migrate": "npx prisma migrate deploy && yarn typeorm migration:run -- -d ./src/tenant/metadata/metadata.datasource.ts", "prisma:seed": "npx prisma db seed", "prisma:reset": "npx prisma migrate reset && yarn prisma:generate", - "typeorm": "typeorm-ts-node-commonjs" + "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js" }, "dependencies": { "@apollo/server": "^4.7.3", @@ -54,7 +54,7 @@ "add": "^2.0.6", "apollo-server-express": "^3.12.0", "axios": "^1.4.0", - "bcrypt": "^5.1.0", + "bcrypt": "^5.1.1", "body-parser": "^1.20.2", "bytes": "^3.1.2", "class-transformer": "^0.5.1", @@ -70,6 +70,7 @@ "lodash.isobject": "^3.0.2", "lodash.kebabcase": "^4.1.1", "lodash.merge": "^4.6.2", + "lodash.snakecase": "^4.1.1", "ms": "^2.1.3", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", @@ -98,6 +99,7 @@ "@types/jest": "28.1.8", "@types/lodash.isobject": "^3.0.7", "@types/lodash.kebabcase": "^4.1.7", + "@types/lodash.snakecase": "^4.1.7", "@types/ms": "^0.7.31", "@types/node": "^16.0.0", "@types/passport-google-oauth20": "^2.0.11", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 79cee3713..9fb2e8b02 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -14,6 +14,7 @@ import { IntegrationsModule } from './integrations/integrations.module'; import { PrismaModule } from './database/prisma.module'; import { HealthModule } from './health/health.module'; import { AbilityModule } from './ability/ability.module'; +import { TenantModule } from './tenant/tenant.module'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { AbilityModule } from './ability/ability.module'; AbilityModule, IntegrationsModule, CoreModule, + TenantModule, ], providers: [AppService], }) diff --git a/server/src/tenant/metadata/data-source/data-source.util.ts b/server/src/tenant/metadata/data-source/data-source.util.ts index 5cc23c7e1..0658a1e46 100644 --- a/server/src/tenant/metadata/data-source/data-source.util.ts +++ b/server/src/tenant/metadata/data-source/data-source.util.ts @@ -6,9 +6,16 @@ * @returns */ export function uuidToBase36(uuid: string): string { + let devId = false; + + if (uuid.startsWith('twenty-')) { + devId = true; + // Clean dev uuids (twenty-) + uuid = uuid.replace('twenty-', ''); + } const hexString = uuid.replace(/-/g, ''); const base10Number = BigInt('0x' + hexString); const base36String = base10Number.toString(36); - return base36String; + return `${devId ? 'twenty_' : ''}${base36String}`; } diff --git a/server/src/tenant/metadata/entity-schema-generator/base.entity.ts b/server/src/tenant/metadata/entity-schema-generator/base.entity.ts index f26bb08ef..46a7cbdeb 100644 --- a/server/src/tenant/metadata/entity-schema-generator/base.entity.ts +++ b/server/src/tenant/metadata/entity-schema-generator/base.entity.ts @@ -4,4 +4,12 @@ export const baseColumns = { type: 'uuid', generated: 'uuid', }, + createdAt: { + type: 'timestamp', + createDate: true, + }, + updatedAt: { + type: 'timestamp', + updateDate: true, + }, } as const; diff --git a/server/src/tenant/metadata/metadata.controller.ts b/server/src/tenant/metadata/metadata.controller.ts index 514f9de50..48f6234e8 100644 --- a/server/src/tenant/metadata/metadata.controller.ts +++ b/server/src/tenant/metadata/metadata.controller.ts @@ -9,6 +9,7 @@ import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { DataSourceMetadataService } from './data-source-metadata/data-source-metadata.service'; import { EntitySchemaGeneratorService } from './entity-schema-generator/entity-schema-generator.service'; import { DataSourceService } from './data-source/data-source.service'; +import { uuidToBase36 } from './data-source/data-source.util'; @UseGuards(JwtAuthGuard) @Controller('metadata') @@ -39,6 +40,10 @@ export class MetadataController { entities.push(...dataSourceEntities); } + this.dataSourceService.createWorkspaceSchema(workspace.id); + + console.log('entities', uuidToBase36(workspace.id), workspace.id); + this.dataSourceService.connectToWorkspaceDataSource(workspace.id); return entities; diff --git a/server/src/tenant/metadata/metadata.datasource.ts b/server/src/tenant/metadata/metadata.datasource.ts new file mode 100644 index 000000000..adfee49fa --- /dev/null +++ b/server/src/tenant/metadata/metadata.datasource.ts @@ -0,0 +1,25 @@ +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; + +import { DataSource, DataSourceOptions } from 'typeorm'; +import { config } from 'dotenv'; + +config(); + +const configService = new ConfigService(); + +export const typeORMMetadataModuleOptions: TypeOrmModuleOptions = { + url: configService.get('PG_DATABASE_URL')!, + type: 'postgres', + logging: false, + schema: 'metadata', + entities: [__dirname + '/**/*.entity{.ts,.js}'], + synchronize: false, + migrationsRun: true, + migrationsTableName: '_typeorm_migrations', + migrations: [__dirname + '/migrations/**/*{.ts,.js}'], +}; + +export const connectionSource = new DataSource( + typeORMMetadataModuleOptions as DataSourceOptions, +); diff --git a/server/src/tenant/metadata/metadata.module.ts b/server/src/tenant/metadata/metadata.module.ts index 3bf9540b5..5cb1f9a12 100644 --- a/server/src/tenant/metadata/metadata.module.ts +++ b/server/src/tenant/metadata/metadata.module.ts @@ -1,36 +1,24 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { EnvironmentService } from 'src/integrations/environment/environment.service'; - import { MetadataService } from './metadata.service'; import { MetadataController } from './metadata.controller'; +import { typeORMMetadataModuleOptions } from './metadata.datasource'; import { DataSourceModule } from './data-source/data-source.module'; import { DataSourceMetadataModule } from './data-source-metadata/data-source-metadata.module'; import { FieldMetadataModule } from './field-metadata/field-metadata.module'; import { ObjectMetadataModule } from './object-metadata/object-metadata.module'; import { EntitySchemaGeneratorModule } from './entity-schema-generator/entity-schema-generator.module'; -import { DataSourceMetadata } from './data-source-metadata/data-source-metadata.entity'; -import { FieldMetadata } from './field-metadata/field-metadata.entity'; -import { ObjectMetadata } from './object-metadata/object-metadata.entity'; -const typeORMFactory = async ( - environmentService: EnvironmentService, -): Promise => ({ - url: environmentService.getPGDatabaseUrl(), - type: 'postgres', - logging: false, - schema: 'metadata', - entities: [DataSourceMetadata, FieldMetadata, ObjectMetadata], - synchronize: true, +const typeORMFactory = async (): Promise => ({ + ...typeORMMetadataModuleOptions, }); @Module({ imports: [ TypeOrmModule.forRootAsync({ useFactory: typeORMFactory, - inject: [EnvironmentService], name: 'metadata', }), DataSourceModule, diff --git a/server/src/tenant/metadata/migrations/1695198840363-migrations.ts b/server/src/tenant/metadata/migrations/1695198840363-migrations.ts new file mode 100644 index 000000000..d6674dbda --- /dev/null +++ b/server/src/tenant/metadata/migrations/1695198840363-migrations.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Migrations1695198840363 implements MigrationInterface { + name = 'Migrations1695198840363'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "metadata"."data_source_metadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "url" character varying, "schema" character varying, "type" "metadata"."data_source_metadata_type_enum" NOT NULL DEFAULT 'postgres', "name" character varying, "is_remote" boolean NOT NULL DEFAULT false, "workspace_id" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_923752b7e62a300a4969bd0e038" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "metadata"."field_metadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "object_id" uuid NOT NULL, "type" character varying NOT NULL, "name" character varying NOT NULL, "is_custom" boolean NOT NULL DEFAULT false, "workspace_id" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c75db587904cad6af109b5c65f1" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "metadata"."object_metadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "data_source_id" character varying NOT NULL, "name" character varying NOT NULL, "is_custom" boolean NOT NULL DEFAULT false, "workspace_id" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c8c5f885767b356949c18c201c1" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ADD CONSTRAINT "FK_38179b299795e48887fc99f937a" FOREIGN KEY ("object_id") REFERENCES "metadata"."object_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" DROP CONSTRAINT "FK_38179b299795e48887fc99f937a"`, + ); + await queryRunner.query(`DROP TABLE "metadata"."object_metadata"`); + await queryRunner.query(`DROP TABLE "metadata"."field_metadata"`); + await queryRunner.query(`DROP TABLE "metadata"."data_source_metadata"`); + } +} diff --git a/server/src/tenant/tenant.module.ts b/server/src/tenant/tenant.module.ts index 8da06930d..f31d9daf3 100644 --- a/server/src/tenant/tenant.module.ts +++ b/server/src/tenant/tenant.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { MetadataModule } from './metadata/metadata.module'; +import { UniversalModule } from './universal/universal.module'; @Module({ - imports: [MetadataModule], + imports: [MetadataModule, UniversalModule], }) export class TenantModule {} diff --git a/server/src/tenant/universal/args/base-universal.args.ts b/server/src/tenant/universal/args/base-universal.args.ts new file mode 100644 index 000000000..8ed90d8f5 --- /dev/null +++ b/server/src/tenant/universal/args/base-universal.args.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@ArgsType() +export class BaseUniversalArgs { + @Field(() => String) + @IsNotEmpty() + @IsString() + entity: string; +} diff --git a/server/src/tenant/universal/args/delete-one-universal.args.ts b/server/src/tenant/universal/args/delete-one-universal.args.ts new file mode 100644 index 000000000..998efeaf4 --- /dev/null +++ b/server/src/tenant/universal/args/delete-one-universal.args.ts @@ -0,0 +1,10 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { BaseUniversalArgs } from './base-universal.args'; +import { UniversalEntityInput } from './universal-entity.input'; + +@ArgsType() +export class DeleteOneUniversalArgs extends BaseUniversalArgs { + @Field(() => UniversalEntityInput, { nullable: true }) + where?: UniversalEntityInput; +} diff --git a/server/src/tenant/universal/args/find-many-universal.args.ts b/server/src/tenant/universal/args/find-many-universal.args.ts new file mode 100644 index 000000000..082d42caf --- /dev/null +++ b/server/src/tenant/universal/args/find-many-universal.args.ts @@ -0,0 +1,35 @@ +import { ArgsType, Field, Int } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; +import { IsNotEmpty, IsString } from 'class-validator'; + +import { ConnectionArgs } from 'src/utils/pagination'; + +import { UniversalEntityInput } from './universal-entity.input'; +import { UniversalEntityOrderByRelationInput } from './universal-entity-order-by-relation.input'; + +@ArgsType() +export class FindManyUniversalArgs extends ConnectionArgs { + @Field(() => String) + @IsNotEmpty() + @IsString() + entity: string; + + @Field(() => UniversalEntityInput, { nullable: true }) + where?: UniversalEntityInput; + + @Field(() => UniversalEntityOrderByRelationInput, { nullable: true }) + orderBy?: UniversalEntityOrderByRelationInput; + + @Field(() => GraphQLJSON, { nullable: true }) + cursor?: UniversalEntityInput; + + @Field(() => Int, { nullable: true }) + take?: number; + + @Field(() => Int, { nullable: true }) + skip?: number; + + @Field(() => [String], { nullable: true }) + distinct?: Array; +} diff --git a/server/src/tenant/universal/args/find-unique-universal.args.ts b/server/src/tenant/universal/args/find-unique-universal.args.ts new file mode 100644 index 000000000..85432323b --- /dev/null +++ b/server/src/tenant/universal/args/find-unique-universal.args.ts @@ -0,0 +1,10 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { BaseUniversalArgs } from './base-universal.args'; +import { UniversalEntityInput } from './universal-entity.input'; + +@ArgsType() +export class FindUniqueUniversalArgs extends BaseUniversalArgs { + @Field(() => UniversalEntityInput, { nullable: true }) + where?: UniversalEntityInput; +} diff --git a/server/src/tenant/universal/args/universal-entity-order-by-relation.input.ts b/server/src/tenant/universal/args/universal-entity-order-by-relation.input.ts new file mode 100644 index 000000000..12f0f46f8 --- /dev/null +++ b/server/src/tenant/universal/args/universal-entity-order-by-relation.input.ts @@ -0,0 +1,29 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { registerEnumType } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; + +export enum TypeORMSortOrder { + ASC = 'ASC', + DESC = 'DESC', +} + +registerEnumType(TypeORMSortOrder, { + name: 'TypeORMSortOrder', + description: undefined, +}); + +@InputType() +export class UniversalEntityOrderByRelationInput { + @Field(() => TypeORMSortOrder, { nullable: true }) + id?: keyof typeof TypeORMSortOrder; + + @Field(() => GraphQLJSON, { nullable: true }) + data?: Record; + + @Field(() => TypeORMSortOrder, { nullable: true }) + createdAt?: keyof typeof TypeORMSortOrder; + + @Field(() => TypeORMSortOrder, { nullable: true }) + updatedAt?: keyof typeof TypeORMSortOrder; +} diff --git a/server/src/tenant/universal/args/universal-entity.input.ts b/server/src/tenant/universal/args/universal-entity.input.ts new file mode 100644 index 000000000..149a3e0ce --- /dev/null +++ b/server/src/tenant/universal/args/universal-entity.input.ts @@ -0,0 +1,18 @@ +import { Field, ID, InputType } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; + +@InputType() +export class UniversalEntityInput { + @Field(() => ID, { nullable: true }) + id?: string; + + @Field(() => GraphQLJSON, { nullable: true }) + data?: Record; + + @Field(() => Date, { nullable: true }) + createdAt?: Date; + + @Field(() => Date, { nullable: true }) + updatedAt?: Date; +} diff --git a/server/src/tenant/universal/args/update-one-custom.args.ts b/server/src/tenant/universal/args/update-one-custom.args.ts new file mode 100644 index 000000000..f8f19eea4 --- /dev/null +++ b/server/src/tenant/universal/args/update-one-custom.args.ts @@ -0,0 +1,13 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { BaseUniversalArgs } from './base-universal.args'; +import { UniversalEntityInput } from './universal-entity.input'; + +@ArgsType() +export class UpdateOneCustomArgs extends BaseUniversalArgs { + @Field(() => UniversalEntityInput, { nullable: false }) + data!: UniversalEntityInput; + + @Field(() => UniversalEntityInput, { nullable: true }) + where?: UniversalEntityInput; +} diff --git a/server/src/tenant/universal/universal.entity.ts b/server/src/tenant/universal/universal.entity.ts new file mode 100644 index 000000000..fe16223f3 --- /dev/null +++ b/server/src/tenant/universal/universal.entity.ts @@ -0,0 +1,25 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; + +import { Paginated } from 'src/utils/pagination'; + +@ObjectType() +export class UniversalEntity { + @Field(() => ID, { nullable: false }) + id!: string; + + @Field(() => GraphQLJSON, { nullable: false }) + data!: Record; + + @Field(() => Date, { nullable: false }) + createdAt!: Date; + + @Field(() => Date, { nullable: false }) + updatedAt!: Date; +} + +@ObjectType() +export class PaginatedUniversalEntity extends Paginated( + UniversalEntity, +) {} diff --git a/server/src/tenant/universal/universal.module.ts b/server/src/tenant/universal/universal.module.ts new file mode 100644 index 000000000..f574030a3 --- /dev/null +++ b/server/src/tenant/universal/universal.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { DataSourceModule } from 'src/tenant/metadata/data-source/data-source.module'; + +import { UniversalService } from './universal.service'; +import { UniversalResolver } from './universal.resolver'; + +@Module({ + imports: [DataSourceModule], + providers: [UniversalService, UniversalResolver], +}) +export class UniversalModule {} diff --git a/server/src/tenant/universal/universal.resolver.spec.ts b/server/src/tenant/universal/universal.resolver.spec.ts new file mode 100644 index 000000000..772e08e88 --- /dev/null +++ b/server/src/tenant/universal/universal.resolver.spec.ts @@ -0,0 +1,26 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { UniversalResolver } from './universal.resolver'; +import { UniversalService } from './universal.service'; + +describe('UniversalResolver', () => { + let resolver: UniversalResolver; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UniversalResolver, + { + provide: UniversalService, + useValue: {}, + }, + ], + }).compile(); + + resolver = module.get(UniversalResolver); + }); + + it('should be defined', () => { + expect(resolver).toBeDefined(); + }); +}); diff --git a/server/src/tenant/universal/universal.resolver.ts b/server/src/tenant/universal/universal.resolver.ts new file mode 100644 index 000000000..bb523b875 --- /dev/null +++ b/server/src/tenant/universal/universal.resolver.ts @@ -0,0 +1,56 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; + +import { Workspace } from '@prisma/client'; + +import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; +import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; + +import { UniversalEntity, PaginatedUniversalEntity } from './universal.entity'; +import { UniversalService } from './universal.service'; + +import { FindManyUniversalArgs } from './args/find-many-universal.args'; +import { FindUniqueUniversalArgs } from './args/find-unique-universal.args'; +import { UpdateOneCustomArgs } from './args/update-one-custom.args'; + +@UseGuards(JwtAuthGuard) +@Resolver(() => UniversalEntity) +export class UniversalResolver { + constructor(private readonly customService: UniversalService) {} + + @Query(() => PaginatedUniversalEntity) + findMany( + @Args() args: FindManyUniversalArgs, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.customService.findManyUniversal(args, workspace); + } + + @Query(() => UniversalEntity) + findUnique( + @Args() args: FindUniqueUniversalArgs, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.customService.findUniqueUniversal(args, workspace); + } + + @Query(() => UniversalEntity) + updateOneCustom(@Args() args: UpdateOneCustomArgs): UniversalEntity { + return { + id: 'exampleId', + data: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + } + + @Query(() => UniversalEntity) + deleteOneCustom(@Args() args: UpdateOneCustomArgs): UniversalEntity { + return { + id: 'exampleId', + data: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +} diff --git a/server/src/tenant/universal/universal.service.spec.ts b/server/src/tenant/universal/universal.service.spec.ts new file mode 100644 index 000000000..5cc21c13b --- /dev/null +++ b/server/src/tenant/universal/universal.service.spec.ts @@ -0,0 +1,27 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { DataSourceService } from 'src/tenant/metadata/data-source/data-source.service'; + +import { UniversalService } from './universal.service'; + +describe('UniversalService', () => { + let service: UniversalService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UniversalService, + { + provide: DataSourceService, + useValue: {}, + }, + ], + }).compile(); + + service = module.get(UniversalService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/tenant/universal/universal.service.ts b/server/src/tenant/universal/universal.service.ts new file mode 100644 index 000000000..c3b2be942 --- /dev/null +++ b/server/src/tenant/universal/universal.service.ts @@ -0,0 +1,100 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; + +import { Workspace } from '@prisma/client'; + +import { DataSourceService } from 'src/tenant/metadata/data-source/data-source.service'; +import { findManyCursorConnection } from 'src/utils/pagination'; + +import { UniversalEntity, PaginatedUniversalEntity } from './universal.entity'; +import { + getRawTypeORMOrderByClause, + getRawTypeORMWhereClause, +} from './universal.util'; + +import { FindManyUniversalArgs } from './args/find-many-universal.args'; +import { FindUniqueUniversalArgs } from './args/find-unique-universal.args'; + +@Injectable() +export class UniversalService { + constructor(private readonly dataSourceService: DataSourceService) {} + + async findManyUniversal( + args: FindManyUniversalArgs, + workspace: Workspace, + ): Promise { + await this.dataSourceService.createWorkspaceSchema(workspace.id); + + const workspaceDataSource = + await this.dataSourceService.connectToWorkspaceDataSource(workspace.id); + + let query = workspaceDataSource + ?.createQueryBuilder() + .select() + .from(args.entity, args.entity); + + if (!query) { + throw new InternalServerErrorException(); + } + + if (query && args.where) { + const { where, parameters } = getRawTypeORMWhereClause( + args.entity, + args.where, + ); + + query = query.where(where, parameters); + } + + if (query && args.orderBy) { + const orderBy = getRawTypeORMOrderByClause(args.entity, args.orderBy); + + query = query.orderBy(orderBy); + } + + return findManyCursorConnection(query, args, { + recordToEdge({ id, createdAt, updatedAt, ...data }) { + return { + node: { + id, + data, + createdAt, + updatedAt, + }, + }; + }, + }); + } + + async findUniqueUniversal( + args: FindUniqueUniversalArgs, + workspace: Workspace, + ): Promise { + await this.dataSourceService.createWorkspaceSchema(workspace.id); + + const workspaceDataSource = + await this.dataSourceService.connectToWorkspaceDataSource(workspace.id); + + let query = workspaceDataSource + ?.createQueryBuilder() + .select() + .from(args.entity, args.entity); + + if (query && args.where) { + const { where, parameters } = getRawTypeORMWhereClause( + args.entity, + args.where, + ); + + query = query.where(where, parameters); + } + + const { id, createdAt, updatedAt, ...data } = await query?.getRawOne(); + + return { + id, + data, + createdAt, + updatedAt, + }; + } +} diff --git a/server/src/tenant/universal/universal.util.ts b/server/src/tenant/universal/universal.util.ts new file mode 100644 index 000000000..c90b1a8ef --- /dev/null +++ b/server/src/tenant/universal/universal.util.ts @@ -0,0 +1,59 @@ +import { snakeCase } from 'src/utils/snake-case'; + +import { UniversalEntityInput } from './args/universal-entity.input'; +import { + UniversalEntityOrderByRelationInput, + TypeORMSortOrder, +} from './args/universal-entity-order-by-relation.input'; + +export const getRawTypeORMWhereClause = ( + entity: string, + where?: UniversalEntityInput | undefined, +) => { + if (!where) { + return { + where: '', + parameters: {}, + }; + } + + const { id, data, createdAt, updatedAt } = where; + const flattenWhere: any = { + ...(id ? { id } : {}), + ...data, + ...(createdAt ? { createdAt } : {}), + ...(updatedAt ? { updatedAt } : {}), + }; + + return { + where: Object.keys(flattenWhere) + .map((key) => `${entity}.${snakeCase(key)} = :${key}`) + .join(' AND '), + parameters: flattenWhere, + }; +}; + +export const getRawTypeORMOrderByClause = ( + entity: string, + orderBy?: UniversalEntityOrderByRelationInput | undefined, +) => { + if (!orderBy) { + return {}; + } + + const { id, data, createdAt, updatedAt } = orderBy; + const flattenWhere: any = { + ...(id ? { id } : {}), + ...data, + ...(createdAt ? { createdAt } : {}), + ...(updatedAt ? { updatedAt } : {}), + }; + + const orderByClause: Record = {}; + + for (const key of Object.keys(flattenWhere)) { + orderByClause[`${entity}.${snakeCase(key)}`] = flattenWhere[key]; + } + + return orderByClause; +}; diff --git a/server/src/utils/pagination/find-many-cursor-connection.ts b/server/src/utils/pagination/find-many-cursor-connection.ts new file mode 100644 index 000000000..d11e9b6b7 --- /dev/null +++ b/server/src/utils/pagination/find-many-cursor-connection.ts @@ -0,0 +1,162 @@ +import { + LessThanOrEqual, + MoreThanOrEqual, + ObjectLiteral, + SelectQueryBuilder, +} from 'typeorm'; + +import { IEdge } from './interfaces/edge.interface'; +import { IConnectionArguments } from './interfaces/connection-arguments.interface'; +import { IOptions } from './interfaces/options.interface'; +import { IConnection } from './interfaces/connection.interface'; +import { validateArgs } from './utils/validate-args'; +import { mergeDefaultOptions } from './utils/default-options'; +import { + isBackwardPagination, + isForwardPagination, +} from './utils/pagination-direction'; +import { encodeCursor, extractCursorKeyValue } from './utils/cursor'; + +/** + * Override cursors options + */ +export async function findManyCursorConnection< + Entity extends ObjectLiteral, + Record = Entity, + Cursor = { id: string }, + Node = Record, + CustomEdge extends IEdge = IEdge, +>( + query: SelectQueryBuilder, + args: IConnectionArguments = {}, + initialOptions?: IOptions, +): Promise> { + if (!validateArgs(args)) { + throw new Error('Should never happen'); + } + + const options = mergeDefaultOptions(initialOptions); + const totalCountQuery = query.clone(); + const totalCount = await totalCountQuery.getCount(); + // Only to extract cursor shape + const cursorKeys = Object.keys(options.getCursor(undefined) as any); + + let records: Array; + let hasNextPage: boolean; + let hasPreviousPage: boolean; + + // Add order by based on the cursor keys + for (const key of cursorKeys) { + query.addOrderBy(key, 'ASC'); + } + + if (isForwardPagination(args)) { + // Fetch one additional record to determine if there is a next page + const take = args.first + 1; + + // Extract cursor map based on the encoded cursor + const cursorMap = extractCursorKeyValue(args.after, options); + const skip = cursorMap ? 1 : undefined; + + if (cursorMap) { + const [keys, values] = cursorMap; + + // Add `cursor` filter in where condition + query.andWhere( + keys.reduce((acc, key, index) => { + return { + ...acc, + [key]: MoreThanOrEqual(values[index]), + }; + }, {}), + ); + } + + // Add `take` and `skip` to the query + query.take(take).skip(skip); + + // Fetch records + records = await options.getRecords(query); + + // See if we are "after" another record, indicating a previous page + hasPreviousPage = !!args.after; + + // See if we have an additional record, indicating a next page + hasNextPage = records.length > args.first; + + // Remove the extra record (last element) from the results + if (hasNextPage) records.pop(); + } else if (isBackwardPagination(args)) { + // Fetch one additional record to determine if there is a previous page + const take = -1 * (args.last + 1); + + // Extract cursor map based on the encoded cursor + const cursorMap = extractCursorKeyValue(args.before, options); + const skip = cursorMap ? 1 : undefined; + + if (cursorMap) { + const [keys, values] = cursorMap; + + // Add `cursor` filter in where condition + query.andWhere( + keys.reduce((acc, key, index) => { + return { + ...acc, + [key]: LessThanOrEqual(values[index]), + }; + }, {}), + ); + } + + // Add `take` and `skip` to the query + query.take(take).skip(skip); + + // Fetch records + records = await options.getRecords(query); + + // See if we are "before" another record, indicating a next page + hasNextPage = !!args.before; + + // See if we have an additional record, indicating a previous page + hasPreviousPage = records.length > args.last; + + // Remove the extra record (first element) from the results + if (hasPreviousPage) records.shift(); + } else { + // Fetch records + records = await options.getRecords(query); + + hasNextPage = false; + hasPreviousPage = false; + } + + // The cursors are always the first & last elements of the result set + const startCursor = + records.length > 0 ? encodeCursor(records[0], options) : undefined; + const endCursor = + records.length > 0 + ? encodeCursor(records[records.length - 1], options) + : undefined; + + // Allow the recordToEdge function to return a custom edge type which will be inferred + type EdgeExtended = typeof options.recordToEdge extends ( + record: Record, + ) => infer X + ? X extends CustomEdge + ? X & { cursor: string } + : CustomEdge + : CustomEdge; + + const edges = records.map((record) => { + return { + ...options.recordToEdge(record), + cursor: encodeCursor(record, options), + } as EdgeExtended; + }); + + return { + edges, + pageInfo: { hasNextPage, hasPreviousPage, startCursor, endCursor }, + totalCount, + }; +} diff --git a/server/src/utils/pagination/index.ts b/server/src/utils/pagination/index.ts new file mode 100644 index 000000000..7708d25c7 --- /dev/null +++ b/server/src/utils/pagination/index.ts @@ -0,0 +1,2 @@ +export { ConnectionCursor, ConnectionArgs, Paginated } from './paginated'; +export { findManyCursorConnection } from './find-many-cursor-connection'; diff --git a/server/src/utils/pagination/interfaces/connection-arguments.interface.ts b/server/src/utils/pagination/interfaces/connection-arguments.interface.ts new file mode 100644 index 000000000..45b240523 --- /dev/null +++ b/server/src/utils/pagination/interfaces/connection-arguments.interface.ts @@ -0,0 +1,15 @@ +export interface IConnectionArguments { + first?: number | null; + after?: string | null; + last?: number | null; + before?: string | null; +} + +export type ConnectionArgumentsUnion = + | ForwardPaginationArguments + | BackwardPaginationArguments + | NoPaginationArguments; + +export type ForwardPaginationArguments = { first: number; after?: string }; +export type BackwardPaginationArguments = { last: number; before?: string }; +export type NoPaginationArguments = Record; diff --git a/server/src/utils/pagination/interfaces/connection-cursor.type.ts b/server/src/utils/pagination/interfaces/connection-cursor.type.ts new file mode 100644 index 000000000..91e8cffc7 --- /dev/null +++ b/server/src/utils/pagination/interfaces/connection-cursor.type.ts @@ -0,0 +1 @@ +export type ConnectionCursor = string; diff --git a/server/src/utils/pagination/interfaces/connection.interface.ts b/server/src/utils/pagination/interfaces/connection.interface.ts new file mode 100644 index 000000000..95fa6d5db --- /dev/null +++ b/server/src/utils/pagination/interfaces/connection.interface.ts @@ -0,0 +1,8 @@ +import { IEdge } from './edge.interface'; +import { IPageInfo } from './page-info.interface'; + +export interface IConnection = IEdge> { + edges: Array; + pageInfo: IPageInfo; + totalCount: number; +} diff --git a/server/src/utils/pagination/interfaces/edge.interface.ts b/server/src/utils/pagination/interfaces/edge.interface.ts new file mode 100644 index 000000000..842b38324 --- /dev/null +++ b/server/src/utils/pagination/interfaces/edge.interface.ts @@ -0,0 +1,4 @@ +export interface IEdge { + cursor: string; + node: T; +} diff --git a/server/src/utils/pagination/interfaces/find-many-aguments.interface.ts b/server/src/utils/pagination/interfaces/find-many-aguments.interface.ts new file mode 100644 index 000000000..66063d854 --- /dev/null +++ b/server/src/utils/pagination/interfaces/find-many-aguments.interface.ts @@ -0,0 +1,5 @@ +export interface IFindManyArguments { + cursor?: Cursor; + take?: number; + skip?: number; +} diff --git a/server/src/utils/pagination/interfaces/options.interface.ts b/server/src/utils/pagination/interfaces/options.interface.ts new file mode 100644 index 000000000..24b430e79 --- /dev/null +++ b/server/src/utils/pagination/interfaces/options.interface.ts @@ -0,0 +1,19 @@ +import { GraphQLResolveInfo } from 'graphql'; +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { IEdge } from './edge.interface'; + +export interface IOptions< + Entity extends ObjectLiteral, + Record, + Cursor, + Node, + CustomEdge extends IEdge, +> { + getRecords?: (args: SelectQueryBuilder) => Promise; + getCursor?: (record: Record | undefined) => Cursor; + encodeCursor?: (cursor: Cursor) => string; + decodeCursor?: (cursorString: string) => Cursor; + recordToEdge?: (record: Record) => Omit; + resolveInfo?: GraphQLResolveInfo | null; +} diff --git a/server/src/utils/pagination/interfaces/page-info.interface.ts b/server/src/utils/pagination/interfaces/page-info.interface.ts new file mode 100644 index 000000000..37f894cb9 --- /dev/null +++ b/server/src/utils/pagination/interfaces/page-info.interface.ts @@ -0,0 +1,6 @@ +export interface IPageInfo { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor?: string; + endCursor?: string; +} diff --git a/server/src/utils/pagination/page-info.ts b/server/src/utils/pagination/page-info.ts new file mode 100644 index 000000000..0b9f35e88 --- /dev/null +++ b/server/src/utils/pagination/page-info.ts @@ -0,0 +1,19 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IPageInfo } from './interfaces/page-info.interface'; +import { ConnectionCursor } from './interfaces/connection-cursor.type'; + +@ObjectType({ isAbstract: true }) +export class PageInfo implements IPageInfo { + @Field({ nullable: true }) + public startCursor!: ConnectionCursor; + + @Field({ nullable: true }) + public endCursor!: ConnectionCursor; + + @Field(() => Boolean) + public hasPreviousPage!: boolean; + + @Field(() => Boolean) + public hasNextPage!: boolean; +} diff --git a/server/src/utils/pagination/paginated.ts b/server/src/utils/pagination/paginated.ts new file mode 100644 index 000000000..ff11f4779 --- /dev/null +++ b/server/src/utils/pagination/paginated.ts @@ -0,0 +1,80 @@ +import { Type } from '@nestjs/common'; +import { ArgsType, Directive, Field, ObjectType } from '@nestjs/graphql'; + +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +import { PageInfo } from './page-info'; + +import { IConnectionArguments } from './interfaces/connection-arguments.interface'; +import { IConnection } from './interfaces/connection.interface'; +import { IEdge } from './interfaces/edge.interface'; +import { IPageInfo } from './interfaces/page-info.interface'; + +export type ConnectionCursor = string; + +/** + * ConnectionArguments + */ +@ArgsType() +export class ConnectionArgs implements IConnectionArguments { + @Field({ nullable: true, description: 'Paginate before opaque cursor' }) + @IsString() + @IsOptional() + public before?: ConnectionCursor; + + @Field({ nullable: true, description: 'Paginate after opaque cursor' }) + @IsString() + @IsOptional() + public after?: ConnectionCursor; + + @Field({ nullable: true, description: 'Paginate first' }) + @IsNumber() + @IsOptional() + public first?: number; + + @Field({ nullable: true, description: 'Paginate last' }) + @IsNumber() + @IsOptional() + public last?: number; +} + +/** + * Paginated graphQL class inheritance + */ +export function Paginated(classRef: Type): Type> { + @ObjectType(`${classRef.name}Edge`, { isAbstract: true }) + class Edge implements IEdge { + public name = `${classRef.name}Edge`; + + @Field({ nullable: true }) + public cursor!: ConnectionCursor; + + @Field(() => classRef, { nullable: true }) + @Directive(`@cacheControl(inheritMaxAge: true)`) + public node!: T; + } + + @ObjectType(`${classRef.name}Connection`, { isAbstract: true }) + class Connection implements IConnection { + public name = `${classRef.name}Connection`; + + @Field(() => [Edge], { nullable: true }) + @Directive(`@cacheControl(inheritMaxAge: true)`) + public edges!: IEdge[]; + + @Field(() => PageInfo, { nullable: true }) + @Directive(`@cacheControl(inheritMaxAge: true)`) + public pageInfo!: IPageInfo; + + @Field() + totalCount: number; + } + + return Connection as Type>; +} + +// export const encodeCursor = (cursor: Cursor) => +// Buffer.from(JSON.stringify(cursor)).toString('base64'); + +// export const decodeCursor = (cursor: string) => +// JSON.parse(Buffer.from(cursor, 'base64').toString('ascii')) as Cursor; diff --git a/server/src/utils/pagination/utils/cursor.ts b/server/src/utils/pagination/utils/cursor.ts new file mode 100644 index 000000000..30f4d61a0 --- /dev/null +++ b/server/src/utils/pagination/utils/cursor.ts @@ -0,0 +1,54 @@ +import { ObjectLiteral } from 'typeorm'; + +import { IEdge } from 'src/utils/pagination/interfaces/edge.interface'; + +import { MergedOptions } from './default-options'; + +export function decodeCursor< + Entity extends ObjectLiteral, + Record, + Cursor, + Node, + CustomEdge extends IEdge, +>( + connectionCursor: string | undefined, + options: MergedOptions, +): Cursor | undefined { + if (!connectionCursor) { + return undefined; + } + + return options.decodeCursor(connectionCursor); +} + +export function encodeCursor< + Entity extends ObjectLiteral, + Record, + Cursor, + Node, + CustomEdge extends IEdge, +>( + record: Record, + options: MergedOptions, +): string { + return options.encodeCursor(options.getCursor(record)); +} + +export function extractCursorKeyValue< + Entity extends ObjectLiteral, + Record, + Cursor, + Node, + CustomEdge extends IEdge, +>( + connectionCursor: string | undefined, + options: MergedOptions, +): [string[], unknown[]] | undefined { + const cursor = decodeCursor(connectionCursor, options); + + if (!cursor) { + return undefined; + } + + return [Object.keys(cursor), Object.values(cursor)]; +} diff --git a/server/src/utils/pagination/utils/default-options.ts b/server/src/utils/pagination/utils/default-options.ts new file mode 100644 index 000000000..4b6a63342 --- /dev/null +++ b/server/src/utils/pagination/utils/default-options.ts @@ -0,0 +1,42 @@ +import { ObjectLiteral } from 'typeorm'; + +import { IEdge } from 'src/utils/pagination/interfaces/edge.interface'; +import { IOptions } from 'src/utils/pagination/interfaces/options.interface'; + +export type MergedOptions< + Entity extends ObjectLiteral, + Record, + Cursor, + Node, + CustomEdge extends IEdge, +> = Required>; + +export function mergeDefaultOptions< + Entity extends ObjectLiteral, + Record, + Cursor, + Node, + CustomEdge extends IEdge, +>( + pOptions?: IOptions, +): MergedOptions { + return { + getRecords: async (query) => { + return query.getRawMany(); + }, + getCursor: (record: Record | undefined) => + ({ id: (record as unknown as { id: string })?.id } as unknown as Cursor), + encodeCursor: (cursor: Cursor) => + Buffer.from((cursor as unknown as { id: string }).id.toString()).toString( + 'base64', + ), + decodeCursor: (cursorString: string) => + ({ + id: Buffer.from(cursorString, 'base64').toString(), + } as unknown as Cursor), + recordToEdge: (record: Record) => + ({ node: record } as unknown as Omit), + resolveInfo: null, + ...pOptions, + }; +} diff --git a/server/src/utils/pagination/utils/pagination-direction.ts b/server/src/utils/pagination/utils/pagination-direction.ts new file mode 100644 index 000000000..e602342b8 --- /dev/null +++ b/server/src/utils/pagination/utils/pagination-direction.ts @@ -0,0 +1,17 @@ +import { + BackwardPaginationArguments, + ConnectionArgumentsUnion, + ForwardPaginationArguments, +} from 'src/utils/pagination/interfaces/connection-arguments.interface'; + +export function isForwardPagination( + args: ConnectionArgumentsUnion, +): args is ForwardPaginationArguments { + return 'first' in args && args.first != null; +} + +export function isBackwardPagination( + args: ConnectionArgumentsUnion, +): args is BackwardPaginationArguments { + return 'last' in args && args.last != null; +} diff --git a/server/src/utils/pagination/utils/validate-args.ts b/server/src/utils/pagination/utils/validate-args.ts new file mode 100644 index 000000000..213c452d2 --- /dev/null +++ b/server/src/utils/pagination/utils/validate-args.ts @@ -0,0 +1,38 @@ +import { + ConnectionArgumentsUnion, + IConnectionArguments, +} from 'src/utils/pagination/interfaces/connection-arguments.interface'; + +export function validateArgs( + args: IConnectionArguments, +): args is ConnectionArgumentsUnion { + // Only one of `first` and `last` / `after` and `before` can be set + if (args.first != null && args.last != null) { + throw new Error('Only one of "first" and "last" can be set'); + } + + if (args.after != null && args.before != null) { + throw new Error('Only one of "after" and "before" can be set'); + } + + // If `after` is set, `first` has to be set + if (args.after != null && args.first == null) { + throw new Error('"after" needs to be used with "first"'); + } + + // If `before` is set, `last` has to be set + if (args.before != null && args.last == null) { + throw new Error('"before" needs to be used with "last"'); + } + + // `first` and `last` have to be positive + if (args.first != null && args.first <= 0) { + throw new Error('"first" has to be positive'); + } + + if (args.last != null && args.last <= 0) { + throw new Error('"last" has to be positive'); + } + + return true; +} diff --git a/server/src/utils/snake-case.ts b/server/src/utils/snake-case.ts new file mode 100644 index 000000000..40102a07a --- /dev/null +++ b/server/src/utils/snake-case.ts @@ -0,0 +1,26 @@ +import isObject from 'lodash.isobject'; +import lodashSnakeCase from 'lodash.snakecase'; +import { SnakeCase, SnakeCasedPropertiesDeep } from 'type-fest'; + +export const snakeCase = (text: T) => + lodashSnakeCase(text as unknown as string) as SnakeCase; + +export const snakeCaseDeep = (value: T): SnakeCasedPropertiesDeep => { + // Check if it's an array + if (Array.isArray(value)) { + return value.map(snakeCaseDeep) as SnakeCasedPropertiesDeep; + } + + // Check if it's an object + if (isObject(value)) { + const result: Record = {}; + + for (const key in value) { + result[snakeCase(key)] = snakeCaseDeep(value[key]); + } + + return result as SnakeCasedPropertiesDeep; + } + + return value as SnakeCasedPropertiesDeep; +}; diff --git a/server/yarn.lock b/server/yarn.lock index 5d7b48fe3..d3afc2e30 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1710,7 +1710,7 @@ resolved "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz" integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== -"@mapbox/node-pre-gyp@^1.0.10": +"@mapbox/node-pre-gyp@^1.0.11": version "1.0.11" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== @@ -2959,6 +2959,13 @@ dependencies: "@types/lodash" "*" +"@types/lodash.snakecase@^4.1.7": + version "4.1.7" + resolved "https://registry.yarnpkg.com/@types/lodash.snakecase/-/lodash.snakecase-4.1.7.tgz#2a1ca7cbc08b63e7c3708f6291222e69b0d3216d" + integrity sha512-nv9M+JJokFyfZ9QmaWVXZu2DfT40K0GictZaA8SwXczp3oCzMkjp7PtvUBQyvdoG9SnlCpoRXZDIVwQRzJbd9A== + dependencies: + "@types/lodash" "*" + "@types/lodash@*": version "4.14.195" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632" @@ -3888,12 +3895,12 @@ base64url@3.x.x: resolved "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz" integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== -bcrypt@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.0.tgz#bbb27665dbc400480a524d8991ac7434e8529e17" - integrity sha512-RHBS7HI5N5tEnGTmtR/pppX0mmDSBpQ4aCBsj7CEQfYXDcO74A8sIBYcJMuCsis2E81zDxeENYhv66oZwLiA+Q== +bcrypt@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2" + integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww== dependencies: - "@mapbox/node-pre-gyp" "^1.0.10" + "@mapbox/node-pre-gyp" "^1.0.11" node-addon-api "^5.0.0" binary-extensions@^2.0.0: @@ -6768,6 +6775,11 @@ lodash.omit@4.5.0: resolved "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz" integrity sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg== +lodash.snakecase@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" + integrity sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw== + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz" @@ -8550,9 +8562,9 @@ tar-stream@^2.1.4, tar-stream@^2.2.0: readable-stream "^3.1.1" tar@^6.1.11: - version "6.1.15" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.15.tgz#c9738b0b98845a3b344d334b8fa3041aaba53a69" - integrity sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A== + version "6.2.0" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73" + integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ== dependencies: chownr "^2.0.0" fs-minipass "^2.0.0"