Skip to content

feat: 索引の射影 — 本文ごと落ちる索引が「次ページ」を書けなくしている #78

Description

@snakajima

記事を出すアプリ(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 つで、どれも索引を諦めている:

  1. maxBytes.body を下げる — 索引を深くするために本文の長さを削る。無関係な 2 つが宣言の上で結びついている。
  2. limit を上限(MAX_INDEX_BYTES / Σmaxbytes、body 60,000 なら 16 本)で止める — コレクションは伸び続けるので、古い記事は静かに索引から落ちていく。記事自体は /a/{slug}/{id} で生きているのに、辿り着く道だけが消える。
  3. 拒否文が勧める「title と summary だけの第二のコレクション」を作る — 投稿・修正・削除のたびに 2 レコードを同期する仕掛けをアプリの作者が書く。書き手向けの指示も倍になり、同期は原子的でない。

3 番目が本来の解であり、だからこそプラットフォームの仕事だと思う。

今どうなっているか(確認したこと)

  • 索引の読みはビジターのブラウザで一発。mulmoserver src/firestore/appViews.ts:253-276getDocs(query(items, orderBy(stampField,"desc"), limit(n)))startAfter は import すらされていない。
  • データは push 一方通行。ページからホストに頼める ask は lookup(自分の 1 行)と open(記事へ遷移)だけで、「続きをくれ」は wire に無い(src/view/protocol.tsVIEW_MESSAGE)。
  • ルールはフィールドを落とせない(原則 5)ので索引は body ごと落ちる。ゆえに MAX_INDEX_BYTES = 1_000_000 の掛け算検査(src/publishChecks.tsdrawnTextFields を使っている箇所)。行数の上限 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_MESSAGEpage / pageResult
  • BridgePortsoptionalpage ポート(lookup と同じく、無いホストは「わからない」を返す。mine の「absence は UNKNOWN であって no ではない」約束をそのまま踏襲する)
  • mulmoserver の viewConstraintsstartAfter(stampField は文字列順=時刻順なので where(stamp, "<", cursor) でも足りる)

単独では解にならないことを書いておく: 射影が無いままだと「次へ」1 回で最大 60 万バイト落ちる。ただしコストの性質は変わる — 今は全員が毎回払う固定費、pull は押した人だけが払う変動費。なのでバイトの掛け算検査は「初回 push の分だけ」に緩められるはずで、その判断は提案 1 が入ってからでよい。

やらないこと

view.query()(タグ絞り込み・月別アーカイブ・検索)は別の話。索引が軽くなっている(提案 1)ことが前提で、複合インデックスの管理も要る。ここには含めない。

出口

提案 1 は宣言・検査・射影の追加なので、この repo が先頭。mulmoserver 側は索引コレクションを読むだけで、ページ送り自体はアプリの HTML で書ける。提案 2 は wire protocol を跨ぐので、live のときと同じ 3 リポジトリの順番が要る。

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions