@aiquants/daily-report 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -3
- package/README.md +68 -36
- package/dist/client.d.mts +7 -5
- package/dist/client.d.ts +7 -5
- package/dist/client.js +6 -6
- package/dist/client.mjs +6 -6
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/server.d.mts +5 -3
- package/dist/server.d.ts +5 -3
- package/dist/server.js +8 -8
- package/dist/server.mjs +8 -8
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +1 -1
- /package/dist/{comment-adapter-BvGGQkBE.d.ts → comment-adapter-DOUwSlKf.d.ts} +0 -0
- /package/dist/{comment-adapter-pzx3O8_h.d.mts → comment-adapter-_h3W1duG.d.mts} +0 -0
- /package/dist/{search-marks-wire-DCFuK5yr.d.mts → search-marks-wire-CZRU4q3h.d.mts} +0 -0
- /package/dist/{search-marks-wire-DCFuK5yr.d.ts → search-marks-wire-CZRU4q3h.d.ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,17 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@aiquants/daily-report` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.37.0 (2026-10-09)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **読みの印の説明**: ローマ字の読みでだけ当たった印 (`reading`) は、支援技術へも説明 (`aria-description`) で弱い当たりであることを伝える。説明は新しいラベル `searchReadingMarkDescription` (en `Matched by its romaji reading`、ja `ローマ字の読みで一致`) で、打った語の印 (`word`) は説明を持たない (よくある印の読み上げを増やさない)。印の属性の値と説明は tier ごとの 1 つの表 (`SEARCH_MARK_LOOKS`) から取る。固有キーは 101 から 102 (文字列のキー 85・整形関数 17。キーの表は全 113 キー)。
|
|
10
|
+
以前は読みの当たりと語の当たりの違いが下線の形 (点線か実線か) にしか無く、読み上げはどちらも同じ `mark` として伝えた (WCAG 1.3.1)。
|
|
11
|
+
- **DetailList の行の説明に一致の印の文**: 画面が印の store を持つ間、DetailList の行の説明 (`aria-describedby`) は、状態・添付の目印・種別のバッジの後に、見出しの一致の印の隠した文 (`キーワード 2 個のうち 1 個に一致`・`この画面に出していない項目 (ラベル・明細など) で一致しました`) を続ける。隠した 2 つの文は 1 つの要素にまとまり、見える印と置き物はその外にある。
|
|
12
|
+
以前は行の説明が一致の印を含まず、矢印キーで行を移る読み上げには、目で見る利用者が行ごとに見る `一致 k/n` と `表示外で一致` が届かなかった。
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **検索の印の要求は送る時に決める**: 印の store は、購読された日報を 1 つの待ち行列に置き、要求の枠が空くたびに、まだ行が購読している日報を新しい順に 100 件まで取って送る (`createIdBatchScheduler`。同時に 2 本まで)。待つ間に行が外れた日報は送らないので、スクロールや押し続けたキーが通り過ぎた行の印は頼まず、止まった窓の印は空いた最初の枠で頼む。
|
|
17
|
+
どの行も購読しなくなった要求は止め (サーバーはその印の計算をやめる。下の Fixed)、送る前に外れた行と、止めた要求の行は、戻ってきたときに頼み直す。日報ごとの記録 (印・送った順・頼んだ epoch・429 のやり直し・再検証) は 1 つにまとまり、どの行も購読しない日報の記録は `SEARCH_MARKS_RECORD_BOUND` (400) を超えた分を古いものから忘れるので、持つ量はスクロールした距離でなく描いている窓に従う。
|
|
18
|
+
以前はマイクロタスクごとに購読された ID をそのまま束にし、束を古い順に 2 本ずつ、行がまだ載っているかを見ずに送った。2 秒の PageDown の押し続けでは要求 61 本の ID の 93.5 %、1 秒の List のスクロールバーの引きでは 95.2 % が送る時に既に外れた行のもので、止まった窓の印は要求 1 本が 196 / 500 / 927 ms のとき押し終わりから 4.4 / 13.9 / 26.8 秒後に届いた (store の模擬。今は外れた行の ID は 0 で、止まった窓の印は押し終わりの後の最初の要求で届く)。
|
|
19
|
+
- **印が届いても描き直すのは葉と一致の印だけ**: 行 (List のカード・DetailList の本文カード・側面ペインの中身) は自分の部分木を印の囲み (`DailyReportSearchMarksScope`) で包み、日報の印を購読するのは囲みで、行の本体は印を読まない。印が届いて描き直すのは囲みと印を読む葉 (`SearchMarkedText`) と一致の印だけで、行の本体・見出し・添付の一覧・コメントの節は急ぎの描画でも後追いの描画でも描き直さない。
|
|
20
|
+
以前は行の本体が購読のフックを呼んだので、印が届くたびに本体の部分木を急ぎの描画 (出力は同じ) と後追いの描画で 2 度描いた (載っている 8 行で子孫の描画 48 回・5.9 ms)。
|
|
21
|
+
- **`search-marks` の答えは ETag を持たない**: 経路が組むどの答え (印・400・404・429・離れた要求の 503) も、1 回だけ直列化した `Cache-Control: no-store` の JSON で、ETag を持たず、`If-None-Match` を読まない (共有のセキュリティヘッダーは同じ)。まとまりは送るたびに描いている行から組むので同じ URL はほとんど繰り返されず、パッケージのクライアントは `If-None-Match` を送らない。以前は本文全体の SHA-256 で使われない ETag を作った。
|
|
22
|
+
- **行の並びは塊を共有する永続の並び**: プロバイダーの行は 1,024 行の書き換えない塊を背骨が束ねる並び (`ReportRowList`) で、導きの編集は触らない塊をどれも前の版と共有した新しい版を 1 回で作る。末尾へ足す publish が書くのは背骨と最後の塊の行と足した行だけで、80,000 行に 4,000 行を足す publish の参照の書き込みは 84,000 から 4,128 になった。途中の削除と差し込みは、最初に変わる塊から後ろを 1 回だけ詰め直す。
|
|
23
|
+
ビュー・キーボード・アクションの状態・検索の一覧の導きは、行を長さ・位置 (`at`)・走査だけで読む (下の Breaking)。一覧に無い id の走査 (削除の直後の選択) は塊を位置で読み、行ごとの関数の呼び出しを持たない (22.8 万行で 4.1〜4.6 ms から 1.9〜2.6 ms)。
|
|
24
|
+
- **答えを見せている間の一覧の導き**: 検索の答えの一覧 (`deriveSearchListItems`、`src/client/search/search-list-items.ts`) は、publish が書いた塊だけを確かめ (前の版と共有する塊は確かめ直さない)、答えの日報に関わらない publish では前の一覧そのものを返すので、ビューも行も描き直さない。以前は publish のたびにストリームの一覧全体をなめ、答えを見せている間の押し続けたキーの 1 回の保持ごとに 10〜22 ms の描画を足した。
|
|
25
|
+
- **ids のストリームの無音の見張りは接続ごとにタイマー 1 本**: 本文の read の無音は、接続ごとに 1 本のタイマーが上限に足りない分だけ張り直して見張り (`watchReadSilence`)、無音が `readStallTimeoutMs` に届いたときだけ読み手を取り消してカーソルから再開する。以前は read ごとにタイマーを張って外し、配信中の 1 回の保持ごとに 26〜46 ms を使った。
|
|
26
|
+
- **数の整形は作り置きの書式**: 組み込みの整形関数は、ロケールごとに 1 度だけ作った `Intl.NumberFormat` で桁を区切る (出力は同じ)。以前は呼ぶたびに書式を作る `toLocaleString` を、ストリームの publish ごとに描き直す行の位置と件数のたびに呼んだ。
|
|
27
|
+
- **本文の位置つきの置き換えは 1 回の走査**: 位置つきの表示の文字の置き換え (`replaceDisplayText`) は、区間の位置を前へだけ進める 1 回の走査で、側面ペインの本文 (`paneContentOf`) はタグの数に比例する (段落 2,000 で 122 ms から 1.3 ms。出力は同じ)。添付のファイル名の NFC の表示 (`nfcDisplayOf`) も名前の長さに比例する (244 単位で 1,184 µs から 89 µs)。以前はどちらも長さの 2 乗だった。
|
|
28
|
+
- **読み上げ用のファイル名は自分のはみ出しを切らない**: 添付のファイル名の読み上げ用の写し (名前の中とダウンロードのリンクの中) は、`sr-only` (`overflow: hidden` と `clip`) でなく、切らない透明な 1 px の文 (`VISUALLY_HIDDEN_CLASS_NAME`) に layout の閉じ込めを足して隠す (`HiddenFileName`)。閉じ込めは、仮想化した行の外 (側面ペイン・オーバーレイ) で 1 行の長い名前がスクロールの箱の横幅を広げないため。添付の節はほかに切る中身 (見える名前の省略・収めた名前・大きさの注記・サムネイルの枠の角) を持つので、添付のある DetailList の行は窓をずらすスクロールの 1 段でなお描き直される。
|
|
29
|
+
- **ログ: クライアントの ids のストリームのチャンネル**: ids のストリームのクライアントの警告 (再検証の諦め・読めない行と種類の分からない行の読み飛ばし・形の誤ったアンカー) は、チャンネル `[DailyReportIdsStream]` (`DAILY_REPORT_LOG_CHANNELS` の `IdsStream`) から console へ書く。どの行も接頭辞をちょうど 1 つ持ち、古い詳細の更新の行は `[DailyReportDetailLoader] Stale update skipped id=<id>`、アンカーの行は `skipped a malformed streamAnchor: <値>`。`DailyReportIdsStreamClientOptions.warn` へ注入した書き先はチャンネルの代わりに、文面と引数を接頭辞無しで受ける。
|
|
30
|
+
以前はクライアントの ids のストリームが文面に `[DailyReportIdsStream]` を手で綴り (一覧に無い 4 つ目のクライアントのチャンネル)、古い更新の行は `[DailyReportDetailLoader] [DailyReportCache] …` と接頭辞を 2 つ持った。
|
|
31
|
+
- **公開の docstring は既定値を値で書く**: `createDailyReportServer` の `cacheDefaultTtlMs` と `cacheMaxBuckets` の docstring は、ホストが読み込めないモジュールの内部の定数の名前でなく、既定値 (60,000 ms と 4,096) を書く。振る舞いは同じ。
|
|
32
|
+
- **開発者向け**:
|
|
33
|
+
- `src/docs-examples.spec.ts` は、公開する markdown (`package.json` の `files` の `.md`) がパッケージの例のスキーマ (`dbo_app`・`dbo_daily_report_search`) のほかのデータベースのスキーマを名指さないことを確かめる (修飾した `dbo_<名前>.<表>`・角括弧で囲んだスキーマで修飾した名前・修飾した `dbo`、修飾の無い `dbo_<名前>`)。0.36.0 の README と CHANGELOG はホストの表の名前を 1 つずつ持った (下の Fixed)。
|
|
34
|
+
- `src/shared/logger.call-sites.spec.ts` は、ロガーのモジュールの外のどの文字列の字面も `[DailyReport` の接頭辞の形を綴らないことを確かめる。
|
|
35
|
+
- 12 px の文字の下限の検査は、`src/client` の下のどのモジュールも 1 回の走査で読み (例外の一覧は無い)、検索の葉と一致の印も対象にする。
|
|
36
|
+
- 印が届いたときの描画の回数 (List・DetailList・画面)、`表示外で一致` の場合ごとのビューのテスト、印の store の模擬の場合 (60 回の確定・2 つの聞き手・429 と dispose)、塊の共有、下書きの規則の SQL (本物の SQL の処理系で流す) を、それぞれの spec が確かめる。
|
|
37
|
+
- 印の tier は名前 (`DAILY_REPORT_SEARCH_TIER`: `word` が 0、`reading` が 1。入口からは出さない) で書き、通信の読み取りと計画と印の見え方の表が同じ判定 (`isDailyReportSearchAlternativeTier`) を使う。表に無い tier の印はパッケージの名を名乗る誤りで描画を止める (通信の読み取りが通さないので、来るのは崩れた不変条件だけ)。
|
|
38
|
+
- `display-text.ts` の改行のタグの規則は 1 つの元 (`LINE_BREAK_TAG_SOURCE`) から 3 つのパターンを作る (振る舞いは同じ)。
|
|
39
|
+
- 文書の直し: `TabsContent` の `tabStop` (最初の意味のある中身がフォーカスを受けないか、中身が Tab の届くべきスクロールの容器のとき)、内部ユーザーを持たない閲覧者の読み取り (データの問い合わせは無いが、ID の解決は受け入れの前と読み取りの中で 2 回走り、見つからない間はキャッシュしない)、`mergeComments` と `resolveCommentColorClass` の docstring。
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **`表示外で一致` はビューの葉が描いた印から決める**: 行の葉 (`SearchMarkedText`) は描いている間だけ、欄の印が描く値について今のものかと塗った印の数を行の集計 (`drawn-search-marks-tally.ts`) へ登録し、一致の印は集計だけを読む。登録がどれも今のもので合計が 0 のときだけ `表示外で一致` を出す。
|
|
44
|
+
以前の一致の印はビューが描く欄の写しを手で持ち (`countVisibleSearchMarks`)、4 つの場合でビューと食い違って、印も `表示外で一致` も出さなかった: 側面ペインの関連のタブ (記事を描かないのに件名・顧客・本文・添付・コメントを描いたと数えた)、利用者が削除を送っているコメント、ホストのひな形が素で描く面談者の札、収めたファイル名の省いた中ほどだけの当たり。CSS の省略が隠す当たりは、印を塗るので今も描いたものと数える (何を隠すかはブラウザーが決める)。
|
|
45
|
+
- **一致の印の書体は 12 px**: 一致の印 (`SEARCH_INDICATOR_CLASS_NAME`) は、ページの文字の書体でパッケージの下限の 12 px (`text-xs`) を 16 px の行に置き、数字は等幅の数字 (`tabular-nums`) で `一致 k/n` の幅を一定にする。色と比は同じ (slate-600 / 暗色 slate-300)。以前は 11 px の等幅で、パッケージの 12 px の下限を割り、`一致`・`表示外で一致` は各 OS の等幅の和文の代わりの書体で描かれた (下限の検査は、検索の部品のディレクトリを読んでいなかった)。
|
|
46
|
+
- **側面ペインの作成者の行は折り返す**: 作成者の行 (作成者・種別のバッジ・一致の印の枠) は 4 px の間で折り返し、見出し語の `作成者` は縮めずに 1 行のまま、値だけが縮んで折り返す。狭いペインで収まらない一致の印の枠は、自分の 16 px の行へ落ちる。以前は行が折り返さず、200 px の最小の幅から約 400 px までの間は枠が作成者を押し潰し (`作成者` が 1 字ずつ縦に積まれ、長い名前で見出しの行が 24 px から 160 px に伸びた)、最小の幅では枠が見出しの箱からはみ出して切られた。
|
|
47
|
+
- **検索の印の要求は離れたら枠を返し、新しい答えのまとまりは待つ**: `search-marks` の経路は要求のシグナルをサービスへ渡し (`markDailyReportSearchHits(…, ids, { signal })`)、サービスは読み取りの前と日報ごとの印の前に確かめて、離れた要求の仕事をそこで止める (走っている SQL の読み取りは止められないので、枠はその終わりまで持つ)。離れた要求には本文の無い 503 (`Retry-After` 付き) で答え、誤りの行を書かない。
|
|
48
|
+
受け入れは、ゲートが満杯か閲覧者が取り分を持ちきっているとき、検索の印だけを待たせる (閲覧者ごとに `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` 個・全体で受け入れの同時数まで、`SEARCH_MARKS_WAIT_MS` = 2,000 ms まで。検索と ID でまとめた詳細は今までどおりすぐに断る)。中断された要求の拒否は、3 つの経路のどれでも満杯として記録しない。
|
|
49
|
+
以前は離れた要求のサービスが 2 つの SQL の読み取りと印の計算を最後まで走らせて閲覧者の取り分を持ち、新しい検索の最初のまとまりが待たずに 429 (`Retry-After: 1`) で断られた。負荷の下では 1 回きりのやり直しも断られ、その行はその epoch の間、印なしのままだった。
|
|
50
|
+
- **取りこぼしのゲートは枠の少ない閲覧者へ先に渡す**: キャッシュの取りこぼしのゲート (`createCacheMissGate`) とサムネイルの生成の待ち行列は、空いた枠を、上限に達していない利用者のうち保持数の最も少ない利用者の最も古い待機者へ渡す (`createConcurrencyGate`。利用者の中と保持数の等しい利用者の間は到着順)。以前は上限に達していない最も古い待機者へ渡したので、全部の枠が埋まっている間は、上限まで持つ閲覧者の先に並んだ読み取りが枠を取り直し続け、後から来た閲覧者の最初の読み取りが待った (模擬では、忙しい閲覧者が 8 人いるときの新しい閲覧者の最初の読み取りの中央値が 1,293 ms から 334 ms になった。残りはドライバーの接続の待ち)。
|
|
51
|
+
- **429 を待つ間の dispose も頼み直しを残さない**: 印の store を dispose すると、429 のやり直しの時計が持つ日報も、待っている・送っている日報と一緒に頼んでいないものに戻す。以前は時計だけを消したので、React が同じ store をもう一度マウントすると (`<Activity>` を隠して見せ直す)、その日報はその epoch の間二度と頼まれず、行は印なしのままだった。429 で断られた行が `Retry-After` の前に外れて後で戻った場合も同じだった。
|
|
52
|
+
- **長い行の印は書記素の塊を割らない**: 4,000 単位より長く片に分けた行では、片の境目が書記素の塊を割りうるので、印の範囲を生の値の塊の境目まで広げる (`Intl.Segmenter`)。以前は 👍🏽 の 4,000 単位を超える連なりを 👍 で探すと、印が絵文字を割った。上限以内の行の範囲は同じ。
|
|
53
|
+
- **印の範囲のまとめは出現の数と欄の長さに比例する**: 欄の印は tier ごとの覆いの差分からまとめる (`markSearchText`)。範囲は重ならない最大の連なりなので、欄の範囲は単位の数を超えず、答えは描く値の長さに比例する (1 字の語で 21,200 単位の日報 100 件を探す答えは 1.6〜2.9 MB で、同じ日報の詳細の答えは 2.2 MB)。以前は出現ごとに覆う単位を塗ったので、費用は出現の数 × 覆う長さで、長い書記素の塊の中の出現では数の 2 乗に伸びた (塊の境目まで広げた肌の色の修飾 10 万個の塊 1 つで 51 秒)。
|
|
54
|
+
- **読みの計画は語を打つ順によらない**: 語とキーワードの重複を、正規化した文字でなく候補の集まり全体 (文字と tier。読みを含む) で除く。正規化して同じでも読みを持つかが違う語 (`Ⅱです` は読まず、`iiです` は イイデス と読む) は両方残り、語を打つ順を入れ替えた検索語は同じ計画になる (キーワードと語の並びのほか)。上限は今までどおり打った語で数える。以前は先に打った語だけが残り、読みを探すかが打つ順で決まり、OR の検索語に語を足すと答えが狭まりえた。
|
|
55
|
+
- **長音を重ねた 1 字の読みは足さない**: 読みの文字の塊 (`readingLetterClusterCountOf`) は、長音の連なりを 1 つと数える。`ha--` → ハーー と `ha---` → ハーーー は文字の塊が 2 つなので読みを持たず、`site-` → シテー と `ko-hi-` → コーヒー は読む。以前は連なりの長音を 1 つずつ数えたので、ハーー は 3 つと数えて足し、「それは――」(索引では ソレハーー) のような言い回しに当たった。
|
|
56
|
+
- **NFKC で英字でなくなる修飾文字の語は読まない**: 読みを持つには、打った語に加えて正規化した語も英字の文字を含む必要がある。英字の修飾文字 U+10781 (→ ː) と U+10782 (→ ˑ) を足した語 (`納期ヲ確認` と修飾文字) は読みを持たず、0.35.0 と同じ計画になる。以前は打った語の判定だけを通り、正規化した語の読みが `納期オ確認` のような同音の畳み込みを足した。
|
|
57
|
+
- **下書きの規則は 1 つの定義で、日報は 1 行**: 閲覧者が何を見てよいかを決めるサービスの読み取り (ID の一覧と最初のページ・添付・ID でまとめた詳細と営業日の詳細と検索の印が使う `fetchVisibleHubDetails`・詳細・ミューテーションの対象の確かめ) と検索の SQL は、下書きの規則を 1 つの定義 (`buildDraftVisibilityCondition`: 作成者か、日報のどのラベルも下書きの名前を持たないことを、ラベルの関係への `NOT EXISTS` で書く) から作る。見える日報の集合は同じ。
|
|
58
|
+
以前の読み取りは下書きのラベルの関係を `LEFT JOIN` で結合して絞ったので、閲覧者自身の日報が下書きのラベルを 2 つ持つと (`draftLabelNames` に複数の名前) その日報を 2 行で返した (`search-marks` だけは答えの中で重複を除いていた)。ステージング (22.8 万件、下書きの関係は 0 件) の ID の一覧の全量の読み取りは、CPU が 500〜516 ms から 248〜263 ms に減り、経過時間は計画が並列のハッシュ結合から直列の入れ子のループになって 128〜162 ms から 248〜263 ms に延びた (最初のページは 7 ms)。
|
|
59
|
+
- **自動既読のスイッチの文言は自分のスイッチに結ぶ**: 自動既読のスイッチと文言は `useId` の id で結び、開発用の SSE のスイッチは固定の id を持たない (自分の `aria-label` が名前)。以前は文書全体で固定の id (`auto-read-mode`・`sse-mode`) を使ったので、画面が 2 つ描かれると (`<Activity>` が隠した画面を残すとき) 2 つ目の文言が 1 つ目のスイッチを切り替え、2 つ目のスイッチは名前を失った。
|
|
60
|
+
- **文書の誤り**: 0.36.0 の README と CHANGELOG は、外部の文字だけで当たった日報の例にホストの表の名前を書いていた (README の **Highlights** と、下の 0.36.0 の Added。汎用の口 `externalSourceTexts` の言葉で書き直した)。README は、一覧の 1 行に切った後ろとファイル名の省いた部分を「印は付くが見えない」と書いたが、Chromium は印を切る省略記号を印の中に描く (印の地は `…` の下まで続き、下線は最後に見える文字で終わる)。
|
|
61
|
+
|
|
62
|
+
### Breaking
|
|
63
|
+
|
|
64
|
+
- **`DailyReportActionState.items` は配列でない**: アクションの状態の行 (`useDailyReportActionContext().items`) は、長さ (`length`)・位置の日報 (`at`)・走査だけで読む永続の並び (型は `Pick<ReadonlyArray<DailyReportItem>, "length" | "at"> & Iterable<DailyReportItem>`) で、行が変わるたびに新しいオブジェクトになる。ビューの `dailyReportItems` も同じ形を受ける (配列も渡せる)。
|
|
65
|
+
Migration: `items` を配列として読むホスト (`items[i]`・`map`・`filter`・`find`) は、`items.at(i)`・`for…of`・`Array.from(items)` で読む (`Array.from` は一覧全体を写す)。
|
|
66
|
+
- **`markDailyReportSearchHits` は要求のシグナルを求める**: サービスの `markDailyReportSearchHits(scope, userId, plan, ids, { signal })` は 5 つ目の引数に要求のシグナルを取り、確かめた時点で中断されていればその `reason` を投げる。
|
|
67
|
+
Migration: サービスを自分で呼ぶホストは、要求の `request.signal` (止めないなら `new AbortController().signal`) を渡し、投げた `reason` を要求の中断として扱う。
|
|
68
|
+
|
|
5
69
|
## 0.36.0 (2026-10-08)
|
|
6
70
|
|
|
7
71
|
### Added
|
|
8
72
|
|
|
9
73
|
- **検索の答えのハイライト**: 答えを見せている間、List のカード (本文の先頭の行と作成者)・側面ペイン・DetailList の行は、サーバーが当たりと判定した所に `<mark data-daily-report-search-mark>` を付ける。打った語の形で当たった所 (正規化でだけ当たった所を含む: `デンプン` は でんぷん に付く) は `word` で実線の下線、ローマ字の読みでだけ当たった所は `reading` で点線の下線。
|
|
10
74
|
側面ペインと DetailList は検索する欄の描いている文字すべて (件名・本文・作成者と更新者と社員の名前・区分と作成区分・顧客・面談者の名前と所属・添付のファイル名・コメントの作者と本文) に付ける。2 つ以上のキーワードの OR では、日報が当たったキーワードの語だけに付ける。
|
|
11
|
-
印を付けないもの: ラベル名と外部の文字 (
|
|
75
|
+
印を付けないもの: ラベル名と外部の文字 (ホストの読み取りの口が外部ソースの区分ごとに渡す文字 (`externalSourceTexts`) はビューが描かないので、それだけで当たった日報には印が出ず、一致の印が `表示外で一致` を出す)・一覧と側面ペインが除くタグ・一覧の 4 行目以降と切った後ろ・省いたファイル名の部分・編集のフォーム・作者不明の代わりの名・値でない文字。そのまま値を差し込まない `interviewerWithAffiliation` のひな形は札を素で描く。
|
|
12
76
|
位置はサーバーが索引と同じ処理から求めるので、印は検索が当たらない所に付かず、ブラウザーは正規化しない (`@aiquants/fuzzy-search` はクライアントの束に入らない。`src/client-bundle.structure.spec.ts` が確かめる)。印は箱を 0 にして書体を受け継ぐので、行の幅も高さも変えない。yellow-200 に slate-900 (暗色 yellow-800 に yellow-50、15.33:1 と 6.61:1)、強制配色では `Mark` と `MarkText`。
|
|
13
77
|
- **一致の印**: 側面ペインの作成者の行と DetailList の行の見出しに、contact map の点数と同じ書体 (11 px の等幅、slate-600 / 暗色 slate-300) の控えめな印を出す。2 つ以上のキーワードの OR では当たったキーワードの数 (`一致 2/3`)、日報が当たったのにそのビューが描く印が 1 つも無いとき (ラベルや明細だけで当たったなど) は `表示外で一致` (en `Hidden match`)。答えの最も長い文字の幅を前もって取っておくので、印が届いても見出しは流れ直さない。印は `aria-hidden` で `title` を持ち、画面に出さない文を添える。
|
|
14
78
|
- **ローマ字の読み**: 英字を打った検索語は、fuzzy-search の公開関数 (`queryNeedles` と `convertRomaji`) で読んだ完全な仮名の読みでも探す (`denpun` → でんぷん・デンプン・デンプン)。ヘボン式と IME の綴りの両方 (`shinbun` と `sinbun`)。読みは文字の塊 3 つ以上 (`SEARCH_READING_MIN_CLUSTERS`。数字・記号・先頭のダッシュは数えない) を語ごとに 2 つまで (`DAILY_REPORT_SEARCH_READINGS_PER_WORD`) で、英字の綴りそのものも候補に残る。
|
|
15
|
-
文字の塊が 1 つか 2
|
|
79
|
+
文字の塊が 1 つか 2 つの読みは足さない (0.36.0 は長音の連なりの長音を 1 つずつ数えた。下の Fixed を参照): 索引は平仮名を片仮名へ畳むので、短い読みはありふれた言い回しに当たる (日報 228,794 件で `site` → シテ が「して」に当たって 43.55 %、1,244 ms)。`memo` や `kome` のような 2 文字の語は仮名で打ち、`ha100` も読みを持たない。
|
|
16
80
|
漢字には届かず (`nouki` は 納期 に当たらない)、長音は畳まず、同音の綴りは片側だけ (`numazu` は ぬまづ に当たらないので `numadu` か仮名で打つ)。仮名の語でローマ字の本文は探さない。英字を打っていない語 (NFKC で英字になるローマ数字や № の `Ⅱです`・`№12` を含む) の計画は変わらず、正規化の版も変わらないので、索引の作り直しは要らない。
|
|
17
81
|
- **検索の答えの `words`**: `search` の答えは `{ ids, businessDates, sourceTypes, words }` で、`words` は計画の語 (キーワード → 語 → 候補 `{ text, tier }`)。`q` と `combine` だけで決まり、閲覧者のことは明かさない。パッケージのクライアントは語を厳しく読み、崩れていれば答えを丸ごと拒む。
|
|
18
82
|
- **検索の印の要求**: `GET {apiBasePath}/search-marks?q=&combine=&ids=` (ID は 100 件まで) が、頼んだ日報のうち閲覧者が読めるものの欄ごとの印の範囲 (生の値の UTF-16 の位置と tier、値の指紋) と、2 つ以上のキーワードの OR では当たったキーワードの組を返す (`{ wordsDigest, reports }`)。設定が無ければ 404。
|
|
@@ -59,7 +123,7 @@ All notable changes to `@aiquants/daily-report` are documented here.
|
|
|
59
123
|
- **DetailList の行は自分のはみ出しを切る中身を持たない**: 行の中の画面に出さない文 (見出しと状態・種別のバッジの展開した名前) は `sr-only` (`overflow: hidden` と `clip`) でなく、切らない透明な 1 px の文 (`VISUALLY_HIDDEN_CLASS_NAME`) で描く。Chromium は窓をずらすスクロールの 1 段で、はみ出しを切る中身を持つ行をすべて描き直す (描く範囲の 11 行から 2 行へ)。
|
|
60
124
|
- **ID でまとめた詳細 (`reports`) は、閲覧者 1 人にクライアントが同時に送る数の読み取りを通す**: 受け入れの同時数は、検索の `concurrency` と `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` (2) の大きい方の `REPORTS_CONCURRENCY_PER_SEARCH_CONCURRENCY` (2) 倍で、閲覧者の取り分は 2 を下回らない (`concurrency` が 1 ならプロセスで 4)。以前は `concurrency` が 1 のとき取り分が 1 で、クライアントの 2 本目の読み取りが 429 で断られた。
|
|
61
125
|
- **検索の印とローマ字の読み (上の Added) の直し**:
|
|
62
|
-
- 読みの下限は文字の塊で数える (`readingLetterClusterCountOf`): 数字・記号・先頭のダッシュは数えないので、仮名 1〜2 字の読み (`ha100` → ハ100・`no12` → ノ12・`-site` → ーシテ) を足さず、「単価は100円」「10月の12
|
|
126
|
+
- 読みの下限は文字の塊で数える (`readingLetterClusterCountOf`): 数字・記号・先頭のダッシュは数えないので、仮名 1〜2 字の読み (`ha100` → ハ100・`no12` → ノ12・`-site` → ーシテ) を足さず、「単価は100円」「10月の12日」「コピーして」のような言い回しに当たらない。ただし 0.36.0 は長音の連なりの長音を 1 つずつ数えたので、仮名 1 字に長音を 2 つ以上続けた読み (`ha--` → ハーー) は足し、「それは――」(索引では ソレハーー) のような言い回しに当たった (連なりを 1 つと数えるのは次の版から)。
|
|
63
127
|
- 読むかは打った語で決める: 英字の文字を打った語だけが読みを持ち、英字の文字を打たずに NFKC で英字になるローマ数字や № を含む語 (`Ⅱです`・`№12`・`第Ⅲ期`) は 0.35.0 と同じ計画になる。
|
|
64
128
|
- 詳細の読み取りが同じ日報を 2 行で返しても (自分の日報が 2 つの下書きのラベルを持つとき)、`search-marks` の答えは日報ごとに 1 つで、クライアントの読み取りが束ごと拒んで窓の印を落とすことは無い。
|
|
65
129
|
- 閲覧者 1 人は、クライアントが同時に送る数の `search-marks` の要求を持てる (`concurrency` が 1 でも 2 本目が 429 にならない)。
|
package/README.md
CHANGED
|
@@ -200,17 +200,20 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
200
200
|
It is not derived from HTTP/1.1's 6 connections per origin, since one HTTP/2 connection carries many streams. A held stream costs about 10 KB of heap and one visibility re-resolution every 60 s, so 32 streams cost about 310 KB and 0.53 re-resolutions a second.
|
|
201
201
|
A stream takes its slot after the checks that decide its status — on the SSE route after the resume cursor and before the visibility is resolved or anything subscribed, on the ids stream after its 400s and the refresh bucket — and gives it back exactly once, at whichever comes first: the request's abort, at any time after the slot was taken, or the settle path that ends its body (the SSE connection's close; the ids stream's end, cancel or error, and a resumed `GET` that fails before its body starts).
|
|
202
202
|
The admission itself ties the slot to the body's signal (`createStreamSlots().admit`), so a request aborted while its route still awaits — the SSE route resolving the visibility, a resumed ids `GET` waiting for its full query — returns its slot at that moment, also on an adapter that never pulls an aborted body again.
|
|
203
|
-
The request after the last slot starts no body work and is answered 503 with `Retry-After: 5`: the SSE route with the text `Too many streams`, the ids stream with `{"error":{"message":"Too many streams"}}`; both clients come back on their own backoff. A `HEAD`, or a `GET` whose signal is already aborted, takes no slot: it is only checked, and gets the status a `GET` would get. A viewer without an internal user takes none (the SSE route answers it `forbidden`, and its ids stream is one empty line without a query).
|
|
203
|
+
The request after the last slot starts no body work and is answered 503 with `Retry-After: 5`: the SSE route with the text `Too many streams`, the ids stream with `{"error":{"message":"Too many streams"}}`; both clients come back on their own backoff. A `HEAD`, or a `GET` whose signal is already aborted, takes no slot: it is only checked, and gets the status a `GET` would get. A viewer without an internal user takes none (the SSE route answers it `forbidden`, and its ids stream is one empty line without a dataset query).
|
|
204
204
|
The refusals are recorded per viewer in windows of 60 s at `warn` on the `[DailyReportStreams]` channel: the window's first as `503 <sse|ids-stream> viewer=<internal id> reason=streams_per_viewer`, the rest counted into one `streams_per_viewer_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<internal id> routes=sse:<n>,ids-stream:<n>` line written by the window's own timer at its end.
|
|
205
205
|
The slots are per worker process, so the effective bound across workers is the worker count times 32.
|
|
206
206
|
- **The SQL result cache** (`SqlResultCache`; `createDailyReportServer` builds one per process, which the service and the SSE reader share): `cacheDefaultTtlMs` (an integer of at least 1, 60,000 ms by default) is the TTL of a read that names none and the interval of the expired-bucket sweep, and `cacheMaxBuckets` (an integer of at least 1, 4,096 by default) bounds the buckets by construction.
|
|
207
207
|
A write puts its bucket in as the newest-written and evicts the oldest-written while the cache holds more than the maximum, and the expired buckets are swept at most once per default TTL on the monotonic clock, so a write costs the same whatever the number of buckets (100,000 distinct keys keep the size at the maximum).
|
|
208
208
|
4,096 is about 400 views' live keys: a view keeps about 10 (its ids key, its rows' business date and the DetailList's 6 prefetched dates). An evicted key's next read is a miss and passes the miss gate below.
|
|
209
209
|
- **Cache misses per viewer** (`createCacheMissGate` in `src/server/cache-miss-gate.ts`; the endpoints whose row declares `miss-gate`): a read that misses the cache — a forced read included — takes one of its viewer's slots exactly where its fetch starts and gives it back when the fetch settles, either way; a hit and a read that joins the fetch in flight for its key take none.
|
|
210
|
-
One gate per handler factory, keyed by the internal user id, holds `CACHE_MISS_SLOTS_PER_VIEWER` = 8 fetches at once per viewer — what one view of the package's client starts when it paints: the ids stream's full read, the shown rows' business date and the DetailList's `PREFETCH_MAX_DATES` = 6 prefetched dates — and `CACHE_MISS_SLOTS_PER_WORKER` = 64 per worker process
|
|
211
|
-
|
|
210
|
+
One gate per handler factory, keyed by the internal user id, holds `CACHE_MISS_SLOTS_PER_VIEWER` = 8 fetches at once per viewer — what one view of the package's client starts when it paints: the ids stream's full read, the shown rows' business date and the DetailList's `PREFETCH_MAX_DATES` = 6 prefetched dates — and `CACHE_MISS_SLOTS_PER_WORKER` = 64 per worker process. A freed slot goes to the waiting viewer that holds the fewest slots among the viewers under their own limit (that viewer's oldest waiter;
|
|
211
|
+
the oldest one between viewers holding as many), so a viewer without a slot gets the next freed one even while busy viewers keep every slot of the worker and their queued reads ahead of it (the max-min dispatch of `createConcurrencyGate`, which the thumbnail generation queue shares).
|
|
212
|
+
One viewer's 8 stay below a host driver's default pool of 10 connections, while the worker's 64 do not, so across viewers a miss that holds a slot can still wait in the driver's own first-come queue for a connection: the slots bound the fetches, not the connections. In a simulation of the shipped sizes in front of a first-come pool of 10 (50 ms per miss, every busy viewer keeping 32 misses outstanding), a newcomer's median first read took 84 / 183 / 334 ms beside 2 / 4 / 8 busy viewers, against 84 / 184 / 1,293 ms when the oldest waiter of any viewer under its limit took the slot;
|
|
213
|
+
the rest of that wait is the driver's queue.
|
|
214
|
+
A miss without a free slot waits in that queue, at most `CACHE_MISS_QUEUE_PER_VIEWER` = 256 per viewer (everything two browsers of the viewer can have in flight on the edge's 128 HTTP/2 streams) and `CACHE_MISS_QUEUE_PER_WORKER` = 1,024 per worker, for at most `CACHE_MISS_WAIT_MS` = 60 s, so the package's own client is queued, never refused, unless the database does not answer for a minute.
|
|
212
215
|
A read past the queue or the wait starts no query and is answered 503 `{"error":{"message":"Too many reads"}}` with `Retry-After: 5`; on the ids stream, a resumed `GET`, which waits for its full read before it answers, gets that 503, and a fresh one, which has already answered 200, ends its body with the stream's error line, from which the client resumes on its own backoff.
|
|
213
|
-
A request whose signal is already aborted takes no slot and starts no fetch, an abort while it waits leaves the queue, and a viewer without an internal user is charged nothing
|
|
216
|
+
A request whose signal is already aborted takes no slot and starts no fetch, an abort while it waits leaves the queue, and a viewer without an internal user is charged nothing: its reads run no dataset query, only the lookup of its internal id, which runs before the admission and again inside the read and is not cached while it finds no internal user.
|
|
214
217
|
The refusals are recorded per viewer in windows of 60 s at `warn` on the `[DailyReportReads]` channel: the window's first as `503 <business-date|ids-stream|report> viewer=<internal id> reason=cache_miss_admission`, the rest counted into one `cache_miss_admission_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<internal id> endpoints=business-date:<n>,ids-stream:<n>,report:<n>` line written by the window's own timer at its end; the refusal of an aborted request writes nothing.
|
|
215
218
|
The slots and the queues are per worker process, so the effective bounds across workers are the worker count times these.
|
|
216
219
|
- **Security headers**: every response of the API, action and SSE routes, whatever its status — the JSON answers and refusals, the action's answers, the request isolation's refusals, the SSE stream, its terminal frames and its errors, the ids NDJSON — carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and `Content-Security-Policy: default-src 'none'` (`RESOURCE_RESPONSE_SECURITY_HEADERS` in `src/server/response.ts`, set last by every builder, so no header of its own overrides them).
|
|
@@ -298,7 +301,8 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
298
301
|
| `[DailyReportSseReader]` | `info` | the shared SSE reader's Redis errors |
|
|
299
302
|
| `[DailyReportExternalSource]` | `info` | `transformJsonArray`'s malformed JSON columns of an external source |
|
|
300
303
|
|
|
301
|
-
The client has no logger port: its
|
|
304
|
+
The client has no host logger port: its four channels of the same list write to the console from `info` — `[DailyReportActionProvider]` (the action provider: a failed action, the queue of SSE messages about uncached reports, a cache reload), `[DailyReportDetailLoader]` (the detail loading: a stale update of a report's cache, a failed prefetch of a business date), `[DailyReportIdsStream]` (the ids stream client: a revalidation that gave up, a skipped malformed line or line of an unknown kind, a malformed stream anchor) and `[DailyReportSseConnection]` (the SSE connection hook).
|
|
305
|
+
Every line, the server's and the client's, carries exactly one channel prefix: no message spells a `[DailyReport…]` prefix of its own (`src/shared/logger.call-sites.spec.ts` rejects that form in any string literal outside the logger module). The ids stream client's `warn` option (`DailyReportIdsStreamClientOptions.warn`) is a test seam: a sink injected there takes the channel's place and receives the message and its arguments without a prefix.
|
|
302
306
|
|
|
303
307
|
A 500 is one line: its message (`500 endpoint=<name> elapsed=<ms>ms name=<error name> code=<code or N/A> message=<message>`, `500 action elapsed=<ms>ms name=<error name> message=<message>`) and the error object go in one call, so the error's stack never becomes a line of its own that concurrent requests interleave with.
|
|
304
308
|
A JSON endpoint's answer is traced by the API route alone, as one `debug` line on this logger after the endpoint answered (`<status> endpoint=<name> etag_match=<true|false>`; the default `[DailyReportAPI]` console logger starts at `error`, so without a host logger the line is not written). The JSON builder itself (`jsonResponseWithETag` in `src/server/response.ts`) writes nothing, and no line carries a request header's value or traces an answer given before the endpoint (the 401, 404 and 405), so a client decides neither the number nor the size of the lines.
|
|
@@ -784,7 +788,8 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
784
788
|
A generation's outcome carries this identity only when what it read matched the row's size (**verified**; see **Read port** above); an **unverified** outcome gets no validator and is never cached.
|
|
785
789
|
- **Revalidation**: a verified preview carries `Cache-Control: private, no-cache` and the ETag. `If-None-Match` is compared weakly (`W/` stripped, lists accepted); `*` never matches. A match answers 304 without touching the gate, storage or renderer. Re-mounted thumbnails cost one 304 round trip, and a logout or a visibility change takes effect on the next revalidation.
|
|
786
790
|
An unverified preview is sent with `Cache-Control: no-store` and no `ETag`: the browser neither keeps nor revalidates it, so a later `If-None-Match` can never pin it through 304s, and the next view generates again.
|
|
787
|
-
- **Generation**: a process-local gate with `attachments.thumbnails.concurrency` slots and a
|
|
791
|
+
- **Generation**: a process-local gate with `attachments.thumbnails.concurrency` slots and a wait queue (at most 64 waiting, 8 per user, waiting at most `queueWaitMs`) that hands a freed slot to the waiting user holding the fewest generations (arrival order within a user and between users holding as many), so one user's burst of tiles does not keep another user's first preview waiting behind it. A slot is held while the original is read and rendered. Concurrent requests for the same identity join one generation;
|
|
792
|
+
the joined requests share one read made with the first requester's principal. A request that disconnects withdraws from the shared generation, but its handler still settles with the shared outcome (which no client receives).
|
|
788
793
|
**One decode per content key at a time**: a render that its generation no longer waits for — past its deadline, or after every requester left — keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
|
|
789
794
|
The wait for the still-running render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind a still-running render of the same content is answered from that render's result.
|
|
790
795
|
The generation has its own abort signal, which fires only when every joined request has gone, and passes it to the gate wait and to both ports. An abandoned generation leaves the wait queue, and a port that honours the signal settles early and hands the slot to the next queued generation. A read that settles after the abort is discarded before its result is looked at (see **Read port** above) and nothing is rendered; a render that is running at the abort is kept as above.
|
|
@@ -933,12 +938,13 @@ export const reconcileEveryReport = (server: DailyReportServer, signal: AbortSig
|
|
|
933
938
|
- **What matches.** A query is space-separated keywords; words in double quotes form one keyword that needs every one of its words. The AND / OR toggle combines the keywords (every keyword, or any). A word matches as a substring of the normalized text of one field or of one comment the viewer may read (a word never spans two fields).
|
|
934
939
|
The normalization is fuzzy-search's default (`DEFAULT_NORMALIZE_OPTIONS`) after removing zero-width characters, so full-width and half-width forms, kana and case fold together. Two kinds of substrings do not match: a word that starts with an iteration mark (`々木` does not match `佐々木`) and a word cut inside a grapheme (`デンフ`, half-width kana before its voiced mark, does not match `デンプン`).
|
|
935
940
|
A word typed with a Latin letter also searches its kana readings (romaji, read by fuzzy-search's `queryNeedles` with `convertRomaji` and normalized like a word): Hepburn and IME spellings alike (`shinbun` and `sinbun` both search シンブン, `chokin` and `tyokin` チョキン).
|
|
936
|
-
|
|
937
|
-
A reading is kept only when the whole word is read (`report` adds nothing), it differs from the word and it has at least 3 letter clusters (`SEARCH_READING_MIN_CLUSTERS`: grapheme clusters, not counting digits, symbols and a leading dash, so `ha100` → ハ100, `no12` → ノ12
|
|
938
|
-
A reading of 1 or 2 letter clusters is not added: the index folds hiragana into katakana, so such a reading matches common kana phrasing (on 228,794 reports, `site` → シテ matched the して of 99,636 of them, 43.55 %, in 1,244 ms; `kome` → コメ the コメント of 4,131; `n` → ン 163,571, in 1,302 ms), and digits or a
|
|
941
|
+
Both the word as typed and its normalization must hold a Latin letter: full-width letters count (`DENPUN` searches デンプン), while Roman numerals and `№`, which the normalization turns into Latin letters, are no letters as typed (`Ⅱです`, `№12` and `第Ⅲ期` have no reading), and the two Latin modifier letters the normalization turns into other marks (U+10781 into ː, U+10782 into ˑ) leave no Latin letter after it (`納期ヲ確認` followed by one has no reading).
|
|
942
|
+
A reading is kept only when the whole word is read (`report` adds nothing), it differs from the word and it has at least 3 letter clusters (`SEARCH_READING_MIN_CLUSTERS`: grapheme clusters, not counting digits, symbols and a leading dash, and counting a run of prolonged sound marks once, so `ha100` → ハ100, `no12` → ノ12, `-site` → ーシテ and `ha--` → ハーー are not kept, while `site-` → シテー is), at most 2 per word in fuzzy-search's order (`DAILY_REPORT_SEARCH_READINGS_PER_WORD`: `numadu` searches ヌマズ and ヌマヅ, `konnyaku` コンニャク and コンヤク), and the Latin spelling itself stays a candidate.
|
|
943
|
+
A reading of 1 or 2 letter clusters is not added: the index folds hiragana into katakana, so such a reading matches common kana phrasing (on 228,794 reports, `site` → シテ matched the して of 99,636 of them, 43.55 %, in 1,244 ms; `kome` → コメ the コメント of 4,131; `n` → ン 163,571, in 1,302 ms), and digits, a dash or a run of prolonged sound marks around it do not make it rarer (ハ100 matches 単価は100円, ーシテ matches コピーして, ハーー matches それは――, which the index holds as ソレハーー). Two-character words such as `memo`, `imo` and `kome` are therefore typed in kana.
|
|
939
944
|
A 3-cluster reading of common phrasing can still match many reports (`shitei` → シテイ, the してい of している, matched 60,168, 26.3 %), while ordinary readings match 0.01–1.24 %.
|
|
940
945
|
A reading never reaches kanji (`nouki` searches ノウキ, not 納期; `tanaka` タナカ, not 田中) and folds no long vowel (`kouhi-` searches コウヒー, not コーヒー; `ko-hi-` does). fuzzy-search's homophone spellings work one way: `numazu` searches ヌマズ and not ぬまづ, `otagei` オタゲイ and not をたげい, so type `numadu`, `wotagei` or the kana.
|
|
941
|
-
A word in kana never searches romaji text (デンプン does not find `denpun`), and a word
|
|
946
|
+
A word in kana never searches romaji text (デンプン does not find `denpun`), and a word without a Latin letter, as typed or after normalization, compiles to the plan it had before readings existed.
|
|
947
|
+
Words and keywords are de-duplicated by their whole set of alternatives, readings included, so two words that normalize alike but differ in their readings (`Ⅱです` reads nothing, `iiです` reads イイデス) are both kept: the plan does not depend on the order the words are typed in, and adding a word to an OR query never takes an alternative away.
|
|
942
948
|
- **What is searched.** The fields the detail view shows, resolved the same way (the title, the body, the people masked like the detail, the categories, the customer, the interviewers and the attachment file names), the label names (searched although the views do not draw them), the text the host's readers return for an external source (`externalSourceTexts`: lines the detail view does not show, never the audit actors or raw ids), and the comments.
|
|
943
949
|
A posted comment is searched for a viewer the detail view would show it to (the comment scope allows the report's source type, or the viewer wrote it); an external comment follows the comment scope alone.
|
|
944
950
|
- **Authorization.** The search SQL applies the detail's conditions itself: no deleted report, no other user's draft (by the draft label names), only the viewer's visible source types.
|
|
@@ -956,9 +962,17 @@ export const reconcileEveryReport = (server: DailyReportServer, signal: AbortSig
|
|
|
956
962
|
`GET {apiBasePath}/search-marks?q=<text>&combine=intersection|union&ids=<id,id,...>` (the shown answer's query and at most 100 distinct canonical ids, the rows a view draws) answers `{ wordsDigest, reports }` (`DailyReportSearchMarksAnswer`): for each requested report the viewer may read, once, its id, the mask of the keywords it matches (`matchedSets`: bit i for keyword i, for an OR of two or more keywords;
|
|
957
963
|
`null` otherwise) and the marks of every non-empty field value the views draw (`fields`, by key: `subject`, `content`, `author`, `employeeName`, `updatedBy`, `category`, `creationCategory`, `customerName`, `interviewer:<i>:name|affiliation`, `attachment:<i>` and `comment:<id>:author|content`, the comment id as `mergeComments` gives it).
|
|
958
964
|
A field's marks are the fingerprint of the raw value they were computed on (FNV-1a 32 over its UTF-16 units) and flat `[start, end, tier]` triples in UTF-16 offsets of that value; `wordsDigest` is the digest of the answer's `words`.
|
|
959
|
-
|
|
965
|
+
A field's triples are its maximal runs of one tier, overlapping and adjacent occurrences merged into one, so they never overlap and a field carries at most one triple per UTF-16 unit of its value: an answer grows with the length of the values drawn, like the details answer of the same reports, and has no cap (a one-letter word over 100 reports of 21,200 units each answers 1.6–2.9 MB, against 2.2 MB of their details).
|
|
966
|
+
The server merges a field's occurrences per tier in time proportional to their number plus the value's length, whatever length the occurrences cover (`markSearchText` in `src/server/search/search-marks.ts`).
|
|
967
|
+
The server finds the ranges with the index's own processing — the same invisible-character stripping, line splitting and 4,000-unit pieces, mapped back to the raw value through fuzzy-search's position-mapped forms, and on a line longer than 4,000 units, whose pieces may split a grapheme cluster, widened to the raw value's cluster boundaries (`Intl.Segmenter`), so a mark never cuts a character such as 👍🏽 in two — and the keyword mask with the search's own scope conditions in one SQL, so a mark never lands where the search does not match,
|
|
968
|
+
and the browser normalizes nothing (`@aiquants/fuzzy-search` stays out of the client bundle).
|
|
960
969
|
Its admission is a third instance from the same factory, for the same reason as `reports`: `SEARCH_MARKS_CONCURRENCY_PER_SEARCH_CONCURRENCY` (2) × the larger of `concurrency` and `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` reads at once per process (one viewer holds half, never fewer than the client's requests in flight) and `SEARCH_MARKS_RATE_PER_SEARCH_RATE` (10) × `rateLimitPerMinute` per viewer and minute, in a bucket of its own; its refusal lines name `route=search-marks`.
|
|
961
|
-
|
|
970
|
+
Unlike the search and `reports`, which refuse at once, a search-marks request that finds the gate full or its viewer at its share waits for a slot (`SEARCH_ADMISSION_WAITS`): `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` waiters per viewer and as many as the admission's concurrency in all, for at most `SEARCH_MARKS_WAIT_MS` = 2,000 ms (about twice the slowest search-marks request measured under load, 196–927 ms), and a freed slot goes to the viewer holding the fewest.
|
|
971
|
+
A new answer's first batches therefore wait for the abandoned batches of the previous answer, which hold their slots until their reads end, instead of spending their one retry; a request past the queue or the wait bound gets 429 with `Retry-After: 1`.
|
|
972
|
+
Its order is the search's and the details': 404 without the `search` block, 400 for a missing `q` or a bad `combine`, 400 with the plan's `reason`, 400 for the ids, then a viewer without an internal user answered `{ wordsDigest, reports: [] }` with no admission and no dataset query, then the admission with the request's signal, the marks (one report at a time, yielding to the event loop between reports), the slot's release whatever the outcome, and the JSON. It is not cached on the server either.
|
|
973
|
+
The service takes the request's signal too (`markDailyReportSearchHits(scope, userId, plan, ids, { signal })`) and checks it before each read and before each report's marks, stopping there once the client has left; drizzle cannot cancel a running query, so the slot is held until the read in progress ends. A request whose client left — while it waited, at one of the service's checks or after the service answered — is answered 503 without a body, with `Retry-After` and `Cache-Control: no-store`, and writes no error line;
|
|
974
|
+
the refusal of an aborted request is never recorded as busy, on any of the three search routes.
|
|
975
|
+
Every answer the route builds — the marks, its 400, 404 and 429, and that 503 — is JSON without an ETag and with `Cache-Control: no-store` (`noStoreJsonResponse` in `src/server/response.ts`, serialized once, with the shared security headers): the batches are cut from the rows on screen, so a URL rarely repeats, and the package's client sends no `If-None-Match`; an `If-None-Match` is not read.
|
|
962
976
|
- **The search box** (`config.search: {}` searches on Enter only; `{ debounceMs }`, an integer from 300 to 5000, also after typing stops) is a second row in the toolbar's band, under the view-mode toolbar: the AND / OR toggle, the field and a status line. Enter, Esc and the debounce wait while an IME composes.
|
|
963
977
|
The row is one 32 px lattice block whose 24 px controls stand at its bottom (`SEARCH_ROW_CLASS_NAME`), so they sit G = 8 under the toolbar's bar and their bottom edge is G above the view's first surface; the band carries the toolbar's start inset G and the active view's end inset, so the field ends where the cards end in both views, and it stacks above the view, so a control's focus outline paints over the view's G strip (**Origin on the lattice** in [Selection and focus appearance](#selection-and-focus-appearance)).
|
|
964
978
|
The toggle names the current mode (`AND` / `OR`) and keeps focus when pressed, so a second Space flips the mode again and the new name is read where focus stays. The clear button sits inside the field's box at its end, in room the field always keeps free, so typing never changes the field's width; it shows while the field holds text or a search is committed and returns focus to the field.
|
|
@@ -971,23 +985,32 @@ export const reconcileEveryReport = (server: DailyReportServer, signal: AbortSig
|
|
|
971
985
|
- **Where**: a List card marks its preview lines and its creator; the side pane its author, subject, customer, body, attachment file names and comments (author and body, each on its own); a DetailList row its subject, customer and body, the header's author and categories, the metadata's author, updater and categories, the interviewers' names and affiliations, its attachment file names and its comments.
|
|
972
986
|
A file name is marked in both of its forms: in the stem and extension spans of the CSS layout, each with its own part of the name, and in the fitted text, where the synthetic `…` and the elided middle carry no mark (**File name** in [Attachment display](#attachment-display)).
|
|
973
987
|
- **Which words**: every word of an AND answer and of a one-keyword answer; for an OR of two or more keywords, only the words of the keywords the report matched (`matchedSets`; a word that several keywords share is marked when any of them matched), so a quoted keyword whose words appear only in part marks nothing.
|
|
974
|
-
- **Never marked**: label names (searched, never drawn: a report matched only through its labels shows no mark, and the indicator below shows `Hidden match`); the external
|
|
975
|
-
the List preview's lines after the third
|
|
988
|
+
- **Never marked**: label names (searched, never drawn: a report matched only through its labels shows no mark, and the indicator below shows `Hidden match`); the texts a host's readers supply for an external source (`externalSourceTexts`), which the views do not display: a report matched only through them shows no mark either, and the indicator shows `Hidden match`; the markup the List and the side pane strip (a word inside a tag), while the DetailList draws its tags as text and marks them;
|
|
989
|
+
the List preview's lines after the third (not drawn) and the elided middle of a fitted file name, whose synthetic `…` stands outside every mark; the edit form and the viewer's own draft open in it; placeholders (`-`), dates, visit times, badges, the visually hidden heading and the unknown-author substitute; and a comment still being posted.
|
|
990
|
+
Text that the browser hides keeps its marks: a List preview line past its one-line clamp, the creator name of a List card and a file-name part of the CSS layout past their ellipsis. In Chromium a clamp or ellipsis that cuts a mark, or falls at its start, is drawn inside the mark, so the mark's fill continues under the `…` while its underline ends at the last character shown (a cut at a mark's start shows a filled `…` alone).
|
|
976
991
|
An `interviewerWithAffiliation` template that does not insert the name and the affiliation verbatim (it rewrites them, uses one twice, or words itself by their values) draws the pill plain. `LIKE` counts an occurrence inside a grapheme cluster, so such a match marks the whole cluster.
|
|
977
992
|
- **Look**: one token (`SEARCH_MARK_CLASS_NAME`): yellow-200 under slate-900 (dark yellow-800 under yellow-50; 15.33:1 and 6.61:1 on any surface, since the mark sets its own text colour), a solid underline for a word and a dotted one for a reading (a cue that does not depend on colour), and the system `Mark` / `MarkText` in forced colours.
|
|
978
993
|
It writes `margin`, `border` and `padding` 0 and inherits the font, so a host's `mark` rule cannot change it and a mark changes no width, no line box and no row height. Accessible names and descriptions flatten the marks, and find-in-page and copying read the same text as without them.
|
|
994
|
+
A reading mark also carries an accessible description (`aria-description`: `labels.searchReadingMarkDescription`, en `Matched by its romaji reading`, ja `ローマ字の読みで一致`), so the weaker match does not rest on the underline's style alone; a word mark carries none, so the common case adds no speech. The attribute's value and the description come from one table keyed by the tier (`SEARCH_MARK_LOOKS` in `src/client/search/search-marked-text.tsx`); a tier outside it stops the render with an error naming the package.
|
|
979
995
|
- **Arrival**: the screen holds one marks store per shown answer (`search-marks-store.ts`, inside the client bundle), only while a view draws an answer: without `config.search`, before the first answer is drawn, after the release and for a view rendered on its own there is none, and the DOM is the plain one.
|
|
980
|
-
A row
|
|
996
|
+
A row wraps its subtree in the marks scope (`DailyReportSearchMarksScope`), which subscribes to the report's marks while the row is mounted and hands them to its leaves. The row body reads no marks, so an arrival re-renders only the scope, the marked leaves (`SearchMarkedText`) and the match indicator — never the row's body, header, attachment list or comment section, and never another row; a newly mounted row draws its text plain first and its marks in a deferred render.
|
|
997
|
+
The requests are decided at send time (`createIdBatchScheduler` in `src/client/search/id-batch-scheduler.ts`): a subscribed report waits in one queue, and each free request slot — two in flight at most — takes the newest waiting reports that a row still subscribes to, at most 100 a request, never cached. A report whose rows left while it waited is dropped, so a scroll or a held key sends nothing for the rows it passed, and the window it stops on goes out in the first free slot.
|
|
998
|
+
A request whose rows have all left is aborted (the server then stops computing its marks; **Requests** above), and a row that leaves before its marks were sent, or whose request was aborted, asks again when it returns.
|
|
999
|
+
The store keeps one record per report (its marks, the send order that brought them, the epoch it was asked in, its 429 retry and its revalidations) and forgets the oldest records of reports no row subscribes to beyond `SEARCH_MARKS_RECORD_BOUND` = 400 (2 × the two requests in flight × 100: the answers still in flight and as many rows just left), so its memory follows the drawn window, not the distance scrolled; a forgotten report asks again when a row subscribes to it.
|
|
981
1000
|
Answers apply in send order: a late answer to an earlier request (of a stale epoch, or sent before an edit and overtaken by the edit's revalidation) never overwrites the marks a later request brought.
|
|
982
1001
|
When a new search replaces the shown answer, a row that stays mounted draws its text plain until the new answer's marks arrive, never the previous answer's marks or matched-keyword count.
|
|
983
1002
|
A field is painted only when its marks were computed on the value drawn (the fingerprint): after a save or an SSE update the old ranges are not painted and the field asks once for fresh marks. An answer whose `wordsDigest` differs from the shown answer's words is dropped; a quiet re-query of the same search makes the marks stale and the mounted rows ask again;
|
|
984
|
-
401 and 403 call `onSessionExpired
|
|
985
|
-
|
|
986
|
-
|
|
1003
|
+
401 and 403 call `onSessionExpired`; a 429 is asked again once, after its `Retry-After`, for the reports a row still subscribes to then (one whose rows left meanwhile asks again when a row returns); any other failure leaves those rows plain for as long as the shown answer stays the same.
|
|
1004
|
+
Disposing the store — a new search, or React hiding the screen inside an `<Activity>` — aborts its requests, cancels its retry timers and gives every report they held back, so a store React mounts again asks for them again.
|
|
1005
|
+
- **Match indicator.** There is no score: an answer can tell only how many of its keywords a report matched, and only an OR of two or more keywords varies it. While the marks store exists, the side pane's author line (after the source badge) and the end of a DetailList row's header hold a slot in the contact map's score colours (`SEARCH_INDICATOR_CLASS_NAME`: the page's text face at the package's 12 px floor with tabular figures, on a 16 px line;
|
|
1006
|
+
slate-600 / dark slate-300, 7.56:1 on white and 12.02:1 on slate-900), as wide as the longest text the answer can show (an invisible placeholder), so nothing reflows when the marks arrive.
|
|
1007
|
+
The side pane's author line wraps with a 4 px gap, the label kept whole on one line (`SIDE_PANE_AUTHOR_LINE_CLASS_NAME`), so in a pane too narrow for the author and the slot the slot drops to a 16 px line of its own instead of crushing the author. List cards show none.
|
|
987
1008
|
- `labels.searchMatchedKeywords(k, n)` (en `Match 2/3`, ja `一致 2/3`; `data-daily-report-search-matched-keywords="k/n"`) shows for an OR of two or more keywords once the report's marks arrive, k being the set bits of `matchedSets`.
|
|
988
|
-
- `labels.searchMatchOutsideView` (en `Hidden match`, ja `表示外で一致`; `data-daily-report-search-outside-view`) shows when the report matched (k ≥ 1, or no mask), the view is not editing it,
|
|
989
|
-
|
|
990
|
-
|
|
1009
|
+
- `labels.searchMatchOutsideView` (en `Hidden match`, ja `表示外で一致`; `data-daily-report-search-outside-view`) shows when the report matched (k ≥ 1, or no mask), the view is not editing it, and the view's leaves paint none of its marks: a report matched only through its labels, its external texts or fields the view does not draw says why it is listed.
|
|
1010
|
+
What the view draws is read from the leaves themselves: inside the row's marks scope every leaf (`SearchMarkedText`) registers, while it is drawn, whether its field's marks are current for the value drawn and how many marks it paints, in the row's tally (`drawn-search-marks-tally.ts`), and the token shows only when every registration is current for the marks the scope settled and their sum is 0.
|
|
1011
|
+
So a field the view does not draw at that moment never counts: the side pane on its Related tab (`tabRelations`; the article is not mounted then), a comment the viewer is deleting, an interviewer pill a host template draws plain and the elided middle of a fitted file name all show the token when the report matched only there. A file-name part that a CSS ellipsis hides counts as drawn, because its marks are painted and the browser decides what it hides.
|
|
1012
|
+
- Both tokens are `aria-hidden` with a `title`, and one visually hidden element holds their sentences (`labels.searchMatchedKeywordsDescription(k, n)`: en `Matches 2 of 3 keywords`; `labels.searchMatchOutsideViewDescription`: en `Matched in fields this view does not show, such as labels or details`).
|
|
1013
|
+
A DetailList row's description (`aria-describedby`) ends with that element while the screen holds a marks store, so moving through the rows with the arrow keys reads the sentences each row shows.
|
|
991
1014
|
|
|
992
1015
|
## Client wiring
|
|
993
1016
|
|
|
@@ -1061,7 +1084,8 @@ Every context behind an exported provider defaults to `null` and is read only th
|
|
|
1061
1084
|
The badge's own retrying and failed contents are live regions only where the page does not announce them: `DailyReportIdsStreamStatus` placed alone keeps its `role="status"`.
|
|
1062
1085
|
|
|
1063
1086
|
The provider takes no items: it derives its rows from the module-resident ids stream session (below), so establish the session first — `createDailyReportClientLoader` in the route, or `ensureDailyReportIdsStreamSession({ apiBasePath, userKey })` on mount, as `DailyReportPage` does — and mount one provider per user (`key={userId}`), which also starts the new user with an empty editing store (**Editing** in [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)).
|
|
1064
|
-
The provider builds its actions once — the functions, the ledgers of the optimistic state and the viewing user, in one value whose identity changes only with the viewing user — and hands out its state apart: the rows (`items
|
|
1087
|
+
The provider builds its actions once — the functions, the ledgers of the optimistic state and the viewing user, in one value whose identity changes only with the viewing user — and hands out its state apart: the rows (`items`, a persistent list read by its length, the report at a position (`at`) and iteration, not an array: **Rows follow the stream by its changes** below), the version that announces a change of the ledgers, and the SSE state (`isSseEnabled`, `sseStatus`).
|
|
1088
|
+
Each function calls the implementation of the last committed render, so a function kept from an earlier render does what the current one does.
|
|
1065
1089
|
The ledgers are read-only views (`DailyReportLedgerView`: a frozen `{ current }` of a read-only map) whose contents only the provider's one writer changes, and the writer advances `version` in the same call, so no ledger changes without a re-render of what reads it through `version`: a comment whose post fails disappears from the list and a comment whose deletion fails comes back, while the notice reports the failure.
|
|
1066
1090
|
The package's rows, List cards, DetailList rows and report cards, the side pane's content and the edit form read only the actions, so an ids stream publish, an SSE message or a change of the connection status re-renders none of them; of a report's parts, a version bump re-renders only its comment section (`ReportComments`). `useDailyReportActionContext()` returns the actions and the state as one value, the same value on a render where the state did not change; its caller re-renders whenever the state changes.
|
|
1067
1091
|
|
|
@@ -1080,8 +1104,11 @@ If your app overrides `config.apiBasePath`, pass the same value to `createDailyR
|
|
|
1080
1104
|
The list itself is not part of a publish, so a publish costs the size of its change, never the list's length; a reader that needs the whole list builds it when it reads (`DailyReportIdsStreamClient.readItems()`: the list as of the last publish, with the changes still waiting in the coalescing window taken back).
|
|
1081
1105
|
`DailyReportActionProvider` keeps its rows in the canonical order (business date descending, then id descending, reports without a date last) and applies only the changes since the revision it last derived, folded into one however many publishes a render receives.
|
|
1082
1106
|
A change is located by reading a few rows, never by walking the list: rows that arrive after the old last row are placed by one comparison with it (a publish of Δ such rows reads at most Δ + 1 rows, the stream client included), and a row that arrives out of order, a changed row, a removed row and a held row that a settled stream lets go are each found by binary search (⌈log2 n⌉ reads); the user's own holds, releases and removals read the held rows' table and one binary search the same way.
|
|
1083
|
-
The
|
|
1084
|
-
|
|
1107
|
+
The rows are a persistent list (`ReportRowList` in `src/client/contexts/report-row-list.ts`): immutable chunks of `REPORT_ROW_CHUNK_SIZE` = 1,024 rows behind one spine, every chunk full but the last, and a derivation's edits make a new version in one pass that shares every chunk they leave alone (`editReportRowList`), once per publish however often React runs the render again.
|
|
1108
|
+
A publish that appends rows — what the stream mostly delivers — writes the spine (⌈n / 1,024⌉ references), the last chunk's rows and the new rows, never the n references of the list (4,128 references for 4,000 rows appended to 80,000, where a copy of the list wrote 84,000); edits that only replace rows copy the spine and those rows' chunks; a removal or an insertion inside the list re-packs the rows from the first chunk it changes, once.
|
|
1109
|
+
The rows are written by position with no `concat`, `filter`, `flat`, slice or spread of the list: `Array.prototype.concat` is fast only while the page-wide `Symbol.isConcatSpreadable` protector holds, which one assignment by any library breaks for the page's lifetime (apache-arrow assigns it on its prototypes, for one), and the slow path then visits every row.
|
|
1110
|
+
The views, the keyboard, the action state (`items`) and the search's list derivation read rows only by their length, the report at a position (`at`, two array reads) and iteration (`ReportRows` in `src/client/utils/report-index.ts`), so a plain array (a search answer) and the provider's list read alike; a search for one id walks the chunks by position (`findReportIndex`), without a call per row.
|
|
1111
|
+
While an answer shows, the search's list derivation (`deriveSearchListItems` in `src/client/search/search-list-items.ts`) examines only the chunks a publish wrote (for an append, the last chunk and the new rows' chunks), since the chunks a version shares were examined before, and returns the previous list itself when the publish changed none of the answer's reports, so such a publish re-renders neither the views nor their rows.
|
|
1085
1112
|
React runs a render-phase update again whenever it restarts the render — a transition that carries a publish is thrown away and restarted from the root by every key a held key repeats —, so one base and one set of inputs (the source's revision and change chain, the settledness and the deletion marks) are derived once and reused, the same rows object, by every rerun (`deriveReportRowsOnce`).
|
|
1086
1113
|
The deletion marks are a read-only set that the provider replaces on every change, so that set's identity is an exact part of the key. Without it, one publish under a held key was derived and copied once per restart (21 to 25 copies in a concurrent root, about 320 KB each at 80,000 rows).
|
|
1087
1114
|
It reads the whole list (once) and derives from it only on its first render, after the session is replaced, when it falls more than 32 publishes behind, and after the development cache clear drops the tombstones (which brings back the reports they hid).
|
|
@@ -1152,8 +1179,9 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
1152
1179
|
- **One Tab order rule for both views: only the Tab-stop row's controls are in the Tab order.** The Tab-stop row is the row that owns focus (below) while it is inside the visible range, otherwise the selected row while it is visible, otherwise the first visible row (until the view has reported its visible range: the focus owner, then the selection).
|
|
1153
1180
|
Every focusable control the package draws inside a row reads the rule: the List card's primary button, ★ and 既読, the DetailList row's ★, 既読, 編集 (on the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment field and its send button), the edit form's fields and buttons, and the attachment links keep their natural order in the Tab-stop row and have `tabIndex=-1` in every other row. The stop row's own element is a stop too where it takes focus: a DetailList row (`tabIndex=0`) and a List row frame whose card is not shown.
|
|
1154
1181
|
So the Tab path through a view is one row's controls long, however many rows are rendered: crossing the List takes exactly 3 presses (the primary button, ★ and 既読). Because the focus owner is the stop, a control reached by pointer continues within its own row, and a row in edit mode keeps its form in the Tab order.
|
|
1155
|
-
The view-mode tab panel that holds a view is no Tab stop: a panel is a stop
|
|
1156
|
-
The
|
|
1182
|
+
The view-mode tab panel that holds a view is no Tab stop: a panel is a stop when its first meaningful content is not focusable, or when it is a scroll container that Tab must reach so the keys can scroll it (WAI-ARIA APG Tabs; `TabsContent` takes a required `tabStop`), and a view's content begins with its own stop, the cursor row.
|
|
1183
|
+
The panel carries no `tabindex` at all (not −1, which would let a pointer press on blank view space focus the panel, an ancestor of the list where the view's keys never arrive), so one Tab from the last control before the view reaches the cursor row, and `Ctrl+Home` from a row returns to that control.
|
|
1184
|
+
The side pane's article and relations panels are stops of their own: each is the pane's scroll box, which Tab must reach so the keys scroll it, although the article holds buttons, the comment field and attachment links further down.
|
|
1157
1185
|
The scroll bar is pointer-only in both views, like a native scroll bar, because the keys above already scroll the list: both views pass `enableArrowButtonTabStops: false`, `@aiquants/virtualscroll`'s one signal that the host scrolls by keyboard itself (3.10.0, its README "Scrollbar accessibility and the Tab order").
|
|
1158
1186
|
The bar carries `aria-hidden="true"`, so assistive technology sees no `scrollbar` and no `slider` in either view, named or not; its arrow buttons have `tabIndex=-1` and the bar and its thumb no `tabindex`, so the bar adds no Tab stop; and a press anywhere on the bar has its default prevented, so it moves no focus, neither onto a part of the bar nor away from the row that holds it. Every pointer interaction still scrolls: the arrows on a press and while held, the track, the thumb and the tap-scroll circle.
|
|
1159
1187
|
The bar and its thumb still carry their names (the engine keys `verticalScrollBar` and `verticalScrollThumb`, below), which the hidden bar does not expose.
|
|
@@ -1239,7 +1267,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
1239
1267
|
The position text reads the row's place from the same context as the row frame, so an insert or a delete before the rendered window re-renders the frames and these texts, never a card's body.
|
|
1240
1268
|
The total the position names is the size of the set the rows belong to — every row frame's `aria-setsize` and the header's total count read the same value, which `DailyReportResolvedContent` decides once for both views (`resolveRowSetSize`):
|
|
1241
1269
|
while the ids stream delivers, the total the server declared on the stream's first line (never fewer than the rows shown, and unchanged as chunks arrive); before the server has declared it, unknown — `aria-setsize="-1"`, the header shows `labels.totalCountLoading`, and the position reads `labels.listRowPositionInUnknownTotal` (en "Item 3", ja 「3 件目」); once the scan has completed, the rows shown.
|
|
1242
|
-
A host that renders `DailyReportList` or `DailyReportDetailList` itself passes that size as `rowSetSize` (−1 while unknown), or omits it when its rows are the whole set; the views' `VirtualScroll` always counts the rows given. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
|
|
1270
|
+
A host that renders `DailyReportList` or `DailyReportDetailList` itself passes that size as `rowSetSize` (−1 while unknown), or omits it when its rows are the whole set; the views' `VirtualScroll` always counts the rows given. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints, and, while the screen holds a search marks store, by the match indicator's hidden sentences last (**Match indicator** in [Text search](#text-search)).
|
|
1243
1271
|
The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one, otherwise its label); a description references only the markers that are painted. A row without content (loading, failed, missing, editing) is named by its ISO business date, with `aria-busy="true"` while loading. At most one element per view carries `aria-current`. ★, 既読, 編集 and 削除 carry `aria-label` equal to their `title` and no `aria-pressed`.
|
|
1244
1272
|
- **Document structure**: every report has a heading at `config.headingLevel` (an integer from 2 to 5, default 3; pick the level that continues the host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
|
|
1245
1273
|
The side pane and the mobile overlay start with two label / value pairs: `labels.businessDate`, whose value is the report heading (the business date alone), and `labels.author` with the author's name; each label and each value is read once, and the source badge sits beside the pair, outside it. The overlay names its dialog by the heading and the author's value together (`<date> <author>`, the name of the List card).
|
|
@@ -1389,7 +1417,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1389
1417
|
The tile around the frame is on the lattice anyway: its last line carries the pad δ(t) = ⌈h / q⌉ · q − h (0 to q − 1 px, q the block quantum of **Row slots on the lattice**) as a bottom margin, so every tile is ⌈h / q⌉ · q + 48 px tall, every tile row top and every grid's height are multiples of q, and what follows a grid stays on whole device pixels (**Tile** in [Attachment display](#attachment-display)).
|
|
1390
1418
|
A bordered box counts its border in its inset, so that its content edge sits on the grid measured from the visible edge: border + padding is on the ladder — 16 for the card surfaces, the error panel, the sides of the error notice and the mobile overlay's close button (1 + 15), 8 for the form fields, the comment input and the top and bottom of the error notice (1 + 7), 4 for the bordered tab bar, the development box and the top and bottom of the text input (1 + 3) — or equals a 12 px corner.
|
|
1391
1419
|
**The 12 px tier**: every panel with a 12 px corner has one inset, 12, equal to its corner, so its content box's corner is concentric with the panel's — the reading panel, the DetailList metadata column and the DetailList skeleton's inner panel (`p-3`, no border), the placeholder panel, a posted comment and the bordered pill (1 + 11); a 24 px pill has the same 12 px padding at its round ends. The tab bars are not in that tier: their 4 px inset makes them concentric with their 8 px tabs (12 − 4 = 8).
|
|
1392
|
-
The only other exceptions are the visually hidden text (Tailwind's `sr-only`, and inside virtualized rows the transparent 1 px box of `VISUALLY_HIDDEN_CLASS_NAME`, which clips nothing) and the 24 px the search field keeps free at its end for the clear button inside its box. `src/client/ui/spacing-ladder.spec.ts` checks every class the package writes, that every bordered box's border + padding is on the ladder or equals its corner, and that every 12 px-corner panel's inset is 12.
|
|
1420
|
+
The only other exceptions are the visually hidden text (Tailwind's `sr-only`, and inside virtualized rows and for every attachment file name the transparent 1 px box of `VISUALLY_HIDDEN_CLASS_NAME`, which clips nothing) and the 24 px the search field keeps free at its end for the clear button inside its box. `src/client/ui/spacing-ladder.spec.ts` checks every class the package writes, that every bordered box's border + padding is on the ladder or equals its corner, and that every 12 px-corner panel's inset is 12.
|
|
1393
1421
|
- **Origin on the lattice**: the views' centred column (at most 72 rem, `max-w-6xl`, centred with `mx-auto`) starts at the centring offset rounded down to a multiple of the block quantum q (`VIEW_COLUMN_ORIGIN_CLASS_NAME`: `ml-[max(0px,round(down,calc((100%-72rem)/2),var(--aqdr-lattice-block)))]`, part of `VIEW_COLUMN_CLASS_NAME`, which carries q itself, so the status screen starts there too), less than q left of the exact centre, and keeps q from the frame's top and side edges.
|
|
1394
1422
|
q CSS px is a whole number of device pixels at every ratio of the lattice's domain (4 px at the multiples of 1/4, k device pixels at the ratio k/4; 8 px at the odd multiples of 1/8), so every inline edge built on the origin — G, the ladder steps of 8 and 16, the card, the row frame — starts on a whole device pixel: on a 412 px phone at 2.625 a DetailList surface's left edge is 16 px in, at device pixel 42.
|
|
1395
1423
|
Below the column's top inset the view-mode toolbar sits in its band, as tall as its content rounded up to q (`VIEW_TOOLBAR_BAND_CLASS_NAME`; the search row, when configured, is inside the band: one more 32 px block, `SEARCH_ROW_CLASS_NAME`), so the views start q + the band below the column's top — 4 + 32 = 36 px at the multiples of 1/4 and 8 + 32 = 40 px at the odd eighths, or 4 + 64 = 68 px and 8 + 64 = 72 px with the search row — on the lattice at every ratio.
|
|
@@ -1422,7 +1450,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1422
1450
|
`word-break: auto-phrase` breaks Japanese at phrase boundaries where the host document's `lang` is `ja` (ja 「プレビューを / 表示できません」 instead of 「プレビューを表示できませ / ん」 in a 171 px track) and behaves as `normal` in other languages;
|
|
1423
1451
|
and `overflow-wrap: anywhere` breaks inside a phrase or a word only when it does not fit on a line by itself, so a label never crosses its frame at any width, root font size or font. It also counts those breaks in the label's min-content width, so a flex item's automatic minimum width no longer holds the label at its longest phrase (the prompt is a flex item, and the tile label's text is the anonymous flex item of its box: the property is inherited, which is how it reaches that item, where a `min-width` on the label's box would not). Where a phrase fits, the lines are the same as without it.
|
|
1424
1452
|
The token sets nothing else, and the labels that use it declare no other line-breaking property, so nothing cancels it: `src/client/ui/style-tokens.spec.ts` pins the token's three declarations, the side pane's spec the prompt's classes and the panel minimum derived from the compiled insets and the prompt's last phrase, the tile's spec compiles the label's classes and checks that its line-breaking declarations are exactly the token's, and the search box's spec renders every status message in both locales and checks that the line carries the token and no truncating class.
|
|
1425
|
-
- **Type and contrast**: no text is smaller than 12 px (`text-xs`); text reaches 4.5:1 and the indicators 3:1 in both themes (section labels slate-500 / slate-400: 4.77 / 7.09). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (through the class both views pass in `VirtualScroll`'s tap-scroll circle options).
|
|
1453
|
+
- **Type and contrast**: no text is smaller than 12 px (`text-xs`; a scan of every module under `src/client` rejects a smaller arbitrary size, `src/client/components/report-views.spec.tsx`); text reaches 4.5:1 and the indicators 3:1 in both themes (section labels slate-500 / slate-400: 4.77 / 7.09). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (through the class both views pass in `VirtualScroll`'s tap-scroll circle options).
|
|
1426
1454
|
- **Action buttons and icons**: the List card's star and read toggles, the star, read, edit and delete buttons of the DetailList header and the side pane, and the trash button of the viewer's comments share one 24 px round target that never shrinks, with a 16 px SVG icon centred in it (4 px on every side), so the icons sit on the 4 px grid without depending on a font. In the List card the markers and toggles stand 4 px apart; the header pills and the source badge are 24 px tall like the targets.
|
|
1427
1455
|
The star is a regular five-pointed star, outlined when off and filled gold when on (with a darker gold edge in the light theme); read is a check, unread an 8 px dot, edit a pencil, delete a trash can. Every icon state reaches at least 3.59:1 against each background it sits on, the button's hover background included, in both themes; in forced colours the star is drawn in `CanvasText` (off) and `Highlight` (on). The side pane shows them in the order star, read, edit, delete, centred on the first line of the subject.
|
|
1428
1456
|
Unread is shown by the dot on the read toggle and by the row state in the description; no surface changes its border or its layout for it (a card's border is the same 1 px in every state).
|
|
@@ -1460,6 +1488,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1460
1488
|
— after dropping that font's measure, since the glyph widths change while the font string stays the same; other font loads re-render nothing.
|
|
1461
1489
|
Outside a grid (the rows of non-image attachments) and until the grid's width is known, the name truncates with CSS: the stem ellipsizes and the extension, kept up to half the width, follows it.
|
|
1462
1490
|
The visible name is hidden from assistive technology either way, and a visually hidden copy of the whole name gives each link one unbroken name (no space before the extension); `title` holds the whole name too.
|
|
1491
|
+
Every hidden copy of a file name, the download link's included, is `HiddenFileName`: `VISUALLY_HIDDEN_CLASS_NAME` with `contain: layout`. It clips nothing, so it never makes a DetailList row repaint on a scroll step that shifts the window, and its layout containment keeps the long one-line name from widening a scroll box outside the virtualized rows (the side pane, the mobile overlay).
|
|
1492
|
+
The attachment section still holds content that clips — the visible name's ellipsis, a fitted name's `overflow: hidden`, the size note's ellipsis and the thumbnail frame's rounded corners — so a DetailList row with attachments is still repainted on such a step.
|
|
1463
1493
|
While a search answer shows, the visible name carries the search marks of its `attachment:<i>` key (i, the attachment's index in the report, taken before the split into tiles and rows; **Highlights** in [Text search](#text-search)): in the CSS layout the stem span and the extension span each draw their own part of the raw name, so a match across the dot marks both;
|
|
1464
1494
|
a fitted name maps back to the raw name — unit for unit when the whole name fits, otherwise by the NFC runs it was cut from (`fitFileName` returns a `DisplayText`), so a mark on a decomposed name covers the whole composed character — and its synthetic `…` and the elided middle carry no mark.
|
|
1465
1495
|
The visually hidden copy, `title` and `data-attachment-file-name` stay plain.
|
|
@@ -1565,7 +1595,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1565
1595
|
```
|
|
1566
1596
|
|
|
1567
1597
|
- **One surface for all wording.** The catalog covers the 11 engine chrome keys of
|
|
1568
|
-
`@aiquants/virtualscroll` plus
|
|
1598
|
+
`@aiquants/virtualscroll` plus 102 own keys: field headings, the page title (`title`, passed to
|
|
1569
1599
|
`renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state and the List card's position read to
|
|
1570
1600
|
screen readers, tabs, buttons, placeholders, the `confirm()` text, stream badges, the search box and the search's match indicator, the status screen and the page's status region
|
|
1571
1601
|
and the mutation-failure message with its repeat count. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
|
|
@@ -1582,10 +1612,11 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1582
1612
|
`toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `listRowPosition(position, total)`, `listRowPositionInUnknownTotal(position)`,
|
|
1583
1613
|
`searchHitCount(count)`, `searchFailed(failure)` (`failure` is `invalid` / `busy` / `unavailable` / `failed`), `searchMatchedKeywords(matched, total)` and `searchMatchedKeywordsDescription(matched, total)`. An override of such a key must be a function too.
|
|
1584
1614
|
- **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
|
|
1585
|
-
the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`)
|
|
1615
|
+
the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`), through one `Intl.NumberFormat` per
|
|
1616
|
+
locale built once (the row positions and counts re-render on every stream publish); a host override receives
|
|
1586
1617
|
the raw number and formats it itself.
|
|
1587
1618
|
- **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
|
|
1588
|
-
`RangeError` at render. So do an unknown `labels` key (a key error that lists the
|
|
1619
|
+
`RangeError` at render. So do an unknown `labels` key (a key error that lists the 113 keys), a string
|
|
1589
1620
|
key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
|
|
1590
1621
|
function. An `undefined` value keeps the catalog value.
|
|
1591
1622
|
- **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
|
|
@@ -1623,7 +1654,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1623
1654
|
grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
|
|
1624
1655
|
that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
|
|
1625
1656
|
|
|
1626
|
-
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all
|
|
1657
|
+
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 113 keys, the 11 engine keys
|
|
1627
1658
|
first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
|
|
1628
1659
|
(the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
|
|
1629
1660
|
`locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
|
|
@@ -1678,7 +1709,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
1678
1709
|
| `devSseToggle` | dev toolbox: the SSE switch's accessible name (`aria-label` on the `role="switch"` button) and tooltip | Toggle SSE real-time sync | SSE リアルタイム同期切替 |
|
|
1679
1710
|
| `devReload` | dev toolbox: reload tooltip | Reload data | データ再取得 (リロード) |
|
|
1680
1711
|
| `devClearCache` | dev toolbox: clear-cache tooltip | Clear the cache and reload | キャッシュ破棄 & 再取得 |
|
|
1681
|
-
| `autoRead` | auto-read switch label | Auto-read | 自動既読 |
|
|
1712
|
+
| `autoRead` | auto-read switch label, tied to its switch by an id of its own (`useId`, so two mounted screens never share one) | Auto-read | 自動既読 |
|
|
1682
1713
|
| `autoReadTitle` | auto-read switch: its description (`aria-describedby`) and tooltip | Mark reports as read automatically while viewing them | 日報閲覧時の自動既読処理切替 |
|
|
1683
1714
|
| `loadErrorBadge` | header annotation on load failure | Error | エラー |
|
|
1684
1715
|
| `loadError` | status panel on load failure; the page's status region announces it as an alert | Failed to load the daily reports. | 日報データの読み込みに失敗しました。 |
|
|
@@ -1878,7 +1909,7 @@ A type the closure brings in is published under a name that carries `DailyReport
|
|
|
1878
1909
|
- **client** (`@aiquants/daily-report/client`, React):
|
|
1879
1910
|
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider` (required above the action provider: [Client wiring](#client-wiring)),
|
|
1880
1911
|
and their props types `DailyReportPageProps` / `DailyReportResolvedContentProps` / `DailyReportListProps` / `DailyReportDetailListProps` (with `DailyReportDetailListScrollRestore`, the scroll anchor and row heights it keeps across unmounts) / `DailyReportAttachmentIndicatorProps`.
|
|
1881
|
-
- Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (the provider's actions — functions that keep their identity while the provider is mounted, the ledgers and `user` — and its state: `items`, `version`, `isSseEnabled`, `sseStatus`, and for the search `searchInvalidation`, `isReportRemoved`, `removedVersion`; `updateReport` takes both `title` and `content`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` /
|
|
1912
|
+
- Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (the provider's actions — functions that keep their identity while the provider is mounted, the ledgers and `user` — and its state: `items` (the rows as a persistent list, read by `length`, `at` and iteration), `version`, `isSseEnabled`, `sseStatus`, and for the search `searchInvalidation`, `isReportRemoved`, `removedVersion`; `updateReport` takes both `title` and `content`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` /
|
|
1882
1913
|
`useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `loadedCount`, `streamAnchor`, and `itemsRevision` / `itemsChanges`, the revision of the session's list and its latest net changes; the list itself is not part of the state).
|
|
1883
1914
|
Their types: the action context's `DailyReportActionContextType` (what `useDailyReportActionContext` returns) / `DailyReportActions` (the functions, the ledgers and the viewing user) / `DailyReportActionFunctions` / `DailyReportActionLedgers` (with `DailyReportLedgerView`, a ledger's read-only `{ current }`) / `DailyReportActionState` / `DailyReportActionBusinessDate` (the business date an action sends), `DailyReportDetailResource` (the detail source the screen provides while it shows a search answer),
|
|
1884
1915
|
`UseDailyReportSseConnectionOptions`, the ids stream's `DailyReportIdsStreamState` / `DailyReportIdsStreamPhase` / `DailyReportIdsStreamClientOptions` / `DailyReportIdsStreamItemsChange` (one net change) / `DailyReportIdsStreamItemReplacement` (one value change inside it), and `DailyReportSearchFailure` (why a search failed, as the search box tells it).
|
|
@@ -1888,7 +1919,8 @@ A type the closure brings in is published under a name that carries `DailyReport
|
|
|
1888
1919
|
- Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `DailyReportClientConfig` (the resolved configuration `useDailyReportConfig` returns) / `DailyReportHeaderProps` (what `renderHeader` receives) / `DailyReportSearchSettingInput` / `DailyReportSearchSetting` (`config.search` as passed and as resolved) / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`,
|
|
1889
1920
|
and the end-to-end test handle's contract `DailyReportViewTestHandle` / `DailyReportViewTestReadHandle` / `DailyReportRevealOptions` / `DailyReportListNavigationHandle` (the read-only getters of the views' `VirtualScroll` handle that `DailyReportViewTestReadHandle` builds on) (see [Test hooks](#test-hooks)).
|
|
1890
1921
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1891
|
-
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`;
|
|
1922
|
+
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`;
|
|
1923
|
+
and for the search `searchLimits`, `null` without the `search` block, `searchDailyReports`, `getDailyReportDetailsByIdsByExternalId`, `markDailyReportSearchHits` (it takes the request's `{ signal }` and throws its reason at its next check after an abort), `reconcileSearchIndex`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `defineDailyReportSearchSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1892
1924
|
Their types: `DailyReportService` (what `createDailyReportService` returns and `DailyReportHandlersConfig.service` takes), `DailyReportEpochStore`, `DailyReportVisibleSourceTypeSet` (a viewer scope's normalized set), `DailyReportResourceRouteArgs` (the arguments of a resource route's loader or action that the handlers read), `DailyReportSseReaderConfig`, and `DailyReportSqlResultCacheQueryOptions` with `DailyReportSqlResultCacheMissAdmission` (the admission a read passes for the fetch its miss starts).
|
|
1893
1925
|
- Attachments: `createSharpThumbnailRenderer` / `predictSharpThumbnailWorkingSetBytes` / `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (the render deadline `renderMs` and the decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
|
|
1894
1926
|
- SSE and caching: `DailyReportSseReader` / `readDailyReportSseStreamEntry(raw, logger)` (reads one Redis stream entry, `{ id, message }` as the client returns it, once: `{ entry, json }`, the frozen `StreamEntry` the shared reader hands every subscriber and the entry's parsed JSON value; for a host whose own reader keeps the same contract) /
|