├── .gitignore ├── .vscode └── launch.json ├── README.md ├── backend ├── core │ ├── article.ts │ ├── sql.generated.ts │ └── sql.ts ├── functions │ └── graphql │ │ ├── builder.ts │ │ ├── graphql.ts │ │ ├── schema.ts │ │ └── types │ │ └── article.ts ├── migrations │ ├── first.mjs │ └── second.mjs ├── package.json ├── test │ └── functions │ │ └── graphql │ │ ├── article.test.ts │ │ └── client.ts ├── tsconfig.json └── update.ts ├── cdk.context.json ├── graphql ├── genql │ ├── guards.esm.js │ ├── index.d.ts │ ├── index.js │ ├── schema.graphql │ ├── schema.ts │ └── types.esm.js ├── package.json └── schema.graphql ├── package.json ├── sst.json ├── stacks ├── Api.ts ├── Database.ts ├── Web.ts └── index.ts ├── tsconfig.json ├── vitest.config.ts ├── web ├── .gitignore ├── index.html ├── package.json ├── src │ ├── favicon.svg │ ├── index.css │ ├── main.tsx │ ├── pages │ │ └── Article.tsx │ ├── sst-env.d.ts │ ├── urql.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .sst 2 | .build 3 | node_modules 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug SST Start", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", 9 | "runtimeArgs": ["start", "--increase-timeout"], 10 | "console": "integratedTerminal", 11 | "skipFiles": ["/**"], 12 | "env": {} 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Ideal Stack Preview 2 | 3 | This is a preview of the upcoming ideal stack. It is a modern starter that contains everything you need to ship full-stack serverless applications. It's built on top of SST and has tools like GraphQL already setup. 4 | 5 | You can checkout a demo of it given at our v1 Conf [here](https://youtu.be/6FzLjpMYcu8?t=5182) 6 | 7 | ### Getting started 8 | 9 | This is a standard SST app so you can bring everything up with `yarn start` 10 | 11 | ### File Structure 12 | 13 | ``` 14 | backend 15 | ├── core 16 | ├── functions 17 | ├── graphql 18 | ├── migrations 19 | stacks 20 | graphql 21 | web 22 | ``` 23 | 24 | - `backend` - Package that contains all backend code, both business logic and functions 25 | - `core` - Contains pure business logic. Should implement features here, do things like read/write to the database. 26 | - `functions` - Contains handlers for lambda functions. These should not contain much business logic and instead import from `core` to coordinate work 27 | - `graphql` - This is the function for the GraphQL server. It uses Pothos to define the schema + resolvers in native typescript. You can view documentation for that library [here](https://pothos-graphql.dev/) 28 | - `migrations` - This stack currently uses the `sst.RDS` construct and this folder contains migrations. This isnt mandatory, you can use any database you want including DynamoDB 29 | - `stacks` - Standard SST stacks folder. Currently contains an API configured for graphql, an RDS cluster, and a static site for the frontend 30 | - `graphql` - The new API route for graphql will handle codegeneration from pothos definitions and this is the folder it goes into. It's currently setup to generate a [genql](https://genql.vercel.app/) client for use in tests and the frontend but you can configure whatever codegen steps you want. 31 | - `web` - A standard React application created with [vite](https://vitejs.dev/) configured with URQL to use as the GraphQL client. It has helpers in there to bring typesafety features through genql. 32 | -------------------------------------------------------------------------------- /backend/core/article.ts: -------------------------------------------------------------------------------- 1 | import { SQL } from "@my-sst-app/core/sql"; 2 | import { ulid } from "ulid"; 3 | 4 | export * as Article from "./article"; 5 | 6 | export async function addComment(articleID: string, text: string) { 7 | return await SQL.DB.insertInto("comment") 8 | .values({ 9 | commentID: ulid(), 10 | articleID, 11 | text, 12 | }) 13 | .returningAll() 14 | .executeTakeFirstOrThrow(); 15 | } 16 | 17 | export async function comments(articleID: string) { 18 | return await SQL.DB.selectFrom("comment") 19 | .selectAll() 20 | .where("articleID", "=", articleID) 21 | .execute(); 22 | } 23 | 24 | export async function create(title: string, url: string) { 25 | const [result] = await SQL.DB.insertInto("article") 26 | .values({ articleID: ulid(), url, title }) 27 | .returningAll() 28 | .execute(); 29 | return result; 30 | } 31 | 32 | export async function list() { 33 | return await SQL.DB.selectFrom("article") 34 | .selectAll() 35 | .orderBy("created", "desc") 36 | .execute(); 37 | } 38 | 39 | -------------------------------------------------------------------------------- /backend/core/sql.generated.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file was generated by a tool. 3 | * Rerun sql-ts to regenerate this file. 4 | */ 5 | export interface article { 6 | articleID: string; 7 | created?: Date | null; 8 | title: string; 9 | url: string; 10 | } 11 | export interface comment { 12 | articleID: string; 13 | commentID: string; 14 | text: string; 15 | } 16 | export interface kysely_migration { 17 | name: string; 18 | timestamp: string; 19 | } 20 | export interface kysely_migration_lock { 21 | id: string; 22 | is_locked?: number; 23 | } 24 | 25 | export interface Database { 26 | article: article; 27 | comment: comment; 28 | kysely_migration: kysely_migration; 29 | kysely_migration_lock: kysely_migration_lock; 30 | } 31 | -------------------------------------------------------------------------------- /backend/core/sql.ts: -------------------------------------------------------------------------------- 1 | import { RDSDataService } from "aws-sdk"; 2 | import { Kysely, Selectable } from "kysely"; 3 | import { DataApiDialect } from "kysely-data-api"; 4 | import type { Database } from "./sql.generated"; 5 | 6 | export const DB = new Kysely({ 7 | dialect: new DataApiDialect({ 8 | mode: "postgres", 9 | driver: { 10 | secretArn: process.env.RDS_SECRET_ARN!, 11 | resourceArn: process.env.RDS_ARN!, 12 | database: process.env.RDS_DATABASE!, 13 | client: new RDSDataService(), 14 | }, 15 | }), 16 | }); 17 | 18 | export type Row = { 19 | [Key in keyof Database]: Selectable; 20 | }; 21 | 22 | export * as SQL from "./sql"; 23 | -------------------------------------------------------------------------------- /backend/functions/graphql/builder.ts: -------------------------------------------------------------------------------- 1 | import SchemaBuilder from "@pothos/core"; 2 | 3 | export const builder = new SchemaBuilder({}); 4 | 5 | builder.queryType({}); 6 | builder.mutationType({}); 7 | -------------------------------------------------------------------------------- /backend/functions/graphql/graphql.ts: -------------------------------------------------------------------------------- 1 | import { schema } from "./schema"; 2 | import { createGQLHandler } from "@serverless-stack/node/graphql"; 3 | 4 | export const handler = createGQLHandler({ 5 | schema 6 | }); 7 | -------------------------------------------------------------------------------- /backend/functions/graphql/schema.ts: -------------------------------------------------------------------------------- 1 | import { builder } from "./builder"; 2 | 3 | import "./types/article"; 4 | 5 | export const schema = builder.toSchema({}); 6 | -------------------------------------------------------------------------------- /backend/functions/graphql/types/article.ts: -------------------------------------------------------------------------------- 1 | import { Article } from "@my-sst-app/core/article"; 2 | import { SQL } from "@my-sst-app/core/sql"; 3 | import { builder } from "../builder"; 4 | 5 | const ArticleType = builder.objectRef("Article").implement({ 6 | fields: t => ({ 7 | id: t.exposeID("articleID"), 8 | title: t.exposeID("title"), 9 | url: t.exposeID("url"), 10 | comments: t.field({ 11 | type: [CommentType], 12 | resolve: article => Article.comments(article.articleID) 13 | }) 14 | }) 15 | }); 16 | 17 | const CommentType = builder.objectRef("Comment").implement({ 18 | fields: t => ({ 19 | id: t.exposeString("commentID"), 20 | text: t.exposeString("text") 21 | }) 22 | }); 23 | 24 | builder.queryFields(t => ({ 25 | articles: t.field({ 26 | type: [ArticleType], 27 | resolve: () => Article.list() 28 | }) 29 | })); 30 | 31 | builder.mutationFields(t => ({ 32 | addComment: t.field({ 33 | type: CommentType, 34 | args: { 35 | articleID: t.arg.string({ required: true }), 36 | text: t.arg.string({ required: true }) 37 | }, 38 | resolve: (_, args) => Article.addComment(args.articleID, args.text) 39 | }), 40 | createArticle: t.field({ 41 | type: ArticleType, 42 | args: { 43 | title: t.arg.string({ required: true }), 44 | url: t.arg.string({ required: true }) 45 | }, 46 | resolve: (_, args) => Article.create(args.title, args.url) 47 | }) 48 | })); 49 | -------------------------------------------------------------------------------- /backend/migrations/first.mjs: -------------------------------------------------------------------------------- 1 | import { Kysely } from "kysely"; 2 | 3 | /** 4 | * @param db {Kysely} 5 | */ 6 | export async function up(db) { 7 | await db.schema 8 | .createTable("article") 9 | .addColumn("articleID", "text", col => col.primaryKey()) 10 | .addColumn("title", "text", col => col.notNull()) 11 | .addColumn("url", "text", col => col.notNull()) 12 | .addColumn("created", "timestamp", col => col.defaultTo("now()")) 13 | .execute(); 14 | 15 | await db.schema 16 | .createIndex("idx_article_created") 17 | .on("article") 18 | .column("created") 19 | .execute(); 20 | } 21 | 22 | /** 23 | * @param db {Kysely} 24 | */ 25 | export async function down(db) { 26 | await db.schema.dropIndex("idx_article_created").execute(); 27 | await db.schema.dropTable("article").execute(); 28 | } 29 | -------------------------------------------------------------------------------- /backend/migrations/second.mjs: -------------------------------------------------------------------------------- 1 | import { Kysely } from "kysely"; 2 | 3 | /** 4 | * @param db {Kysely} 5 | */ 6 | export async function up(db) { 7 | await db.schema 8 | .createTable("comment") 9 | .addColumn("commentID", "text", col => col.primaryKey()) 10 | .addColumn("articleID", "text", col => col.notNull()) 11 | .addColumn("text", "text", col => col.notNull()) 12 | .execute(); 13 | } 14 | 15 | /** 16 | * @param db {Kysely} 17 | */ 18 | export async function down(db) { 19 | await db.schema.dropTable("comment").execute(); 20 | } 21 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@my-sst-app/backend", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "scripts": {}, 6 | "dependencies": { 7 | "@pothos/core": "^3.11.0", 8 | "@serverless-stack/node": "^1.1.1", 9 | "aws-sdk": "^2.1135.0", 10 | "kysely": "^0.19.2", 11 | "kysely-data-api": "^0.0.9", 12 | "ulid": "^2.3.0" 13 | }, 14 | "devDependencies": { 15 | "@types/aws-lambda": "^8.10.97" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/test/functions/graphql/article.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Client } from "./client"; 3 | 4 | describe("articles", () => { 5 | it("create and list", async () => { 6 | const created = await Client.mutation({ 7 | createArticle: [ 8 | { url: "https://google.com", title: "My Upload" }, 9 | { 10 | id: true, 11 | }, 12 | ], 13 | }); 14 | 15 | const result = await Client.query({ 16 | articles: { 17 | id: true, 18 | }, 19 | }); 20 | 21 | expect( 22 | result.articles.find((x) => x.id === created.createArticle.id) 23 | ).not.toBeNull(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /backend/test/functions/graphql/client.ts: -------------------------------------------------------------------------------- 1 | import { Chain, useZeusVariables } from "@my-sst-app/graphql/zeus"; 2 | 3 | const chain = Chain("https://b08531nku7.execute-api.us-east-1.amazonaws.com"); 4 | 5 | export const Client = { 6 | query: chain("query"), 7 | mutation: chain("mutation"), 8 | }; 9 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["functions", "core"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "target": "es2017", 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true, 9 | "baseUrl": ".", 10 | "paths": { 11 | "@my-sst-app/core/*": ["./core/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/update.ts: -------------------------------------------------------------------------------- 1 | import notes from "./notes"; 2 | 3 | export async function main(event) { 4 | const note = notes[event.pathParameters.id]; 5 | 6 | if (!note) { 7 | return { 8 | statusCode: 404, 9 | body: JSON.stringify({ error: true }), 10 | }; 11 | } 12 | 13 | const data = JSON.parse(event.body); 14 | 15 | note.content = data.content; 16 | 17 | return { 18 | statusCode: 200, 19 | body: JSON.stringify(note), 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "availability-zones:account=280826753141:region=us-east-1": [ 3 | "us-east-1a", 4 | "us-east-1b", 5 | "us-east-1c", 6 | "us-east-1d", 7 | "us-east-1e", 8 | "us-east-1f" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /graphql/genql/guards.esm.js: -------------------------------------------------------------------------------- 1 | 2 | var Article_possibleTypes = ['Article'] 3 | export var isArticle = function(obj) { 4 | if (!obj || !obj.__typename) throw new Error('__typename is missing in "isArticle"') 5 | return Article_possibleTypes.includes(obj.__typename) 6 | } 7 | 8 | 9 | 10 | var Comment_possibleTypes = ['Comment'] 11 | export var isComment = function(obj) { 12 | if (!obj || !obj.__typename) throw new Error('__typename is missing in "isComment"') 13 | return Comment_possibleTypes.includes(obj.__typename) 14 | } 15 | 16 | 17 | 18 | var Mutation_possibleTypes = ['Mutation'] 19 | export var isMutation = function(obj) { 20 | if (!obj || !obj.__typename) throw new Error('__typename is missing in "isMutation"') 21 | return Mutation_possibleTypes.includes(obj.__typename) 22 | } 23 | 24 | 25 | 26 | var Query_possibleTypes = ['Query'] 27 | export var isQuery = function(obj) { 28 | if (!obj || !obj.__typename) throw new Error('__typename is missing in "isQuery"') 29 | return Query_possibleTypes.includes(obj.__typename) 30 | } 31 | -------------------------------------------------------------------------------- /graphql/genql/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FieldsSelection, 3 | GraphqlOperation, 4 | ClientOptions, 5 | Observable, 6 | } from '@genql/runtime' 7 | import { SubscriptionClient } from 'subscriptions-transport-ws' 8 | export * from './schema' 9 | import { 10 | QueryRequest, 11 | QueryPromiseChain, 12 | Query, 13 | MutationRequest, 14 | MutationPromiseChain, 15 | Mutation, 16 | } from './schema' 17 | export declare const createClient: (options?: ClientOptions) => Client 18 | export declare const everything: { __scalar: boolean } 19 | export declare const version: string 20 | 21 | export interface Client { 22 | wsClient?: SubscriptionClient 23 | 24 | query( 25 | request: R & { __name?: string }, 26 | ): Promise> 27 | 28 | mutation( 29 | request: R & { __name?: string }, 30 | ): Promise> 31 | 32 | chain: { 33 | query: QueryPromiseChain 34 | 35 | mutation: MutationPromiseChain 36 | } 37 | } 38 | 39 | export type QueryResult = FieldsSelection< 40 | Query, 41 | fields 42 | > 43 | 44 | export declare const generateQueryOp: ( 45 | fields: QueryRequest & { __name?: string }, 46 | ) => GraphqlOperation 47 | export type MutationResult = FieldsSelection< 48 | Mutation, 49 | fields 50 | > 51 | 52 | export declare const generateMutationOp: ( 53 | fields: MutationRequest & { __name?: string }, 54 | ) => GraphqlOperation 55 | -------------------------------------------------------------------------------- /graphql/genql/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | linkTypeMap, 3 | createClient as createClientOriginal, 4 | generateGraphqlOperation, 5 | assertSameVersion, 6 | } from '@genql/runtime' 7 | import types from './types.esm' 8 | var typeMap = linkTypeMap(types) 9 | export * from './guards.esm' 10 | 11 | export var version = '2.10.0' 12 | assertSameVersion(version) 13 | 14 | export var createClient = function(options) { 15 | options = options || {} 16 | var optionsCopy = { 17 | url: undefined, 18 | queryRoot: typeMap.Query, 19 | mutationRoot: typeMap.Mutation, 20 | subscriptionRoot: typeMap.Subscription, 21 | } 22 | for (var name in options) { 23 | optionsCopy[name] = options[name] 24 | } 25 | return createClientOriginal(optionsCopy) 26 | } 27 | 28 | export var generateQueryOp = function(fields) { 29 | return generateGraphqlOperation('query', typeMap.Query, fields) 30 | } 31 | export var generateMutationOp = function(fields) { 32 | return generateGraphqlOperation('mutation', typeMap.Mutation, fields) 33 | } 34 | export var generateSubscriptionOp = function(fields) { 35 | return generateGraphqlOperation('subscription', typeMap.Subscription, fields) 36 | } 37 | export var everything = { 38 | __scalar: true, 39 | } 40 | -------------------------------------------------------------------------------- /graphql/genql/schema.graphql: -------------------------------------------------------------------------------- 1 | type Article { 2 | comments: [Comment!]! 3 | id: ID! 4 | title: ID! 5 | url: ID! 6 | } 7 | 8 | type Comment { 9 | id: String! 10 | text: String! 11 | } 12 | 13 | type Mutation { 14 | addComment(articleID: String!, text: String!): Comment! 15 | createArticle(title: String!, url: String!): Article! 16 | } 17 | 18 | type Query { 19 | articles: [Article!]! 20 | } -------------------------------------------------------------------------------- /graphql/genql/schema.ts: -------------------------------------------------------------------------------- 1 | import {FieldsSelection,Observable} from '@genql/runtime' 2 | 3 | export type Scalars = { 4 | ID: string, 5 | String: string, 6 | Boolean: boolean, 7 | } 8 | 9 | export interface Article { 10 | comments: Comment[] 11 | id: Scalars['ID'] 12 | title: Scalars['ID'] 13 | url: Scalars['ID'] 14 | __typename: 'Article' 15 | } 16 | 17 | export interface Comment { 18 | id: Scalars['String'] 19 | text: Scalars['String'] 20 | __typename: 'Comment' 21 | } 22 | 23 | export interface Mutation { 24 | addComment: Comment 25 | createArticle: Article 26 | __typename: 'Mutation' 27 | } 28 | 29 | export interface Query { 30 | articles: Article[] 31 | __typename: 'Query' 32 | } 33 | 34 | export interface ArticleRequest{ 35 | comments?: CommentRequest 36 | id?: boolean | number 37 | title?: boolean | number 38 | url?: boolean | number 39 | __typename?: boolean | number 40 | __scalar?: boolean | number 41 | } 42 | 43 | export interface CommentRequest{ 44 | id?: boolean | number 45 | text?: boolean | number 46 | __typename?: boolean | number 47 | __scalar?: boolean | number 48 | } 49 | 50 | export interface MutationRequest{ 51 | addComment?: [{articleID: Scalars['String'],text: Scalars['String']},CommentRequest] 52 | createArticle?: [{title: Scalars['String'],url: Scalars['String']},ArticleRequest] 53 | __typename?: boolean | number 54 | __scalar?: boolean | number 55 | } 56 | 57 | export interface QueryRequest{ 58 | articles?: ArticleRequest 59 | __typename?: boolean | number 60 | __scalar?: boolean | number 61 | } 62 | 63 | 64 | const Article_possibleTypes: string[] = ['Article'] 65 | export const isArticle = (obj?: { __typename?: any } | null): obj is Article => { 66 | if (!obj?.__typename) throw new Error('__typename is missing in "isArticle"') 67 | return Article_possibleTypes.includes(obj.__typename) 68 | } 69 | 70 | 71 | 72 | const Comment_possibleTypes: string[] = ['Comment'] 73 | export const isComment = (obj?: { __typename?: any } | null): obj is Comment => { 74 | if (!obj?.__typename) throw new Error('__typename is missing in "isComment"') 75 | return Comment_possibleTypes.includes(obj.__typename) 76 | } 77 | 78 | 79 | 80 | const Mutation_possibleTypes: string[] = ['Mutation'] 81 | export const isMutation = (obj?: { __typename?: any } | null): obj is Mutation => { 82 | if (!obj?.__typename) throw new Error('__typename is missing in "isMutation"') 83 | return Mutation_possibleTypes.includes(obj.__typename) 84 | } 85 | 86 | 87 | 88 | const Query_possibleTypes: string[] = ['Query'] 89 | export const isQuery = (obj?: { __typename?: any } | null): obj is Query => { 90 | if (!obj?.__typename) throw new Error('__typename is missing in "isQuery"') 91 | return Query_possibleTypes.includes(obj.__typename) 92 | } 93 | 94 | 95 | export interface ArticlePromiseChain{ 96 | comments: ({get: (request: R, defaultValue?: FieldsSelection[]) => Promise[]>}), 97 | id: ({get: (request?: boolean|number, defaultValue?: Scalars['ID']) => Promise}), 98 | title: ({get: (request?: boolean|number, defaultValue?: Scalars['ID']) => Promise}), 99 | url: ({get: (request?: boolean|number, defaultValue?: Scalars['ID']) => Promise}) 100 | } 101 | 102 | export interface ArticleObservableChain{ 103 | comments: ({get: (request: R, defaultValue?: FieldsSelection[]) => Observable[]>}), 104 | id: ({get: (request?: boolean|number, defaultValue?: Scalars['ID']) => Observable}), 105 | title: ({get: (request?: boolean|number, defaultValue?: Scalars['ID']) => Observable}), 106 | url: ({get: (request?: boolean|number, defaultValue?: Scalars['ID']) => Observable}) 107 | } 108 | 109 | export interface CommentPromiseChain{ 110 | id: ({get: (request?: boolean|number, defaultValue?: Scalars['String']) => Promise}), 111 | text: ({get: (request?: boolean|number, defaultValue?: Scalars['String']) => Promise}) 112 | } 113 | 114 | export interface CommentObservableChain{ 115 | id: ({get: (request?: boolean|number, defaultValue?: Scalars['String']) => Observable}), 116 | text: ({get: (request?: boolean|number, defaultValue?: Scalars['String']) => Observable}) 117 | } 118 | 119 | export interface MutationPromiseChain{ 120 | addComment: ((args: {articleID: Scalars['String'],text: Scalars['String']}) => CommentPromiseChain & {get: (request: R, defaultValue?: FieldsSelection) => Promise>}), 121 | createArticle: ((args: {title: Scalars['String'],url: Scalars['String']}) => ArticlePromiseChain & {get: (request: R, defaultValue?: FieldsSelection) => Promise>}) 122 | } 123 | 124 | export interface MutationObservableChain{ 125 | addComment: ((args: {articleID: Scalars['String'],text: Scalars['String']}) => CommentObservableChain & {get: (request: R, defaultValue?: FieldsSelection) => Observable>}), 126 | createArticle: ((args: {title: Scalars['String'],url: Scalars['String']}) => ArticleObservableChain & {get: (request: R, defaultValue?: FieldsSelection) => Observable>}) 127 | } 128 | 129 | export interface QueryPromiseChain{ 130 | articles: ({get: (request: R, defaultValue?: FieldsSelection[]) => Promise[]>}) 131 | } 132 | 133 | export interface QueryObservableChain{ 134 | articles: ({get: (request: R, defaultValue?: FieldsSelection[]) => Observable[]>}) 135 | } -------------------------------------------------------------------------------- /graphql/genql/types.esm.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "scalars": [ 3 | 1, 4 | 3, 5 | 6 6 | ], 7 | "types": { 8 | "Article": { 9 | "comments": [ 10 | 2 11 | ], 12 | "id": [ 13 | 1 14 | ], 15 | "title": [ 16 | 1 17 | ], 18 | "url": [ 19 | 1 20 | ], 21 | "__typename": [ 22 | 3 23 | ] 24 | }, 25 | "ID": {}, 26 | "Comment": { 27 | "id": [ 28 | 3 29 | ], 30 | "text": [ 31 | 3 32 | ], 33 | "__typename": [ 34 | 3 35 | ] 36 | }, 37 | "String": {}, 38 | "Mutation": { 39 | "addComment": [ 40 | 2, 41 | { 42 | "articleID": [ 43 | 3, 44 | "String!" 45 | ], 46 | "text": [ 47 | 3, 48 | "String!" 49 | ] 50 | } 51 | ], 52 | "createArticle": [ 53 | 0, 54 | { 55 | "title": [ 56 | 3, 57 | "String!" 58 | ], 59 | "url": [ 60 | 3, 61 | "String!" 62 | ] 63 | } 64 | ], 65 | "__typename": [ 66 | 3 67 | ] 68 | }, 69 | "Query": { 70 | "articles": [ 71 | 0 72 | ], 73 | "__typename": [ 74 | 3 75 | ] 76 | }, 77 | "Boolean": {} 78 | } 79 | } -------------------------------------------------------------------------------- /graphql/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@my-sst-app/graphql", 3 | "version": "0.0.0", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | type Article { 2 | comments: [Comment!]! 3 | id: ID! 4 | title: ID! 5 | url: ID! 6 | } 7 | 8 | type Comment { 9 | id: String! 10 | text: String! 11 | } 12 | 13 | type Mutation { 14 | addComment(articleID: String!, text: String!): Comment! 15 | createArticle(title: String!, url: String!): Article! 16 | } 17 | 18 | type Query { 19 | articles: [Article!]! 20 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-sst-app", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "sst start", 7 | "build": "sst build", 8 | "deploy": "sst deploy --stage=production", 9 | "remove": "sst remove", 10 | "console": "sst console", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "devDependencies": { 14 | "@genql/cli": "^2.10.0", 15 | "@serverless-stack/cli": "1.2.15", 16 | "@serverless-stack/resources": "1.2.15", 17 | "@tsconfig/node16": "^1.0.2", 18 | "typescript": "^4.6.4" 19 | }, 20 | "workspaces": [ 21 | "backend", 22 | "web", 23 | "graphql" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /sst.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-sst-app", 3 | "region": "us-east-1", 4 | "main": "stacks/index.ts" 5 | } -------------------------------------------------------------------------------- /stacks/Api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StackContext, 3 | use, 4 | Api as ApiGateway 5 | } from "@serverless-stack/resources"; 6 | import { Database } from "./Database"; 7 | 8 | export function Api({ stack }: StackContext) { 9 | const rds = use(Database); 10 | const api = new ApiGateway(stack, "api", { 11 | defaults: { 12 | function: { 13 | permissions: [rds], 14 | environment: { 15 | RDS_SECRET_ARN: rds.secretArn, 16 | RDS_ARN: rds.clusterArn, 17 | RDS_DATABASE: rds.defaultDatabaseName 18 | } 19 | } 20 | }, 21 | routes: { 22 | "POST /graphql": { 23 | type: "pothos", 24 | function: { 25 | handler: "functions/graphql/graphql.handler" 26 | } 27 | /* 28 | schema: "backend/functions/graphql/schema.ts", 29 | output: "graphql/schema.graphql", 30 | commands: [ 31 | "npx genql --output ./graphql/genql --schema ./graphql/schema.graphql --esm" 32 | ] 33 | */ 34 | } 35 | } 36 | }); 37 | 38 | stack.addOutputs({ 39 | API_URL: api.url 40 | }); 41 | 42 | return api; 43 | } 44 | -------------------------------------------------------------------------------- /stacks/Database.ts: -------------------------------------------------------------------------------- 1 | import { RDS, StackContext } from "@serverless-stack/resources"; 2 | 3 | export function Database({ stack }: StackContext) { 4 | const rds = new RDS(stack, "rds", { 5 | engine: "postgresql10.14", 6 | migrations: "backend/migrations", 7 | types: "backend/core/sql.generated.ts", 8 | defaultDatabaseName: "mysstapp" 9 | }); 10 | 11 | return rds; 12 | } 13 | -------------------------------------------------------------------------------- /stacks/Web.ts: -------------------------------------------------------------------------------- 1 | import { StackContext, use, ViteStaticSite } from "@serverless-stack/resources"; 2 | import { Api } from "./Api"; 3 | 4 | export function Web({ stack }: StackContext) { 5 | const api = use(Api); 6 | 7 | const site = new ViteStaticSite(stack, "site", { 8 | path: "web", 9 | buildCommand: "npm run build", 10 | environment: { 11 | VITE_GRAPHQL_URL: api.url + "/graphql" 12 | } 13 | }); 14 | 15 | stack.addOutputs({ 16 | SITE_URL: site.url 17 | }); 18 | 19 | return api; 20 | } 21 | -------------------------------------------------------------------------------- /stacks/index.ts: -------------------------------------------------------------------------------- 1 | import { App } from "@serverless-stack/resources"; 2 | import { Api } from "./Api"; 3 | import { Database } from "./Database"; 4 | import { Web } from "./Web"; 5 | 6 | export default function main(app: App) { 7 | app.setDefaultFunctionProps({ 8 | runtime: "nodejs16.x", 9 | srcPath: "backend" 10 | }); 11 | app 12 | .stack(Database) 13 | .stack(Api) 14 | .stack(Web); 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "include": ["stacks"] 4 | } 5 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from "vitest/config"; 4 | 5 | export default defineConfig({ 6 | test: { 7 | testTimeout: 30000, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "sst-env -- vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@serverless-stack/web": "^1.0.11", 12 | "amazon-cognito-identity-js": "^5.2.8", 13 | "graphql": "^16.5.0", 14 | "graphql-tag": "^2.12.6", 15 | "react": "^18.0.0", 16 | "react-dom": "^18.0.0", 17 | "react-query": "^3.39.0", 18 | "react-router-dom": "^6.3.0", 19 | "urql": "^2.2.0" 20 | }, 21 | "devDependencies": { 22 | "@serverless-stack/static-site-env": "^1.0.11", 23 | "@types/react": "^18.0.0", 24 | "@types/react-dom": "^18.0.0", 25 | "@vitejs/plugin-react": "^1.3.0", 26 | "typescript": "^4.6.3", 27 | "vite": "^2.9.7" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /web/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; 5 | import { Provider as UrqlProvider, createClient, defaultExchanges } from "urql"; 6 | import { List } from "./pages/Article"; 7 | 8 | const urql = createClient({ 9 | url: import.meta.env.VITE_GRAPHQL_URL, 10 | exchanges: defaultExchanges 11 | }); 12 | 13 | ReactDOM.createRoot(document.getElementById("root")!).render( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | 21 | function App() { 22 | return ( 23 | 24 | 25 | } /> 26 | } /> 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /web/src/pages/Article.tsx: -------------------------------------------------------------------------------- 1 | import { useTypedMutation, useTypedQuery } from "../urql"; 2 | 3 | interface ArticleForm { 4 | title: string; 5 | url: string; 6 | } 7 | 8 | interface CommentForm { 9 | text: string; 10 | articleID: string; 11 | } 12 | 13 | export function List() { 14 | const [articles] = useTypedQuery({ 15 | query: { 16 | articles: { 17 | id: true, 18 | title: true, 19 | url: true, 20 | comments: { 21 | text: true 22 | } 23 | } 24 | } 25 | }); 26 | 27 | const [, createArticle] = useTypedMutation((opts: ArticleForm) => ({ 28 | createArticle: [ 29 | opts, 30 | { 31 | id: true, 32 | url: true 33 | } 34 | ] 35 | })); 36 | 37 | const [, addComment] = useTypedMutation((opts: CommentForm) => ({ 38 | addComment: [ 39 | { text: opts.text, articleID: opts.articleID }, 40 | { 41 | id: true, 42 | text: true 43 | } 44 | ] 45 | })); 46 | 47 | return ( 48 |
49 |

Articles

50 |

Submit

51 |
{ 53 | e.preventDefault(); 54 | const fd = new FormData(e.currentTarget); 55 | createArticle({ 56 | url: fd.get("url")!.toString(), 57 | title: fd.get("title")!.toString() 58 | }); 59 | e.currentTarget.reset(); 60 | }} 61 | > 62 | 63 | 64 | 65 |
66 |

