Serein
Rust로 만든 가볍고 성능 좋은 Discord 전용 네이티브 클라이언트
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Next.js App Router の教材をベースにした Twitter(X)風アプリ。要件は docs/functions.md、画面遷移は docs/front_design.md を参照。
| 層 | 場所 | 役割 |
|---|---|---|
| shared | shared/ | UUID正規化など業務知識を含まない処理。他の層をimportしない |
| domain | domain/ | entities/(ユーザーの型)、value_objects/(入力ルール・画像の状態)、repository_ifs/(永続化の契約)、external_service_ifs/(パスワードハッシュ・セッション終了・ストレージ・画像検査の契約)。フレームワークやDB、上位層に依存しない |
| application | application/ | usecases/<機能>/(登録・投稿などの処理)、dtos/<機能>/(一覧型・結果型)、query_service_ifs/<機能>/(タイムライン・プロフィール・お気に入りの読み取り契約)。Next.jsや具体実装に依存しない |
| infra | infra/ | persistence/postgres/(接続・マイグレーション・repositories/・query_services/)と external_services/(scrypt・Vercel Blob・sharp)。domain/applicationの契約を実装する |
| main | main/container/ | 具体実装の組み立て(DI)。Web用は機能別ファイルで server-only・遅延初期化、CLI用は cli/ に置き server-only を使わない |
| app | app/ | 画面、Server Action、Route Handler、Auth.js の設定。サーバー側の処理は main/container/ の取得関数だけを呼び、クライアントコンポーネントからは import しない |
DB は Vercel の Storage から作成する Postgres(Neon)を使う。
pnpm dlx vercel link)。POSTGRES_URL などの環境変数が自動で登録される。AUTH_SECRET(openssl rand -base64 32 の値)を追加する。AUTH_URL は Vercel 上では設定しない。pnpm install
pnpm dlx vercel env pull .env.local # POSTGRES_URL などを .env.local に保存
pnpm db:migrate # POSTGRES_URL_NON_POOLING(なければ POSTGRES_URL)に適用
pnpm dev
.env.local が .env より優先される。ローカル専用の AUTH_URL などは .env に書く(.env.example 参照)。vercel env pull で取得するDBは本番と同じ。開発で分けたい場合は Neon で開発用ブランチを作り、その接続URLを .env.development.local の POSTGRES_URL(プール経由)と POSTGRES_URL_NON_POOLING(プールなし)に書く。pnpm dev と pnpm db:migrate はこちらを優先する。twitter に作成する。pnpm db:migrate を実行する。結合テストと E2E はデータを書き込むため、本番・開発とは別の DB を TEST_POSTGRES_URL に指定する。Neon コンソールでテスト用のブランチ(または別の database)を作り、その接続URLを .env に書く。Vercel の環境変数には登録しない。
pnpm db:migrate --test で TEST_POSTGRES_URL 側にも適用できる(テストは自動で適用する)。
| コマンド | 内容 |
|---|---|
pnpm test:unit | domain・ユースケース・Server Action・UI の単体テスト(DB不要) |
pnpm test:integration | 実 PostgreSQL の Repository と実 scrypt のテスト |
pnpm test:e2e | Playwright。テストDBに向けた本番ビルドを port 3100 で起動して検証 |
pnpm typecheck | 型チェック |
TEST_POSTGRES_URL が必須。未設定、またはアプリ用の URL(Neon のプール経由・プールなしを含む)と同じ DB なら実行前に失敗する。pnpm exec playwright install chromium でブラウザを取得する。/login は登録時と同じ形式ルールでID・パスワードを検証してから認証する。形式違反はAuth.jsへの直接リクエストでも認証ユースケースで拒否する。/login は / に移動する。Cookieの有無だけでは判断しない。twitter.tweets)・フォロー(twitter.follows)・リツイート(twitter.retweets)は 002-create-timeline.sql で作成する。利用者を削除すると関連行もcascadeで削除される。(出来事の日時, 種類, ID) のキーセットで20件ずつページングする。OFFSETは使わない。timestamptz 型のパラメータを Date(ミリ秒)に変換するため、SQLでは ::text::timestamptz で渡している。test/support/timeline-fixtures.ts)。/profile/{ユーザーID} は未ログインでも閲覧できる。自分かどうかはDBの対象UUIDとセッションのUUIDで判断する。follower_id = 本人、フォロワー数は follow_id = 本人 の件数。向きごとの独立したサブクエリで数える。002 までを使う)。/profile/{ユーザーID}/edit は本人だけが使える。画面の表示時に加え、保存のたびにServer Actionでセッションと利用者を確認し、actorId === targetId のときだけ更新する(フォームの値や previousState は権限判断に使わない)。users_display_id_ci_unique の違反だけを競合として扱い、部分的な更新は起きない。revalidatePath でメイン・プロフィール・編集画面の表示を無効化し、表示用Cookie flash_profile で更新通知を出す(登録通知の flash とは別)。textarea の改行はブラウザがCRLFで送るため、自己紹介はサーバーでLFに揃えてから検証・保存する。/profile/{ユーザーID}/delete は本人だけが使える。ページ表示(GET)では削除せず、Server Actionで毎回セッションのUUIDを確認し、actorId === targetId かつ現在のパスワードが一致したときだけ削除する。DELETE FROM twitter.users WHERE id = ... の1文だけで行い、投稿・フォロー・リツイートは既存FKのON DELETE CASCADEで消える(論理削除・復元はなし)。revalidatePath で無効化し、Auth.jsの signOut({ redirect: false }) で認証Cookieを無効化してから /login へ移動する。通知は表示用Cookie flash_account(deleted / signout-failed / account-missing)で出す。BLOB_READ_WRITE_TOKEN / BLOB_STORE_ID(開発は .env.development.local)、テスト用は TEST_BLOB_READ_WRITE_TOKEN / TEST_BLOB_STORE_ID(.env)に書く。003-create-tweet-media.sql を pnpm db:migrate(開発)・pnpm db:migrate --test(テスト)で適用する。既存データの移行は不要。CRON_SECRET を登録する。vercel.json のCronが毎日 18:00 UTC(日本時間3:00)に回収を実行する(Hobbyプランの日次制約に合わせている)。/api/tweet-images/upload で予約し、予約パス(tweet-uploads/{下書きID}/{予約ID})だけに書き込める15分のトークンを受け取って、ブラウザから Blob へ直接アップロードする。画像の本体はアプリのサーバーを通らない(Vercel Functionsの4.5MB上限を避ける)。/api/tweet-images/verify で、サーバーが実体を読み(5MBで打ち切り)、sharpでJPEG/PNGとしてデコードできること・画素数(4,000万以下)・Content-Typeとの一致を確かめる。完了webhookは使わない。/media/{画像ID} からストリームで配信する(private, no-store・nosniff)。DBで公開中の投稿に属することを毎回確認する。pnpm media:cleanup(--test でテスト用)とCronが同じ回収処理を実行する。期限切れの下書きの破棄、時期が来た削除予約の実行、管理パス配下の24時間以上古い孤立した実体の削除を行う。Blobの削除に失敗した予約は残して再試行する。sql.begin)で行う。004-edit-tweet.sql を pnpm db:migrate(開発)・pnpm db:migrate --test(テスト)で適用する(003 の後)。投稿の版(tweets.revision)、下書きの用途(tweet_drafts.purpose = create / edit・対象の投稿・準備時の版)、画像の位置の一意制約の遅延化、削除予約の時刻の変更を含む。updated_at を進める(何も変えない保存は成功するが、版・編集日時は変わらない)。投稿日時(created_at)は変えないので、一覧の並びとカーソルは変わらない。revalidatePath で表示を更新し、成功の通知はルートlayoutのダイアログ管理(TweetComposerProvider)が出す。003・004 の適用が前提)。expectedActorId)と照合する。食い違えば誰の投稿も削除しない(Aで開いてBに切り替えた場合)。未ログインならログイン画面で「ツイートを削除するにはログインしてください」を表示する。DELETE ... WHERE id AND owner_id を1つのトランザクションで行う。画像・リツイートはCASCADE、画像の実体の削除予約は既存のトリガーが同じトランザクション内に作る。ON DELETE SET NULL は投稿のロック後に下書きを更新するため、編集の確定も、下書きから対象を読んだうえで投稿→下書きの順にロックする。下書きを先にロックする方式では、ロック後に作られた編集用の下書きでデッドロックが起き得た(順序を固定した結合テストで確認)。pnpm media:cleanup で消える。開いていた編集用の下書きは対象がNULLになり、その新規画像は下書きの破棄・期限切れで回収される(無関係な投稿・画像は残る)。revalidatePath で / と全プロフィールを更新し、同じ元投稿のカード・リツイートをすべて消す。成功の通知はルートlayoutの TweetComposerProvider が1回だけ出し、フォーカスは一覧の見出しへ移す。pnpm build 成功。削除後の pnpm media:cleanup --test は、トークンの期限前は実体を残し、期限後に削除した投稿の画像だけを回収した(残す投稿の画像は残り、再実行も成功)。005-create-favorites.sql を pnpm db:migrate(開発)・pnpm db:migrate --test(テスト)で適用する(004 の後)。ER図のLike(相手からも見える「♡」)とFavorite(自分専用の保存)は別機能で、Favoriteは自分専用の保存としてLikeとは独立して実装した(ER図の executor_id は実装に合わせて user_id とした)。aria-pressed。押したら次の状態を明示して送り、トグルにしない。FOR KEY SHARE でロックしてから行う。保存済みへの登録は保存日時・UUIDを変えず(ON CONFLICT DO NOTHING)、解除済みへの解除も成功。解除後の再追加は新しい保存日時で先頭に出る。競合中に関連が消えた場合の外部キー違反だけを「対象なし」に変え、他のDBエラーは成功扱いしない。expectedActorId)と現在の利用者が違えば、誰の保存も変えない。失敗時は表示中の状態を変えずに理由を通知する(通知はルートlayoutの ActionFeedbackProvider)。処理中は押せない。/favorites はログイン中の本人だけの一覧(未ログインは /login、URLのuserIdは無視)。保存した日時の降順 (created_at, id) で20件ずつ、元投稿の現在の本文・画像・作者を1ページ1回のSQLで表示する。カーソルは本人のUUIDと一覧の種類に結び付け、他人のカーソルは不正として扱う。空・不正なカーソル・取得障害を区別し、解除して空になったページにも先頭へのリンクを出す。/favorites を再取得させる。pnpm build 成功。開発用DB(Neonの開発ブランチ)にも005を適用済み。app/ui/tweets/invoke-dialog-action.ts で包む。NETWORK_ERROR、クライアントだけが作るコード)へ変える。redirect()・notFound() 等のNext.jsの制御例外は導入版の公開API unstable_rethrow で同じ値のまま再throwし、投稿成功後の /・未ログイン時の /login への移動を妨げない(unstableなAPIへの依存はこのファイルだけ)。readOnly)。/ へ移動し、編集は再送として成功(版・画像・削除予約を増やさない)、削除は「このツイートは既に削除されています」として閉じて一覧を更新する。app/ui/tweets/tweet-composer.tsx)。サーバーが返した準備の失敗の理由(「このツイートは削除できません」など)は対象外で、次の準備操作まで表示する。006-create-likes.sql を pnpm db:migrate(開発)・pnpm db:migrate --test(テスト)で適用する(005 の後)。ER図のLike(executor_id / tweet_id / created_at)のとおり、Favoriteとは別の twitter.likes に保存し、利用者・元投稿ごとに1行。aria-pressed、件数は公開(読み上げは「いいねN件」)。押したら次の状態を明示して送り、トグルにしない。FOR KEY SHARE、元投稿を FOR NO KEY UPDATE でロックして(お気に入りと同じ利用者→投稿の順)同じ元投稿への操作を直列化し、最後に件数を読み返す。登録済みへの登録はUUID・日時を変えず、解除済みへの解除も成功。解除後の再登録は新しい日時で公開一覧の先頭に出る。投稿の本文・日時・版は変えない。競合中に関連が消えた場合の外部キー違反だけを「対象なし」に変え、他のDBエラーは成功扱いしない。invokeDialogAction で同じ値のまま再throwする。処理中は押せない。?tab=likes(「いいね」タブ)は、その利用者がいいねした元投稿の公開一覧で、未ログインでも閲覧できる。いいねした日時の降順 (created_at, id) で20件ずつ、元投稿の現在の本文・画像を表示する(投稿の日時は変えず、ホームの新しい出来事にもしない)。カーソルは対象者のUUIDとlikesタブに結び付け、投稿・リツイートのカーソルとは混ぜない。test/integration/postgres-likes.test.ts ほか)・E2E(test/e2e/likes.spec.ts)・pnpm build はコーディネーターの確認待ち。twitter.retweets(executor_id / tweet_id の一意制約)を使い、利用者・元投稿ごとに1行。引用リツイートは扱わない。aria-pressed、読み上げは「リツイートN件」)で元投稿のリツイートを登録・解除する。カードの並びはリツイート→いいね→お気に入り(右端の旗)。リツイートのカードで押しても対象は元投稿のUUIDで、表示中のリツイート行やその実行者は送らない。押したら次の状態を明示して送り、トグルにしない。FOR KEY SHARE、元投稿を FOR NO KEY UPDATE でロックして(いいね・お気に入りと同じ利用者→投稿の順。投稿を先にロックすると退会のCASCADEとデッドロックする)同じ元投稿への操作を直列化し、同じトランザクションで件数を読み返す。登録済みへの登録はUUID・日時を変えず、タイムラインの順位も上がらない。解除済みへの解除も成功。解除後の再実行は新しいUUID・日時となり、タイムラインの先頭側へ出る。元投稿の本文・日時・版・画像、お気に入り・いいねは変えない。競合中に関連が消えた場合の外部キー違反だけを「対象なし」に変え、他のDBエラーは成功扱いしない。invokeDialogAction で同じ値のまま再throwする。処理中は押せない。test/integration/postgres-retweets.test.ts)・E2E(test/e2e/retweets.spec.ts)・pnpm build はコーディネーターの確認待ち。twitter.follows(follower_id / follow_id の一意制約、自己フォロー禁止のCHECK)を使い、方向ごとに1行。鍵アカウント・承認・ブロックは扱わない。aria-pressed)を表示し、以前の操作できない「フォロー中」表示を置き換えた。自分のプロフィールには出さない。対象は表示IDではなく表示中のプロフィールのUUIDで送るので、表示後に相手がIDを変えたり、旧IDを別人が使ったりしても、表示した相手を操作する。押したら次の状態を明示して送り、トグルにしない。users 行をUUID順に FOR UPDATE でロックしてから行う(相互フォローの同時操作で逆順にロックしない。退会とも直列化する)。登録は ON CONFLICT (follower_id, follow_id) DO NOTHING で、登録済みへの登録は関係のUUID・日時を変えない。解除は follower_id = 本人 AND follow_id = 対象 だけを消し、逆方向の行には触れない。解除済みへの解除も成功。解除後の再登録は新しいUUID・日時となり、一覧の先頭に出る。競合中に消えた利用者の外部キー違反だけを「対象なし」(本人なら未ログイン)に変え、他のDBエラーは成功扱いしない。invokeDialogAction で同じ値のまま再throwする。処理中は押せない。test/integration/postgres-follows.test.ts)・E2E(test/e2e/follows.spec.ts、変更した test/e2e/profile-screen.spec.ts)・退会時の両方向CASCADE・pnpm build はコーディネーターの確認待ち。signOut({redirect:false,redirectTo:'/login'}) を使い、認証Cookieの削除はAuth.jsに任せる。成功後は /login に移動し「ログアウトしました」を3秒表示する。通知は一度だけ消費し、再読み込みでは出さない。auth() のUUIDと画面表示時のUUIDを照合し、別の利用者に切り替わっていたら、その利用者をログアウトさせない。認証そのものが失敗したときは処理せず、既に匿名の場合は残っているCookieを清掃できる。ユーザーDBの実在確認やDB接続をログアウトの前提にしない。通信失敗対応、公開いいね、リツイート操作、フォロー操作、通常ログアウトを実装し、既存の登録・編集・削除・お気に入りと合わせて検証した。単体/UI1011件、結合164件、全E2E105件、型チェック、本番ビルドが成功。いいね・フォロー・ログアウトは全体後の22件の再実行でもすべて成功した。詳細は 全体計画 を参照。
006はテストDBと開発用DBに適用済み。本番DB・本番Blobへの反映と、デプロイ環境での5MiB画像確認はリリース時の別作業として残る。