記事を出すアプリ(AI Journal, /a/ai-journal)で「次の10本」を出そうとして、書けないことが分かった。ページ側の問題ではなく、索引がレコードを丸ごと落とす設計の帰結なので、ここに置く。
何が起きたか
索引は最新 10 本しか出せていない。11 本目以降へ送る「次へ」を足そうとして views[].limit を 50 に上げたところ、check が正しく拒否した:
views[0] reads 50 articles whose drawn text may total 61100 bytes each, so one open of the
index costs a reader at least 3055000 bytes — above 1000000 ... the only way to a cheap
index is a second collection carrying title and summary alone.
拒否は正しい。問題は、この拒否から出る道が今ひとつも無いことである。作者に残された選択肢は 3 つで、どれも索引を諦めている:
maxBytes.body を下げる — 索引を深くするために本文の長さを削る。無関係な 2 つが宣言の上で結びついている。
limit を上限(MAX_INDEX_BYTES / Σmaxbytes、body 60,000 なら 16 本)で止める — コレクションは伸び続けるので、古い記事は静かに索引から落ちていく。記事自体は /a/{slug}/{id} で生きているのに、辿り着く道だけが消える。
- 拒否文が勧める「title と summary だけの第二のコレクション」を作る — 投稿・修正・削除のたびに 2 レコードを同期する仕掛けをアプリの作者が書く。書き手向けの指示も倍になり、同期は原子的でない。
3 番目が本来の解であり、だからこそプラットフォームの仕事だと思う。
今どうなっているか(確認したこと)
- 索引の読みはビジターのブラウザで一発。mulmoserver
src/firestore/appViews.ts:253-276 — getDocs(query(items, orderBy(stampField,"desc"), limit(n)))。startAfter は import すらされていない。
- データは push 一方通行。ページからホストに頼める ask は
lookup(自分の 1 行)と open(記事へ遷移)だけで、「続きをくれ」は wire に無い(src/view/protocol.ts の VIEW_MESSAGE)。
- ルールはフィールドを落とせない(原則 5)ので索引は body ごと落ちる。ゆえに
MAX_INDEX_BYTES = 1_000_000 の掛け算検査(src/publishChecks.ts、drawnTextFields を使っている箇所)。行数の上限 MAX_VIEW_LIMIT = 1000 は縛っていない。縛っているのはバイト数である。
つまり 3 つが直列に効いている: 射影できない → 索引が高い → 深くできない → ページ送りが書けない。手前から解くべきで、view.page() を先に足しても解決しない。
提案 1(本命): 索引の射影をホストが維持する
宣言したフィールドだけを持つ軽いレコードを、記事の書き込みと同じバッチで索引コレクションに書く。
{
"collections": {
"articles": { "index": { "collection": "article-index",
"fields": ["title", "summary", "byline", "tags"] } },
"article-index": { "indexOf": "articles" }
},
"views": [
{ "id": "public", "audience": "public", "path": "views/home.html",
"collections": ["article-index"], "limit": { "article-index": 200 },
"article": { "collection": "articles", "title": "title", "body": "body", ... } }
]
}
mirror / mirrorOf が先例である。予約の有無を slot の state に写す 2 文書 1 バッチを、ルールはすでに受けている。あちらは 1 フィールドの状態同期、こちらは宣言したフィールドの複写で、必要な性質(原子性・片割れの禁止・publish が両半分を要求すること)は同じ。だから Cloud Function もトリガも要らない。
- 索引側の id は記事と同じ(
idFrom: "slug" がそのまま効く)。stampField は複写され、limit の順序付けはそのまま。
- 効果: 1 本あたり約 1,400 バイト(title 200 + summary 800 + byline 100 + tags 200 + slug + stamp)。同じ 1 MB の予算で 約 700 本。本文の長さ制限は 1 バイトも下げなくてよい。 ページ送りはアプリ側のクライアントコードのままで何年も持つ。
- 検査は減るのではなく移る: 掛け算は索引コレクションの
maxBytes に対して行い、article を持つビューが本文を持つコレクションを直接読んでいたら「索引を宣言せよ」と言えるようになる。今の拒否文が代替を持てるようになる、というのがこの提案の核心。
要検討:
- 削除の片割れ(記事だけ消えて索引が残る)を、mirror と同じくルールで塞げるか。
correct が索引フィールドに触れたときの複写。書き込みが 1 バッチである限り同じ話のはず。
- 既存アプリ:
index を書かなければ射影は 1 バイトも変わらないこと。
提案 2(後続): view.page(cid, { before, rows })
提案 1 の後、記事が窓を超えたときに効く。lookup と同じ形で足せる:
VIEW_MESSAGE に page / pageResult
BridgePorts に optional な page ポート(lookup と同じく、無いホストは「わからない」を返す。mine の「absence は UNKNOWN であって no ではない」約束をそのまま踏襲する)
- mulmoserver の
viewConstraints に startAfter(stampField は文字列順=時刻順なので where(stamp, "<", cursor) でも足りる)
単独では解にならないことを書いておく: 射影が無いままだと「次へ」1 回で最大 60 万バイト落ちる。ただしコストの性質は変わる — 今は全員が毎回払う固定費、pull は押した人だけが払う変動費。なのでバイトの掛け算検査は「初回 push の分だけ」に緩められるはずで、その判断は提案 1 が入ってからでよい。
やらないこと
view.query()(タグ絞り込み・月別アーカイブ・検索)は別の話。索引が軽くなっている(提案 1)ことが前提で、複合インデックスの管理も要る。ここには含めない。
出口
提案 1 は宣言・検査・射影の追加なので、この repo が先頭。mulmoserver 側は索引コレクションを読むだけで、ページ送り自体はアプリの HTML で書ける。提案 2 は wire protocol を跨ぐので、live のときと同じ 3 リポジトリの順番が要る。
記事を出すアプリ(AI Journal,
/a/ai-journal)で「次の10本」を出そうとして、書けないことが分かった。ページ側の問題ではなく、索引がレコードを丸ごと落とす設計の帰結なので、ここに置く。何が起きたか
索引は最新 10 本しか出せていない。11 本目以降へ送る「次へ」を足そうとして
views[].limitを 50 に上げたところ、checkが正しく拒否した:拒否は正しい。問題は、この拒否から出る道が今ひとつも無いことである。作者に残された選択肢は 3 つで、どれも索引を諦めている:
maxBytes.bodyを下げる — 索引を深くするために本文の長さを削る。無関係な 2 つが宣言の上で結びついている。limitを上限(MAX_INDEX_BYTES / Σmaxbytes、body 60,000 なら 16 本)で止める — コレクションは伸び続けるので、古い記事は静かに索引から落ちていく。記事自体は/a/{slug}/{id}で生きているのに、辿り着く道だけが消える。3 番目が本来の解であり、だからこそプラットフォームの仕事だと思う。
今どうなっているか(確認したこと)
src/firestore/appViews.ts:253-276—getDocs(query(items, orderBy(stampField,"desc"), limit(n)))。startAfterは import すらされていない。lookup(自分の 1 行)とopen(記事へ遷移)だけで、「続きをくれ」は wire に無い(src/view/protocol.tsのVIEW_MESSAGE)。MAX_INDEX_BYTES = 1_000_000の掛け算検査(src/publishChecks.ts、drawnTextFieldsを使っている箇所)。行数の上限MAX_VIEW_LIMIT = 1000は縛っていない。縛っているのはバイト数である。つまり 3 つが直列に効いている: 射影できない → 索引が高い → 深くできない → ページ送りが書けない。手前から解くべきで、
view.page()を先に足しても解決しない。提案 1(本命): 索引の射影をホストが維持する
宣言したフィールドだけを持つ軽いレコードを、記事の書き込みと同じバッチで索引コレクションに書く。
{ "collections": { "articles": { "index": { "collection": "article-index", "fields": ["title", "summary", "byline", "tags"] } }, "article-index": { "indexOf": "articles" } }, "views": [ { "id": "public", "audience": "public", "path": "views/home.html", "collections": ["article-index"], "limit": { "article-index": 200 }, "article": { "collection": "articles", "title": "title", "body": "body", ... } } ] }mirror/mirrorOfが先例である。予約の有無を slot のstateに写す 2 文書 1 バッチを、ルールはすでに受けている。あちらは 1 フィールドの状態同期、こちらは宣言したフィールドの複写で、必要な性質(原子性・片割れの禁止・publish が両半分を要求すること)は同じ。だから Cloud Function もトリガも要らない。idFrom: "slug"がそのまま効く)。stampFieldは複写され、limitの順序付けはそのまま。maxBytesに対して行い、articleを持つビューが本文を持つコレクションを直接読んでいたら「索引を宣言せよ」と言えるようになる。今の拒否文が代替を持てるようになる、というのがこの提案の核心。要検討:
correctが索引フィールドに触れたときの複写。書き込みが 1 バッチである限り同じ話のはず。indexを書かなければ射影は 1 バイトも変わらないこと。提案 2(後続):
view.page(cid, { before, rows })提案 1 の後、記事が窓を超えたときに効く。
lookupと同じ形で足せる:VIEW_MESSAGEにpage/pageResultBridgePortsに optional なpageポート(lookupと同じく、無いホストは「わからない」を返す。mineの「absence は UNKNOWN であって no ではない」約束をそのまま踏襲する)viewConstraintsにstartAfter(stampField は文字列順=時刻順なのでwhere(stamp, "<", cursor)でも足りる)単独では解にならないことを書いておく: 射影が無いままだと「次へ」1 回で最大 60 万バイト落ちる。ただしコストの性質は変わる — 今は全員が毎回払う固定費、pull は押した人だけが払う変動費。なのでバイトの掛け算検査は「初回 push の分だけ」に緩められるはずで、その判断は提案 1 が入ってからでよい。
やらないこと
view.query()(タグ絞り込み・月別アーカイブ・検索)は別の話。索引が軽くなっている(提案 1)ことが前提で、複合インデックスの管理も要る。ここには含めない。出口
提案 1 は宣言・検査・射影の追加なので、この repo が先頭。mulmoserver 側は索引コレクションを読むだけで、ページ送り自体はアプリの HTML で書ける。提案 2 は wire protocol を跨ぐので、
liveのときと同じ 3 リポジトリの順番が要る。