Latest

67 |
    68 | {articles.data?.articles.map(article => ( 69 |
  1. 70 |
    71 |
    72 | {article.title} - {article.url} 73 |
    74 |
    75 | Comments 76 |
      77 | {article.comments.map(comment => ( 78 |
    1. {comment.text}
    2. 79 | ))} 80 |
    81 |
    82 |
    { 84 | const fd = new FormData(e.currentTarget); 85 | addComment({ 86 | text: fd.get("text")!.toString(), 87 | articleID: article.id 88 | }); 89 | e.currentTarget.reset(); 90 | e.preventDefault(); 91 | }} 92 | > 93 | 94 | 95 |
    96 |
    97 |
  2. 98 | ))} 99 |
100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /web/src/sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_GRAPHQL_URL: string 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv 9 | } -------------------------------------------------------------------------------- /web/src/urql.ts: -------------------------------------------------------------------------------- 1 | import { OperationContext, RequestPolicy, useQuery, useMutation } from "urql"; 2 | import { useEffect, useState } from "react"; 3 | import { TypedQueryDocumentNode } from "graphql"; 4 | import { 5 | generateQueryOp, 6 | generateMutationOp, 7 | QueryRequest, 8 | QueryResult, 9 | MutationRequest, 10 | MutationResult 11 | } from "@my-sst-app/graphql/genql"; 12 | 13 | export function useTypedQuery(opts: { 14 | query: Query; 15 | requestPolicy?: RequestPolicy; 16 | context?: Partial; 17 | pause?: boolean; 18 | }) { 19 | const { query, variables } = generateQueryOp(opts.query); 20 | return useQuery>({ 21 | ...opts, 22 | query, 23 | variables 24 | }); 25 | } 26 | 27 | export function useTypedMutation< 28 | Variables extends Record, 29 | Mutation extends MutationRequest 30 | >(builder: (vars: Variables) => Mutation) { 31 | const [mutation, setMutation] = useState(); 32 | const [variables, setVariables] = useState(); 33 | const [result, execute] = useMutation, Variables>( 34 | mutation as any 35 | ); 36 | 37 | function executeWrapper(vars: Variables) { 38 | const mut = builder(vars); 39 | const { query, variables } = generateMutationOp(mut); 40 | setMutation(query); 41 | setVariables(variables); 42 | } 43 | 44 | useEffect(() => { 45 | if (!mutation) return; 46 | execute(variables).then(() => setMutation(undefined)); 47 | }, [mutation]); 48 | 49 | return [result, executeWrapper] as const; 50 | } 51 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()] 7 | }) 8 | --------------------------------------------------------------------------------