Upgrade to Pro — share decks privately, control downloads, hide ads and more …

スタートアップで高速検証するためのAmplify Gen 2 〜利便性と、ハマるS3認可や一覧...

スタートアップで高速検証するためのAmplify Gen 2 〜利便性と、ハマるS3認可や一覧画面実装の解決テンプレート〜

JAWS FESTA 2025 in 金沢で発表した内容です!
https://jawsfesta2025.jaws-ug.jp/sessions/timetable/77/

概要は以下の通りです。

このセッションでは、Amplify Gen 2を用いてMVP/ファンクショナルプロトタイプ開発をする上で、それに適した使い方をテンプレートでご紹介します。

スタートアップでは欠かせない事業やプロダクトの検証ですが、Amplify Gen 2を用いることは特に軽量な選択肢であり、ECSやEC2をコンピューティングリソースとして活用するよりも低いインフラ構築コストと、300PV/日程度のアプリでも$10/月程度未満のランニングコストを達成できます。

新規事業開発を専門的に行う弊社でも検証は常に行われており、数年前から開発テンプレートによる簡単な構築や運用を続けてきましたが、検証段階においてはコストの高いテンプレートでした。そこで採用されたのがAmplify Gen 2であり、利用実績もあるものとして特に有益な部分を今回紹介させていただきます。

しかしながら、そのようなメリットのあるAmplify Gen 2にも、以下のような注意すべき点があります。

- ユーザーのCognito認証でのグループによる認可制御とS3のオブジェクト所有者認可制御の併存

- いわゆる一覧画面を実装する上でのページネーション・フィルタ・ソートのDynamoDBでの実現

このセッションを聴講すると、新規事業開発に取り組む上で欠かせない「MVP/ファンクショナルプロトタイプ開発」の実践的なノウハウを活かせるようになります。

Avatar for amixedcolor

amixedcolor

October 10, 2025
Tweet

More Decks by amixedcolor

Other Decks in Technology

