labs 機能(Zenn books 風ハンズオン教材)の GitHub 同期 + admin 閲覧
起票日: 2026-07-28
関連:
GitHub webhook の非同期化(articles 同期の現行フロー)
logs 管理画面のアーキテクチャ決定(admin.shuntaka.dev の基盤)
コンテンツ元:
~/repos/github.com/shuntaka9576/lab-backup-2026-0530(旧 lab サイトのバックアップ)
ステータス: 完了(2026-07-28 リリース 2026.0728.0 で prd 反映済み)
起票理由
CS 知識を蓄積するハンズオン教材(旧 lab サイト)を、Zenn の books のような「1 冊 = 複数チャプター」の形式で復活させたい。
旧構成(lab-backup-2026-0530)は Astro SPA + Cognito + CloudFront Signed URL の独立インフラで、コンテンツが .astro ファイルにハードコードされており執筆体験が悪く、インフラも本体と二重管理だった。今回は以下の方針で作り直す。
コンテンツは private リポジトリで MDX + 画像として管理し、既存の articles と同じ GitHub Apps webhook 同期の仕組みで TiDB に取り込む
閲覧は admin.shuntaka.dev/labs。認証は既存 admin の Cognito セッション(同一オリジン Cookie)に相乗りするが、フロントは admin-web に同居させず独立 SPA(マイクロフロントエンド) として実装し、CloudFront のパスルーティングで合成する。独立認証基盤は作らない
画像は認証なしの公開配信でよい(旧構成の Signed URL / DDB ACL のような保護はしない)。既存の
images.shuntaka.dev(CloudFront + S3 OAC)の仕組みに載せる
現状整理
旧 lab-backup の資産(移植対象)
種別 |
場所 |
内容 |
|---|---|---|
本文 |
|
db(occ / write-skew / deadlock / deleted-at-index / glossary)、raft(overview / leader-election-minimal)、security の各ページ。本文が Astro コンポーネント内にハードコード |
画像 |
|
db / raft / security 配下に PNG 約 20 枚 |
ハンズオンコード |
|
チャプターから参照する演習・解答コード |
旧構成のインフラ(Cognito / BFF / Signed URL / DDB ACL / Lambda@Edge)は一切引き継がない。
流用する既存の仕組み
articles 同期: GitHub App webhook →
POST /webhooks/github(署名検証 + push/main 判定のみ)→ 自己 Lambda Event invoke →POST /eventsで実処理。apps/blog-api/api/src/handler/webhooks.rsinstallation.id →
users.github_installation_idでユーザー解決(webhooks.rs:307付近)。lab リポジトリを同じ GitHub App のインストールに追加すれば installation.id は変わらず、この解決ロジックはそのまま使えるMarkdown → HTML 事前生成:
apps/blog-api/markdowncrate(comrak + syntect、Zenn 風記法)で同期時にcontent_htmlを生成し DB 保存(articles と同じ)admin 基盤: CloudFront(
admin.<fqdn>/images.<fqdn>の 2 エイリアス)+ SPA(apps/admin-web)+ Hono API(apps/admin-api)+ Cognito SRP + セッション Cookie。iac/aws/lib/admin/admin-stack.ts
設計方針
論点 |
決定 |
|---|---|
コンテンツリポジトリ |
新規 private リポジトリを stage ごとに 2 つ 作成: |
リポジトリ判別 |
webhook payload の |
フロント構成 |
マイクロフロントエンド。labs リーダーは新規 SPA |
labs-web の FW |
Svelte 5 + SvelteKit(adapter-static)。admin-web(React + TanStack)とあえて別構造にし、新 FW の実験台を兼ねる。マイクロフロントエンドなので FW が違ってもオリジン合成に影響しない |
コンテンツ形式 |
拡張子は |
データモデル |
|
章の順序 |
Zenn books と同じく |
削除の扱い |
リポジトリから消えた lab / chapter は DB からハード削除(admin 閲覧専用なので影響範囲が閉じている)。articles の upsert-only とは異なる点に注意 |
画像の配信 |
認証なし公開。新規 S3 バケット(AdminStack 所有・物理名固定 |
画像の同期 |
webhook 同期時に blog-api が GitHub から取得し S3 に put(コピー。ソースオブトゥルースはリポジトリ)。GitHub blob sha を S3 オブジェクトメタデータ |
ベクトル検索 / embedding |
対象外。lab_chapters は PLaMO embedding チャンク生成をしない |
公開サイトへの露出 |
スコープ外。閲覧は admin のみ。将来ブログ本体に公開する場合は published フラグを使って別タスクで対応 |
ハンズオンコード |
同リポジトリ |
マイクロフロントエンド構成の詳細
admin CloudFront(admin.<fqdn> / images.<fqdn> の 2 エイリアス、1 ディストリビューション)にパスで別 origin を足す、ルート単位の合成方式。module federation のようなランタイム統合はしない。
パス |
origin |
用途 |
|---|---|---|
|
admin-web SPA バケット(既存) |
管理画面本体 |
|
API Gateway → admin-api(既存) |
labs の read API もここに追加 |
|
moments 画像バケット(既存) |
変更なし |
|
labs-web SPA バケット(新規) |
labs リーダー SPA |
|
lab 画像バケット(新規、MainStack 所有) |
lab 教材画像(認証なし) |
labs-web は SvelteKit の
paths.base = '/labs'+ adapter-static(fallback: 'index.html')でビルドし、専用バケットに独立デプロイ。admin-web と互いのリリースに影響しない認証は同一オリジンなので既存のセッション Cookie(
__Host-session)がそのまま/api/*に飛ぶ。labs-web は/api/meで判定し、未認証なら/login?returnTo=/labs/...へ遷移(ログイン画面は admin-web 側のまま。returnTo 対応だけ追加)CloudFront Function(HostGuardFunction)の SPA fallback rewrite を「
/labs/配下の拡張子なし URI →/labs/index.html、それ以外 →/index.html」に拡張。Host ガード(admin ホスト以外 403)は/labs/*にも適用する将来 labs を公開サイト化する場合も、labs-web バケットを別ディストリビューション(例: labs.shuntaka.dev)に向け直すだけで済む
画像バケットの所有スタック(Phase C で MainStack → AdminStack に変更)
当初は「MainStack 所有 + AdminStack が props で受け取り behavior を張る」計画だったが、実装時に CloudFront OAC はバケットポリシーに distribution ARN を書き戻すため、MainStack のバケット × AdminStack の distribution の組み合わせは相互参照(循環依存)になることが判明。以下の構成に変更した。
バケットは AdminStack 所有(distribution と同一スタックなので OAC のポリシー付与が閉じる)
バケット名は 物理名固定(
<physicalPrefix>-lab-assets)。blog-api(MainStack)の envLAB_IMAGES_BUCKET_NAMEには config から同じ文字列を計算して渡すため、MainStack → AdminStack のクロススタック参照が発生しないblog-api Lambda への書き込み許可は、MainStack から Lambda ロール ARN を AdminStack に渡し、AdminStack 側で iam.Policy をアタッチする(
lab-assets/*への PutObject / GetObject。HeadObject は GetObject 権限で足りる)。依存方向は AdminStack → MainStack の一方向のみ
既存の moments 画像バケットを流用しない理由は従来どおり(bucketName がクロススタック token になり循環するため + prefix 運用の混在を避ける)。S3 キーと URL パスは 1:1 に揃える。
リポジトリ: labs/<lab-slug>/images/foo.png
S3 キー: lab-assets/<lab-slug>/images/foo.png
URL: https://images.shuntaka.dev/lab-assets/<lab-slug>/images/foo.png
コンテンツリポジトリ構成(lab-contents / lab-contents-dev 共通)
labs/
├── db/
│ ├── config.yaml # 本のメタ情報 + 章の順序
│ ├── occ.mdx # 章(frontmatter に title)
│ ├── write-skew.mdx
│ ├── deadlock.mdx
│ ├── deleted-at-index.mdx
│ ├── glossary.mdx
│ └── images/ # この本の画像(サブディレクトリ可)
│ └── occ/snapshot-3-engines.png
├── raft/
│ ├── config.yaml
│ ├── overview.mdx
│ ├── leader-election-minimal.mdx
│ └── images/
└── security/
├── config.yaml
├── tanstack-npm-supply-chain.mdx
└── images/
handson/ # 同期対象外(章から GitHub リンクで参照)
├── raft/
└── sql/
config.yaml スキーマ
title: 'DB トランザクション演習'
summary: 'OCC / write skew / deadlock などを 3 エンジンで比較するハンズオン'
published: true # false なら admin 一覧でドラフト表示
chapters: # この順序が position になる。拡張子なしの slug
- occ
- write-skew
- deadlock
- deleted-at-index
- glossary
章 frontmatter スキーマ
---
title: '楽観的同時実行制御 (OCC)'
---
title のみ必須。章 slug はファイル名から拡張子を除いたもの(articles と同じ規則)。本文中の画像は images/... からの相対パスで書く(同期時に公開 URL へ書き換え)。
ウィジェット記法(インタラクティブ表現)
旧 lab の StepRow / EnginePane(3 エンジン比較タブ)のような表現力を、Markdown ベースのまま失わないための仕組み。ブログの X 埋め込みと同じ「プレースホルダ + hydration」パターンを汎用化する。
記法(章の Markdown 内):
:::widget engine-steps
num: 1
title: "A が BEGIN + UPDATE +10"
description: "..."
panes:
mysql: { command: ..., output: ..., note: ..., result: ok|error|block|idle }
postgres: ...
dsql: ...
:::
変換の契約(プレビューの extractWidgets と本実装の markdown crate で共通):
:::widget <name>〜:::のブロックを<div class="lab-widget" data-widget="<name>" data-payload="<base64(YAML)>"></div>に置換する。ペイロードは Markdown として解釈しないlabs-web 側は
content_html描画後に.lab-widget[data-widget]を走査し、src/lib/widgets/hydrate.tsのレジストリにあるウィジェットへ Svelte コンポーネント(mount())を差し込む。章遷移時は cleanup でunmount()未知のウィジェット名は console.warn のみで無視(前方互換)。ブログ側は hydration しないので影響なし
第一号は
engine-steps(src/lib/widgets/EngineSteps.svelte)。新ウィジェットはレジストリに追加するだけ
執筆時の注意: YAML のブロックスカラーで 1 行目の先頭に空白がある場合(psql 出力の id | ... など)は、インデント指示子つき |2- を使わないと 2 行目以降でブロックが途切れる。
同期フロー
push (main) → POST /webhooks/github
→ 署名検証 + push/main 判定(既存 screen_push_event)
→ repository.full_name == LAB_REPO_FULL_NAME なら kind=lab の封筒で自己 Event invoke
→ POST /events → process_lab_push_event
1. installation token 取得(既存 GithubClient)
2. Git Trees API (GET /repos/{o}/{r}/git/trees/{head}?recursive=1) で labs/ 配下を一括列挙
※ articles 同期の contents API 方式と異なり、ネストした images/ を 1 リクエストで拾うため
3. lab ごとに config.yaml を取得・パース
4. 画像: S3 HeadObject でメタデータ github-sha を比較し、差分のみ PutObject
5. 章: .mdx / .md を取得 → frontmatter パース → 画像相対パスを
{IMAGES_BASE_URL}/lab-assets/<lab-slug>/images/...?v=<sha8> に書き換え → markdown crate で HTML 変換
6. labs / lab_chapters に upsert(content 不変なら HTML 再生成スキップ、articles と同じ差分判定)
7. リポジトリに存在しない lab / chapter の行を削除
章数が増えても articles と同様に全件フルスキャン + 冪等 upsert。10 秒制約は自己 invoke で既に回避済み
画像取得は contents API の base64(1MB 超は
Accept: application/vnd.github.raw)を使う同期対象は
labs/配下のみ。handson/等の変更でも webhook は飛ぶが、冪等なので差分なしで完走する
DB スキーマ案
tools/dsql-cli/dsl-tidb/schema/ に追加(既存スタイル踏襲: FK なし、CHECK なし、${SCHEMA} プレースホルダ)。
10_labs.sql
CREATE TABLE IF NOT EXISTS `${SCHEMA}`.`labs` (
`lab_id` CHAR(36) NOT NULL DEFAULT (UUID()),
`user_id` CHAR(36) NOT NULL,
`slug` VARCHAR(255) NOT NULL,
`title` VARCHAR(500) NOT NULL,
`summary` TEXT NULL,
`published` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`lab_id`),
UNIQUE KEY `uq_labs_slug` (`slug`),
KEY `idx_labs_user_id` (`user_id`)
);
11_lab_chapters.sql
CREATE TABLE IF NOT EXISTS `${SCHEMA}`.`lab_chapters` (
`chapter_id` CHAR(36) NOT NULL DEFAULT (UUID()),
`lab_id` CHAR(36) NOT NULL,
`slug` VARCHAR(255) NOT NULL,
`title` VARCHAR(500) NOT NULL,
`position` INT NOT NULL,
`content` LONGTEXT NOT NULL,
`content_html` LONGTEXT NULL,
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`chapter_id`),
UNIQUE KEY `uq_lab_chapters_lab_slug` (`lab_id`, `slug`),
KEY `idx_lab_chapters_lab_position` (`lab_id`, `position`)
);
適用は既存運用どおり個別に sed 's|${SCHEMA}|blog_dev|g' schema/10_labs.sql | mysql -h tidb.$TAILNET ...(load.sh 全実行は 04 の ALTER が非冪等なので不可)。適用後 docs/ で bun run doc-gen(tbls)。
変更対象コンポーネント
blog-api(Rust)
crate |
変更 |
|---|---|
shared |
|
kernel |
|
infrastructure |
|
adapter |
|
api |
|
markdown |
|
iac/aws(CDK)
MainStack: lab 画像用 S3 バケット新設(OAC 前提、パブリックブロック維持)、blog-api Lambda に
grantPut、envLAB_REPO_FULL_NAME/LAB_IMAGES_BUCKET_NAME追加(IMAGES_BASE_URLは設定済み:iac/aws/lib/api/blog-api-construct.ts:214)AdminStack:
labs-web SPA 用 S3 バケット新設 +
BucketDeployment(apps/labs-web/dist。admin-web の SpaDeployment とはバケットを分け、互いの deploy が prune し合わないようにする)additionalBehaviors['/labs/*']→ labs-web SPA バケット(OAC)。HostGuardFunction(Host ガード + SPA fallback rewrite の/labs/index.html対応)をアタッチadditionalBehaviors['/lab-assets/*']→ lab 画像バケット(MainStack から props で受領、OAC)。ガード関数なし(/images/*と同じ公開扱い)
bin/cdk.ts: mainStack → adminStack へ lab 画像バケットを props 渡し(作成順そのまま、循環なしを synth で確認)
admin-api(Hono)
db/types.tsにlabs/lab_chaptersテーブル型を追加routes/labs.ts新設(既存 auth middleware 配下、read-only)GET /api/labs— 一覧(slug, title, summary, published, 章数, updated_at)GET /api/labs/:slug— 本の詳細 + 章一覧(slug, title, position)GET /api/labs/:slug/chapters/:chapterSlug— 章本文(content_html)
labs-web(新規 SPA / マイクロフロントエンド、Svelte 5)
apps/labs-webを新設。Svelte 5(runes)+ SvelteKit + adapter-static + Tailwind CSS 4。SSR はしない(全ルートexport const ssr = falseの SPA 構成、fallback: 'index.html')svelte.config.jsでpaths.base = '/labs'。ルートはroutes/+page.svelte(一覧)/routes/[labSlug]/+page.svelte(章一覧)/routes/[labSlug]/[chapterSlug]/+page.svelte(リーダー)リーダー画面は左サイドバーに章ナビ + 前後章リンク(Zenn books 風)。本文は
content_htmlを{@html}で描画記事 HTML 用 CSS: syntect がインラインスタイルを吐くか確認の上、apps/web の記事表示スタイルから必要分を labs-web に移植。Svelte の scoped CSS とは分けてグローバル CSS として持つ(
{@html}部分にはスコープが効かないため)認証・API クライアントは admin-web の実装(
/api/me判定、Cookie 前提の fetch、401 で/login?returnTo=へ)を参考に Svelte 用へ小さく書き直す。fetch ラッパー程度なので FW 横断の共有パッケージ化はしないコード品質チェック: oxlint は
.svelteを見ないため、labs-web にはsvelte-check+prettier-plugin-svelteを導入し、workspace のcheckタスク(Turbo)にsvelte-checkをぶら下げる。cspell は既存設定のまま対象に含める
admin-web(既存、最小変更)
ナビに labs へのリンク追加(
<a href="/labs/">。SPA が別物なのでフルページ遷移)login ページに
returnToクエリ対応を追加(同一オリジンのパスのみ許可)
実装フェーズ
[x] Phase 0: ローカルプレビュー(
apps/labs-webの Svelte プロトタイプ + モック API で Zenn books 風リーダーの体験確認。DB・同期・CDK には触れない)# ルートの `bun run dev` (turbo) に labs-web も含まれる。 # labs-web の dev タスク (preview/dev.ts) がモック API と vite を両方起動する。 # 単体起動なら: cd apps/labs-web && bun run dev # API + vite (個別は dev:api / dev:web) # ポートは worktree ごとに .env.local で採番。bun run port の labs-web 行の URL を開く # (main worktree の既定は labs-web: 43006 / labs-api: 43007)
[x] Phase A: DB スキーマ追加(10/11 SQL 作成 → blog_dev 適用 → tbls doc-gen)
[x] Phase B: blog-api の lab 同期実装(config / 分岐 / Trees API / S3 / 変換 / upsert・delete)+ ユニットテスト
[x] Phase C: CDK 変更(バケット / grant / env / CloudFront behavior)→ dev デプロイ
[x] Phase D: コンテンツリポジトリ
lab-contents-dev/lab-contents作成、lab-backup から db / raft / security を MDX 化して lab-contents-dev へ移植(画像パスをimages/...相対に統一、handson コードも同 repo へ)[x] Phase E: dev 用 GitHub App のインストールに
lab-contents-devを追加 → push して dev(blog_dev / shuntaka.tech)で同期確認 ※ 必ず Phase C(分岐デプロイ)の後。先にインストールすると lab の push が articles 同期として走り、Event invoke がエラーリトライする[x] Phase F: admin-api の labs read API +
apps/labs-web新設(Svelte 5 + SvelteKit adapter-static、CloudFront/labs/*合成、admin-web は returnTo 対応とナビリンクのみ)[x] Phase G: prd 反映(blog_prd DDL → デプロイ → prd 用 GitHub App に
lab-contentsを追加 → 同期・表示確認)、開発ドキュメント(01_development.md)への手順追記
スコープ外(将来タスク候補)
MDX の JSX コンポーネント実行(インタラクティブ教材)
ブログ本体(shuntaka.dev)への lab 公開、全文検索・ベクトル検索への組み込み
S3 上の孤児画像のクリーンアップ(削除章の画像は当面残置)
旧 lab-backup リポジトリ・旧 AWS リソース(LabSite スタック群)の後片付け
本番適用手順(Phase G)
dev で全フェーズ検証済みの状態から prd へ反映する手順。順序厳守(特に手順 5 は 4 のデプロイ完了後)。
1. blog_prd へ DDL 適用
export TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
cd tools/dsql-cli/dsl-tidb
sed 's|${SCHEMA}|blog_prd|g' schema/10_labs.sql | mysql -h tidb.$TAILNET -P 4000 -u root
sed 's|${SCHEMA}|blog_prd|g' schema/11_lab_chapters.sql | mysql -h tidb.$TAILNET -P 4000 -u root
# 確認
mysql -h tidb.$TAILNET -P 4000 -u root -e 'SHOW CREATE TABLE blog_prd.labs\G SHOW CREATE TABLE blog_prd.lab_chapters\G'
CREATE TABLE IF NOT EXISTS のみで冪等。既存テーブルへの ALTER は無いため blog_prd の他テーブルに影響しない。
2. PR #733 を Ready 化してマージ
マージで main push → dev CDK の自動デプロイが走る(デプロイ済み内容と同一なので冪等)。
3. tagpr リリース PR のマージ(人間が実施)
main マージ後に tagpr が作成・追従するリリース PR(tagpr ラベル付き)をマージすると、CalVer タグ → prd CDK(st-tidb-proxy → main → admin の順)→ Vercel 本番の順にデプロイされる。prd 側に入るもの:
p-st-main: blog-api に
LAB_REPO_FULL_NAME=shuntaka9576/lab-contents/LAB_IMAGES_BUCKET_NAME=p-st-lab-assetsp-st-admin: LabImagesBucket(p-st-lab-assets、RETAIN)/ LabsSpaBucket + labs-web SPA /
/labs/*/lab-assets/*behavior / labs read API 入り admin-api
4. prd デプロイの完了確認
gh run list --workflow=tagpr.yaml --limit 1 # または Actions 画面
curl -s -o /dev/null -w '%{http_code}\n' https://admin.shuntaka.dev/labs/ # 200
curl -s -o /dev/null -w '%{http_code}\n' https://admin.shuntaka.dev/api/labs # 401 (認証必須)
5. prd 用 GitHub App に lab-contents を追加(人間が実施)
GitHub Settings → Developer settings → GitHub Apps → prd 用 App(webhook URL が api.shuntaka.dev のもの)→ Install App → Repository access に lab-contents を追加。installation_id は変わらないため users テーブルの更新は不要。必ず手順 4 の完了後に行う(先に追加すると push が articles 同期として走りエラーリトライになる)。
6. lab-contents へコンテンツを push
dev で検証済みの lab-contents-dev の内容をそのまま prd リポジトリへ push する。
cd ~/repos/github.com/shuntaka9576/lab-contents-dev
git remote add prd git@github.com:shuntaka9576/lab-contents.git # 初回のみ
git push prd main
以後の運用も同じ流れ(lab-contents-dev で執筆・dev 確認 → 確定分を git push prd main)。公開したい本は config.yaml の published: true を忘れずに。
7. prd 同期の確認
mysql -h tidb.$TAILNET -P 4000 -u root -e "SELECT l.slug, l.published, COUNT(c.chapter_id) FROM blog_prd.labs l LEFT JOIN blog_prd.lab_chapters c ON c.lab_id = l.lab_id GROUP BY l.slug, l.published ORDER BY l.slug;"
curl -s -o /dev/null -w '%{http_code}\n' 'https://images.shuntaka.dev/lab-assets/001-db-transaction-exercises/images/deadlock/deadlock-compare.png' # 200
ブラウザで https://admin.shuntaka.dev/labs/ にログインして一覧・章・画像・engine-steps ウィジェットを確認。
切り戻し
同期を止める: prd 用 App の Repository access から lab-contents を外す(既存の articles 同期には影響なし)
表示を消す: lab-contents 側で labs/ 配下を空にして push すれば全 lab がハード削除される。テーブル自体は残しても他機能に影響なし
インフラの切り戻しは通常のリリースフロー(revert PR → タグリリース)に従う
作業ログ
2026-08-12
ローカルプレビューで本番反映前の
lab-contentsを直接確認できるよう、preview/server.tsのLAB_CONTENTS_DIR未指定時の参照先を~/repos/github.com/shuntaka9576/lab-contents-devから~/repos/github.com/shuntaka9576/lab-contentsへ変更。必要な場合は引き続きLAB_CONTENTS_DIRで任意のリポジトリルートに切り替え可能
2026-07-28(Phase G / リリース)
blog_prd へ DDL 適用(labs / lab_chapters)
PR #733 をマージ後、リリース 2026.0728.0(PR #737)で prd デプロイ。blog_prd に 3 labs / 8 章同期、images.shuntaka.dev/lab-assets の画像配信 200、admin.shuntaka.dev/labs/ 200 / /api/labs 401 を確認
チェンジログ整理のハマりどころ: 当初、リリース PR の不要項目を除くために CalVer タグを手動で打ったが、これは tagpr が管理する成果物の模倣で、tagpr の追従状態を壊す悪手だった(置き去りリリース PR #717 と重複 PR #736 が発生し、いずれもクローズして手動タグも削除)。正解は
.github/release.ymlの除外設定を使い、対象 PR にskip-changelogラベルを付けて tagpr を再実行するだけ。tagpr まわりはタグ・リリース PR を手で作らず、ラベル操作と再実行のみで扱うこと
2026-07-28
起票。lab-backup-2026-0530 の資産棚卸しと、articles 同期・admin 基盤・DB スキーマの現状調査を実施し本計画を作成
フロント構成を admin-web 同居からマイクロフロントエンド(
apps/labs-webを新設し CloudFront/labs/*behavior でパス合成)に変更。これに伴い lab 画像の配信パスを/labs/*→/lab-assets/*に変更(/labs/*は SPA が使用)labs-web の FW を Svelte 5 + SvelteKit(adapter-static) に決定。admin-web(React + TanStack)とあえて別構造にして新 FW の実験台を兼ねる。候補比較は Svelte 5 / SolidStart / TanStack Start SPA mode / Qwik City の 4 つで実施
コンテンツリポジトリ名を stage 別の 2 本(dev:
lab-contents-dev/ prd:lab-contents)に決定。webhook URL が stage ごとの blog-api を向いている既存の GitHub App 構成に合わせる実装順を変更し、本実装の前に Phase 0: ローカルプレビュー(labs-web プロトタイプ + モック API)で UI と読書体験を先に確認することにした
Phase 0 を実装。
apps/labs-webに SvelteKit(Svelte 5 runes / adapter-static /paths.base='/labs'/ Tailwind 4)の 3 画面(一覧・章一覧・リーダー)と、preview/server.ts(Bun + Hono。admin-api に将来実装する labs read API と同じ契約で、preview/contents/labs/を packages/markdown-wasm で変換して返すモック)を追加。サンプルとして lab-backup から raft の 2 章(フル変換)+ db の 2 章(抜粋)+ 画像 10 枚を計画のリポジトリ構成(config.yaml + mdx + images/)どおりに配置動作確認済み: 章サイドバー・前後章ナビ、
rust:src/lib.rs形式のファイル名タブ + syntect ハイライト、:::message/:::message alert/:::details、テーブル、画像の/lab-assets/*配信(相対images/参照の書き換え)。記事 CSS は apps/web の globals.css から prose / message / code-block / link-card / github-embed をsrc/article.cssに抽出して流用同期時の画像 URL に
?v=<blob sha 先頭8桁>を付与する方針を追記(同名差し替え時の CloudFront キャッシュ対策。invalidation 不要になる)補足: svelte-check と bun-types が衝突するため labs-web の tsconfig は
types: []とし、preview/server.ts側に/// <reference types="bun" />を置いたポート管理に組み込み:
.config/wt.toml(pre-start の.env.local生成 + post-remove kill)とscripts/port.shにLABS_WEB_PORT(main 既定 43006)/LABS_API_PORT(同 43007)を追加。vite.config.ts / preview/server.ts は env からポートを読む。既存 worktree への反映はwt hook pre-start env --dry-runでレンダリング値を確認して.env.localに追記(フック本実行は approval が要るため)ルート
bun run dev(turbo)が labs-web の dev タスクを拾うようになったため、devスクリプトをpreview/dev.ts(モック API を同一プロセスで起動 + vite dev を子プロセス起動、SIGINT/SIGTERM 転送)に変更。vite 単体はdev:web。turbo からの起動・シグナル終了時の両ポート解放を確認済みUI は Phase 0 プレビューで承認。コンテンツ形式は「.mdx 拡張子のまま、中身は当面 Zenn 風 Markdown(JSX 非実行)」で確定。インタラクティブ表現はプレースホルダ + Svelte hydration 方式を第一候補、本物の MDX 化は必要時に別タスクとする
「旧 lab の表現力を失いたくない」という要望を受けて、ウィジェット記法(
:::widget)を設計しプレビューに実装。第一号として旧 StepRow / EnginePane 相当のengine-steps(3 エンジンタブ切り替え、command / output / note / 結果バッジ)を Svelte で再現し、occ 章を旧 lab の実データで全 7 ステップ移植して動作確認した。プレビューはpreview/server.tsの JS 変換で実現しており、本実装では同一契約を markdown crate(Rust)に移す(Phase B)ハマりどころ: YAML ブロックスカラーの 1 行目が先頭空白つき(psql 出力)だとインデント自動判定が 7 になり以降の行で途切れる。
|2-の明示で解決(執筆ガイドとして記録)Phase A 完了。
schema/10_labs.sql/11_lab_chapters.sqlを作成し blog_dev に個別適用(sed 's|${SCHEMA}|blog_dev|g' schema/1X_*.sql | mysql -h tidb.$TAILNET -P 4000 -u root)。updated_atは moments と同じON UPDATE CURRENT_TIMESTAMP(6)方式を採用。tbls doc-gen 済み(05_db/labs.md / lab_chapters.md 生成)markdown crate に
:::widget変換を実装(process_widgets。全前処理の最前段で実行し、ペイロードを他の前処理に触れさせない。base64 は標準エンコード)。cargo test -p markdown 66 pass / clippy クリーン / wasm pkg 再生成 + markdown-wasm テスト 21 pass。これに伴い preview/server.ts の JS 版 extractWidgets を撤去し、プレビューも crate 実装(wasm 経由)で変換する構成に一本化(occ 章 7 ウィジェットの変換を確認済み)プレビューのサンプルコンテンツ(labs/ の mdx + 画像 10 枚)をモノレポから
~/repos/github.com/shuntaka9576/lab-contents-dev(ローカル git リポジトリ、初期コミット済み)へ移設。preview/server.ts はLAB_CONTENTS_DIR(既定: ghq 配置の lab-contents-dev)を参照する構成に変更し、モノレポにはコンテンツ・画像を置かない形に是正。GitHub 上の private リポジトリ作成と push は Phase D で実施Phase C 着手時に OAC の循環依存を発見し、lab 画像バケットの所有を MainStack → AdminStack に変更(詳細は「画像バケットの所有スタック」セクション)
Phase C のコード実装完了(dev デプロイ前まで)。config に
labs.contentsRepoFullName/labs.imagesBucketName(<prefix>-lab-assets)を stage 別に追加、blog-api Lambda に env 2 個注入 + ロール ARN 公開、AdminStack に LabImagesBucket / LabsSpaBucket //labs/*/lab-assets/*behavior / BlogApiLabAssetsPolicy / HostGuardFunction 拡張(/labs→/labs/301、/labs/*の SPA fallback)。labs-web の build 成果物が無い場合は addWarning でスキップ(CI 整備までの暫定。admin-web の addError は維持)。スナップショット差分は加算のみで既存リソースの変更・置換なし、d-st-admin→d-st-mainの一方向依存を manifest で確認lint 整備:
docs/scripts/check-search.tsの floating promise を修正(labs-web workspace 追加の bun install で型定義が更新され顕在化した既存問題)、cspell 辞書に splitn / tinyint / handson / qwik を追加。ルートbun run lintグリーンデプロイ方針: PR はマージせず、
deploy.yamlの workflow_dispatch(ref: PR ブランチ / stageName=dev / stack=all)で PR の状態のまま dev に反映して動作確認する。reusable-deploy の admin ステップに labs-web ビルドを追加7 コミットに分割して push、Draft PR #733(
feat/labs)を作成。ブランチを lab-combine → feat/labs にリネームした際、GitHub のブランチリネームで PR #732 が CLOSED になったため作り直した(リネームは PR 作成前に行うこと)。workflow_dispatch で dev デプロイを起動Phase D 完了。db 全 5 章の完全移植(occ 深掘り復元 / write-skew 完全版差し替え / deadlock / deleted-at-index / glossary、engine-steps ウィジェット計 34 個)、security 事例集 1 章、handson コード(Rust workspace、solution のテスト pass 確認済み)を lab-contents-dev へ。あわせて lab slug の命名規則を 連番 + 説明的 kebab 名(
001-db-transaction-exercises/002-raft-minimal-implementation/003-security-incident-cases)に決定。一覧 API が slug 昇順のため連番がそのまま表示順になる。リネームは同期の「旧 lab 削除 + 新規作成」で自然に反映されることを dev で確認(旧プレフィックスの S3 画像は設計どおり孤児として残置)Phase F 完了。admin-api に read-only の labs ルート 3 本(sessionAuth 配下、Kysely 手書き型追加)、labs-web に 401 →
/login?returnTo=フルページ遷移、admin-web に returnTo 対応(同一オリジンパスのみ)と labs ナビリンクを実装し dev デプロイ。/api/labsが 404 → 401 に変わったことを確認。未ログインで/labs/を開くとログイン画面へ誘導され、ログイン後に戻る動線が繋がったPhase E 完了。lab-contents-dev / lab-contents の private リポジトリを作成し、dev 用 GitHub App にインストール後の push で E2E 同期を確認(blog_dev に labs 2 件 / lab_chapters 4 件、S3 に画像 10 枚、images.shuntaka.tech/lab-assets/ で全画像 200)。ハマりどころ 2 件: (1) 新設の S3 クライアントが proxy 反映済み config を使っておらず
infrastructure::aws::load_sdk_config()に共通化。(2) 真因は s3:ListBucket が無いロールの HeadObject は存在しないキーに 404 ではなく 403 を返す S3 仕様で、未アップロード判定に入れず全画像スキップになっていた。ListBucket(prefix 条件付き)のポリシー追加 + head 失敗時はアップロード試行にフォールバックする二段構えで解消dev デプロイ完了(Phase C 完了)。ハマりどころ 2 件: (1) adminStack が mainStack に依存したことで、admin デプロイの synth でも main stack の env 未設定エラー注釈が効くようになり、reusable-deploy の admin ステップに GH_APP_ID 等の env を追加して解消。(2) CloudFront の
/labs/*behavior は URI をそのまま S3 キーとして転送するため、labs SPA はdestinationKeyPrefix: 'labs'で labs/ 配下に配置する必要があった(直下配置だと 403)。admin.shuntaka.tech/labs/200 //labs→/labs/301 を確認Phase B 完了。kernel(model/lab.rs, repository/labs.rs)/ adapter(repository/labs.rs)/ infrastructure(github: Trees API recursive + raw blob 取得、s3: aws-sdk-s3 で
github-shaメタデータ差分アップロード)/ api(process_lab_push_event、画像 URL 書き換え、削除同期)を実装。cargo test 全 150 件 pass / clippy -D warnings クリーン(レビュー側でも独立に再実行して確認)設計判断(実装時の発見による逸脱):
GithubPushEnvelope.kindは従来「封筒スキーマ識別のマジック値」だったが、articles/labs のルーティング用途に転用した。/events側は kind == "labs" のみ labs 分岐、それ以外は articles にフォールバック(旧デプロイの in-flight 封筒も従来挙動で処理される)。kind の厳密一致チェックは撤去したが、安全性は封筒内 HMAC 署名の再検証(分岐前に実施)が引き続き担保する削除セマンティクス: lab は「labs/
/ 配下が tree に存在するか」、章は「参照ファイルが tree に存在するか」を keep 基準とし、フェッチ失敗やパース失敗など一時エラーでは削除しないフェイルセーフにした(hard delete 導入に伴う誤削除防止) 運用メモ: 実装中にホストのディスクが枯渇(残 232MiB)し、この worktree の
target/(25GB 超)を cargo clean で解放した。検証ビルド後の空きは 2.6GB とタイト。次の大物候補は main worktree のtarget/(15GB、再生成可能)