├── .github └── FUNDING.yml ├── .gitignore ├── src ├── index.ts ├── ttl-transformer.ts └── __tests__ │ └── ttl-transformer.test.ts ├── jest.config.js ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: flogy 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { TtlTransformer } from "./ttl-transformer"; 2 | 3 | export default TtlTransformer; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.tsx?$": "ts-jest", 4 | }, 5 | testRegex: "(src/__tests__/.*.test.*)$", 6 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "lib": [], 6 | "declaration": true, 7 | "sourceMap": true, 8 | "outDir": "./dist", 9 | "noImplicitAny": true, 10 | "strict": true 11 | }, 12 | "exclude": ["dist", "src/__tests__/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Florian Gyger Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-ttl-transformer", 3 | "version": "2.0.2", 4 | "description": "Enable DynamoDB's time-to-live feature to auto-delete old entries in your AWS Amplify API!", 5 | "author": "Florian Gyger ", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "tsc -w", 9 | "build": "tsc", 10 | "test": "jest", 11 | "prepublishOnly": "npm run test && npm run build", 12 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"" 13 | }, 14 | "main": "./dist/index.js", 15 | "files": [ 16 | "dist" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/flogy/graphql-ttl-transformer.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/flogy/graphql-ttl-transformer/issues" 24 | }, 25 | "homepage": "https://github.com/flogy/graphql-ttl-transformer#readme", 26 | "keywords": [ 27 | "aws", 28 | "amplify", 29 | "grapqhl", 30 | "ttl", 31 | "transformer", 32 | "time", 33 | "to", 34 | "live", 35 | "dynamodb" 36 | ], 37 | "devDependencies": { 38 | "@aws-amplify/graphql-model-transformer": "^1.4.0", 39 | "@types/jest": "^29.5.1", 40 | "jest": "^29.5.0", 41 | "prettier": "^2.8.7", 42 | "ts-jest": "^29.1.0", 43 | "typescript": "^4.9.5" 44 | }, 45 | "dependencies": { 46 | "@aws-amplify/amplify-cli-core": "^4.2.7", 47 | "@aws-amplify/graphql-transformer-core": "^1.4.0", 48 | "graphql": "^15.8.0", 49 | "graphql-mapping-template": "^4.20.8", 50 | "graphql-transformer-common": "^4.24.5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ttl-transformer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InvalidDirectiveError, 3 | TransformerPluginBase, 4 | } from "@aws-amplify/graphql-transformer-core"; 5 | import { 6 | TransformerContextProvider, 7 | TransformerSchemaVisitStepContextProvider, 8 | } from "@aws-amplify/graphql-transformer-interfaces"; 9 | import { 10 | DirectiveNode, 11 | ObjectTypeDefinitionNode, 12 | InterfaceTypeDefinitionNode, 13 | FieldDefinitionNode, 14 | } from "graphql"; 15 | import { getBaseType, ModelResourceIDs } from "graphql-transformer-common"; 16 | import { Table, CfnTable } from "aws-cdk-lib/aws-dynamodb"; 17 | import { DynamoDbDataSource } from "aws-cdk-lib/aws-appsync"; 18 | import { IConstruct } from "constructs"; 19 | 20 | export class TtlTransformer extends TransformerPluginBase { 21 | private readonly ttlFields: Map< 22 | ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, 23 | string 24 | > = new Map(); 25 | 26 | constructor() { 27 | super("TtlTransformer", "directive @ttl on FIELD_DEFINITION"); 28 | } 29 | 30 | public field = ( 31 | parent: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, 32 | definition: FieldDefinitionNode, 33 | directive: DirectiveNode, 34 | acc: TransformerSchemaVisitStepContextProvider 35 | ) => { 36 | if (!["AWSTimestamp", "Int"].includes(getBaseType(definition.type))) { 37 | throw new InvalidDirectiveError( 38 | 'Directive "@ttl" must be used only on AWSTimestamp or Int type fields.' 39 | ); 40 | } 41 | 42 | let numberOfTtlDirectivesInsideParentType = 0; 43 | if (parent.fields) { 44 | parent.fields.forEach((field) => { 45 | if (field.directives) { 46 | numberOfTtlDirectivesInsideParentType += field.directives.filter( 47 | (directive) => directive.name.value === "ttl" 48 | ).length; 49 | } 50 | }); 51 | } 52 | if (numberOfTtlDirectivesInsideParentType > 1) { 53 | throw new InvalidDirectiveError( 54 | 'Directive "@ttl" must be used only once in the same type.' 55 | ); 56 | } 57 | 58 | const fieldName = definition.name.value; 59 | this.ttlFields.set(parent, fieldName); 60 | }; 61 | 62 | public generateResolvers = (ctx: TransformerContextProvider): void => { 63 | this.ttlFields.forEach((fieldName, parent) => { 64 | const ddbTable = this.getTable( 65 | ctx, 66 | parent as ObjectTypeDefinitionNode 67 | ) as Table; 68 | (ddbTable["table"] as CfnTable).timeToLiveSpecification = { 69 | attributeName: fieldName, 70 | enabled: true, 71 | }; 72 | }); 73 | }; 74 | 75 | private getTable = ( 76 | context: TransformerContextProvider, 77 | definition: ObjectTypeDefinitionNode 78 | ): IConstruct => { 79 | const ddbDataSource = context.dataSources.get( 80 | definition 81 | ) as DynamoDbDataSource; 82 | const tableName = ModelResourceIDs.ModelTableResourceID( 83 | definition.name.value 84 | ); 85 | const table = ddbDataSource.ds.stack.node.findChild(tableName); 86 | return table; 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > ♻ Enable DynamoDB's time-to-live feature to auto-delete old entries in your AWS Amplify API! 2 | 3 | # graphql-ttl-transformer 4 | 5 | [![Pull requests are welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen)](#contribute-) 6 | [![npm](https://img.shields.io/npm/v/graphql-ttl-transformer)](https://www.npmjs.com/package/graphql-ttl-transformer) 7 | [![GitHub license](https://img.shields.io/github/license/flogy/graphql-ttl-transformer)](https://github.com/flogy/graphql-ttl-transformer/blob/main/LICENSE) 8 | 9 | ## Installation 10 | 11 | `npm install --save graphql-ttl-transformer` 12 | 13 | For projects using the old GraphQL Transformer v1 run: 14 | 15 | `npm install --save graphql-ttl-transformer@1` 16 | 17 | ## How to use 18 | 19 | ### Setup custom transformer 20 | 21 | Edit `amplify/backend/api//transform.conf.json` and append `"graphql-ttl-transformer"` to the `transformers` field. 22 | 23 | ```json 24 | "transformers": [ 25 | "graphql-ttl-transformer" 26 | ] 27 | ``` 28 | 29 | ### Use @ttl directive 30 | 31 | Append `@ttl` to target fields. 32 | 33 | ```graphql 34 | type ExpiringChatMessage @model { 35 | id: ID! 36 | message: String 37 | expirationUnixTime: AWSTimestamp! @ttl 38 | } 39 | ``` 40 | 41 | It is important that the field you use the directive is of type `AWSTimestamp` (recommended) or `Int`, as the expiration timestamp must be in [Unix time](https://en.wikipedia.org/wiki/Unix_time) format. 42 | 43 | ## Contribute 🦸 44 | 45 | Contributions are more than welcome! I love how AWS Amplify helps us developers building great apps in a short time. That's why I'd like to give back with contributions like this. If you feel the same and would like to join me in this project it would be awesome to get in touch! 😊 46 | 47 | Please feel free to create, comment and of course solve some of the issues. To get started you can also go for the easier issues marked with the `good first issue` label if you like. 48 | 49 | ### Development 50 | 51 | 1. Clone this repository and open it in your code editor. 52 | 2. Run `npm link` in the cloned project directory and `npm link graphql-ttl-transformer` in your test project where you want to use it. Maybe you'll have to uninstall the previously installed dependency as installed from NPM repository. 53 | 3. Run `npm start` in your cloned project directory. Every code change is now immediately used in your test project, so you can just modify code and test it using `amplify codegen models` or `amplify push`. 54 | 55 | **Hint:** It is important to always make sure the version of the installed `graphql` dependency matches the `graphql` version the `graphql-transformer-core` depends on. 56 | 57 | ### Publish new NPM package version 58 | 59 | 1. Make sure version number is updated. 60 | 2. Run `npm publish`. 61 | 3. Create new release in GitHub including a tag. 62 | 63 | ## License 64 | 65 | The [MIT License](LICENSE) 66 | 67 | ## Credits 68 | 69 | The _graphql-ttl-transformer_ library is maintained and sponsored by the Swiss web and mobile app development company [Florian Gyger Software](https://floriangyger.ch). 70 | 71 | If this library saved you some time and money please consider [sponsoring me](https://github.com/sponsors/flogy), so I can build more libraries for free and actively maintain them for you. Thank you 🙏 72 | -------------------------------------------------------------------------------- /src/__tests__/ttl-transformer.test.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLTransform } from "@aws-amplify/graphql-transformer-core"; 2 | import { ModelTransformer } from "@aws-amplify/graphql-model-transformer"; 3 | import { ModelResourceIDs } from "graphql-transformer-common"; 4 | import TtlTransformer from "../index"; 5 | 6 | test("@ttl directive can be used on fields", () => { 7 | const schema = ` 8 | type ExpiringChatMessage @model { 9 | id: ID! 10 | message: String 11 | expirationUnixTime: AWSTimestamp! @ttl 12 | } 13 | `; 14 | const transformer = new GraphQLTransform({ 15 | transformers: [new ModelTransformer(), new TtlTransformer()], 16 | }); 17 | expect(() => transformer.transform(schema)).not.toThrow(); 18 | }); 19 | 20 | test("@ttl directive can not be used on types", () => { 21 | const schema = ` 22 | type ExpiringChatMessage @model @ttl { 23 | id: ID! 24 | message: String 25 | expirationUnixTime: AWSTimestamp! 26 | } 27 | `; 28 | const transformer = new GraphQLTransform({ 29 | transformers: [new ModelTransformer(), new TtlTransformer()], 30 | }); 31 | expect(() => transformer.transform(schema)).toThrowError( 32 | 'Directive "@ttl" may not be used on OBJECT.' 33 | ); 34 | }); 35 | 36 | test("@ttl directive can not be used on fields other than Int and AWSTimestamp", () => { 37 | const schema = ` 38 | type ExpiringChatMessage @model { 39 | id: ID! 40 | message: String 41 | expirationUnixTime: String! @ttl 42 | } 43 | `; 44 | const transformer = new GraphQLTransform({ 45 | transformers: [new ModelTransformer(), new TtlTransformer()], 46 | }); 47 | expect(() => transformer.transform(schema)).toThrowError( 48 | 'Directive "@ttl" must be used only on AWSTimestamp or Int type fields.' 49 | ); 50 | }); 51 | 52 | test("@ttl directive can be used on fields with AWSTimestamp type", () => { 53 | const schema = ` 54 | type ExpiringChatMessage @model { 55 | id: ID! 56 | message: String 57 | expirationUnixTime: AWSTimestamp! @ttl 58 | } 59 | `; 60 | const transformer = new GraphQLTransform({ 61 | transformers: [new ModelTransformer(), new TtlTransformer()], 62 | }); 63 | expect(() => transformer.transform(schema)).not.toThrow(); 64 | }); 65 | 66 | test("@ttl directive can be used on fields with Int type", () => { 67 | const schema = ` 68 | type ExpiringChatMessage @model { 69 | id: ID! 70 | message: String 71 | expirationUnixTime: Int! @ttl 72 | } 73 | `; 74 | const transformer = new GraphQLTransform({ 75 | transformers: [new ModelTransformer(), new TtlTransformer()], 76 | }); 77 | expect(() => transformer.transform(schema)).not.toThrow(); 78 | }); 79 | 80 | test("Only one @ttl directive per type is allowed", () => { 81 | const schema = ` 82 | type ExpiringChatMessage @model { 83 | id: ID! 84 | message: String 85 | expirationUnixTime: AWSTimestamp! @ttl 86 | anotherExpirationUnixTime: AWSTimestamp! @ttl 87 | } 88 | `; 89 | const transformer = new GraphQLTransform({ 90 | transformers: [new ModelTransformer(), new TtlTransformer()], 91 | }); 92 | expect(() => transformer.transform(schema)).toThrowError( 93 | 'Directive "@ttl" must be used only once in the same type.' 94 | ); 95 | }); 96 | 97 | const getPropertiesOfSchemaTable = (schema: string, schemaTypeName: string) => { 98 | const tableName = ModelResourceIDs.ModelTableResourceID(schemaTypeName); 99 | const transformer = new GraphQLTransform({ 100 | transformers: [new ModelTransformer(), new TtlTransformer()], 101 | }); 102 | const resources = 103 | transformer.transform(schema).stacks[schemaTypeName].Resources; 104 | if (!resources) { 105 | throw new Error("Expected to have resources in the stack"); 106 | } 107 | const table = resources[tableName]; 108 | if (!table) { 109 | throw new Error( 110 | `Expected to have a table resource called ${tableName} in the stack` 111 | ); 112 | } 113 | const properties = table.Properties; 114 | if (!properties) { 115 | throw new Error(`Expected to have a properties in table ${tableName}`); 116 | } 117 | return properties; 118 | }; 119 | 120 | test("Generated CloudFormation document contains the TimeToLiveSpecification property", () => { 121 | const schema = ` 122 | type ExpiringChatMessage @model { 123 | id: ID! 124 | message: String 125 | expirationUnixTime: AWSTimestamp! @ttl 126 | } 127 | `; 128 | const properties = getPropertiesOfSchemaTable(schema, "ExpiringChatMessage"); 129 | const timeToLiveSpecificationProperty = properties["TimeToLiveSpecification"]; 130 | expect(timeToLiveSpecificationProperty).toBeDefined(); 131 | }); 132 | 133 | test("TimeToLiveSpecification property is pointing to the field where the @ttl directive was used", () => { 134 | const schema = ` 135 | type ExpiringChatMessage @model { 136 | id: ID! 137 | message: String 138 | expirationUnixTime: AWSTimestamp! @ttl 139 | } 140 | `; 141 | const properties = getPropertiesOfSchemaTable(schema, "ExpiringChatMessage"); 142 | const timeToLiveSpecificationProperty = properties["TimeToLiveSpecification"]; 143 | expect(timeToLiveSpecificationProperty.AttributeName).toEqual( 144 | "expirationUnixTime" 145 | ); 146 | }); 147 | --------------------------------------------------------------------------------