Transcript

  1. 2 • スタートアップでAWSを使いたい/使おうと思っている • プロトタイプ開発に適切なAWSサービスを知りたい • Amplify Gen 2 …

    • を聞いたことがある • の特徴と注意点を知りたい • を使ってみたい/使おうと思っている • ハマってしまいやすいポイント… • の具体的な解決策を知りたい • を自分で解決できるようになりたい 想定聴衆
  2. 5 自己紹介 保 龍児(エイミ/amixedcolor) 2025 Japan AWS Jr. Champion 業務内容

    : 自社新規事業SaaS開発リーダー エンジニア(WebアプリFE/BE/インフラ) 好きなトピック : アジャイル、スクラム、新規事業開発、 AWS、完全没入型仮想現実 よくいるコミュニティ : AWSコミュニティ、アジャイルコミュニティ @amixedcolor
  3. 10 MVP/ファンクショナルプロトタイプの開発の定義 考慮事項 • MVP(Minimum Viable Product)は提供価値で定義しているため、作るプ ロダクトの粒度の自由度が高い • 単に「プロトタイプ」と呼ぶ場合もペーパープロトタイプからファンク

    ショナルプロトタイプまで定義が幅広い 本発表で扱うのは主に「ファンクショナルプロトタイプ」 • この定義を基本的に踏襲 • その上で、提供価値を体現できることも一部含めるが、状況によっては ここまで大きなプロトタイプにしなくてもMVPになる可能性がある
  4. 17 公式ドキュメント曰く • https://docs.amplify.aws/ • 曰く「ウェブ・モバイルアプリを構築する上で必要な全て」 • > AWS Amplify

    is everything you need to build web and mobile apps. • 曰く「簡単に始められ、簡単にスケールできる」 • > Easy to start, easy to scale. • 要は、「とっても便利!」
  5. 18 Gen「2」について Gen「2」? • 元々無印の「AWS Amplify」というサービスがあり、大幅なアップデー トを経て「Amplify Gen 2」がリリースされました •

    以来、無印は「Gen 1」と呼ばれています 大幅なアップデート? Gen 構築 構築方法 追跡可能性 カスタマイズ性 Gen 1 自動 CLI Gen 2 自動 CDK
  6. 19 • フルTypeScript • フルスタックで強力な型補完 • バックエンド(いわゆるインフラ)もCDKで管理 • Everything as

    Code • プライベートなバックエンド環境「Cloud Sandbox」 • 環境構築の手間減↓↓ • デプロイ環境との差分によるエラーリスク減↓↓ 特徴
  7. 26 サンプルアプリをデプロイする場合 • https://docs.amplify.aws/react/start/quickstart/ • わずか30分足らず その後の開発においても低い • 構築済みのCDKを参考にできる •

    Cloud Sandboxを用いた、ローカルでの同一アーキテクチャによる検証 注意点 • カスタムリソースを使う場合は基本フルスクラッチのCDK • 認証・ストレージ・DB・Lambda関数 以外 比較的低いインフラ構築コスト
  8. 30 公式の料金例 • https://aws.amazon.com/jp/amplify/pricing/ 例1 • 300PV/日、平均3分のビルドを月20回 • →およそ8USD/月 料金の内訳

    • ホスティング:およそ2USD/月 • ビルド:およそ6USD/月 • →ホスティングは安く、ビルド/デプロイにかかる費用が主 注意点 • Bedrockなど、他のサービスを使う場合は加算される 比較的低いランニングコスト
  9. 34 • Amplify Gen 2 特有の記法がある • 認証の取り扱い • ストレージの取り扱い

    • DBのデータの取り扱い • きめ細やかなカスタマイズは困難な側面がある • マネージドサービスあるある • 利用規模が大きくなるとランニングコスト面で不利 • ECS/RDSなどを使用する方が有利 Amplify Gen 2 自体の注意点
  10. 40 S3認可における以下2つの併存 • Cognitoのユーザーグループによる認可 • オブジェクトの所有者による認可 一覧画面における以下の実装 • 全カラムのソート •

    ソートを実装した上での、全カラムのフィルタ • 単なるページネーション • フィルタを実現した上での、ページネーション いざ開発してみるとハマりやすいポイント
  11. 41 S3認可における以下2つの併存 • Cognitoのユーザーグループによる認可 • オブジェクトの所有者による認可 一覧画面における以下の実装 • 全カラムのソート •

    ソートを実装した上での、全カラムのフィルタ • 単なるページネーション • フィルタを実現した上での、ページネーション いざ開発してみるとハマりやすいポイント
  12. 42 状況 • Cognitoのユーザーグループによってユーザーの権限/認可制御を管理し ている • S3を使う必要があり、ユーザーの所属するグループによって権限/認可制 御を変えたい • S3での認可制御では、オブジェクトの所有者による認可制御も行いたい

    問題 • 2025/10/08時点で、前述の2つの認可制御はデフォルトで併存できない • Cognitoのユーザーグループによる認可 • オブジェクトの所有者による認可 • GitHub Issueに上がったまま • https://github.com/aws-amplify/amplify-backend/issues/1771 • ここでも解決策の概要はコメント済 状況と問題
  13. 45 • スライドで端的に表現するためにインデントや改行など、調整を かけているため、以下の可能性があります • 可読性の高くないコードになっている • エラーのあるコードになっている • 不足するコードがある

    • できる限りすべてのコードを含めていますが、割愛している部分があります • 動作の確認は行なっていますが、動作の保証をするわけではあり ません • Xなどでご質問いただければ、できる限り回答いたします • 同じ解決策を実現するコードは複数あり、あくまで一例です 解決策の具体的なコードを提示する前に
  14. 46 import { StoragePath, StorageAction} from '@aws-amplify/backend-storage'; import { PolicyStatement,

    Role } from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; interface CustomPolicyProps { roleArn: string; bucketArn: string; path: StoragePath; allow: StorageAction[]; } const decodeEntityIdInPath = (path: string) => { return path.replace(/{entity_id}/, '${cognito-identity.amazonaws.com:sub}'); }; 解決策の具体的なコード:カスタムリソースのresource.ts(1/4)
  15. 47 import { StoragePath, StorageAction} from '@aws-amplify/backend-storage'; import { PolicyStatement,

    Role } from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; interface CustomPolicyProps { roleArn: string; bucketArn: string; path: StoragePath; allow: StorageAction[]; } const decodeEntityIdInPath = (path: string) => { return path.replace(/{entity_id}/, '${cognito-identity.amazonaws.com:sub}'); }; 解決策の具体的なコード:カスタムリソースのresource.ts(1/4)
  16. 48 export class AddStorageAuthWithGroup extends Construct { constructor(scope: Construct, id:

    string, propsArray: CustomPolicyProps[]) { super(scope, id); propsArray.forEach((props, index) => { const role = Role.fromRoleArn(scope, `additionalStorageAccess-${index}`, props.roleArn); const decodedPath = decodeEntityIdInPath(props.path); const pathWithAsterisk = decodedPath; const pathWithoutAsterisk = decodedPath.replace(/\*$/, ''); if (props.allow.includes('get') || props.allow.includes('read')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:GetObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`], })); } 解決策の具体的なコード:カスタムリソースのresource.ts(2/4)
  17. 49 export class AddStorageAuthWithGroup extends Construct { constructor(scope: Construct, id:

    string, propsArray: CustomPolicyProps[]) { super(scope, id); propsArray.forEach((props, index) => { const role = Role.fromRoleArn(scope, `additionalStorageAccess-${index}`, props.roleArn); const decodedPath = decodeEntityIdInPath(props.path); const pathWithAsterisk = decodedPath; const pathWithoutAsterisk = decodedPath.replace(/\*$/, ''); if (props.allow.includes('get') || props.allow.includes('read')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:GetObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`], })); } 解決策の具体的なコード:カスタムリソースのresource.ts(2/4)
  18. 50 export class AddStorageAuthWithGroup extends Construct { constructor(scope: Construct, id:

    string, propsArray: CustomPolicyProps[]) { super(scope, id); propsArray.forEach((props, index) => { const role = Role.fromRoleArn(scope, `additionalStorageAccess-${index}`, props.roleArn); const decodedPath = decodeEntityIdInPath(props.path); const pathWithAsterisk = decodedPath; const pathWithoutAsterisk = decodedPath.replace(/\*$/, ''); if (props.allow.includes('get') || props.allow.includes('read')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:GetObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`], })); } 解決策の具体的なコード:カスタムリソースのresource.ts(2/4)
  19. 51 export class AddStorageAuthWithGroup extends Construct { constructor(scope: Construct, id:

    string, propsArray: CustomPolicyProps[]) { super(scope, id); propsArray.forEach((props, index) => { const role = Role.fromRoleArn(scope, `additionalStorageAccess-${index}`, props.roleArn); const decodedPath = decodeEntityIdInPath(props.path); const pathWithAsterisk = decodedPath; const pathWithoutAsterisk = decodedPath.replace(/\*$/, ''); if (props.allow.includes('get') || props.allow.includes('read')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:GetObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`], })); } 解決策の具体的なコード:カスタムリソースのresource.ts(2/4)
  20. 52 if (props.allow.includes('list') || props.allow.includes('read')) { role.addToPrincipalPolicy(new PolicyStatement({ conditions: {

    StringLike: { 's3:prefix': [ `${pathWithAsterisk}`, `${pathWithoutAsterisk}` ], }, }, actions: ['s3:ListBucket'], resources: [props.bucketArn], })); } 解決策の具体的なコード:カスタムリソースのresource.ts(3/4)
  21. 53 if (props.allow.includes('list') || props.allow.includes('read')) { role.addToPrincipalPolicy(new PolicyStatement({ conditions: {

    StringLike: { 's3:prefix': [ `${pathWithAsterisk}`, `${pathWithoutAsterisk}` ], }, }, actions: ['s3:ListBucket'], resources: [props.bucketArn], })); } 解決策の具体的なコード:カスタムリソースのresource.ts(3/4)
  22. 54 if (props.allow.includes('list') || props.allow.includes('read')) { role.addToPrincipalPolicy(new PolicyStatement({ conditions: {

    StringLike: { 's3:prefix': [ `${pathWithAsterisk}`, `${pathWithoutAsterisk}` ], }, }, actions: ['s3:ListBucket'], resources: [props.bucketArn], })); } 解決策の具体的なコード:カスタムリソースのresource.ts(3/4)
  23. 55 if (props.allow.includes('list') || props.allow.includes('read')) { role.addToPrincipalPolicy(new PolicyStatement({ conditions: {

    StringLike: { 's3:prefix': [ `${pathWithAsterisk}`, `${pathWithoutAsterisk}` ], }, }, actions: ['s3:ListBucket'], resources: [props.bucketArn], })); } 解決策の具体的なコード:カスタムリソースのresource.ts(3/4)
  24. 56 if (props.allow.includes('write')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:PutObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`],

    })); } if (props.allow.includes('delete')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:DeleteObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`], })); } }); } } 解決策の具体的なコード:カスタムリソースのresource.ts(4/4)
  25. 57 if (props.allow.includes('write')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:PutObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`],

    })); } if (props.allow.includes('delete')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:DeleteObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`], })); } }); } } 解決策の具体的なコード:カスタムリソースのresource.ts(4/4)
  26. 58 if (props.allow.includes('write')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:PutObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`],

    })); } if (props.allow.includes('delete')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:DeleteObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`], })); } }); } } 解決策の具体的なコード:カスタムリソースのresource.ts(4/4)
  27. 59 if (props.allow.includes('write')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:PutObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`],

    })); } if (props.allow.includes('delete')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:DeleteObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`], })); } }); } } 解決策の具体的なコード:カスタムリソースのresource.ts(4/4)
  28. 60 if (props.allow.includes('write')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:PutObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`],

    })); } if (props.allow.includes('delete')) { role.addToPrincipalPolicy(new PolicyStatement({ actions: ['s3:DeleteObject'], resources: [`${props.bucketArn}/${pathWithAsterisk}`], })); } }); } } 解決策の具体的なコード:カスタムリソースのresource.ts(4/4)
  29. 61 import { defineBackend } from '@aws-amplify/backend’; import { AddStorageAuthWithGroup

    } from './custom/AddStorageAuthWithGroup/resource’; const backend = defineBackend({auth, storage}); new AddStorageAuthWithGroup( backend.createStack('custom-storage-auth-with-group'), 'CustomStorageAuthWithGroup', [ { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } ] ); 解決策の使用例
  30. 62 import { defineBackend } from '@aws-amplify/backend’; import { AddStorageAuthWithGroup

    } from './custom/AddStorageAuthWithGroup/resource’; const backend = defineBackend({auth, storage}); new AddStorageAuthWithGroup( backend.createStack('custom-storage-auth-with-group'), 'CustomStorageAuthWithGroup', [ { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } ] ); 解決策の使用例
  31. 63 import { defineBackend } from '@aws-amplify/backend’; import { AddStorageAuthWithGroup

    } from './custom/AddStorageAuthWithGroup/resource’; const backend = defineBackend({auth, storage}); new AddStorageAuthWithGroup( backend.createStack('custom-storage-auth-with-group'), 'CustomStorageAuthWithGroup', [ { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } ] ); 解決策の使用例
  32. 64 import { defineBackend } from '@aws-amplify/backend’; import { AddStorageAuthWithGroup

    } from './custom/AddStorageAuthWithGroup/resource’; const backend = defineBackend({auth, storage}); new AddStorageAuthWithGroup( backend.createStack('custom-storage-auth-with-group'), 'CustomStorageAuthWithGroup', [ { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } ] ); 解決策の使用例
  33. 65 import { defineBackend } from '@aws-amplify/backend’; import { AddStorageAuthWithGroup

    } from './custom/AddStorageAuthWithGroup/resource’; const backend = defineBackend({auth, storage}); new AddStorageAuthWithGroup( backend.createStack('custom-storage-auth-with-group'), 'CustomStorageAuthWithGroup', [ { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } ] ); 解決策の使用例
  34. 66 import { defineBackend } from '@aws-amplify/backend’; import { AddStorageAuthWithGroup

    } from './custom/AddStorageAuthWithGroup/resource’; const backend = defineBackend({auth, storage}); new AddStorageAuthWithGroup( backend.createStack('custom-storage-auth-with-group'), 'CustomStorageAuthWithGroup', [ { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } ] ); 解決策の使用例
  35. 67 // 通常版 export const storage = defineStorage({ name: 'my-app-storage',

    access: (allow) => ({ 'limited/{entity_id}/*': [ allow.entity('identity').to(['read', 'write', 'delete']), ], }) }); // 解決策版 { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } 通常の記法との比較
  36. 68 // 通常版 export const storage = defineStorage({ name: 'my-app-storage',

    access: (allow) => ({ 'limited/{entity_id}/*': [ allow.entity('identity').to(['read', 'write', 'delete']), ], }) }); // 解決策版 { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } 通常の記法との比較
  37. 69 // 通常版 export const storage = defineStorage({ name: 'my-app-storage',

    access: (allow) => ({ 'limited/{entity_id}/*': [ allow.entity('identity').to(['read', 'write', 'delete']), ], }) }); // 解決策版 { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } 通常の記法との比較
  38. 70 // 通常版 export const storage = defineStorage({ name: 'my-app-storage',

    access: (allow) => ({ 'limited/{entity_id}/*': [ allow.entity('identity').to(['read', 'write', 'delete']), ], }) }); // 解決策版 { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } 通常の記法との比較
  39. 71 // 通常版 export const storage = defineStorage({ name: 'my-app-storage',

    access: (allow) => ({ 'limited/{entity_id}/*': [ allow.entity('identity').to(['read', 'write', 'delete']), ], }) }); // 解決策版 { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } 通常の記法との比較
  40. 72 // 通常版 export const storage = defineStorage({ name: 'my-app-storage',

    access: (allow) => ({ 'limited/{entity_id}/*': [ allow.entity('identity').to(['read', 'write', 'delete']), ], }) }); // 解決策版 { roleArn: backend.auth.resources.groups['GENERAL_USERS'].role.roleArn, bucketArn: backend.storage.resources.bucket.bucketArn, path: 'limited/{entity_id}/*', allow: ['read', 'write', 'delete'] } 通常の記法との比較
  41. 74 S3認可における以下2つの併存 • Cognitoのユーザーグループによる認可 • オブジェクトの所有者による認可 一覧画面における以下の実装 • 全カラムのソート •

    ソートを実装した上での、全カラムのフィルタ • 単なるページネーション • フィルタを実現した上での、ページネーション いざ開発してみるとハマりやすいポイント
  42. 75 状況 • いわゆる一覧画面を実装したい • 全カラムのソート・全カラムのフィルタ・ページネーションが必要 問題 • ソートにおいて •

    ソートキーの他にパーティションキーが必須 • 通常使うカラムだけでは全カラム全レコードのソート機能が実現できない • フィルタにおいて • Amplify Gen 2 独自の記法に従う必要があり、簡便のために型ヒントを使いたい • しかし、ソートキーごとにメソッドがあり、複数のメソッドの複合型にしようとす ると「型のネストが深すぎる」とエラーになるため、型ヒントが使えない • ページネーションにおいて • ページネーションを実現する公式に用意されたメソッドはない • フィルタしつつ安直にページネーションすると、データが歯抜けになる 状況と問題
  43. 76 • ソート用の全レコードで一貫したカラムの用意と使用 • メソッドを動的に使い分けながら、フィルタ用に型を定義 • ソートキーごとにメソッドが異なる部分を差し替える • DBスキーマをGenerics型で受け取ることで実際に型ヒントに使える、汎 用的な型を定義・使用

    • いくつか注意しながらページネーション用の関数を用意 • 最終ページのときにデータの件数がperPageの倍数ちょうどだと、末尾 に空ページが返される • フィルタをすると返されるデータが歯抜けになるので、十分にデータが 集まるまで次のページに繰り返しアクセスする • 最後に、これらを一覧画面用の関数として抽象化すると便利 解決策の概要
  44. 77 import { type ClientSchema, a, defineData } from '@aws-amplify/backend';

    const schema = a.schema({ Todo: a .model({ content: a.string().required(), createdAt: a.datetime(), updatedAt: a.datetime(), recordGroup: a.string().default("ALL_RECORDS"), }) .secondaryIndexes((index) => [ index("recordGroup").sortKeys(["content"]), index("recordGroup").sortKeys(["createdAt"]), index("recordGroup").sortKeys(["updatedAt"]), ]) }); export type Schema = ClientSchema<typeof schema>; export const data = defineData({ schema }); 解決策の具体的なコード:ソートのためのdata/resource.ts
  45. 78 import { type ClientSchema, a, defineData } from '@aws-amplify/backend';

    const schema = a.schema({ Todo: a .model({ content: a.string().required(), createdAt: a.datetime(), updatedAt: a.datetime(), recordGroup: a.string().default("ALL_RECORDS"), }) .secondaryIndexes((index) => [ index("recordGroup").sortKeys(["content"]), index("recordGroup").sortKeys(["createdAt"]), index("recordGroup").sortKeys(["updatedAt"]), ]) }); export type Schema = ClientSchema<typeof schema>; export const data = defineData({ schema }); 解決策の具体的なコード:ソートのためのdata/resource.ts
  46. 79 import { type ClientSchema, a, defineData } from '@aws-amplify/backend';

    const schema = a.schema({ Todo: a .model({ content: a.string().required(), createdAt: a.datetime(), updatedAt: a.datetime(), recordGroup: a.string().default("ALL_RECORDS"), }) .secondaryIndexes((index) => [ index("recordGroup").sortKeys(["content"]), index("recordGroup").sortKeys(["createdAt"]), index("recordGroup").sortKeys(["updatedAt"]), ]) }); export type Schema = ClientSchema<typeof schema>; export const data = defineData({ schema }); 解決策の具体的なコード:ソートのためのdata/resource.ts
  47. 80 import { type ClientSchema, a, defineData } from '@aws-amplify/backend';

    const schema = a.schema({ Todo: a .model({ content: a.string().required(), createdAt: a.datetime(), updatedAt: a.datetime(), recordGroup: a.string().default("ALL_RECORDS"), }) .secondaryIndexes((index) => [ index("recordGroup").sortKeys(["content"]), index("recordGroup").sortKeys(["createdAt"]), index("recordGroup").sortKeys(["updatedAt"]), ]) }); export type Schema = ClientSchema<typeof schema>; export const data = defineData({ schema }); 解決策の具体的なコード:ソートのためのdata/resource.ts
  48. 81 import { type ClientSchema, a, defineData } from '@aws-amplify/backend';

    const schema = a.schema({ Todo: a .model({ content: a.string().required(), createdAt: a.datetime(), updatedAt: a.datetime(), recordGroup: a.string().default("ALL_RECORDS"), }) .secondaryIndexes((index) => [ index("recordGroup").sortKeys(["content"]), index("recordGroup").sortKeys(["createdAt"]), index("recordGroup").sortKeys(["updatedAt"]), ]) }); export type Schema = ClientSchema<typeof schema>; export const data = defineData({ schema }); 解決策の具体的なコード:ソートのためのdata/resource.ts
  49. 82 const sortKeyToListFunctionDict = { content: client.models.Todo.listTodoByRecordGroupAndContent, createdAt: client.models.Todo.listTodoByRecordGroupAndCreatedAt, updatedAt:

    client.models.Todo.listTodoByRecordGroupAndUpdatedAt } const {…, sortKey, …, handleSortKey, …} = useList(…, sortKeyToListFunctionDict, …) export const useList = <…,>(…, sortKeyToListFunctionDict: { [key: string]: Function }, …): const listFunction: Function = sortKeyToListFunctionDict[sortKey] await listFunction(…) 解決策の具体的なコード:メソッドを動的に使い分ける
  50. 83 const sortKeyToListFunctionDict = { content: client.models.Todo.listTodoByRecordGroupAndContent, createdAt: client.models.Todo.listTodoByRecordGroupAndCreatedAt, updatedAt:

    client.models.Todo.listTodoByRecordGroupAndUpdatedAt } const {…, sortKey, …, handleSortKey, …} = useList(…, sortKeyToListFunctionDict, …) export const useList = <…,>(…, sortKeyToListFunctionDict: { [key: string]: Function }, …): const listFunction: Function = sortKeyToListFunctionDict[sortKey] await listFunction(…) 解決策の具体的なコード:メソッドを動的に使い分ける
  51. 84 const sortKeyToListFunctionDict = { content: client.models.Todo.listTodoByRecordGroupAndContent, createdAt: client.models.Todo.listTodoByRecordGroupAndCreatedAt, updatedAt:

    client.models.Todo.listTodoByRecordGroupAndUpdatedAt } const {…, sortKey, …, handleSortKey, …} = useList(…, sortKeyToListFunctionDict, …) export const useList = <…,>(…, sortKeyToListFunctionDict: { [key: string]: Function }, …): const listFunction: Function = sortKeyToListFunctionDict[sortKey] await listFunction(…) 解決策の具体的なコード:メソッドを動的に使い分ける
  52. 85 type FilterSizeRuleOperatorRequireNumber = | 'eq’ | 'ne’ | 'ge’

    | 'gt’ | 'le’ | 'lt' type FilterSizeRule = { [key in FilterSizeRuleOperatorRequireNumber]?: number | undefined } & { between?: [number, number] | undefined } type FilterOperatorRequireNumberOrString = | 'beginsWith’ | 'contains’ | 'notContains’ | 'eq’ | 'ne’ | 'ge’ | 'gt’ | 'le’ | 'lt’ type FilterRule = { attributeExists?: boolean | undefined } & { [key in FilterOperatorRequireNumberOrString]?: number | string | undefined } & { between?: [number, number] | [string, string] | undefined } & { size?: FilterSizeRule } 解決策の具体的なコード:汎用的な型を定義(1/2)
  53. 86 type LogicalOperators<T> = { and?: Array<FilterObject<T>> or?: Array<FilterObject<T>> not?:

    FilterObject<T> } export type FilterElement<T> = { [key in keyof T]?: FilterRule } export type ExcludeRecordGroup<T> = Omit<T, 'recordGroup'> export type FilterObject<T, U = ExcludeRecordGroup<T>> = | LogicalOperators<U> | FilterElement<U> 解決策の具体的なコード:汎用的な型を定義(2/2)
  54. 87 const [beginsWithSearchTerm, setBeginsWithSearchTerm] = useState< string | undefined >(undefined)

    import type { Schema } from '@/amplify/data/resource' const initialFilter: FilterObject<Schema['Todo']['type']> = { content: { beginsWith: beginsWithSearchTerm } } useList(…, initialFilter, …) 解決策の具体的なコード:汎用的な型を使用
  55. 88 const [beginsWithSearchTerm, setBeginsWithSearchTerm] = useState< string | undefined >(undefined)

    import type { Schema } from '@/amplify/data/resource' const initialFilter: FilterObject<Schema['Todo']['type']> = { content: { beginsWith: beginsWithSearchTerm } } useList(…, initialFilter, …) 解決策の具体的なコード:汎用的な型を使用
  56. 89 const [beginsWithSearchTerm, setBeginsWithSearchTerm] = useState< string | undefined >(undefined)

    import type { Schema } from '@/amplify/data/resource' const initialFilter: FilterObject<Schema['Todo']['type']> = { content: { beginsWith: beginsWithSearchTerm } } useList(…, initialFilter, …) 解決策の具体的なコード:汎用的な型を使用
  57. 90 const fetchItems = async ( previousNextToken: NextToken ): Promise<[SchemaModelType[],

    NextToken]> => { const listFunction: Function = sortKeyToListFunctionDict[sortKey] const sanitizedFilter = convertEmptyStringAndNullToUndefined(initialFilter) const [filter, filterWithSortKeyOnly] = splitFilterObjectWithSortKey(sanitizedFilter) const { data: items, nextToken } = await listFunction( { recordGroup: 'ALL_RECORDS', ...filterWithSortKeyOnly }, { sortDirection: isDescSort ? 'DESC' : 'ASC', filter: filter, limit: pageLimit, nextToken: previousNextToken, authMode: authMode } ) return [items, nextToken] } 解決策の具体的なコード:最終的にデータフェッチする関数
  58. 91 const fetchEnoughItems = async (selectedPage: number) => { const

    pageIndex = selectedPage - 1 // 1-indexed -> 0-indexed if ( !hasMorePages || (pageIndex < itemsByPage.length && isEnough(itemsByPage[pageIndex]))) { setCurrentPage(selectedPage) return } const hasAlreadyFetched = pageIndex === itemsByPage.length - 1 const items: SchemaModelType[] = hasAlreadyFetched ? itemsByPage[pageIndex] : [] let previousNextToken: NextToken = nextToken let newItems: SchemaModelType[] = [] let newNextToken: NextToken = null while (!isEnough(items)) { ;[newItems, newNextToken] = await fetchItems(previousNextToken) previousNextToken = newNextToken items.push(...newItems) if (!newNextToken) { break } } setNextToken(newNextToken) setHasMorePages(!!newNextToken) 解決策の具体的なコード:ページネーション用の関数(1/3)
  59. 92 const fetchEnoughItems = async (selectedPage: number) => { const

    pageIndex = selectedPage - 1 // 1-indexed -> 0-indexed if ( !hasMorePages || (pageIndex < itemsByPage.length && isEnough(itemsByPage[pageIndex]))) { setCurrentPage(selectedPage) return } const hasAlreadyFetched = pageIndex === itemsByPage.length - 1 const items: SchemaModelType[] = hasAlreadyFetched ? itemsByPage[pageIndex] : [] let previousNextToken: NextToken = nextToken let newItems: SchemaModelType[] = [] let newNextToken: NextToken = null while (!isEnough(items)) { ;[newItems, newNextToken] = await fetchItems(previousNextToken) previousNextToken = newNextToken items.push(...newItems) if (!newNextToken) { break } } setNextToken(newNextToken) setHasMorePages(!!newNextToken) 解決策の具体的なコード:ページネーション用の関数(1/3)
  60. 93 // データの合計数がlimitの正数倍だと、最後のページでもnextTokenがあるというAppSyncの仕様がある // そのnextTokenで取得されるデータは空なので、その場合表示させないために、この後の処理を行わない if (items.length === 0) {

    return } const itemsWithJustCount = items.slice(0, pageLimit) let newItemsByPage: Array<Array<SchemaModelType>> = [] // すでに取得済みのページのデータがある場合はその分を除く if (hasAlreadyFetched) { newItemsByPage = [...itemsByPage.slice(0, itemsByPage.length - 1), itemsWithJustCount] } else { newItemsByPage = [...itemsByPage, itemsWithJustCount] } // 余りのデータがある場合は次回利用する用に保持 const carryOverItems = items.slice(pageLimit) if (carryOverItems.length > 0) { newItemsByPage.push(carryOverItems) } setItemsByPage(newItemsByPage) setCurrentPage(selectedPage) } 解決策の具体的なコード:ページネーション用の関数(2/3)
  61. 94 // データの合計数がlimitの正数倍だと、最後のページでもnextTokenがあるというAppSyncの仕様がある // そのnextTokenで取得されるデータは空なので、その場合表示させないために、この後の処理を行わない if (items.length === 0) {

    return } const itemsWithJustCount = items.slice(0, pageLimit) let newItemsByPage: Array<Array<SchemaModelType>> = [] // すでに取得済みのページのデータがある場合はその分を除く if (hasAlreadyFetched) { newItemsByPage = [...itemsByPage.slice(0, itemsByPage.length - 1), itemsWithJustCount] } else { newItemsByPage = [...itemsByPage, itemsWithJustCount] } // 余りのデータがある場合は次回利用する用に保持 const carryOverItems = items.slice(pageLimit) if (carryOverItems.length > 0) { newItemsByPage.push(carryOverItems) } setItemsByPage(newItemsByPage) setCurrentPage(selectedPage) } 解決策の具体的なコード:ページネーション用の関数(2/3)
  62. 95 // データの合計数がlimitの正数倍だと、最後のページでもnextTokenがあるというAppSyncの仕様がある // そのnextTokenで取得されるデータは空なので、その場合表示させないために、この後の処理を行わない if (items.length === 0) {

    return } const itemsWithJustCount = items.slice(0, pageLimit) let newItemsByPage: Array<Array<SchemaModelType>> = [] // すでに取得済みのページのデータがある場合はその分を除く if (hasAlreadyFetched) { newItemsByPage = [...itemsByPage.slice(0, itemsByPage.length - 1), itemsWithJustCount] } else { newItemsByPage = [...itemsByPage, itemsWithJustCount] } // 余りのデータがある場合は次回利用する用に保持 const carryOverItems = items.slice(pageLimit) if (carryOverItems.length > 0) { newItemsByPage.push(carryOverItems) } setItemsByPage(newItemsByPage) setCurrentPage(selectedPage) } 解決策の具体的なコード:ページネーション用の関数(2/3)
  63. 96 const handleNextPage = () => { if (currentPage <

    itemsByPage.length || hasMorePages) { void fetchEnoughItems(currentPage + 1) } } const handlePreviousPage = () => { if (1 < currentPage) { void fetchEnoughItems(currentPage - 1) } } const handlePageChange = (selectedPage: number) => { if (1 <= selectedPage && selectedPage <= itemsByPage.length) { void fetchEnoughItems(selectedPage) } } useEffect(() => { void fetchEnoughItems(1) }, [toggleOnUpdate]) 解決策の具体的なコード:ページネーション用の関数(3/3)