<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Data Flow on Tarragon</title><link>https://tarrragon.github.io/blog/tags/data-flow/</link><description>Recent content in Data Flow on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 22 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/data-flow/index.xml" rel="self" type="application/rss+xml"/><item><title>監控資料的雙重用途：行為分析與訊號治理</title><link>https://tarrragon.github.io/blog/monitoring/telemetry-data-dual-use/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/telemetry-data-dual-use/</guid><description>&lt;p>SDK 埋的每一筆 event 有兩個下游消費者：產品團隊用它做行為分析（轉換率、留存、歸因），工程團隊用它做訊號治理（cardinality 控制、成本歸因、事故判讀）。兩邊各自有教學章節（&lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">Monitoring 08 Business Analytics&lt;/a> 和 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 04 可觀測性&lt;/a>），但讀者常不知道這是同一份資料的兩種消費方式。本文是橋。&lt;/p>
&lt;h2 id="同一份資料兩種消費路徑">同一份資料、兩種消費路徑&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">SDK 埋點（event / error / metric / lifecycle）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ├── 行為分析路徑 → Monitoring 08
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> │ 消費者：PM / 行銷 / 產品
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> │ 方法：funnel / cohort / attribution / A-B test
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> │ 決策：改 UI、調定價、投廣告
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> └── 訊號治理路徑 → Backend 04
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> 消費者：SRE / platform team / on-call
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> 方法：cardinality budget / cost attribution / signal governance
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> 決策：降 cardinality、調 sampling、改 alert、產出 evidence&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這不是兩套埋點。同一個 &lt;code>button.click&lt;/code> event，產品團隊看的是「哪個步驟流失最多使用者」，工程團隊看的是「這個 event 的 cardinality 是否在預算內、ingestion cost 是否合理」。event 相同，切入角度不同。&lt;/p>
&lt;h2 id="資料格式的交叉點">資料格式的交叉點&lt;/h2>
&lt;p>Monitoring SDK 送出的事件格式（&lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">02 Log Schema&lt;/a>）和 Backend 04 的 log schema / OTel event format 有共通欄位：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>Monitoring SDK 格式&lt;/th>
 &lt;th>Backend 04 / OTel 格式&lt;/th>
 &lt;th>交叉用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>timestamp&lt;/td>
 &lt;td>&lt;code>timestamp&lt;/code>（ISO 8601）&lt;/td>
 &lt;td>&lt;code>TimeUnixNano&lt;/code>&lt;/td>
 &lt;td>兩邊都需要精確時間做時序查詢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event type&lt;/td>
 &lt;td>&lt;code>type&lt;/code>（event/error/metric/lifecycle）&lt;/td>
 &lt;td>&lt;code>SeverityText&lt;/code> / &lt;code>SpanKind&lt;/code>&lt;/td>
 &lt;td>行為分析按 type 做 funnel；訊號治理按 type 做 cardinality budget&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>source&lt;/td>
 &lt;td>&lt;code>source.sdk&lt;/code> / &lt;code>source.platform&lt;/code> / &lt;code>source.app&lt;/code>&lt;/td>
 &lt;td>&lt;code>Resource&lt;/code> attributes&lt;/td>
 &lt;td>行為分析按 platform 切分；訊號治理按 service 做 cost attribution&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>trace context&lt;/td>
 &lt;td>手動注入（若有）&lt;/td>
 &lt;td>&lt;code>TraceId&lt;/code> / &lt;code>SpanId&lt;/code>&lt;/td>
 &lt;td>client-to-server 端到端追蹤的串接欄位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>payload&lt;/td>
 &lt;td>&lt;code>data&lt;/code>（自由 JSON）&lt;/td>
 &lt;td>&lt;code>Attributes&lt;/code> / &lt;code>Body&lt;/code>&lt;/td>
 &lt;td>行為分析讀 business fields；訊號治理讀 operational fields&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>格式一致性的價值是&lt;strong>一份 event 同時餵 BigQuery（行為分析）和 Grafana Loki（訊號查詢）不需要格式轉換&lt;/strong>。如果兩邊各自定義 schema，同一個 event 要寫兩次 adapter，schema drift 的風險倍增。&lt;/p></description><content:encoded><![CDATA[<p>SDK 埋的每一筆 event 有兩個下游消費者：產品團隊用它做行為分析（轉換率、留存、歸因），工程團隊用它做訊號治理（cardinality 控制、成本歸因、事故判讀）。兩邊各自有教學章節（<a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">Monitoring 08 Business Analytics</a> 和 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 04 可觀測性</a>），但讀者常不知道這是同一份資料的兩種消費方式。本文是橋。</p>
<h2 id="同一份資料兩種消費路徑">同一份資料、兩種消費路徑</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">SDK 埋點（event / error / metric / lifecycle）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  ├── 行為分析路徑 → Monitoring 08
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  │     消費者：PM / 行銷 / 產品
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  │     方法：funnel / cohort / attribution / A-B test
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  │     決策：改 UI、調定價、投廣告
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  │
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  └── 訊號治理路徑 → Backend 04
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        消費者：SRE / platform team / on-call
</span></span><span class="line"><span class="ln">10</span><span class="cl">        方法：cardinality budget / cost attribution / signal governance
</span></span><span class="line"><span class="ln">11</span><span class="cl">        決策：降 cardinality、調 sampling、改 alert、產出 evidence</span></span></code></pre></div><p>這不是兩套埋點。同一個 <code>button.click</code> event，產品團隊看的是「哪個步驟流失最多使用者」，工程團隊看的是「這個 event 的 cardinality 是否在預算內、ingestion cost 是否合理」。event 相同，切入角度不同。</p>
<h2 id="資料格式的交叉點">資料格式的交叉點</h2>
<p>Monitoring SDK 送出的事件格式（<a href="/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">02 Log Schema</a>）和 Backend 04 的 log schema / OTel event format 有共通欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>Monitoring SDK 格式</th>
          <th>Backend 04 / OTel 格式</th>
          <th>交叉用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>timestamp</td>
          <td><code>timestamp</code>（ISO 8601）</td>
          <td><code>TimeUnixNano</code></td>
          <td>兩邊都需要精確時間做時序查詢</td>
      </tr>
      <tr>
          <td>event type</td>
          <td><code>type</code>（event/error/metric/lifecycle）</td>
          <td><code>SeverityText</code> / <code>SpanKind</code></td>
          <td>行為分析按 type 做 funnel；訊號治理按 type 做 cardinality budget</td>
      </tr>
      <tr>
          <td>source</td>
          <td><code>source.sdk</code> / <code>source.platform</code> / <code>source.app</code></td>
          <td><code>Resource</code> attributes</td>
          <td>行為分析按 platform 切分；訊號治理按 service 做 cost attribution</td>
      </tr>
      <tr>
          <td>trace context</td>
          <td>手動注入（若有）</td>
          <td><code>TraceId</code> / <code>SpanId</code></td>
          <td>client-to-server 端到端追蹤的串接欄位</td>
      </tr>
      <tr>
          <td>payload</td>
          <td><code>data</code>（自由 JSON）</td>
          <td><code>Attributes</code> / <code>Body</code></td>
          <td>行為分析讀 business fields；訊號治理讀 operational fields</td>
      </tr>
  </tbody>
</table>
<p>格式一致性的價值是<strong>一份 event 同時餵 BigQuery（行為分析）和 Grafana Loki（訊號查詢）不需要格式轉換</strong>。如果兩邊各自定義 schema，同一個 event 要寫兩次 adapter，schema drift 的風險倍增。</p>
<h2 id="資料治理的衝突">資料治理的衝突</h2>
<p>同一份資料被兩邊消費時，治理需求會衝突：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>行為分析需要</th>
          <th>訊號治理需要</th>
          <th>衝突點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>保留期</td>
          <td>長期保留（年級，趨勢與 cohort 需要歷史資料）</td>
          <td>短期保留（30-90 天，debug 用完即丟）</td>
          <td>成本 vs 分析完整度</td>
      </tr>
      <tr>
          <td>粒度</td>
          <td>高粒度（per-user、per-session、per-action）</td>
          <td>低粒度（聚合到 service / endpoint 維度）</td>
          <td>cardinality 爆炸 vs 分析精度</td>
      </tr>
      <tr>
          <td>PII 處理</td>
          <td>去識別但需保留 user segment（國家、裝置、方案）</td>
          <td>完全匿名或 redacted</td>
          <td>分析需求 vs 合規要求</td>
      </tr>
      <tr>
          <td>取樣</td>
          <td>低取樣或全量（行為趨勢需要完整分布）</td>
          <td>可以高取樣（error 全收，正常 request 取樣即可）</td>
          <td>成本 vs 覆蓋度</td>
      </tr>
      <tr>
          <td>查詢延遲</td>
          <td>可接受分鐘級（batch analytics）</td>
          <td>需要秒級（incident debug 不能等）</td>
          <td>儲存分層與查詢 backend 選擇</td>
      </tr>
  </tbody>
</table>
<p>這些衝突無法靠「選一邊」解決。行為分析少了歷史資料就看不到趨勢；訊號治理存太多高粒度資料就 cardinality 爆炸。解法是分流。</p>
<h2 id="解法在-transport-層分流">解法：在 transport 層分流</h2>
<p>把 SDK 送出的 event 在 collector 或 pipeline 層分流到不同 backend，各自按需求治理：</p>
<h3 id="hot-path即時訊號">Hot path：即時訊號</h3>
<p>error 和 metric 類事件即時進入 <a href="/blog/backend/04-observability/telemetry-pipeline/" data-link-title="4.11 Telemetry Pipeline 架構" data-link-desc="把 log / metric / trace 的 agent → collector → ingest → storage → query 分層治理">04 telemetry pipeline</a>（Loki / Prometheus / Tempo），短期 retention（30-90 天），服務 on-call debug 和 incident triage。這條路徑要求秒級延遲、低 cardinality（聚合維度）。</p>
<h3 id="warm-path行為分析">Warm path：行為分析</h3>
<p>全部四類事件進入 data warehouse（BigQuery / ClickHouse / Snowflake），長期 retention（年級），服務 funnel、cohort、attribution 和 A/B test。這條路徑接受分鐘級延遲、高粒度（per-user / per-session）。</p>
<h3 id="cold-path合規留存">Cold path：合規留存</h3>
<p>audit-level event 進入 archive storage（Cloud Storage / S3 / Glacier），法規要求的年級保留（GDPR 刪除請求、HIPAA 6 年、金融業更長）。這條路徑寫入後幾乎不查詢，查詢時接受小時級延遲。</p>
<h3 id="分流的關鍵設計">分流的關鍵設計</h3>
<p>分流在 transport 層做，不在 SDK 層做。SDK 統一送出全部 event 到同一個 endpoint，pipeline 按 event type / source / tag 路由到不同 backend。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">SDK → Collector / OTel Collector / Cloud Logging
</span></span><span class="line"><span class="ln">2</span><span class="cl">         │
</span></span><span class="line"><span class="ln">3</span><span class="cl">         ├─ [type=error OR type=metric] → Hot path (Loki / Prometheus)
</span></span><span class="line"><span class="ln">4</span><span class="cl">         ├─ [all events]                → Warm path (BigQuery)
</span></span><span class="line"><span class="ln">5</span><span class="cl">         └─ [audit=true]               → Cold path (Cloud Storage)</span></span></code></pre></div><p>SDK 不需要知道下游有幾個消費者。新增一個消費者（例如新的分析平台）只要在 pipeline 加一條路由，不用改 SDK。</p>
<h2 id="實作考量">實作考量</h2>
<p>分流的實作方式取決於 pipeline 架構：</p>
<table>
  <thead>
      <tr>
          <th>架構</th>
          <th>分流機制</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自架 collector（<a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">Monitoring 04</a>）</td>
          <td>Rule engine 按 event type 寫不同 output file / HTTP endpoint</td>
          <td>小規模、自用場景</td>
      </tr>
      <tr>
          <td>OTel Collector</td>
          <td>Processor + 多個 Exporter 組成 pipeline fan-out</td>
          <td>中規模、已採用 OTel</td>
      </tr>
      <tr>
          <td>Cloud Logging（GCP）</td>
          <td>Subscription filter + Sink（BigQuery / Cloud Storage / Pub/Sub）</td>
          <td>GCP 生態</td>
      </tr>
      <tr>
          <td>Kinesis / Firehose（AWS）</td>
          <td>Firehose delivery stream + Lambda transform</td>
          <td>AWS 生態</td>
      </tr>
  </tbody>
</table>
<p>不論哪種架構，分流後的每條 path 要各自設定 retention、sampling、PII handling 和 cost budget。Hot path 的 <a href="/blog/backend/04-observability/cardinality-cost-governance/" data-link-title="4.7 Cardinality 治理與成本邊界" data-link-desc="把 metric / log / trace 的 cardinality 與成本作為平台一級治理議題">cardinality 治理</a> 規則不該影響 warm path 的分析粒度；warm path 的長期保留成本不該擠壓 hot path 的 freshness。</p>
<h2 id="常見誤區">常見誤區</h2>
<h3 id="用兩套-sdk-替代分流">用兩套 SDK 替代分流</h3>
<p>在 client 端同時整合行為分析 SDK（Mixpanel）和 error tracking SDK（Sentry），看似分工清楚，實際是兩套 schema、兩份 ingestion cost、兩組 PII 風險面、兩套 consent 管理。同一個 user action 在兩個平台各記一次，但欄位名、timestamp 精度、user identifier 可能不同，跨平台 correlation 困難。</p>
<p>統一 SDK + pipeline 分流的成本通常低於雙 SDK 的整合與治理成本。</p>
<h3 id="hot-path-存全量高粒度">Hot path 存全量高粒度</h3>
<p>把 per-user / per-session 的完整事件直接灌進 Prometheus 或 Loki，會導致 cardinality 爆炸（<a href="/blog/backend/04-observability/cardinality-cost-governance/" data-link-title="4.7 Cardinality 治理與成本邊界" data-link-desc="把 metric / log / trace 的 cardinality 與成本作為平台一級治理議題">4.7 Cardinality 治理</a>）。Hot path 的正確做法是在 pipeline 層做 aggregation 或 relabeling，只保留 service / endpoint / status 等低 cardinality 維度。高粒度資料走 warm path。</p>
<h3 id="warm-path-不做-pii-處理">Warm path 不做 PII 處理</h3>
<p>行為分析需要 user segment，但不需要 PII 原文。warm path 的 ingestion pipeline 應該在寫入 warehouse 前做 PII redaction（hash user_id、truncate IP、strip email）。<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">Monitoring 07 去識別化</a> 的策略同時適用於 hot 和 warm path。</p>
<h2 id="讀者路由">讀者路由</h2>
<table>
  <thead>
      <tr>
          <th>如果你想</th>
          <th>先讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>理解 event 格式設計</td>
          <td><a href="/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">Monitoring 02 Log Schema</a></td>
      </tr>
      <tr>
          <td>理解行為分析方法</td>
          <td><a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">Monitoring 08 Business Analytics</a></td>
      </tr>
      <tr>
          <td>理解訊號治理和成本控制</td>
          <td><a href="/blog/backend/04-observability/cardinality-cost-governance/" data-link-title="4.7 Cardinality 治理與成本邊界" data-link-desc="把 metric / log / trace 的 cardinality 與成本作為平台一級治理議題">Backend 04 Cardinality 治理</a>、<a href="/blog/backend/04-observability/cost-attribution/" data-link-title="4.15 Cost Attribution / Chargeback" data-link-desc="把 observability 成本拆到團隊、產品、環境維度">4.15 Cost Attribution</a></td>
      </tr>
      <tr>
          <td>理解 pipeline 分流架構</td>
          <td><a href="/blog/backend/04-observability/telemetry-pipeline/" data-link-title="4.11 Telemetry Pipeline 架構" data-link-desc="把 log / metric / trace 的 agent → collector → ingest → storage → query 分層治理">Backend 04 Telemetry Pipeline</a></td>
      </tr>
      <tr>
          <td>理解 PII 去識別化</td>
          <td><a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">Monitoring 07 Security Privacy</a></td>
      </tr>
      <tr>
          <td>理解 client-to-server 端到端觀測串接</td>
          <td><a href="/blog/backend/04-observability/client-server-trace-integration/" data-link-title="4.24 Client-to-Server 端到端觀測串接" data-link-desc="用一個結帳場景走完 browser click → trace context → server span → 統一 waterfall 的完整實作鏈路">Backend 04 Client-to-Server 觀測串接</a></td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Filter 與 Source 的抽象層錯位</title><link>https://tarrragon.github.io/blog/report/view-layer-filter-vs-source-layer/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/view-layer-filter-vs-source-layer/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Filter 必須跟它過濾的資料源在同一層運作。&lt;/strong> 把 filter 寫在視覺層（querySelector + show/hide）、把 source 留在資料層分批產出（paginated fetch / streaming / lazy iterator）— 兩層的「一筆」定義不一致、filter 看不到 source 還沒產出的東西、結果跟使用者意圖之間有語意縫。&lt;/p>
&lt;p>更廣義的說法：&lt;strong>stream 操作（filter / sort / count / transform / search）必須跟 stream 的 materialization 同層或更上游&lt;/strong>。在下游做 stream 操作、操作的對象是已經 materialize 的 subset、不是完整的 stream。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼層錯位產生語意縫">為什麼層錯位產生語意縫&lt;/h2>
&lt;h3 id="一筆在不同層有不同定義">「一筆」在不同層有不同定義&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層&lt;/th>
 &lt;th>「一筆」是什麼&lt;/th>
 &lt;th>邊界&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料層&lt;/td>
 &lt;td>Source 產出的一筆 record&lt;/td>
 &lt;td>全部、或還沒產出的下一批&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>渲染層&lt;/td>
 &lt;td>已 render 進 DOM 的一筆&lt;/td>
 &lt;td>= 已 fetch 並 render 過的子集&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>視覺層&lt;/td>
 &lt;td>螢幕上看得見的一筆&lt;/td>
 &lt;td>= render 層之中沒被 hide 的子集&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Filter 寫在視覺層、它的「過濾全部」≡「過濾螢幕上看得見的全部」≡「過濾已 fetch 已 render 的子集」。&lt;strong>離資料層的真實全集差兩層&lt;/strong>。使用者意圖（「給我所有 title 含 X 的結果」）對應的是資料層的全集、不是視覺層的子集。&lt;/p>
&lt;h3 id="silent-失敗的條件">Silent 失敗的條件&lt;/h3>
&lt;p>層錯位不會在「filter 子集裡有命中」的情境下被發現。它只在以下條件下顯露：&lt;/p>
&lt;ol>
&lt;li>已 materialize 的子集裡剛好沒命中&lt;/li>
&lt;li>但完整 stream 裡有命中、只是還沒 materialize&lt;/li>
&lt;li>使用者沒有訊號知道「還有沒抓的」&lt;/li>
&lt;/ol>
&lt;p>三個條件同時滿足、使用者看到「filter 後是空的」、誤以為是「沒有命中」、放棄。&lt;/p>
&lt;h3 id="為什麼這個-bug-容易寫出來">為什麼這個 bug 容易寫出來&lt;/h3>
&lt;p>視覺層 filter 是寫起來最簡單的版本：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">display&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dataset&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">title&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">includes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">query&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;none&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>5 行解決、看起來能用、第一輪測試（手動輸入 query → 看到 filter 生效）會通過。&lt;strong>「能用」的訊號出現太早、掩蓋了語意缺口&lt;/strong>。&lt;/p>
&lt;p>這是 &lt;a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關&lt;/a> 在「filter × source」情境的具體展現 — 容易寫的位置（已 materialize 的 view 層）跟對齊意圖的位置（source 層）方向相反。&lt;/p>
&lt;hr>
&lt;h2 id="哪些-source-形狀有層錯位風險">哪些 source 形狀有層錯位風險&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Source 型態&lt;/th>
 &lt;th>是否有層錯位風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一次性 fetch、靜態陣列&lt;/td>
 &lt;td>否（沒有 subset）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Paginated fetch（load more / cursor）&lt;/td>
 &lt;td>是 — 本次任務的 case&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Streaming（SSE / WebSocket）&lt;/td>
 &lt;td>視 server 是否限額&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lazy iterator + take(N) / break&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cached + revalidate&lt;/td>
 &lt;td>是（cache vs fresh 兩 dataset）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>四類 source 共用同個結構：&lt;strong>source 分批 / 限額 / 延遲 materialize、filter 在下游 → silent 缺口&lt;/strong>。詳細形狀分析見 &lt;a href="../data-source-shape-defines-feature-shape/">#63 資料源的形狀決定 feature 的形狀&lt;/a>。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際情境">這次任務的實際情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>搜尋頁實作 title / content filter：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// pagefind 分批 load (load more 按鈕)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">pagefind&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">query&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nx">results&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">results&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">start&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">start&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">container&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">render&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">)));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">// 我們在 view 層 post-filter
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="nx">applyFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scope&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.result&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">hidden&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">matchesScope&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">scope&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑出來的問題：使用者選 title-only filter、第二批 8 筆全部 title 不含 query → 點 &amp;ldquo;load more&amp;rdquo; 後畫面閃了一下、新增的 8 筆全 hidden、使用者看到的內容沒變。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Filter 必須跟它過濾的資料源在同一層運作。</strong> 把 filter 寫在視覺層（querySelector + show/hide）、把 source 留在資料層分批產出（paginated fetch / streaming / lazy iterator）— 兩層的「一筆」定義不一致、filter 看不到 source 還沒產出的東西、結果跟使用者意圖之間有語意縫。</p>
<p>更廣義的說法：<strong>stream 操作（filter / sort / count / transform / search）必須跟 stream 的 materialization 同層或更上游</strong>。在下游做 stream 操作、操作的對象是已經 materialize 的 subset、不是完整的 stream。</p>
<hr>
<h2 id="為什麼層錯位產生語意縫">為什麼層錯位產生語意縫</h2>
<h3 id="一筆在不同層有不同定義">「一筆」在不同層有不同定義</h3>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>「一筆」是什麼</th>
          <th>邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料層</td>
          <td>Source 產出的一筆 record</td>
          <td>全部、或還沒產出的下一批</td>
      </tr>
      <tr>
          <td>渲染層</td>
          <td>已 render 進 DOM 的一筆</td>
          <td>= 已 fetch 並 render 過的子集</td>
      </tr>
      <tr>
          <td>視覺層</td>
          <td>螢幕上看得見的一筆</td>
          <td>= render 層之中沒被 hide 的子集</td>
      </tr>
  </tbody>
</table>
<p>Filter 寫在視覺層、它的「過濾全部」≡「過濾螢幕上看得見的全部」≡「過濾已 fetch 已 render 的子集」。<strong>離資料層的真實全集差兩層</strong>。使用者意圖（「給我所有 title 含 X 的結果」）對應的是資料層的全集、不是視覺層的子集。</p>
<h3 id="silent-失敗的條件">Silent 失敗的條件</h3>
<p>層錯位不會在「filter 子集裡有命中」的情境下被發現。它只在以下條件下顯露：</p>
<ol>
<li>已 materialize 的子集裡剛好沒命中</li>
<li>但完整 stream 裡有命中、只是還沒 materialize</li>
<li>使用者沒有訊號知道「還有沒抓的」</li>
</ol>
<p>三個條件同時滿足、使用者看到「filter 後是空的」、誤以為是「沒有命中」、放棄。</p>
<h3 id="為什麼這個-bug-容易寫出來">為什麼這個 bug 容易寫出來</h3>
<p>視覺層 filter 是寫起來最簡單的版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">items</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">title</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">query</span><span class="p">)</span> <span class="o">?</span> <span class="s1">&#39;&#39;</span> <span class="o">:</span> <span class="s1">&#39;none&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>5 行解決、看起來能用、第一輪測試（手動輸入 query → 看到 filter 生效）會通過。<strong>「能用」的訊號出現太早、掩蓋了語意缺口</strong>。</p>
<p>這是 <a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a> 在「filter × source」情境的具體展現 — 容易寫的位置（已 materialize 的 view 層）跟對齊意圖的位置（source 層）方向相反。</p>
<hr>
<h2 id="哪些-source-形狀有層錯位風險">哪些 source 形狀有層錯位風險</h2>
<table>
  <thead>
      <tr>
          <th>Source 型態</th>
          <th>是否有層錯位風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一次性 fetch、靜態陣列</td>
          <td>否（沒有 subset）</td>
      </tr>
      <tr>
          <td>Paginated fetch（load more / cursor）</td>
          <td>是 — 本次任務的 case</td>
      </tr>
      <tr>
          <td>Streaming（SSE / WebSocket）</td>
          <td>視 server 是否限額</td>
      </tr>
      <tr>
          <td>Lazy iterator + take(N) / break</td>
          <td>是</td>
      </tr>
      <tr>
          <td>Cached + revalidate</td>
          <td>是（cache vs fresh 兩 dataset）</td>
      </tr>
  </tbody>
</table>
<p>四類 source 共用同個結構：<strong>source 分批 / 限額 / 延遲 materialize、filter 在下游 → silent 缺口</strong>。詳細形狀分析見 <a href="../data-source-shape-defines-feature-shape/">#63 資料源的形狀決定 feature 的形狀</a>。</p>
<hr>
<h2 id="這次任務的實際情境">這次任務的實際情境</h2>
<h3 id="觀察">觀察</h3>
<p>搜尋頁實作 title / content filter：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// pagefind 分批 load (load more 按鈕)
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">start</span><span class="p">,</span> <span class="nx">start</span> <span class="o">+</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">container</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="nx">render</span><span class="p">(</span><span class="nx">r</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">// 我們在 view 層 post-filter
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">applyFilter</span><span class="p">(</span><span class="nx">scope</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">hidden</span> <span class="o">=</span> <span class="o">!</span><span class="nx">matchesScope</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="nx">scope</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>跑出來的問題：使用者選 title-only filter、第二批 8 筆全部 title 不含 query → 點 &ldquo;load more&rdquo; 後畫面閃了一下、新增的 8 筆全 hidden、使用者看到的內容沒變。</p>
<h3 id="判讀">判讀</h3>
<p>問題的根因不在「畫面閃」這個視覺現象、而在 filter 的層級錯位：</p>
<table>
  <thead>
      <tr>
          <th>使用者意圖</th>
          <th>filter 實際對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「title 符合的」</td>
          <td>「已載入 + title 符合的」</td>
      </tr>
      <tr>
          <td>「全部結果」</td>
          <td>「已載入的全部」</td>
      </tr>
  </tbody>
</table>
<p>兩個定義在一般狀況看起來一樣（已載入子集裡有命中）、稀疏 case 暴露縫。</p>
<h3 id="執行解法選擇">執行（解法選擇）</h3>
<p>解法選擇展開見 <a href="../filter-source-composition-strategies/">#59 Filter × Source 合成策略五選一</a> — A 推進 query / B 自動續抓 / C 預先 index / D 誠實 UX / E 明示縮小。本文聚焦「先識別這是層錯位、不是 UI bug」 — 識別錯了、後續解法都會在錯誤的層上補救。</p>
<hr>
<h2 id="內在屬性比較filter-該放哪一層">內在屬性比較：filter 該放哪一層</h2>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>看到的範圍</th>
          <th>跟使用者意圖的距離</th>
          <th>寫作成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>視覺層</td>
          <td>已 render 的子集</td>
          <td>最遠（差兩層）</td>
          <td>最低</td>
      </tr>
      <tr>
          <td>渲染層</td>
          <td>已 fetch 的子集</td>
          <td>中（差一層）</td>
          <td>低</td>
      </tr>
      <tr>
          <td>資料層 (源頭)</td>
          <td>完整 dataset</td>
          <td>最近</td>
          <td>中-高</td>
      </tr>
      <tr>
          <td>Source 之外</td>
          <td>重 query</td>
          <td>最近 + 最新</td>
          <td>高（query 重設計）</td>
      </tr>
  </tbody>
</table>
<p>「寫作成本最低」跟「跟意圖最近」是反相關 — 這個反相關本身是 <a href="../ease-of-writing-vs-intent-alignment/">#67</a> 的核心命題、本卡是它在 filter × source 情境的展開。</p>
<hr>
<h2 id="識別層錯位的三問">識別層錯位的三問</h2>
<p>寫 filter / sort / count / transform 之前自問：</p>
<h3 id="1-這個操作的對象是什麼層的一筆">1. 這個操作的「對象」是什麼層的「一筆」？</h3>
<p>如果寫在 view 層、對象是「螢幕上的元素」 — 那源頭如果分批、就有缺口。</p>
<h3 id="2-source-是一次給完整-dataset還是分批--限額">2. Source 是「一次給完整 dataset」還是「分批 / 限額」？</h3>
<p>對照前面「哪些 source 形狀有層錯位風險」表 — 任何分批 / 限額 / streaming / cached source 都有風險。一次性 fetch 或靜態陣列才安全。</p>
<h3 id="3-沒命中與還沒-materialize對使用者要不要區分">3. 「沒命中」與「還沒 materialize」對使用者要不要區分？</h3>
<p>要區分 → filter 必須在 source 層或自動續抓、否則使用者無法判斷。
不區分（可接受「在已載入範圍內找」這個語意） → view 層 filter 加誠實 UX。</p>
<p>三問跑完才寫 filter — 跳過任一問就可能掉進層錯位。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 <code>elements.forEach(el =&gt; el.hidden = !matches(el))</code></td>
          <td>停 — 確認 source 是不是分批的；是 → 推到資料層</td>
      </tr>
      <tr>
          <td>Source 是 <code>pagefind.search()</code> / <code>paginatedFetch()</code> / <code>for await</code> 但 filter 在 forEach</td>
          <td>是 — 重看「filter 該放哪一層」</td>
      </tr>
      <tr>
          <td>不確定 source 真實 cardinality 跟分批機制</td>
          <td>用 <a href="../playwright-early-in-loop/">#11 playwright</a> 量 live source 的回傳數量</td>
      </tr>
      <tr>
          <td>Filter 後可能 0 筆但 source 還有未載入</td>
          <td>必須補「自動續抓」或「誠實掃描範圍 UX」</td>
      </tr>
      <tr>
          <td>「Load more」「Show next」按鈕存在、且有 filter</td>
          <td>評估：filter 跟 load more 的 quota 是否同層</td>
      </tr>
      <tr>
          <td>內心 OS：「先做出來、晚點補資料層」</td>
          <td>停 — 補不回來、會 ship 進 production silent 失敗</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：filter / sort / count / transform 是 stream operation、必須跟 stream 的 materialization 同層或更上游。寫在下游 = 操作 subset 而不是 stream、語意縫是必然、不是偶發 bug。</p>
]]></content:encoded></item><item><title>Filter × Source 的合成策略五選一</title><link>https://tarrragon.github.io/blog/report/filter-source-composition-strategies/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/filter-source-composition-strategies/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Filter 跟分批 source 的合成有五種策略、各自機會成本不同&lt;/strong>。沒有絕對最佳 — 選哪個取決於三個變數：&lt;/p>
&lt;ol>
&lt;li>Source 是否支援 server-side filter（capabilities）&lt;/li>
&lt;li>Match 密度（稀疏 vs 密集）&lt;/li>
&lt;li>UX 容忍度（要不要誠實顯示「掃描範圍」）&lt;/li>
&lt;/ol>
&lt;p>本文是 #55 &lt;a href="../view-layer-filter-vs-source-layer/">Filter 與 Source 的層錯位&lt;/a> 的解法展開、列出五個合理選項與適用情境。&lt;/p>
&lt;hr>
&lt;h2 id="五策略對照表">五策略對照表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>一句話&lt;/th>
 &lt;th>對 source 的需求&lt;/th>
 &lt;th>對 UX 的影響&lt;/th>
 &lt;th>工程量&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>A&lt;/td>
 &lt;td>把 filter 推進 source 的 query&lt;/td>
 &lt;td>必須支援該 filter 條件&lt;/td>
 &lt;td>透明（無感）&lt;/td>
 &lt;td>中-高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>B&lt;/td>
 &lt;td>自動續抓直到湊滿 N 個 match&lt;/td>
 &lt;td>任何分批 source&lt;/td>
 &lt;td>透明（稍慢）&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>C&lt;/td>
 &lt;td>預先建獨立 index（每種 mode 一份）&lt;/td>
 &lt;td>能控 source 的 build pipeline&lt;/td>
 &lt;td>透明（最快）&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>D&lt;/td>
 &lt;td>誠實 UX 顯示「已掃 N / 命中 K」&lt;/td>
 &lt;td>任何 source&lt;/td>
 &lt;td>顯眼（多按鈕）&lt;/td>
 &lt;td>低&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>E&lt;/td>
 &lt;td>接受「filter 範圍 = 已載入」、不承諾 source 全集&lt;/td>
 &lt;td>任何 source&lt;/td>
 &lt;td>隱性語意縮小&lt;/td>
 &lt;td>最低&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="五策略一句話總覽">五策略一句話總覽&lt;/h2>
&lt;p>每個策略各自一張獨立 pattern 卡片、本卡只給總覽與選擇規則。&lt;/p>
&lt;h3 id="策略-a推進-query">策略 A：推進 query&lt;/h3>
&lt;p>把 filter 條件變成 source 的 query 參數、source 端就回符合的。最優、無層錯位 — 但要 source 支援。詳見 &lt;a href="../pattern-query-side-pushdown/">#61 Pattern：推進 query&lt;/a>。&lt;/p>
&lt;h3 id="策略-b自動續抓直到湊滿">策略 B：自動續抓直到湊滿&lt;/h3>
&lt;p>抓一批 → filter → 不夠再抓 → 湊滿 N 個或 source 結束。需要上限保護避免拉爆。詳見 &lt;a href="../pattern-fetch-until-quota/">#60 Pattern：自動續抓&lt;/a>。&lt;/p>
&lt;h3 id="策略-c預先建獨立-index">策略 C：預先建獨立 index&lt;/h3>
&lt;p>Build time 為每種 filter mode 各建一份 source、runtime 切 mode = 切 source。前提是能控 build、mode 有限。詳見 &lt;a href="../pattern-multiple-indexes/">#65 Pattern：多 index&lt;/a>。&lt;/p>
&lt;h3 id="策略-d誠實進度-ux">策略 D：誠實進度 UX&lt;/h3>
&lt;p>保留 view 層 filter、UI 顯示「已掃 N / 命中 K / 共 M」三數字 + 「再掃一批」、使用者手動觸發續抓。詳見 &lt;a href="../pattern-honest-progress-ui/">#62 Pattern：誠實進度 UX&lt;/a>。&lt;/p>
&lt;h3 id="策略-e明示語意縮小">策略 E：明示語意縮小&lt;/h3>
&lt;p>明示告訴使用者「filter 範圍 = 已載入、不承諾全集」、不假裝是全集 filter。比 D 顯眼度低、但成本最低。詳見 &lt;a href="../pattern-explicit-semantic-narrowing/">#66 Pattern：明示語意縮小&lt;/a>。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>D 跟 E 都是 subset 上做、差別&lt;/strong>：D 用三數字持續顯示掃描範圍、E 用文字一次性告知。silent 縮小（既不三數字、也不告知）= 反模式、撞回 &lt;a href="../view-layer-filter-vs-source-layer/">#55 層錯位&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="選擇規則決定矩陣">選擇規則：決定矩陣&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>條件&lt;/th>
 &lt;th>建議策略&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Source 支援 server-side filter&lt;/td>
 &lt;td>A（最優）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 不支援、match 密度高、自動較好&lt;/td>
 &lt;td>B&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 不支援、能控 build、mode 有限&lt;/td>
 &lt;td>C&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 不支援、稀疏、要避免拉爆&lt;/td>
 &lt;td>D&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>原型期、不解決完美&lt;/td>
 &lt;td>E（明示語意縮小）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 一次性給完、無分批&lt;/td>
 &lt;td>view 層 filter 直接寫&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="多策略並用">多策略並用&lt;/h2>
&lt;p>實務上常見組合：&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Filter 跟分批 source 的合成有五種策略、各自機會成本不同</strong>。沒有絕對最佳 — 選哪個取決於三個變數：</p>
<ol>
<li>Source 是否支援 server-side filter（capabilities）</li>
<li>Match 密度（稀疏 vs 密集）</li>
<li>UX 容忍度（要不要誠實顯示「掃描範圍」）</li>
</ol>
<p>本文是 #55 <a href="../view-layer-filter-vs-source-layer/">Filter 與 Source 的層錯位</a> 的解法展開、列出五個合理選項與適用情境。</p>
<hr>
<h2 id="五策略對照表">五策略對照表</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>一句話</th>
          <th>對 source 的需求</th>
          <th>對 UX 的影響</th>
          <th>工程量</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A</td>
          <td>把 filter 推進 source 的 query</td>
          <td>必須支援該 filter 條件</td>
          <td>透明（無感）</td>
          <td>中-高</td>
      </tr>
      <tr>
          <td>B</td>
          <td>自動續抓直到湊滿 N 個 match</td>
          <td>任何分批 source</td>
          <td>透明（稍慢）</td>
          <td>中</td>
      </tr>
      <tr>
          <td>C</td>
          <td>預先建獨立 index（每種 mode 一份）</td>
          <td>能控 source 的 build pipeline</td>
          <td>透明（最快）</td>
          <td>高</td>
      </tr>
      <tr>
          <td>D</td>
          <td>誠實 UX 顯示「已掃 N / 命中 K」</td>
          <td>任何 source</td>
          <td>顯眼（多按鈕）</td>
          <td>低</td>
      </tr>
      <tr>
          <td>E</td>
          <td>接受「filter 範圍 = 已載入」、不承諾 source 全集</td>
          <td>任何 source</td>
          <td>隱性語意縮小</td>
          <td>最低</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="五策略一句話總覽">五策略一句話總覽</h2>
<p>每個策略各自一張獨立 pattern 卡片、本卡只給總覽與選擇規則。</p>
<h3 id="策略-a推進-query">策略 A：推進 query</h3>
<p>把 filter 條件變成 source 的 query 參數、source 端就回符合的。最優、無層錯位 — 但要 source 支援。詳見 <a href="../pattern-query-side-pushdown/">#61 Pattern：推進 query</a>。</p>
<h3 id="策略-b自動續抓直到湊滿">策略 B：自動續抓直到湊滿</h3>
<p>抓一批 → filter → 不夠再抓 → 湊滿 N 個或 source 結束。需要上限保護避免拉爆。詳見 <a href="../pattern-fetch-until-quota/">#60 Pattern：自動續抓</a>。</p>
<h3 id="策略-c預先建獨立-index">策略 C：預先建獨立 index</h3>
<p>Build time 為每種 filter mode 各建一份 source、runtime 切 mode = 切 source。前提是能控 build、mode 有限。詳見 <a href="../pattern-multiple-indexes/">#65 Pattern：多 index</a>。</p>
<h3 id="策略-d誠實進度-ux">策略 D：誠實進度 UX</h3>
<p>保留 view 層 filter、UI 顯示「已掃 N / 命中 K / 共 M」三數字 + 「再掃一批」、使用者手動觸發續抓。詳見 <a href="../pattern-honest-progress-ui/">#62 Pattern：誠實進度 UX</a>。</p>
<h3 id="策略-e明示語意縮小">策略 E：明示語意縮小</h3>
<p>明示告訴使用者「filter 範圍 = 已載入、不承諾全集」、不假裝是全集 filter。比 D 顯眼度低、但成本最低。詳見 <a href="../pattern-explicit-semantic-narrowing/">#66 Pattern：明示語意縮小</a>。</p>
<blockquote>
<p><strong>D 跟 E 都是 subset 上做、差別</strong>：D 用三數字持續顯示掃描範圍、E 用文字一次性告知。silent 縮小（既不三數字、也不告知）= 反模式、撞回 <a href="../view-layer-filter-vs-source-layer/">#55 層錯位</a>。</p></blockquote>
<hr>
<h2 id="選擇規則決定矩陣">選擇規則：決定矩陣</h2>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>建議策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 支援 server-side filter</td>
          <td>A（最優）</td>
      </tr>
      <tr>
          <td>Source 不支援、match 密度高、自動較好</td>
          <td>B</td>
      </tr>
      <tr>
          <td>Source 不支援、能控 build、mode 有限</td>
          <td>C</td>
      </tr>
      <tr>
          <td>Source 不支援、稀疏、要避免拉爆</td>
          <td>D</td>
      </tr>
      <tr>
          <td>原型期、不解決完美</td>
          <td>E（明示語意縮小）</td>
      </tr>
      <tr>
          <td>Source 一次性給完、無分批</td>
          <td>view 層 filter 直接寫</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="多策略並用">多策略並用</h2>
<p>實務上常見組合：</p>
<ul>
<li><strong>A + D fallback</strong>：query 推進失敗（如使用者用 source 不支援的條件）→ fallback 到 D</li>
<li><strong>B + 上限 → D</strong>：自動續抓到上限後切 D（顯示「已掃 N 筆、再掃？」）</li>
<li><strong>C + B 補強</strong>：預先 index 解一般 case、B 解 index 沒覆蓋的組合</li>
</ul>
<p>並用通常比單選有效、但複雜度也最高。詳細的疊加判準（解不同層 / 沒副作用衝突 / 增量成本可接受）見 <a href="../main-strategy-plus-supplementary/">#75 主策略 + 補強策略</a> — 本表的「並用」就是 #75 的具體展現。</p>
<p>「先 ship 哪個策略、哪個下輪」見 <a href="../incremental-shipping-criteria/">#76 分批 ship 準則</a> — 例如 D（UX）通常先 ship、A/C（結構）下輪。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該選的策略起點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 是 SQL / ES / pagefind 且 filter 條件已索引</td>
          <td>A</td>
      </tr>
      <tr>
          <td>Source 是 pagefind 且 filter 是「title vs content」</td>
          <td>C（重 index 兩份）</td>
      </tr>
      <tr>
          <td>Source 不支援、預期 match 密集、要無感</td>
          <td>B</td>
      </tr>
      <tr>
          <td>工程量限制、能接受顯眼 UX</td>
          <td>D</td>
      </tr>
      <tr>
          <td>原型 / MVP、能接受語意縮小但要明示</td>
          <td>E（含語意聲明）</td>
      </tr>
      <tr>
          <td>使用者意圖明確要「全部命中」、source 不支援、match 稀疏</td>
          <td>A 或 C 重設計、不要 B（會拉爆）</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：Filter × Source 沒有最佳解、只有「對齊三變數（capabilities / 密度 / UX）的取捨」。識別三變數、選對策略 → 比寫漂亮的程式重要。</p>
<p>跟 <a href="../external-component-collaboration-layers/">#45 跟外部組件合作的四層次</a> 同構：A 推進 query ≈ 公共介面層（最穩定）、C 多 index ≈ 邊界層（build pipeline 控制）、B 自動續抓 ≈ 邊界 DOM 層（client 補足）、D / E 誠實或縮小 ≈ 內部結構層（接受限制）。兩個原則的選擇順序都是「離 source 公共介面越近、合作越穩」。</p>
]]></content:encoded></item><item><title>Pattern：自動續抓直到湊滿 quota</title><link>https://tarrragon.github.io/blog/report/pattern-fetch-until-quota/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-fetch-until-quota/</guid><description>&lt;h2 id="pattern-一句話">Pattern 一句話&lt;/h2>
&lt;p>抓一批 → filter → 不夠就再抓 → 湊滿 N 個 match 或 source 結束。&lt;/p>
&lt;p>對應 #59 &lt;a href="../filter-source-composition-strategies/">Filter × Source 合成策略&lt;/a> 的策略 B。&lt;/p>
&lt;hr>
&lt;h2 id="何時用何時不用">何時用、何時不用&lt;/h2>
&lt;h3 id="用">用&lt;/h3>
&lt;ul>
&lt;li>Source 不支援 server-side filter（不能用策略 A）&lt;/li>
&lt;li>不能控 build pipeline 重 index（不能用策略 C）&lt;/li>
&lt;li>Match 密度可預期、不會稀疏到要拉光整個 dataset&lt;/li>
&lt;li>使用者期望「filter 後自動湊夠 N 個」、不要手動續抓&lt;/li>
&lt;/ul>
&lt;h3 id="不用">不用&lt;/h3>
&lt;ul>
&lt;li>Source 支援 server-side filter（直接用策略 A）&lt;/li>
&lt;li>Match 稀疏、可能拉光整個 dataset 才湊到 N（換 D 誠實 UX）&lt;/li>
&lt;li>Source cardinality 大（10 萬筆）、不能拉太多次&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="必要元件">必要元件&lt;/h2>
&lt;h3 id="元件-1quota-跟上限">元件 1：Quota 跟上限&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">TARGET&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 期望湊滿的 match 數
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">MAX_BATCHES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">20&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 最多續抓次數（保護）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">MAX_TIME_MS&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">5000&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 最大時間（保護）
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>沒有上限 = 稀疏時拉爆&lt;/strong>。兩個上限缺一不可。&lt;/p>
&lt;h3 id="元件-2loop-with-break-conditions">元件 2：Loop with break conditions&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">fetchUntilQuota&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">target&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">TARGET&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">collected&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">start&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">let&lt;/span> &lt;span class="nx">batchCount&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">collected&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="nx">target&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">hasMore&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">batchCount&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="nx">MAX_BATCHES&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="nx">start&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="nx">MAX_TIME_MS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">batch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">fetchNext&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">collected&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(...&lt;/span>&lt;span class="nx">batch&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">batchCount&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">collected&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">reachedQuota&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">collected&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="nx">target&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">exhaustedSource&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">hasMore&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">hitLimit&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">batchCount&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="nx">MAX_BATCHES&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">Date&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="nx">start&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="nx">MAX_TIME_MS&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>返回值含三個 flag、UI 用來判斷該顯示哪個狀態（湊滿 / 抓完無更多 / 撞到上限）。&lt;/p>
&lt;h3 id="元件-3可中斷">元件 3：可中斷&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">fetchUntilQuota&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">target&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">signal&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">while&lt;/span> &lt;span class="p">(...)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">signal&lt;/span>&lt;span class="o">?&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">aborted&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">throw&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">DOMException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;aborted&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;AbortError&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">batch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">fetchNext&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">signal&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">// 使用
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">ctrl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">AbortController&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">ctrl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">abort&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">ctrl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">AbortController&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">fetchUntilQuota&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctrl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">signal&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>使用者改 query / filter 時能立刻取消舊的續抓。&lt;strong>沒有可中斷 = 競態 bug&lt;/strong>（舊 query 的結果晚到、覆蓋新 query 的）。&lt;/p>
&lt;hr>
&lt;h2 id="ux-配套">UX 配套&lt;/h2>
&lt;h3 id="載入中顯示進度">載入中顯示進度&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;loading&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>已掃 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>24&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> 筆 / 已命中 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>3 / 10&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不顯示進度 = 使用者不知道是在等還是卡住。&lt;/p>
&lt;h3 id="結束時顯示原因">結束時顯示原因&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>結束原因&lt;/th>
 &lt;th>顯示&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>reachedQuota&lt;/code>&lt;/td>
 &lt;td>「找到 10 個結果」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>exhaustedSource&lt;/code>&lt;/td>
 &lt;td>「全部掃完、共找到 K 個」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>hitLimit&lt;/code>&lt;/td>
 &lt;td>「已掃 N 筆、找到 K 個。要繼續找嗎？」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>不區分原因 = 使用者不知道為什麼停（同 #57 三狀態問題）。&lt;/p>
&lt;hr>
&lt;h2 id="反例">反例&lt;/h2>
&lt;h3 id="反例-1沒上限">反例 1：沒上限&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">collected&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="nx">target&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">hasMore&lt;/span>&lt;span class="p">())&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">collected&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(...(&lt;/span>&lt;span class="kr">await&lt;/span> &lt;span class="nx">fetchNext&lt;/span>&lt;span class="p">()).&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 稀疏 match → 拉光整個 source
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="反例-2沒-abort-signal">反例 2：沒 abort signal&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">fetchUntilQuota&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">render&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// 舊 query 的結果可能覆蓋新 query
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="反例-3每批序列化等">反例 3：每批序列化等&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">let&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="nx">MAX&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">batch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">fetchNext&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 序列、慢
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果 source 支援平行 fetch（多個 page 同時抓） → 改成平行更快：&lt;/p></description><content:encoded><![CDATA[<h2 id="pattern-一句話">Pattern 一句話</h2>
<p>抓一批 → filter → 不夠就再抓 → 湊滿 N 個 match 或 source 結束。</p>
<p>對應 #59 <a href="../filter-source-composition-strategies/">Filter × Source 合成策略</a> 的策略 B。</p>
<hr>
<h2 id="何時用何時不用">何時用、何時不用</h2>
<h3 id="用">用</h3>
<ul>
<li>Source 不支援 server-side filter（不能用策略 A）</li>
<li>不能控 build pipeline 重 index（不能用策略 C）</li>
<li>Match 密度可預期、不會稀疏到要拉光整個 dataset</li>
<li>使用者期望「filter 後自動湊夠 N 個」、不要手動續抓</li>
</ul>
<h3 id="不用">不用</h3>
<ul>
<li>Source 支援 server-side filter（直接用策略 A）</li>
<li>Match 稀疏、可能拉光整個 dataset 才湊到 N（換 D 誠實 UX）</li>
<li>Source cardinality 大（10 萬筆）、不能拉太多次</li>
</ul>
<hr>
<h2 id="必要元件">必要元件</h2>
<h3 id="元件-1quota-跟上限">元件 1：Quota 跟上限</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">TARGET</span> <span class="o">=</span> <span class="mi">10</span><span class="p">;</span>       <span class="c1">// 期望湊滿的 match 數
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">MAX_BATCHES</span> <span class="o">=</span> <span class="mi">20</span><span class="p">;</span>  <span class="c1">// 最多續抓次數（保護）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">MAX_TIME_MS</span> <span class="o">=</span> <span class="mi">5000</span><span class="p">;</span> <span class="c1">// 最大時間（保護）
</span></span></span></code></pre></div><p><strong>沒有上限 = 稀疏時拉爆</strong>。兩個上限缺一不可。</p>
<h3 id="元件-2loop-with-break-conditions">元件 2：Loop with break conditions</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">fetchUntilQuota</span><span class="p">(</span><span class="nx">matches</span><span class="p">,</span> <span class="nx">target</span> <span class="o">=</span> <span class="nx">TARGET</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">const</span> <span class="nx">collected</span> <span class="o">=</span> <span class="p">[];</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">start</span> <span class="o">=</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">let</span> <span class="nx">batchCount</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">while</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">collected</span><span class="p">.</span><span class="nx">length</span> <span class="o">&lt;</span> <span class="nx">target</span> <span class="o">&amp;&amp;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">hasMore</span><span class="p">()</span> <span class="o">&amp;&amp;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">batchCount</span> <span class="o">&lt;</span> <span class="nx">MAX_BATCHES</span> <span class="o">&amp;&amp;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">-</span> <span class="nx">start</span> <span class="o">&lt;</span> <span class="nx">MAX_TIME_MS</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="kr">const</span> <span class="nx">batch</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetchNext</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">collected</span><span class="p">.</span><span class="nx">push</span><span class="p">(...</span><span class="nx">batch</span><span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">matches</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">batchCount</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">collected</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">reachedQuota</span><span class="o">:</span> <span class="nx">collected</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;=</span> <span class="nx">target</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">exhaustedSource</span><span class="o">:</span> <span class="o">!</span><span class="nx">hasMore</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">hitLimit</span><span class="o">:</span> <span class="nx">batchCount</span> <span class="o">&gt;=</span> <span class="nx">MAX_BATCHES</span> <span class="o">||</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">-</span> <span class="nx">start</span> <span class="o">&gt;=</span> <span class="nx">MAX_TIME_MS</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>返回值含三個 flag、UI 用來判斷該顯示哪個狀態（湊滿 / 抓完無更多 / 撞到上限）。</p>
<h3 id="元件-3可中斷">元件 3：可中斷</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">fetchUntilQuota</span><span class="p">(</span><span class="nx">matches</span><span class="p">,</span> <span class="nx">target</span><span class="p">,</span> <span class="nx">signal</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="k">while</span> <span class="p">(...)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">signal</span><span class="o">?</span><span class="p">.</span><span class="nx">aborted</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nx">DOMException</span><span class="p">(</span><span class="s1">&#39;aborted&#39;</span><span class="p">,</span> <span class="s1">&#39;AbortError&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kr">const</span> <span class="nx">batch</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetchNext</span><span class="p">({</span> <span class="nx">signal</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1">// ...
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 使用
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">ctrl</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">AbortController</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nx">ctrl</span><span class="p">.</span><span class="nx">abort</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="nx">ctrl</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">AbortController</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nx">fetchUntilQuota</span><span class="p">(</span><span class="nx">matches</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="nx">ctrl</span><span class="p">.</span><span class="nx">signal</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>使用者改 query / filter 時能立刻取消舊的續抓。<strong>沒有可中斷 = 競態 bug</strong>（舊 query 的結果晚到、覆蓋新 query 的）。</p>
<hr>
<h2 id="ux-配套">UX 配套</h2>
<h3 id="載入中顯示進度">載入中顯示進度</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;loading&#34;</span><span class="p">&gt;</span>已掃 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>24<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> 筆 / 已命中 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>3 / 10<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>不顯示進度 = 使用者不知道是在等還是卡住。</p>
<h3 id="結束時顯示原因">結束時顯示原因</h3>
<table>
  <thead>
      <tr>
          <th>結束原因</th>
          <th>顯示</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>reachedQuota</code></td>
          <td>「找到 10 個結果」</td>
      </tr>
      <tr>
          <td><code>exhaustedSource</code></td>
          <td>「全部掃完、共找到 K 個」</td>
      </tr>
      <tr>
          <td><code>hitLimit</code></td>
          <td>「已掃 N 筆、找到 K 個。要繼續找嗎？」</td>
      </tr>
  </tbody>
</table>
<p>不區分原因 = 使用者不知道為什麼停（同 #57 三狀態問題）。</p>
<hr>
<h2 id="反例">反例</h2>
<h3 id="反例-1沒上限">反例 1：沒上限</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="nx">collected</span><span class="p">.</span><span class="nx">length</span> <span class="o">&lt;</span> <span class="nx">target</span> <span class="o">&amp;&amp;</span> <span class="nx">hasMore</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">collected</span><span class="p">.</span><span class="nx">push</span><span class="p">(...(</span><span class="kr">await</span> <span class="nx">fetchNext</span><span class="p">()).</span><span class="nx">filter</span><span class="p">(</span><span class="nx">matches</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 稀疏 match → 拉光整個 source
</span></span></span></code></pre></div><h3 id="反例-2沒-abort-signal">反例 2：沒 abort signal</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetchUntilQuota</span><span class="p">(</span><span class="nx">matches</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">render</span><span class="p">(</span><span class="nx">r</span><span class="p">);</span>  <span class="c1">// 舊 query 的結果可能覆蓋新 query
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><h3 id="反例-3每批序列化等">反例 3：每批序列化等</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">MAX</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">batch</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetchNext</span><span class="p">();</span>  <span class="c1">// 序列、慢
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>如果 source 支援平行 fetch（多個 page 同時抓） → 改成平行更快：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">batches</span> <span class="o">=</span> <span class="kr">await</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">all</span><span class="p">([</span><span class="nx">fetch</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span> <span class="nx">fetch</span><span class="p">(</span><span class="mi">1</span><span class="p">),</span> <span class="nx">fetch</span><span class="p">(</span><span class="mi">2</span><span class="p">)]);</span></span></span></code></pre></div><p>但平行有 over-fetch 風險（湊滿後其他批白抓） — 適合 match 密度高的情境。</p>
<hr>
<h2 id="跟其他-pattern-的關係">跟其他 Pattern 的關係</h2>
<ul>
<li>跟 #61 <a href="../pattern-query-side-pushdown/">Pattern：推進 query</a>（待補）：A 是最優、B 是 source 不支援時的退路</li>
<li>跟 #62 <a href="../pattern-honest-progress-ui/">Pattern：誠實進度 UX</a>（待補）：B 撞到上限後 fallback 到誠實 UX</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 不支援 filter、要湊滿 N 個結果</td>
          <td>用本 pattern</td>
      </tr>
      <tr>
          <td>寫了 while loop 但沒上限</td>
          <td>補 MAX_BATCHES + MAX_TIME_MS</td>
      </tr>
      <tr>
          <td>Input 改變時舊的續抓還在跑</td>
          <td>補 AbortController</td>
      </tr>
      <tr>
          <td>結束時不知道是「湊滿」「掃完」「撞上限」</td>
          <td>補三個 flag、UI 分支顯示</td>
      </tr>
      <tr>
          <td>Match 稀疏、續抓 50 次才湊到 1 個</td>
          <td>換策略 — B 不適合稀疏 case</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：自動續抓的價值在「使用者透明」、但成本是「上限保護必要」。沒上限的 B 比 silent post-filter 更糟（會拉爆）。</p>
]]></content:encoded></item><item><title>Pattern：把 filter 推進 query 引擎</title><link>https://tarrragon.github.io/blog/report/pattern-query-side-pushdown/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-query-side-pushdown/</guid><description>&lt;h2 id="pattern-一句話">Pattern 一句話&lt;/h2>
&lt;p>把 filter 變成 source 的 query 參數、source 端就回符合的、client 不 post-filter。&lt;/p>
&lt;p>對應 #59 &lt;a href="../filter-source-composition-strategies/">Filter × Source 合成策略&lt;/a> 的策略 A。&lt;/p>
&lt;hr>
&lt;h2 id="何時用何時不用">何時用、何時不用&lt;/h2>
&lt;h3 id="用">用&lt;/h3>
&lt;ul>
&lt;li>Source 支援該 filter 條件（已索引、能在 query 表達）&lt;/li>
&lt;li>想避免任何 client-side post-filter&lt;/li>
&lt;li>想避免層錯位（見 #55）&lt;/li>
&lt;/ul>
&lt;h3 id="不用">不用&lt;/h3>
&lt;ul>
&lt;li>Source 不支援（pagefind 對 title-only 沒 native 支援）&lt;/li>
&lt;li>條件需要 client-side 計算（依 viewport / 隨機抽樣）&lt;/li>
&lt;li>推進 query 後 cardinality 仍大、還是要 paginate（這時 A + B 並用）&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="推進的層次">推進的層次&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層次&lt;/th>
 &lt;th>範例&lt;/th>
 &lt;th>成本&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Query 參數&lt;/td>
 &lt;td>&lt;code>?type=post&amp;amp;tag=js&lt;/code>&lt;/td>
 &lt;td>最低、改 URL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter API&lt;/td>
 &lt;td>&lt;code>pagefind.search(q, { filters: { type: 'post' } })&lt;/code>&lt;/td>
 &lt;td>低、用 SDK&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Re-query&lt;/td>
 &lt;td>重新呼叫 search、不是同個 result 集再過濾&lt;/td>
 &lt;td>低&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Index 重建&lt;/td>
 &lt;td>Build 時加新欄位 / 新 index&lt;/td>
 &lt;td>中-高、要 build&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Schema 修改&lt;/td>
 &lt;td>改 DB schema、加欄位、reindex&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選哪一層 = source 的 capabilities 決定。&lt;/p>
&lt;hr>
&lt;h2 id="評估-source-capabilities">評估 Source Capabilities&lt;/h2>
&lt;p>寫之前讀 source docs / API spec、列出：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>答案範例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Source 接受哪些 filter 條件？&lt;/td>
 &lt;td>&lt;code>=&lt;/code>, &lt;code>IN&lt;/code>, &lt;code>BETWEEN&lt;/code>, full-text, &amp;hellip;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>哪些欄位已索引？&lt;/td>
 &lt;td>&lt;code>type&lt;/code>, &lt;code>tag&lt;/code>, &lt;code>date&lt;/code> (not &lt;code>title&lt;/code>)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>哪些 filter 不支援、需要重 index？&lt;/td>
 &lt;td>&lt;code>title contains&lt;/code>（需 full-text title）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter 有沒有 cost cap（rate limit）？&lt;/td>
 &lt;td>100 query / sec&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>不評估就寫 = 寫到一半發現 source 不支援、回頭走策略 B 或 C。&lt;/p>
&lt;hr>
&lt;h2 id="範例pagefind">範例：Pagefind&lt;/h2>
&lt;h3 id="支援的-filter">支援的 filter&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// pagefind 已支援 filter（透過 _pagefind/filter.json）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">pagefind&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;keyword&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">filters&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">type&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;post&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 支援
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">tag&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">any&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;js&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;css&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">},&lt;/span> &lt;span class="c1">// 支援多選
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="不支援的-filter">不支援的 filter&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// pagefind 不支援「只搜 title」
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">// 因為 pagefind 的 search 對 full-text、不分區
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">pagefind&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;keyword&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">scope&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;title-only&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 不存在（不支援）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>要解決：&lt;/p>
&lt;ul>
&lt;li>方案 1：build 時用 &lt;code>data-pagefind-body&lt;/code> 把 title 標成獨立 region、用 &lt;code>body&lt;/code> filter（pagefind v1.1+）&lt;/li>
&lt;li>方案 2：建兩個獨立 index（一個只 index title、一個只 index content） — 走策略 C&lt;/li>
&lt;li>方案 3：放棄推進 query、用策略 B 自動續抓 + post-filter&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="跟原本-query-邏輯的並用">跟原本 query 邏輯的並用&lt;/h2>
&lt;p>推進 filter 通常不取代原本 query、是「補上條件」：&lt;/p></description><content:encoded><![CDATA[<h2 id="pattern-一句話">Pattern 一句話</h2>
<p>把 filter 變成 source 的 query 參數、source 端就回符合的、client 不 post-filter。</p>
<p>對應 #59 <a href="../filter-source-composition-strategies/">Filter × Source 合成策略</a> 的策略 A。</p>
<hr>
<h2 id="何時用何時不用">何時用、何時不用</h2>
<h3 id="用">用</h3>
<ul>
<li>Source 支援該 filter 條件（已索引、能在 query 表達）</li>
<li>想避免任何 client-side post-filter</li>
<li>想避免層錯位（見 #55）</li>
</ul>
<h3 id="不用">不用</h3>
<ul>
<li>Source 不支援（pagefind 對 title-only 沒 native 支援）</li>
<li>條件需要 client-side 計算（依 viewport / 隨機抽樣）</li>
<li>推進 query 後 cardinality 仍大、還是要 paginate（這時 A + B 並用）</li>
</ul>
<hr>
<h2 id="推進的層次">推進的層次</h2>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>範例</th>
          <th>成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Query 參數</td>
          <td><code>?type=post&amp;tag=js</code></td>
          <td>最低、改 URL</td>
      </tr>
      <tr>
          <td>Filter API</td>
          <td><code>pagefind.search(q, { filters: { type: 'post' } })</code></td>
          <td>低、用 SDK</td>
      </tr>
      <tr>
          <td>Re-query</td>
          <td>重新呼叫 search、不是同個 result 集再過濾</td>
          <td>低</td>
      </tr>
      <tr>
          <td>Index 重建</td>
          <td>Build 時加新欄位 / 新 index</td>
          <td>中-高、要 build</td>
      </tr>
      <tr>
          <td>Schema 修改</td>
          <td>改 DB schema、加欄位、reindex</td>
          <td>高</td>
      </tr>
  </tbody>
</table>
<p>選哪一層 = source 的 capabilities 決定。</p>
<hr>
<h2 id="評估-source-capabilities">評估 Source Capabilities</h2>
<p>寫之前讀 source docs / API spec、列出：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>答案範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 接受哪些 filter 條件？</td>
          <td><code>=</code>, <code>IN</code>, <code>BETWEEN</code>, full-text, &hellip;</td>
      </tr>
      <tr>
          <td>哪些欄位已索引？</td>
          <td><code>type</code>, <code>tag</code>, <code>date</code> (not <code>title</code>)</td>
      </tr>
      <tr>
          <td>哪些 filter 不支援、需要重 index？</td>
          <td><code>title contains</code>（需 full-text title）</td>
      </tr>
      <tr>
          <td>Filter 有沒有 cost cap（rate limit）？</td>
          <td>100 query / sec</td>
      </tr>
  </tbody>
</table>
<p>不評估就寫 = 寫到一半發現 source 不支援、回頭走策略 B 或 C。</p>
<hr>
<h2 id="範例pagefind">範例：Pagefind</h2>
<h3 id="支援的-filter">支援的 filter</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// pagefind 已支援 filter（透過 _pagefind/filter.json）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="s1">&#39;keyword&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">filters</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">type</span><span class="o">:</span> <span class="s1">&#39;post&#39;</span><span class="p">,</span>           <span class="c1">// 支援
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>    <span class="nx">tag</span><span class="o">:</span> <span class="p">{</span> <span class="nx">any</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;js&#39;</span><span class="p">,</span> <span class="s1">&#39;css&#39;</span><span class="p">]</span> <span class="p">},</span> <span class="c1">// 支援多選
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="p">},</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h3 id="不支援的-filter">不支援的 filter</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// pagefind 不支援「只搜 title」
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 因為 pagefind 的 search 對 full-text、不分區
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="s1">&#39;keyword&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">scope</span><span class="o">:</span> <span class="s1">&#39;title-only&#39;</span><span class="p">,</span>  <span class="c1">// 不存在（不支援）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>要解決：</p>
<ul>
<li>方案 1：build 時用 <code>data-pagefind-body</code> 把 title 標成獨立 region、用 <code>body</code> filter（pagefind v1.1+）</li>
<li>方案 2：建兩個獨立 index（一個只 index title、一個只 index content） — 走策略 C</li>
<li>方案 3：放棄推進 query、用策略 B 自動續抓 + post-filter</li>
</ul>
<hr>
<h2 id="跟原本-query-邏輯的並用">跟原本 query 邏輯的並用</h2>
<p>推進 filter 通常不取代原本 query、是「補上條件」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 使用者輸入 query &#34;css&#34;、選 type=post
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="s1">&#39;css&#39;</span><span class="p">,</span> <span class="p">{</span>  <span class="c1">// query
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nx">filters</span><span class="o">:</span> <span class="p">{</span> <span class="nx">type</span><span class="o">:</span> <span class="s1">&#39;post&#39;</span> <span class="p">},</span>              <span class="c1">// filter
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// 兩個都進 source、source 算交集
</span></span></span></code></pre></div><p>Filter 跟 query 是不同維度：query 是「找什麼」、filter 是「在哪些範圍找」。</p>
<hr>
<h2 id="反例">反例</h2>
<h3 id="反例-1推進不完全留-client-side-post-filter-補">反例 1：推進不完全、留 client-side post-filter 補</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">q</span><span class="p">,</span> <span class="p">{</span> <span class="nx">filters</span><span class="o">:</span> <span class="p">{</span> <span class="nx">type</span><span class="o">:</span> <span class="s1">&#39;post&#39;</span> <span class="p">}</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kr">const</span> <span class="nx">filtered</span> <span class="o">=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">x</span> <span class="p">=&gt;</span> <span class="nx">x</span><span class="p">.</span><span class="nx">title</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">q</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// ↑ 這行還是 #55 層錯位
</span></span></span></code></pre></div><p>如果 source 不支援 title-filter、不要用「半推進」 — 直接走策略 C 或 B。</p>
<h3 id="反例-2忽略-cost-cap">反例 2：忽略 cost cap</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// 每個鍵盤事件 fire 一個 search query
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">,</span> <span class="p">{</span> <span class="nx">filters</span><span class="o">:</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// → query rate 100+/秒、撞 rate limit
</span></span></span></code></pre></div><p>加 debounce：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">let</span> <span class="nx">timer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">clearTimeout</span><span class="p">(</span><span class="nx">timer</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">timer</span> <span class="o">=</span> <span class="nx">setTimeout</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">,</span> <span class="p">...),</span> <span class="mi">200</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h3 id="反例-3客製欄位沒進-index寫了-query-失效">反例 3：客製欄位沒進 index、寫了 query 失效</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 期望 filter 「閱讀時間 &gt; 5 分鐘」
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">q</span><span class="p">,</span> <span class="p">{</span> <span class="nx">filters</span><span class="o">:</span> <span class="p">{</span> <span class="nx">readingTime</span><span class="o">:</span> <span class="p">{</span> <span class="nx">gt</span><span class="o">:</span> <span class="mi">5</span> <span class="p">}</span> <span class="p">}</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// → 但 build 時沒把 readingTime 進 filter index → filter 被忽略
</span></span></span></code></pre></div><p>預期 source 不支援 → 評估「是否值得加進 index」（成本 vs 使用率）。</p>
<hr>
<h2 id="跟其他-pattern-的關係">跟其他 Pattern 的關係</h2>
<ul>
<li>A 是最優 — 在 source capabilities 範圍內優先選</li>
<li>A 不可行 → 評估 C（建獨立 index）</li>
<li>C 也不可行 → 退到 B（自動續抓）</li>
<li>都不可行 → D（誠實 UX）</li>
</ul>
<p><strong>選擇順序：A → C → B → D</strong>。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Filter 條件能在 source 端表達</td>
          <td>用本 pattern</td>
      </tr>
      <tr>
          <td>Source 不支援、考慮要不要重 index</td>
          <td>評估 C 的成本</td>
      </tr>
      <tr>
          <td>用了 filter 還寫 client-side post-filter</td>
          <td>半推進是反模式、要嘛全推進、要嘛換策略</td>
      </tr>
      <tr>
          <td>Filter 觸發 query rate 高</td>
          <td>加 debounce / throttle</td>
      </tr>
      <tr>
          <td>Query 跟 filter 概念混淆</td>
          <td>區分：query = 「找什麼」、filter = 「範圍」</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：能推進 query 就推 — 沒層錯位、沒 silent 失敗、跟使用者意圖最近。但前提是 source 支援；不支援就要退到 B / C / D、不要做半推進。</p>
]]></content:encoded></item><item><title>Pattern：預先建獨立 index（每種 mode 一份）</title><link>https://tarrragon.github.io/blog/report/pattern-multiple-indexes/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-multiple-indexes/</guid><description>&lt;h2 id="pattern-一句話">Pattern 一句話&lt;/h2>
&lt;p>Build time 為每種 filter mode 各建一份獨立 source / index、runtime 切換 mode 等於切 source。&lt;/p>
&lt;p>對應 #59 &lt;a href="../filter-source-composition-strategies/">Filter × Source 合成策略&lt;/a> 的策略 C。&lt;/p>
&lt;hr>
&lt;h2 id="何時用何時不用">何時用、何時不用&lt;/h2>
&lt;h3 id="用">用&lt;/h3>
&lt;ul>
&lt;li>能控 source 的 build pipeline（自家 build、不是第三方 API）&lt;/li>
&lt;li>Filter mode 數量有限且穩定（&amp;lt; 5 個、不會爆炸組合）&lt;/li>
&lt;li>兩個（含以上）mode 都重要、流量大、值得獨立 index&lt;/li>
&lt;li>Source 的 query 引擎不支援該 filter（不能用 #61 推進 query）&lt;/li>
&lt;/ul>
&lt;h3 id="不用">不用&lt;/h3>
&lt;ul>
&lt;li>Filter 維度多、組合會爆炸（5 維 × 各 5 選項 = 3125 種 index）&lt;/li>
&lt;li>Index 大小敏感（每份 index 都重複占空間）&lt;/li>
&lt;li>Build pipeline 無法控（外部 API、vendor service）&lt;/li>
&lt;li>Mode 不穩定、常常增刪&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="結構">結構&lt;/h2>
&lt;h3 id="build-pipeline">Build pipeline&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Pagefind 範例：兩份 index 各自掃不同 region&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pagefind --site public --output-subdir _pagefind-all
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">pagefind --site public/title-only --output-subdir _pagefind-title
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 或用 --root-selector 限定 source 範圍&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">pagefind --site public --root-selector &lt;span class="s2">&amp;#34;.post-title&amp;#34;&lt;/span> --output-subdir _pagefind-title&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="runtime-切換">Runtime 切換&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">indexes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">all&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="kr">import&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/_pagefind-all/pagefind.js&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">title&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="kr">import&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/_pagefind-title/pagefind.js&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">mode&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">indexes&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">mode&lt;/span>&lt;span class="p">].&lt;/span>&lt;span class="nx">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">query&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個 mode 對應一份完整 index、search 結果直接是該 mode 的全集。&lt;strong>沒有 post-filter、沒有層錯位&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="多-index-的成本">多 index 的成本&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>成本面&lt;/th>
 &lt;th>影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Build 時間&lt;/td>
 &lt;td>每份 index 各建、線性增加&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>儲存空間&lt;/td>
 &lt;td>每份各自占用（pagefind 約 site 大小 2-5%）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>載入頻寬&lt;/td>
 &lt;td>runtime 載入哪份 = 該 mode 的 size&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>維護&lt;/td>
 &lt;td>改 source / schema 時、所有 index 都要重 build&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>通常 build / 儲存的成本 &amp;lt; 在 runtime 自動續抓（B）的累積請求成本。&lt;/p>
&lt;hr>
&lt;h2 id="跟-59-策略並用">跟 #59 策略並用&lt;/h2>
&lt;p>C 通常跟其他策略並用：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>C + 推進 query (A)&lt;/strong>：在每份 index 內、再用 query filter 細分（如 &lt;code>_pagefind-post.search('css', { filters: { tag: 'js' } })&lt;/code>）&lt;/li>
&lt;li>&lt;strong>C 切 mode + B 自動續抓&lt;/strong>：mode 切換無感、mode 內續抓也無感&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="反例">反例&lt;/h2>
&lt;h3 id="反例-1mode-組合爆炸">反例 1：Mode 組合爆炸&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 5 維 × 各 5 選項 = 3125 份 index&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="nb">type&lt;/span> in post page tutorial faq doc&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> tag in js css html ts py&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> date in &lt;span class="m">2020&lt;/span> &lt;span class="m">2021&lt;/span> &lt;span class="m">2022&lt;/span> &lt;span class="m">2023&lt;/span> 2024&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> pagefind --filter &lt;span class="s2">&amp;#34;type=&lt;/span>&lt;span class="nv">$type&lt;/span>&lt;span class="s2"> tag=&lt;/span>&lt;span class="nv">$tag&lt;/span>&lt;span class="s2"> date=&lt;/span>&lt;span class="nv">$date&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> --output-subdir _pagefind-&lt;span class="nv">$type&lt;/span>-&lt;span class="nv">$tag&lt;/span>-&lt;span class="nv">$date&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">done&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">done&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="k">done&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>組合爆炸時不能用 C — 改用 A（推進 query）讓 source 一份就好。&lt;/p></description><content:encoded><![CDATA[<h2 id="pattern-一句話">Pattern 一句話</h2>
<p>Build time 為每種 filter mode 各建一份獨立 source / index、runtime 切換 mode 等於切 source。</p>
<p>對應 #59 <a href="../filter-source-composition-strategies/">Filter × Source 合成策略</a> 的策略 C。</p>
<hr>
<h2 id="何時用何時不用">何時用、何時不用</h2>
<h3 id="用">用</h3>
<ul>
<li>能控 source 的 build pipeline（自家 build、不是第三方 API）</li>
<li>Filter mode 數量有限且穩定（&lt; 5 個、不會爆炸組合）</li>
<li>兩個（含以上）mode 都重要、流量大、值得獨立 index</li>
<li>Source 的 query 引擎不支援該 filter（不能用 #61 推進 query）</li>
</ul>
<h3 id="不用">不用</h3>
<ul>
<li>Filter 維度多、組合會爆炸（5 維 × 各 5 選項 = 3125 種 index）</li>
<li>Index 大小敏感（每份 index 都重複占空間）</li>
<li>Build pipeline 無法控（外部 API、vendor service）</li>
<li>Mode 不穩定、常常增刪</li>
</ul>
<hr>
<h2 id="結構">結構</h2>
<h3 id="build-pipeline">Build pipeline</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Pagefind 範例：兩份 index 各自掃不同 region</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pagefind --site public --output-subdir _pagefind-all
</span></span><span class="line"><span class="ln">3</span><span class="cl">pagefind --site public/title-only --output-subdir _pagefind-title
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 或用 --root-selector 限定 source 範圍</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">pagefind --site public --root-selector <span class="s2">&#34;.post-title&#34;</span> --output-subdir _pagefind-title</span></span></code></pre></div><h3 id="runtime-切換">Runtime 切換</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">indexes</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">all</span><span class="o">:</span> <span class="kr">await</span> <span class="kr">import</span><span class="p">(</span><span class="s1">&#39;/_pagefind-all/pagefind.js&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">title</span><span class="o">:</span> <span class="kr">await</span> <span class="kr">import</span><span class="p">(</span><span class="s1">&#39;/_pagefind-title/pagefind.js&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="kd">function</span> <span class="nx">search</span><span class="p">(</span><span class="nx">query</span><span class="p">,</span> <span class="nx">mode</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="k">return</span> <span class="nx">indexes</span><span class="p">[</span><span class="nx">mode</span><span class="p">].</span><span class="nx">search</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>每個 mode 對應一份完整 index、search 結果直接是該 mode 的全集。<strong>沒有 post-filter、沒有層錯位</strong>。</p>
<hr>
<h2 id="多-index-的成本">多 index 的成本</h2>
<table>
  <thead>
      <tr>
          <th>成本面</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Build 時間</td>
          <td>每份 index 各建、線性增加</td>
      </tr>
      <tr>
          <td>儲存空間</td>
          <td>每份各自占用（pagefind 約 site 大小 2-5%）</td>
      </tr>
      <tr>
          <td>載入頻寬</td>
          <td>runtime 載入哪份 = 該 mode 的 size</td>
      </tr>
      <tr>
          <td>維護</td>
          <td>改 source / schema 時、所有 index 都要重 build</td>
      </tr>
  </tbody>
</table>
<p>通常 build / 儲存的成本 &lt; 在 runtime 自動續抓（B）的累積請求成本。</p>
<hr>
<h2 id="跟-59-策略並用">跟 #59 策略並用</h2>
<p>C 通常跟其他策略並用：</p>
<ul>
<li><strong>C + 推進 query (A)</strong>：在每份 index 內、再用 query filter 細分（如 <code>_pagefind-post.search('css', { filters: { tag: 'js' } })</code>）</li>
<li><strong>C 切 mode + B 自動續抓</strong>：mode 切換無感、mode 內續抓也無感</li>
</ul>
<hr>
<h2 id="反例">反例</h2>
<h3 id="反例-1mode-組合爆炸">反例 1：Mode 組合爆炸</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 5 維 × 各 5 選項 = 3125 份 index</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">for</span> <span class="nb">type</span> in post page tutorial faq doc<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">for</span> tag in js css html ts py<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">for</span> date in <span class="m">2020</span> <span class="m">2021</span> <span class="m">2022</span> <span class="m">2023</span> 2024<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">      pagefind --filter <span class="s2">&#34;type=</span><span class="nv">$type</span><span class="s2"> tag=</span><span class="nv">$tag</span><span class="s2"> date=</span><span class="nv">$date</span><span class="s2">&#34;</span> --output-subdir _pagefind-<span class="nv">$type</span>-<span class="nv">$tag</span>-<span class="nv">$date</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">done</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="k">done</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>組合爆炸時不能用 C — 改用 A（推進 query）讓 source 一份就好。</p>
<h3 id="反例-2mode-不穩定常常增減">反例 2：Mode 不穩定、常常增減</h3>
<p>每加一個 mode、build pipeline 多一份、deploy 多一份。如果 mode 半年內會大改、不適合 C。</p>
<h3 id="反例-3index-沒對齊-mode">反例 3：Index 沒對齊 mode</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">pagefind --site public --output-subdir _pagefind-title
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># build 完後沒過濾、其實 index 了 title + content 全部</span></span></span></code></pre></div><p>如果只是改 output 路徑、沒改 index 範圍 → 兩份 index 內容一樣、白做。要用 <code>--root-selector</code> 或 <code>data-pagefind-body</code> 標記正確範圍。</p>
<hr>
<h2 id="跟其他-pattern-的關係">跟其他 Pattern 的關係</h2>
<p>選擇順序：A → C → B → D（見 #61）：</p>
<ul>
<li>A 不行（source 不支援該 filter） → 評估 C</li>
<li>C 不行（mode 爆炸 / 不能控 build） → 退到 B</li>
<li>B 不行（match 稀疏會爆） → 退到 D</li>
</ul>
<p><strong>C 是 A 的 build-time 模擬</strong> — 用 build 時間換 runtime 體驗、跟使用者意圖完全對齊（每份 index = 該 mode 的全集）。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 不支援該 filter、想用 A 但做不到</td>
          <td>評估能不能控 build → 是 → C</td>
      </tr>
      <tr>
          <td>Mode 數量 &lt; 5、stable、能控 build</td>
          <td>用本 pattern</td>
      </tr>
      <tr>
          <td>Mode 組合會爆炸（多維 × 多選）</td>
          <td>不要用 C、考慮 A 或重新思考 mode 設計</td>
      </tr>
      <tr>
          <td>兩份 index 內容一樣（沒對齊 mode）</td>
          <td>Build pipeline 出錯、檢查 source 過濾</td>
      </tr>
      <tr>
          <td>Build 時間翻倍但 runtime 體驗沒改善</td>
          <td>重評估：是否值得多份 index</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：C 用 build-time 換 runtime 體驗。前提是 mode 有限、可控、值得 — 否則退到 A 或 B。</p>
]]></content:encoded></item><item><title>Data Flow and Filter Composition — Filter × Source 層錯位與五策略</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/</guid><description>&lt;p>設計 filter / sort / count / transform 等 stream 操作時、確保操作位置跟資料源同層、避免層錯位產生 silent 缺口。原則跨 UI / 後端 / 演算法管線通用 — 不只是前端問題。&lt;/p>
&lt;p>適用：前端 paginated UI 加 filter、後端 API + middleware filter、演算法 pipeline 加 transform、map-reduce 加 post-filter、資料庫 materialized view 加 query。
不適用：純運算演算法（沒有 stream / 沒有 materialization 概念）、純 React state 管理（沒有外部 source）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋層錯位識別、五策略選擇、跨領域範例、playwright 驗證方法。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的第一件事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>即將寫 &lt;code>forEach(el =&amp;gt; el.hidden = !matches(el))&lt;/code>&lt;/td>
 &lt;td>停 — 確認 source 是不是分批 / streaming&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 是 &lt;code>pagefind.search()&lt;/code> / &lt;code>paginatedFetch()&lt;/code> / &lt;code>for await&lt;/code>&lt;/td>
 &lt;td>filter 必須跟 source 同層、不能在 view 層後處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「filter 後 0 筆但 source 還有未載入」可能發生&lt;/td>
 &lt;td>必須補自動續抓 / 推進 query / 誠實 UX&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backend middleware / response wrapper 加 filter&lt;/td>
 &lt;td>推進 ORM query / SQL &lt;code>WHERE&lt;/code>、不在 response 後&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>演算法 pipeline 末端 filter&lt;/td>
 &lt;td>推進 pipeline stage 內、stream-aware&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Map-reduce 完成後加 post-filter&lt;/td>
 &lt;td>推進 map / reduce 階段&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「畫面 / 結果對了但邊界 case 怪」&lt;/td>
 &lt;td>識別這是層錯位、不是 bug 修補能解&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-filter--source-是個結構性議題">為什麼 filter × source 是個結構性議題&lt;/h2>
&lt;p>Filter 操作的定義是「從 stream 中過濾出符合條件的元素」 — &lt;strong>stream&lt;/strong> 是隱含的對象。當 stream 被分層 materialize 時、filter 套在哪一層、決定它能「看見」的元素範圍：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層&lt;/th>
 &lt;th>能看到的範圍&lt;/th>
 &lt;th>filter 結果的語意&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Source 層&lt;/td>
 &lt;td>完整 stream&lt;/td>
 &lt;td>「stream 中所有符合的」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Materialization 中&lt;/td>
 &lt;td>已 materialize 的部分&lt;/td>
 &lt;td>「目前載入的符合的」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>下游（view / response）&lt;/td>
 &lt;td>Materialized 之後 + downstream filter 之前的子集&lt;/td>
 &lt;td>「下游可見的子集中符合的」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>使用者 / 呼叫者意圖的「filter」通常是第一層（stream 全集）— 但寫程式當下手邊的對象通常是第三層（已 materialize 的 subset）。&lt;strong>寫起來最便利的位置 ≠ 對齊意圖的位置&lt;/strong>。&lt;/p>
&lt;p>這是 &lt;a href="https://tarrragon.github.io/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關&lt;/a> 在 stream 操作上的具體展現。&lt;/p></description><content:encoded><![CDATA[<p>設計 filter / sort / count / transform 等 stream 操作時、確保操作位置跟資料源同層、避免層錯位產生 silent 缺口。原則跨 UI / 後端 / 演算法管線通用 — 不只是前端問題。</p>
<p>適用：前端 paginated UI 加 filter、後端 API + middleware filter、演算法 pipeline 加 transform、map-reduce 加 post-filter、資料庫 materialized view 加 query。
不適用：純運算演算法（沒有 stream / 沒有 materialization 概念）、純 React state 管理（沒有外部 source）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋層錯位識別、五策略選擇、跨領域範例、playwright 驗證方法。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 <code>forEach(el =&gt; el.hidden = !matches(el))</code></td>
          <td>停 — 確認 source 是不是分批 / streaming</td>
      </tr>
      <tr>
          <td>Source 是 <code>pagefind.search()</code> / <code>paginatedFetch()</code> / <code>for await</code></td>
          <td>filter 必須跟 source 同層、不能在 view 層後處理</td>
      </tr>
      <tr>
          <td>「filter 後 0 筆但 source 還有未載入」可能發生</td>
          <td>必須補自動續抓 / 推進 query / 誠實 UX</td>
      </tr>
      <tr>
          <td>Backend middleware / response wrapper 加 filter</td>
          <td>推進 ORM query / SQL <code>WHERE</code>、不在 response 後</td>
      </tr>
      <tr>
          <td>演算法 pipeline 末端 filter</td>
          <td>推進 pipeline stage 內、stream-aware</td>
      </tr>
      <tr>
          <td>Map-reduce 完成後加 post-filter</td>
          <td>推進 map / reduce 階段</td>
      </tr>
      <tr>
          <td>「畫面 / 結果對了但邊界 case 怪」</td>
          <td>識別這是層錯位、不是 bug 修補能解</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-filter--source-是個結構性議題">為什麼 filter × source 是個結構性議題</h2>
<p>Filter 操作的定義是「從 stream 中過濾出符合條件的元素」 — <strong>stream</strong> 是隱含的對象。當 stream 被分層 materialize 時、filter 套在哪一層、決定它能「看見」的元素範圍：</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>能看到的範圍</th>
          <th>filter 結果的語意</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 層</td>
          <td>完整 stream</td>
          <td>「stream 中所有符合的」</td>
      </tr>
      <tr>
          <td>Materialization 中</td>
          <td>已 materialize 的部分</td>
          <td>「目前載入的符合的」</td>
      </tr>
      <tr>
          <td>下游（view / response）</td>
          <td>Materialized 之後 + downstream filter 之前的子集</td>
          <td>「下游可見的子集中符合的」</td>
      </tr>
  </tbody>
</table>
<p>使用者 / 呼叫者意圖的「filter」通常是第一層（stream 全集）— 但寫程式當下手邊的對象通常是第三層（已 materialize 的 subset）。<strong>寫起來最便利的位置 ≠ 對齊意圖的位置</strong>。</p>
<p>這是 <a href="/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關</a> 在 stream 操作上的具體展現。</p>
<hr>
<h2 id="跨領域同個結構五個情境">跨領域：同個結構、五個情境</h2>
<h3 id="情境-1前端-ui--pagefind-paginated-search">情境 1：前端 UI + Pagefind paginated search</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 反例：post-filter on view layer
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">all</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">all</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">start</span><span class="p">,</span> <span class="nx">start</span> <span class="o">+</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">render</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">hidden</span> <span class="o">=</span> <span class="o">!</span><span class="nx">matches</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>  <span class="c1">// view 層 filter
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// 第二批全 hidden、使用者看到「load more 沒效果」
</span></span></span></code></pre></div><h3 id="情境-2後端-api--orm-middleware">情境 2：後端 API + ORM middleware</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 反例：middleware 在 pagination 之後 filter</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nd">@app.route</span><span class="p">(</span><span class="s2">&#34;/posts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">posts</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">page</span> <span class="o">=</span> <span class="n">Post</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">paginate</span><span class="p">(</span><span class="n">page</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">per_page</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">page</span><span class="o">.</span><span class="n">items</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">author</span> <span class="o">==</span> <span class="s2">&#34;author_x&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="c1"># 漏掉沒在這頁的符合項</span></span></span></code></pre></div><h3 id="情境-3async-iterator--taken">情境 3：Async iterator + take(N)</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 反例：先 take 後 filter</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">items</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">itertools</span><span class="o">.</span><span class="n">islice</span><span class="p">(</span><span class="n">stream</span><span class="p">(),</span> <span class="mi">100</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">filtered</span> <span class="o">=</span> <span class="p">[</span><span class="n">x</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">items</span> <span class="k">if</span> <span class="n">matches</span><span class="p">(</span><span class="n">x</span><span class="p">)]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># stream 後面可能還有符合的、但被 take 100 切斷了</span></span></span></code></pre></div><h3 id="情境-4map-reduce--post-reduce-filter">情境 4：Map-reduce + post-reduce filter</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[shards] → [map output] → [reduce]
</span></span><span class="line"><span class="ln">2</span><span class="cl">                              ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">                         [filter]  ← 已是 reduce 後的結果</span></span></code></pre></div><p>Filter 應該在 map 階段（per-shard）或 reduce 內、不是 reduce 後。</p>
<h3 id="情境-5materialized-view--select">情境 5：Materialized view + SELECT</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 反例：在 stale view 上 filter
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">posts_summary</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">author_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">42</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- view 可能是某個時點的 snapshot、漏掉之後寫入的 posts
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 對例：filter 推進原表
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">posts</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">author_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">42</span><span class="p">;</span></span></span></code></pre></div><p><strong>五個情境共用結構</strong>：source 是分層 materialize 的、filter 套在下游 → silent 缺口。</p>
<hr>
<h2 id="五種解法策略">五種解法策略</h2>
<p>詳細展開見 <a href="/blog/report/filter-source-composition-strategies/" data-link-title="Filter × Source 的合成策略五選一" data-link-desc="Filter 跟 paginated / streaming source 合成的五種策略、各自機會成本不同：A 推進 query / B 自動續抓 / C 預先 index / D 誠實 UX / E 接受語意縮小。沒有絕對最佳、看 source capabilities、match 密度、UX 容忍度而定。">#59 Filter × Source 合成策略五選一</a>。本卡只列總覽：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>一句話</th>
          <th>對 source 的需求</th>
          <th>工程量</th>
          <th>UX 影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A</td>
          <td>把 filter 推進 source 的 query</td>
          <td>必須支援該 filter 條件</td>
          <td>中-高</td>
          <td>透明（無感）</td>
      </tr>
      <tr>
          <td>B</td>
          <td>自動續抓直到湊滿 N 個 match</td>
          <td>任何分批 source</td>
          <td>中</td>
          <td>透明（稍慢）</td>
      </tr>
      <tr>
          <td>C</td>
          <td>預先建獨立 index（每種 mode 一份）</td>
          <td>能控 source 的 build pipeline</td>
          <td>高</td>
          <td>透明（最快）</td>
      </tr>
      <tr>
          <td>D</td>
          <td>誠實 UX 顯示「已掃 N / 命中 K」</td>
          <td>任何 source</td>
          <td>低</td>
          <td>顯眼（多按鈕）</td>
      </tr>
      <tr>
          <td>E</td>
          <td>明示語意縮小（filter 範圍 = 已載入）</td>
          <td>任何 source</td>
          <td>最低</td>
          <td>隱性語意縮小</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>A → C → B → D → E</strong>（不寫不告知的 silent 縮小、那是反模式）。</p>
<p>對應的 pattern 卡片：<a href="/blog/report/pattern-fetch-until-quota/" data-link-title="Pattern：自動續抓直到湊滿 quota" data-link-desc="Pattern 卡片：分批 source &#43; post-filter 時、自動續抓直到湊滿 N 個 match。含上限保護、進度顯示、可中斷三個必要元件。對應 #59 策略 B 的具體實作。">#60 自動續抓</a> / <a href="/blog/report/pattern-query-side-pushdown/" data-link-title="Pattern：把 filter 推進 query 引擎" data-link-desc="Pattern 卡片：把 client-side filter 推進 source 的 query 引擎、由 source 直接回符合的。對應 #59 策略 A 的具體實作。前提是 source capabilities 支援該 filter 條件、否則要評估重 index。">#61 推進 query</a> / <a href="/blog/report/pattern-honest-progress-ui/" data-link-title="Pattern：誠實進度 UX（已掃 N / 命中 K / 共 M）" data-link-desc="Pattern 卡片：當 filter 跟 source 必然有層錯位、用三數字（已掃 N / 命中 K / 共 M）讓使用者看見掃描範圍、避免誤以為「沒命中」。對應 #59 策略 D 的具體實作。">#62 誠實進度 UX</a> / <a href="/blog/report/pattern-multiple-indexes/" data-link-title="Pattern：預先建獨立 index（每種 mode 一份）" data-link-desc="Pattern 卡片：build time 為每種 filter mode 各建一份 source / index、runtime 切換 mode = 切 source。對應 #59 策略 C 的具體實作。前提是能控 source 的 build pipeline、且 mode 數量有限。">#65 多 index</a> / <a href="/blog/report/pattern-explicit-semantic-narrowing/" data-link-title="Pattern：明示語意縮小（不承諾全集）" data-link-desc="Pattern 卡片：當 filter 必然只能在 subset 上做、明確告訴使用者「這只在已載入範圍內找」、不假裝是全集 filter。對應 #59 策略 E 的具體實作。重點是「明示」、silent 縮小是反模式。">#66 明示語意縮小</a></p>
<hr>
<h2 id="三變數決定策略選擇">三變數決定策略選擇</h2>
<p>選 A / B / C / D / E 看三個變數：</p>
<h3 id="變數-1source-capabilities">變數 1：Source capabilities</h3>
<p>Source 支援哪些 server-side filter？</p>
<ul>
<li>支援該 filter 條件 → A 最優</li>
<li>不支援、能控 build → 評估 C</li>
<li>都不行 → B / D / E</li>
</ul>
<h3 id="變數-2match-密度">變數 2：Match 密度</h3>
<p>每抓一批、預期多少筆 match？</p>
<ul>
<li>高密度（每批 ≥ 1 個 match）→ B 自動續抓 OK</li>
<li>稀疏（要抓很多批才湊到一個）→ B 會拉爆、用 D / E</li>
<li>不可預期 → 加上限保護的 B + fallback 到 D</li>
</ul>
<h3 id="變數-3ux-容忍度">變數 3：UX 容忍度</h3>
<p>使用者能接受多顯眼的「掃描範圍」UX？</p>
<ul>
<li>完全不行（filter 是核心互動）→ A / C</li>
<li>可以顯示三數字 → D</li>
<li>一次性文字告知就行 → E</li>
</ul>
<hr>
<h2 id="playwright-驗證-filter--source-行為">Playwright 驗證 filter × source 行為</h2>
<p>寫完 filter 後、用 playwright 驗證是否有層錯位 silent 缺口。</p>
<h3 id="驗證-1load-more-後-filter-後是否該有結果">驗證 1：「Load more 後 filter 後是否該有結果」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;[data-scope=&#34;title&#34;]&#39;</span><span class="p">);</span>  <span class="c1">// 選 title-only
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="c1">// 載入第一批、量已掃 / 命中
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">before</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">loaded</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">visible</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;button.load-more&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForTimeout</span><span class="p">(</span><span class="mi">500</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="kr">const</span> <span class="nx">after</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">loaded</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">visible</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="c1">// 層錯位徵兆：loaded 增加、visible 沒增加
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">deltaLoaded</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">-</span> <span class="nx">before</span><span class="p">.</span><span class="nx">loaded</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">deltaVisible</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">visible</span> <span class="o">-</span> <span class="nx">before</span><span class="p">.</span><span class="nx">visible</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">isSilentGap</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">&gt;</span> <span class="nx">before</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">&amp;&amp;</span> <span class="nx">after</span><span class="p">.</span><span class="nx">visible</span> <span class="o">===</span> <span class="nx">before</span><span class="p">.</span><span class="nx">visible</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="驗證-2稀疏-case-是否拉爆">驗證 2：「稀疏 case 是否拉爆」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 用一個極少 match 的 query 觸發 B 策略
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=very_rare_keyword&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;[data-scope=&#34;title&#34;]&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="kr">const</span> <span class="nx">startTime</span> <span class="o">=</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.scan-status&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">timeout</span><span class="o">:</span> <span class="mi">10000</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// 應該在 5s 內顯示「已掃完、共命中 K 個」、不該無限續抓
</span></span></span></code></pre></div><h3 id="驗證-3使用者能否區分四狀態">驗證 3：「使用者能否區分四狀態」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">statusVisible</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.filter-status&#39;</span><span class="p">).</span><span class="nx">textContent</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 應該明示 loading / partial / end / empty 之一、不只是 spinner
</span></span></span></code></pre></div><p>寫成 playwright test 固化 — 未來架構改動時 CI 立刻發現 regression（<a href="/blog/report/layout-tests-with-playwright/" data-link-title="用前端測試把排版問題自動化" data-link-desc="排版問題傳統靠人眼檢查、容易遺漏邊界 case。當一個版型被 debug 兩次以上、就值得寫成 playwright 測試把規範固定下來。本文展開測試替代手動檢查的時機。">#15 layout-tests-with-playwright</a>）。</p>
<hr>
<h2 id="設計決策的-checklist">設計決策的 checklist</h2>
<p>寫 filter 之前、跑這份 checklist：</p>
<ul>
<li><input disabled="" type="checkbox"> Source 是不是分批 / streaming / cached / lazy？（<a href="/blog/report/data-source-shape-defines-feature-shape/" data-link-title="資料源的形狀決定 feature 的形狀" data-link-desc="Feature 設計要服從資料源的形狀（一次性 / 分批 / streaming / cached）— 不能憑 UI 想要的形狀去倒推資料層。憑 UI 倒推 = 在錯誤的層解錯誤的問題、產生 #55 層錯位類 bug。">#63 資料源形狀</a>）</li>
<li><input disabled="" type="checkbox"> Filter 的定義域是已載入子集還是 source 全集？（使用者意圖三問、見 <a href="/blog/report/filter-instruction-clarification/" data-link-title="篩選類指令的澄清時機" data-link-desc="「依 X 篩選」這類指令必須先澄清三件事才能寫：定義域（已載入 / 全部 / 子集）、資料分批方式、空狀態的語意。三問跑完才寫、否則必然寫成視覺層 post-filter、撞上 #55 層錯位。">#58</a>）</li>
<li><input disabled="" type="checkbox"> Source 是否支援 server-side filter？（決定能不能用 A）</li>
<li><input disabled="" type="checkbox"> Match 密度可預期嗎？（決定 B 是否可行）</li>
<li><input disabled="" type="checkbox"> 三狀態（loading / empty / end）UX 怎麼區分？（<a href="/blog/report/loading-empty-end-state-distinction/" data-link-title="Loading / Empty / End 三狀態的區分" data-link-desc="「還沒抓」「沒命中」「抓完無更多」三個狀態語意不同、UX 必須區分。共用同個畫面（「空白」或 spinner）會讓使用者無法判斷下一步。本文展開三狀態的內在屬性與 UX 規則。">#57</a>）</li>
<li><input disabled="" type="checkbox"> 對於「filter 後 0 筆」的情境、使用者能否區分「沒命中」vs「還沒抓到」？</li>
</ul>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1搜尋頁-title-only-filter">範例 1：搜尋頁 title-only filter</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// pagefind 分批載入、view 層 post-filter
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">render</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope-title&#39;</span><span class="p">).</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="kr">const</span> <span class="nx">title</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.title&#39;</span><span class="p">).</span><span class="nx">textContent</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">hidden</span> <span class="o">=</span> <span class="o">!</span><span class="nx">title</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>第二批 8 筆 title 不含 query → 全 hidden、使用者看到「load more 沒效果」。</p>
<p><strong>對</strong>（策略 C：多 index + 切換）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Build 階段</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pagefind --site public --output-subdir _pagefind-all
</span></span><span class="line"><span class="ln">3</span><span class="cl">pagefind --site public --root-selector <span class="s2">&#34;article h1, article h2&#34;</span> --output-subdir _pagefind-title</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">const</span> <span class="nx">indexes</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">all</span><span class="o">:</span> <span class="kr">await</span> <span class="kr">import</span><span class="p">(</span><span class="s1">&#39;/_pagefind-all/pagefind.js&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">title</span><span class="o">:</span> <span class="kr">await</span> <span class="kr">import</span><span class="p">(</span><span class="s1">&#39;/_pagefind-title/pagefind.js&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kr">const</span> <span class="nx">pf</span> <span class="o">=</span> <span class="nx">currentScope</span> <span class="o">===</span> <span class="s1">&#39;title&#39;</span> <span class="o">?</span> <span class="nx">indexes</span><span class="p">.</span><span class="nx">title</span> <span class="o">:</span> <span class="nx">indexes</span><span class="p">.</span><span class="nx">all</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pf</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c1">// results 已是「該 scope 的全集」、無層錯位
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">render</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><strong>對</strong>（策略 D：誠實進度 UX、保留 view 層 filter）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;filter-status&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  已掃 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>24<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> / <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>~150<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> 筆 — 命中 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>3<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span><span class="p">&gt;</span>再掃一批<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// view 層 filter 保留、但 UI 顯示掃描範圍 + 提供續抓
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">updateStatus</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kr">const</span> <span class="nx">all</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kr">const</span> <span class="nx">visible</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scanned&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">all</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.matched&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">visible</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="範例-2後端-api-filter">範例 2：後端 API filter</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="nd">@app.route</span><span class="p">(</span><span class="s2">&#34;/posts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">list_posts</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">page</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;page&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">posts</span> <span class="o">=</span> <span class="n">Post</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">paginate</span><span class="p">(</span><span class="n">page</span><span class="o">=</span><span class="n">page</span><span class="p">,</span> <span class="n">per_page</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="n">author</span> <span class="o">:=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;author&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">posts</span><span class="o">.</span><span class="n">items</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">author</span> <span class="o">==</span> <span class="n">author</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="n">posts</span><span class="o">.</span><span class="n">items</span></span></span></code></pre></div><p>中間的 list comprehension 在 pagination 之後 filter — 漏掉沒在這頁的符合項。</p>
<p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="nd">@app.route</span><span class="p">(</span><span class="s2">&#34;/posts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">list_posts</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">query</span> <span class="o">=</span> <span class="n">Post</span><span class="o">.</span><span class="n">objects</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="n">author</span> <span class="o">:=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;author&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="n">filter_by</span><span class="p">(</span><span class="n">author</span><span class="o">=</span><span class="n">author</span><span class="p">)</span>  <span class="c1"># 推進 ORM</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">page</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;page&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="n">query</span><span class="o">.</span><span class="n">paginate</span><span class="p">(</span><span class="n">page</span><span class="o">=</span><span class="n">page</span><span class="p">,</span> <span class="n">per_page</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span><span class="o">.</span><span class="n">items</span></span></span></code></pre></div><p>Filter 在 query 層、pagination 在 filter 之後、無層錯位。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫 filter / sort / count / transform 前：</p>
<ul>
<li><input disabled="" type="checkbox"> 我有沒有問「這個操作的對象是哪一層的 stream」？</li>
<li><input disabled="" type="checkbox"> Source 是分批的嗎？是 → filter 必須同層或推進上游</li>
<li><input disabled="" type="checkbox"> 寫了 view 層 filter？檢查：稀疏 case 會不會 silent 失敗？</li>
<li><input disabled="" type="checkbox"> 用了 B（自動續抓）？有沒有 MAX_BATCHES + MAX_TIME_MS 上限保護？</li>
<li><input disabled="" type="checkbox"> UX 能否區分「載入中 / 沒命中 / 還沒抓到 / 抓完了」四狀態？</li>
<li><input disabled="" type="checkbox"> Playwright 驗證有沒有覆蓋「稀疏 case」「load more 後 visible 是否變」？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>問題分析：</p>
<ul>
<li><a href="/blog/report/view-layer-filter-vs-source-layer/" data-link-title="Filter 與 Source 的抽象層錯位" data-link-desc="Filter 必須跟它過濾的資料源在同一層運作。視覺層的 filter 套在資料層分批產出的 source 上、會在「一筆」的定義上產生語意縫 — 使用者要的「全部符合」變成「目前載入的符合」、然後 silent 失敗。本文展開層錯位的識別與糾正。">#55 Filter 與 Source 的抽象層錯位</a> — 根因</li>
<li><a href="/blog/report/visual-completion-vs-functional-completion/" data-link-title="視覺完成 ≠ 功能完成" data-link-desc="「畫面對了」是視覺驗收訊號、不是功能驗收訊號。視覺完成早於功能完成、容易掩蓋語意缺口。本文展開兩者的區分與識別「畫面對但功能漏」的訊號。">#56 視覺完成 ≠ 功能完成</a> — 「畫面對」是低資訊量訊號</li>
<li><a href="/blog/report/loading-empty-end-state-distinction/" data-link-title="Loading / Empty / End 三狀態的區分" data-link-desc="「還沒抓」「沒命中」「抓完無更多」三個狀態語意不同、UX 必須區分。共用同個畫面（「空白」或 spinner）會讓使用者無法判斷下一步。本文展開三狀態的內在屬性與 UX 規則。">#57 Loading / Empty / End 三狀態的區分</a> — UX 落地</li>
</ul>
<p>指令澄清（在 requirement-protocol skill）：</p>
<ul>
<li><a href="/blog/report/filter-instruction-clarification/" data-link-title="篩選類指令的澄清時機" data-link-desc="「依 X 篩選」這類指令必須先澄清三件事才能寫：定義域（已載入 / 全部 / 子集）、資料分批方式、空狀態的語意。三問跑完才寫、否則必然寫成視覺層 post-filter、撞上 #55 層錯位。">#58 篩選類指令的澄清時機</a> — 三問模板</li>
</ul>
<p>解法策略：</p>
<ul>
<li><a href="/blog/report/filter-source-composition-strategies/" data-link-title="Filter × Source 的合成策略五選一" data-link-desc="Filter 跟 paginated / streaming source 合成的五種策略、各自機會成本不同：A 推進 query / B 自動續抓 / C 預先 index / D 誠實 UX / E 接受語意縮小。沒有絕對最佳、看 source capabilities、match 密度、UX 容忍度而定。">#59 Filter × Source 合成策略五選一</a> — 總覽</li>
<li><a href="/blog/report/pattern-fetch-until-quota/" data-link-title="Pattern：自動續抓直到湊滿 quota" data-link-desc="Pattern 卡片：分批 source &#43; post-filter 時、自動續抓直到湊滿 N 個 match。含上限保護、進度顯示、可中斷三個必要元件。對應 #59 策略 B 的具體實作。">#60-#62, #65-#66 五張 Pattern 卡片</a> — 各策略具體實作</li>
</ul>
<p>抽象原則：</p>
<ul>
<li><a href="/blog/report/data-source-shape-defines-feature-shape/" data-link-title="資料源的形狀決定 feature 的形狀" data-link-desc="Feature 設計要服從資料源的形狀（一次性 / 分批 / streaming / cached）— 不能憑 UI 想要的形狀去倒推資料層。憑 UI 倒推 = 在錯誤的層解錯誤的問題、產生 #55 層錯位類 bug。">#63 資料源的形狀決定 feature 的形狀</a> — 形狀是硬約束</li>
<li><a href="/blog/report/compose-feature-at-source-layer/" data-link-title="Feature 操作要跟 Source 同層合成" data-link-desc="Filter / sort / count / transform / search 是 stream 操作、必須跟 stream 的 materialization 同層或更上游合成。在下游做 = 操作 subset 不是 stream。本原則跨前端 UI、後端 API、演算法管線通用、不只是視覺層 vs 資料層。">#64 Feature 操作要跟 Source 同層合成</a> — 跨領域通用原則</li>
<li><a href="/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關</a> — meta-principle</li>
<li><a href="/blog/report/verification-timeline-checkpoints/" data-link-title="驗收的時間軸：四個 checkpoint" data-link-desc="驗收不是單一動作、是分散在四個時點（寫之前 / 開發中 / ship 前 / ship 後）的累積判斷。每個 checkpoint 能 catch 不同類型的失敗、成本不同。早期 checkpoint 抓越多、晚期 checkpoint 越輕鬆。實務上常常 collapse 成「寫的時候 &#43; ship 後出問題才修」、跳過寫之前 / ship 前。">#68 驗收的時間軸：四個 checkpoint</a> — 驗收策略</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item></channel></rss>