はじめに
こんにちは、もんすんです。
自作の個人用トレンドビューアに、新しいタブを新規追加したときの設計の話です。
このアプリ、もともとは「1日1枚のまとめカード」を表示するものでした。
技術トレンド・ビジネストレンド・Xの技術ポスト、それぞれその日の総まとめを1カードとして並べる、日付中心のダッシュボードです。
そこに、別の自動化(URLを要約して溜めるClaude Codeのスキル)の出力を表示する「DIGEST」タブを足したくなりました。
ところが、表示モデルが根本的に違ったので、どうするか迷いました。
日次まとめは「日付ごとに1枚」。
一方 DIGEST は「1URL = 1枚、新しい順にどんどん流れる無限スクロール」。
性質がまるで違う2つを、どう同居させるか。
この記事はその設計判断の記録です。
「1つのDBテーブルに、性質の違うコンテンツを混ぜたい」というのは個人開発でよく出会う場面だと思います。
私の答えは 「同じテーブルに入れるが、流路は分ける」 でした。
対象読者・前提
- 1つのテーブルに性質の異なるデータを混ぜる設計に悩んでいる人
- Supabase / Postgres の基礎がある人
ぶつかった問題:2つの表示モデルは水と油
まず、2つのモデルがどれだけ違うかを並べます。
| 日次まとめ | DIGEST | |
|---|---|---|
| 単位 | 1日 = 1カード | 1URL = 1カード |
| キー | (date, category) |
1行=1URL(同日に何枚でも) |
| 並び | 日付で切り替え |
created_at 降順で無限ロード
|
| 構造 | 本文に OVERVIEW の要約あり |
OVERVIEWなし、本文インライン |
| 画面 | 日付ダッシュボード | 専用の縦スクロールページ |
日次まとめ → 「カレンダーで日を選ぶ」UI
DIGEST →「Twitterのタイムラインみたいに流す」UI
同じカード描画では成立しません。
素直に考えると「テーブルを分ける」のが正解に見えます。
が、私は テーブルは1つ(posts)のままを選択しました。
設計判断:同じテーブル・別流路
posts テーブルを共用する
DIGEST も同じ posts テーブルに入れます。区別は category カラムだけ。
type Category = 'trend-a' | 'trend-b' | 'trend-c' | 'digest';
DIGEST は category = 'digest'、1行=1URL。
title がカードの見出し、content が要約+元リンク、並びは created_at。
テーブルを分けなかった理由は単純で、投入スクリプト・RLS・型・接続を二重に持ちたくなかったからです。
日次の流路から digest を[除外]する
ここが肝です。
テーブルを共用すると、何もしなければ DIGEST のカードが日次ダッシュボードに漏れてきます。これをローダー側で明示的に除外します。
// postLoader.ts
// 日次ダッシュボードに出すのはこの3つだけ。digest は意図的に含めない。
const CATEGORIES: Category[] = ['trend-a', 'trend-b', 'trend-c'];
日次ビューを組み立てる関数は、この CATEGORIES だけを対象にします。
digest はここに入っていないので、どう転んでも日次画面には出てきません。
そのうえで、DIGEST 専用の流路を別に用意します。
-
getDigestBatch()… 日付・フリーテキスト検索つきのバッチ取得 -
getDigestDates()… 日付一覧 -
/api/digest… 無限ロード用のエンドポイント -
/digest… 専用ページ(DigestStream+DigestCardで描画)
つまり、テーブルは共用しつつ、「読み出すクエリ」と「表示するルート・コンポーネント」は完全に分けたわけです。
posts テーブル(器は1つ)
├─ a/b/c ──► postLoader(CATEGORIES)──► /date/[date] 日次ダッシュボード
└─ digest ───────────► getDigestBatch ──────────► /digest 無限ストリーム
落とし穴:同一バッチ投入で並び順が壊れた
...と、設計どおり動かしたら、DIGEST の並びがおかしくなりました。
created_at の降順にしたのに、同じ日のカードの順番がリロードのたびに変わる。
原因は、投入方法にありました。
DIGEST は複数URLを 1回の upsert(バッチ)でまとめて入れます。
すると全行が同じトランザクション時刻になり、created_at が同値になります。
ORDER BY created_at だけでは、同値の行の相対順序は保証されません(Postgresの仕様)。
だから並びが不定になっていたわけです。
修正は、タイブレーク列を重ねるだけです。
// ❌ 同値だと順序が不定
.order('created_at', { ascending: false })
// ✅ タイブレーク(ソート条件)を足して安定ソートにする
.order('created_at', { ascending: false })
.order('date', { ascending: false })
.order('title')
取り込み側との整合も忘れずに
テーブルを共用するということは、投入側にも digest を教える必要があるということです。
- 要約スキルは
contents/digest/YYYY-MM-DD_NN.md(frontmatter にcategory: digest)を生成 -
upload-post.mjs --dir contents/digestで一括投入
ここで一度ハマったのが、投入スクリプト側の「許可カテゴリ一覧」に digest を追加し忘れると、対象外として黙ってスキップされることでした。
器を増やしたら、入口(投入)と出口(表示の除外/専用流路)の両方を更新する、と覚えておくと事故りません。
まとめ:混ぜる、でも分ける
性質の違う2つの表示モデルを、私はこう同居させました。
- テーブルは共用
-
posts1つ、区別はcategoryだけ - 投入・RLS・型を二重化しない
-
- 流路は分離
- 日次ローダーから
digestを除外 - 専用の getter / API / ルート / コンポーネントを用意
- 日次ローダーから
- 並びはタイブレークで安定化
-
created_at同値に備えて副キーを重ねる
-
「テーブルを分けるか、混ぜるか」の二択に見えて、「混ぜるが、読み出しと表示で分ける」 という第三の道がきれいにハマりました。
同じような分岐で悩んでいる方の参考になれば幸いです。
それでは、良いデータモデリングを。