@aiquants/daily-report 0.32.0 → 0.33.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 CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  All notable changes to `@aiquants/daily-report` are documented here.
4
4
 
5
+ ## 0.33.0 (2026-10-07)
6
+
7
+ 0.32.0 の続き: 一覧の末尾の近くの削除で錨の日報が跳ばないこと、action の照合用 ID を 2 つの正準の形だけにすること、SSE のエントリをプロセスで 1 度だけ読むことと 1 回の読み取りをバイトの予算で縛ること、action の閲覧者ごとのレートの上限、ids ストリームのアイテムの publish をトランジションの優先度で届けることと、見出しの件数とビューの本体を配置の境界にすること、action と JSON のエンドポイントの共有のワイヤの契約、後で呼ばれるタイマーを持たない期限の記録、原本の HEAD の大きさの契約。どれも README の該当の節が今の振る舞いを述べる。
8
+
9
+ ### Fixed
10
+
11
+ - **一覧の末尾の近くで窓より前の日報が消えても、錨の日報は跳ばない**: 末尾の近くで窓より前の日報が消えて中身が縮むと、スクロールの領域は位置を小さくなった最大位置へ動かす (クランプ)。両ビューのスクロールの錨は、VirtualScroll がそのクランプを `onScrollAdjust` の `"clamp"` で知らせると (`@aiquants/virtualscroll` 3.13.0)、錨を記録したまま保ち、記録した位置だけをクランプの `delta` だけ動かすので、一覧の変化の後に錨の日報を同じ px へ戻す。
12
+ 以前はクランプを利用者のスクロールと数えて動いた眺めを保ったので、DetailList は末尾から 42 px 上で 302 px、末尾ちょうどで 344 px 跳んだ (一覧の途中では跳ばない)。`"clamp"` を知らせない版の VirtualScroll の上では、クランプは知らされず以前と同じに振る舞う。
13
+ - **action の照合用 ID は 2 つの形だけ**: `clientTempId` は、パッケージのクライアントが作る小文字の UUID (`crypto.randomUUID()`) と、先頭に 0 を持たない 16 桁までの負の 10 進の整数 (`String(-1 * Date.now())`) だけを受け、ほかは 400 (`Invalid clientTempId`。無いか空なら従来どおり `clientTempId required`) で、どのサービスも呼ばない。SSE のどのメッセージの照合用 ID も同じ形で読み、形の外の ID を持つエントリは配らない。
14
+ 以前は空でない文字列ならフォームの上限 (1 MiB) まで受けたので、約 1 MiB の ID を付けたスターの切り替えが 200 で答え、約 1 MiB の状態のイベントを永続の SSE のストリームへ書いて、接続中のすべての閲覧者と再接続の追いつきへ届けた。
15
+ - **原本の HEAD も大きさの上限の契約を確かめる**: 読み取りポートが HEAD に `attachments.maxBytes` を超える `size` を申告したら、同じ実体の GET と同じポートの契約違反の 500 (`reason=port_contract code=over_max_bytes`、error) で答え、`Content-Length` を付けない。以前は HEAD だけが申告をそのまま `Content-Length` にして 200 で答えた。
16
+ - **宣言の無い上限超えの本文の運ばれ方を文書が正しく述べる**: 宣言した長さが上限を超える本文には 413 が届くが、宣言の無い (チャンク転送の) 本文と長さを偽る本文は、数えた長さが上限を超えたところで要求の本文を取り消し、Node のアダプターでは接続が閉じる (413 は届かない。413 を届けるには、上限の無い大きさの残りを読み切ることになる)。振る舞いは変わらず、数えた拒否は従来どおり warn の行に残る。以前の README と docstring は、どちらにも 413 が届くと述べていた。
17
+ - **後で呼ばれるタイマーを予約しない**: 作成した日報の錠 (1 秒)、自分の操作のエコーの抑止 (30 秒) と、キャッシュをまだ持たない日報への SSE の知らせの待ち (30 秒) は、どれも単調な時計の期限で読み、タイマーを使わない。待ちの購読はアンマウントの確定で解く。以前は錠を外す 1 秒のタイマーとエコーごとの 30 秒のタイマーを張り、プロバイダーがアンマウントした後にも走りえた (待ちの期限のタイマーと購読は受動的な副作用の後始末で止めていた)。
18
+ - **クライアントは action の答えと JSON のエンドポイントの答えを厳格に読む**: action の答えは送った操作の答えの形 (共有の `parseActionResult`) でなければ失敗として巻き戻し、日報の ID を送られたままの 10 進の文字で読む (以前は手書きの型で数と読み、`String()` / `Number()` で食い違いを隠した)。2xx でない答えは状態を持つ誤りで拒む。`report` と `business-date` の答えは厳格なスキーマで読み、合わなければ `Error` で拒む (以前は型の主張と `?? []` で読み、文字列で拒んだ)。
19
+ - **CommonJS のバンドルに死んだホットモジュールの分岐と、ビルドの警告が無い**: ids ストリームのセッションのホットモジュールの後始末は `import.meta.hot?.dispose(destroySession)` の 1 行で、CommonJS の形式はビルドの時に `import.meta.hot` を `undefined` に置き換えて畳む。以前の CommonJS のバンドルは常に偽になる判定を持ち、ビルドのたびに `import.meta` の警告を 3 つ出した。
20
+
21
+ ### Changed
22
+
23
+ - **SSE のエントリはプロセスで 1 度だけ読む**: 共有リーダーはライブのエントリ 1 件を読んだときに 1 度だけ解釈し検証して、凍結した同じ値を全接続へ渡す。接続ごとの仕事は読み終えた値の絞り込みと、メッセージごとに 1 度だけ作るフレームの字面の選択だけで、書く字面は以前と 1 バイトも違わない。以前は接続ごとに解釈・検証・直列化し直したので、1 MiB のイベントで接続 1 つあたり約 1.6 ms、200 接続で約 311 ms をイベントループで使った。
24
+ - **SSE の 1 回の読み取りはバイトの予算で縛る**: 共有リーダーの `XREAD` と catch-up の 1 ページの件数は、64 MiB の予算を 1 件のイベントの上限で割った 10 件 (`DAILY_REPORT_SSE_CATCH_UP_BATCH`)。以前の 100 件と 1,000 件は件数だけの縛りで、最大のイベントばかりのページは約 5.9 GiB を溜めえた。小さなイベント 1,000 件の catch-up は 113 往復になる (往復の時間 0 で約 95 ms、1 ms で約 225 ms)。
25
+ - **ids ストリームのアイテムの publish はキーより後に描く**: アクションのプロバイダーは一覧を 1 つの購読で追い、版か落ち着きが変わったときだけ `startTransition` の中で描き直すので、行の導きとビューの行の数はトランジションの優先度で描かれ、ストリームの間に押したキーは publish の描画より先に確定する。ページの根・SSE の接続・進捗の札・行の集合の大きさは、読む項目だけを持つ見え方を読み、アイテムの publish では描き直さない (本番の React で、4 回の publish でページの根も SSE の接続の部品も 0 回)。公開の `useDailyReportIdsStream` は今までどおり状態の全体を返す。
26
+ - **見出しの件数とビューの本体は配置の境界**: 見せている間に変わる件数の文字 (総件数と走査中の件数) は、幅を見えない寸法の札が取る閉じ込めた箱に置くので、文字の変化は箱の中だけを配置し直す。走査中の件数は publish された値をそのまま見せる (補間しない。総件数は従来どおり補間し、動きを減らす設定に従う)。両ビューの根要素の中に、大きさ・配置・スタイルを閉じ込めたビューの本体の箱があり、行の数やスクロールバーの変化の配置はそこから始まる。List のモバイルのオーバーレイは本体の外に置く。以前は ids ストリームの間、40 回のキーの長押しごとに文書の根からの配置が 34〜51 回 (35〜54 ms) 起きた (ストリームの無いときは 0〜1 回)。
27
+ - **新しいコメントのフォームは隠しの欄を持たない**: 送信は投稿の処理だけが運ぶので、使われない `intent`・`reportHubId`・`businessDate` の隠しの欄とそのための props を除いた。本文の欄は欄自身のウィンドウの `HTMLInputElement` として読む。
28
+ - **開発者向け**: action と JSON のエンドポイントのワイヤはサーバーとクライアントが共有する定義から読む (`src/shared/action-wire.ts`: 操作の名前・欄の名前・指示とその符号化・操作ごとの答えとその厳格な解釈。`src/shared/api-endpoints.ts`: エンドポイントとクエリの引数の名前)。照合用 ID の形は `src/shared/client-temp-id.ts` の 1 つ。SSE のエントリの 1 度だけの読み取りとフレームの字面は `src/server/sse-entry.ts`、読み取りの予算は `src/server/payload-limits.ts` (`SSE_READ_BUDGET_BYTES`・`SSE_READ_ENTRY_COUNT`)。
29
+ レートの制限とバケットは `src/server/rate-limit.ts` へ移り (`attachment-delivery/rate-limiter.ts` は無い)、添付の経路と action が同じ 1 つの仕組みを使う。`pnpm run verify` は網羅率の段の後に、前の公開のタグから足した `src` の行がどれかの spec で走ることを確かめ (`scripts/check-changed-lines-coverage.mjs`。免除は `scripts/changed-lines-coverage-exemptions.json`)、ビルドの警告はどれもビルドを落とす (`scripts/lib/build-warnings.mjs`)。
30
+ クライアントの後で呼ばれるコールバック (タイマー・フレーム・リスナー・監視) を張る場所は、どれも理由付きで `src/client/deferred-callback-scope.spec.ts` に載る。action の `executeAction` は共有の指示を受け、`keepLock` の選択肢は無い。
31
+
32
+ ### Added
33
+
34
+ - server の `parseClientTempId` と、それだけが作る型 `DailyReportClientTempId` (照合用 ID の解釈。サービスを直接呼ぶホストが ID を読む。下の Breaking)。
35
+ - action の閲覧者ごとのレートの上限: 1 分に 600 件 (ハンドラー工場ごと、つまりワーカーのプロセスごと。認証が返す外部の利用者 ID で数える)。超えた要求は本文を読む前に 429 (`{"error":"Too many requests"}`、`Retry-After: 60`) で断り、どのサービスも呼ばない。行は閲覧者の窓ごとに最初の `429 action reason=rate_limit` と、窓の終わりの `rate_limit_suppressed count=<N> since=<…> route=action` の 2 行まで (warn。利用者 ID を書かない)。上限は、留まった側面ペインの自動既読 (ペイン 1 つで 1 分に 120 件) の 5 つ分。
36
+
37
+ ### Breaking
38
+
39
+ - action の照合用 ID は 2 つの形だけ (上の Fixed)。server のサービスの書き込み (`setStarStatus`・`setReadStatus`・`addComment`・`deleteComment`・`createDailyReport`・`updateDailyReport`・`publishDailyReport`・`deleteDailyReport`) は照合用 ID を `DailyReportClientTempId` でだけ受ける (以前は `string`)。SSE のストリームの、形の外の照合用 ID を持つエントリは配らない。
40
+ Migration: パッケージのクライアント以外から action を呼ぶホストは、照合用 ID を小文字の UUID (`crypto.randomUUID()`) か、先頭に 0 を持たない 16 桁までの負の整数で送る。サービスを直接呼ぶホスト (結合試験など) は、ID を `parseClientTempId` で読んでから渡す (どちらの形でもなければ `null`)。
41
+ - server の `StreamEntry` (共有リーダーのポート `DailyReportSseReaderPort` が渡すエントリ) は `{ id, message }` で、`message` は 1 度だけ読んだメッセージ (`{ parsed, published, text }`: スキーマが検証したメッセージ・書かれたままの JSON の値・その字面) か、正しいメッセージを持たないエントリの `null` (以前は `message` がエントリの欄の `Record<string, string>`)。
42
+ Migration: 自分のリーダーを渡すホストは、エントリ 1 件の `data` の欄を JSON として解釈して `dailyReportSseMessageSchema` で検証し、読めなければ `message: null` にして、同じ値をすべての購読者へ渡す。
43
+ - action の `deleteComment` の 200 の答えは、ほかの操作と同じく `intent` (`"deleteComment"`) を持つ。
44
+ Migration: 答えを自分で読むホストは、答えの欄を共有の形 (`ActionResult`) で読む (知らない欄を落とす読み方なら変更は要らない)。
45
+ - action の閲覧者ごとのレートの上限 (上の Added): 1 人の閲覧者が 1 つのワーカーへ 1 分に 600 件を超えて送ると 429。
46
+ Migration: パッケージのクライアント以外から action を呼ぶホストは 429 を失敗として扱い、`Retry-After` の後に送り直す。
47
+ - ピアの `@aiquants/virtualscroll` の下限を 3.13.0 へ上げた (`peer-floors.json`)。一覧の末尾の近くで窓より前の日報が消えても錨の日報が跳ばない修正 (上の Fixed) は、3.13.0 がペインのクランプを `onScrollAdjust` の `"clamp"` で知らせることに頼る。
48
+ Migration: `@aiquants/virtualscroll` を 3.13.0 以降へ上げる。
49
+
5
50
  ## 0.32.0 (2026-10-07)
6
51
 
7
52
  0.31.0 の続き: 添付の設定を 1 つの任意の入れ子のブロック (`attachments`) にまとめること、action の本文の上限と、送られた値 (件名・本文・コメント・真偽値) と `forceRefresh` を 1 つの厳格な解釈で読むこと、SSE のイベント 1 件の上限、ids ストリームのスナップショットトークンを取得ごとに 1 回だけ作ることと publish が一覧を写さないこと、アクションのプロバイダーの操作の値を描画をまたいで同じに保つこと、削除の印の付いた行の外し漏れ、件数の補間が動きを減らす設定に従うこと。どれも README の該当の節が今の契約を記す。
package/README.md CHANGED
@@ -26,7 +26,7 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
26
26
  ```
27
27
 
28
28
  `@aiquants/virtualscroll` / `@aiquants/swipe-overlay` / `@aiquants/resize-panels` / `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**. React must be **19 or later** (`react` / `react-dom` `>=19`): the rows register their keyboard focus targets with ref callbacks that return a cleanup.
29
- The peer floor of `@aiquants/virtualscroll` is **3.11.5**: install 3.11.5 or later.
29
+ The peer floor of `@aiquants/virtualscroll` is **3.13.0**: install 3.13.0 or later.
30
30
  Both views pass its scroll-bar option `enableArrowButtonTabStops: false`, since 3.10.0 its one signal that the host scrolls by keyboard itself, under which the scroll bar is pointer-only — hidden from assistive technology, out of the Tab order and never taking focus on a press (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)) —; its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
31
31
  see [Selection and focus appearance](#selection-and-focus-appearance)); and both views re-record their scroll anchor from its `onScrollAdjust` notification and rely on its edge-keeping snap (3.9.0; see **Host selections and list changes** and [View height](#view-height-host-layout)). The label catalog reuses its 11 engine keys, the names of the scroll bar and its thumb among them (3.10.0; see [Localization](#localization-locale--labels)).
32
32
  `DailyReportRevealOptions`, the options of the test handle's `revealIndex`, is `@aiquants/virtualscroll`'s `scrollToIndex` options type, which takes `align: "nearest"` since 3.11.0, and DetailList rows stay at the tree's tops and heights after a batch of re-measurements whose deltas cancel only because its row memo follows the tree revision (3.11.0).
@@ -42,7 +42,10 @@ An `.mjs` or `.css` target of `exports` without an entry, an entry for a file th
42
42
  Every `publish:*` script measures the build that `pnpm run verify` ends with (verify's last steps are the build and the bundle check) and does not build again: right after verify it runs `node scripts/check-bundle-size.mjs --write`, then `node scripts/check-bundle-size.mjs --exact`, before the leak check (which reads the same build) and the version bump.
43
43
  `--write` lowers every entry the build shrank, so a shrink is recorded into the release by construction (no failed publish, no separate baseline commit); a growth is left unrecorded, and `--exact` — besides the budgets, it fails when any measured size differs from its baseline entry in either direction — stops it and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build after review.
44
44
  So a release always ships the baseline of its own build: an entry left high after a shrink would loosen every budget derived from it, and one left low would judge the next change against a build nobody shipped. `--exact` cannot be combined with `--write` (exit 2).
45
- `pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the examples, the build and, last, the bundle check.
45
+ `pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the changed-lines coverage gate, the examples, the build and, last, the bundle check.
46
+ The changed-lines coverage gate (`scripts/check-changed-lines-coverage.mjs`, `pnpm run check:changed-lines-coverage`) reads the coverage run and the lines added to `src` since the package's latest release tag (`<package name>@<version>`, resolved from `package.json`; untracked files count as added):
47
+ an added line inside a statement that no spec executed fails unless `scripts/changed-lines-coverage-exemptions.json` names it with its text and the reason, and an exemption that covers no such line is stale and fails (exit 0 / 1, and 2 when nothing is proven), so new code cannot hide behind a file's floor.
48
+ The build fails on any esbuild warning (`scripts/lib/build-warnings.mjs`), and the CommonJS bundles replace `import.meta.hot` with `undefined`, so they carry no `import.meta` while the ESM bundles keep the hot-module clean-up for the host's Vite.
46
49
  The unit tests include the repository's publish leak guard over the markdown the package ships (`src/shipped-markdown-leaks.spec.ts`: every markdown file `package.json` `files` ships — `README.md`, `CHANGELOG.md` and any shipped docs — packed with `package.json` into a tarball and checked by the guard itself, with its own rules), so an internal name in them fails verify instead of stopping a publish; the publish paths still run the guard on the packed tarball, `dist` included.
47
50
  Every source file under `src` (without specs, tests, declarations and test helpers) and every gate script under `scripts` (`scripts/**/*.mjs`) has a committed floor of branch and function coverage in `coverage-floors.json`: Vitest fails a file below its floor, `pnpm run check:coverage` (`scripts/ratchet-coverage.mjs`) fails a file without an entry or an entry without a file, and `pnpm run coverage:ratchet` raises each floor to the measured percentage rounded down after a whole-suite coverage run (a file at 100 % stays at 100; no floor is ever lowered by the script).
48
51
  Each gate script is a two-statement command-line entry around the `main({ packageRoot, argv })` of its module under `scripts/lib/` (the shared exit codes, JSON readers and error descriptions are `scripts/lib/cli.mjs`); the specs run `main` in the test process against temporary packages and keep one subprocess test of each entry, so the scripts' logic is measured by the same floors as `src` (all at 100).
@@ -177,7 +180,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
177
180
  - **The API route's methods**: authentication comes first (401), then the endpoint — an unknown name is 404, an inherited name such as `toString` included — and then the method: one the endpoint does not answer is 405 with `Allow`.
178
181
  The endpoint table (`API_ENDPOINT_BODY_KINDS` in `src/server/api-endpoint.ts`) states once what kind of body each endpoint answers, and the methods follow from it (`API_ENDPOINT_METHODS`): `GET` for a JSON body (`report` and `business-date`, whose ETag is computed from the body, so a `HEAD` would build the body only to discard it) and `GET, HEAD` for a streaming body (the ids stream). The API route's refusal of data requests above follows from the same table.
179
182
 
180
- **Request values**: the API route and the action read every value of a request — business dates, ids, time stamps, `forceRefresh` and the action's payload — with one strict parser each, after authentication and before any query or service call, and answer a value they do not accept with 400:
183
+ **Request values**: the API route and the action read every value of a request — business dates, ids, time stamps, `forceRefresh`, the action's echo id and the rest of its payload — with one strict parser each, after authentication and before any query or service call, and answer a value they do not accept with 400:
181
184
 
182
185
  - **Business dates** (`parseBusinessDateKey`): `YYYY-MM-DD`, or `YYYY/MM/DD` with the same separator twice (read as the same day), naming a day of the calendar from 0001-01-01 to 9999-12-31, the range of the database's `date` column.
183
186
  Anything else — surrounding whitespace, a time stamp (`2026-10-06T00:00:00.000Z`), a date written as text, a day off the calendar (`2026-02-30`, `2026-13-40`, month 0), the year 0 — is refused: the `business-date` endpoint answers `{"error":{"message":"Invalid business date"}}` (also when `businessDate` is missing), and the action `{"error":"Invalid businessDate"}` for every intent that sends one, `create` included.
@@ -186,17 +189,29 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
186
189
  `1e3`, `12abc`, ` 7 `, `0x10`, `1.9`, `-3`, `0` and `9007199254740993` (2^53 + 1, which would round to its neighbour and could name another row) are `Invalid reportHubId` / `Invalid commentId`.
187
190
  The ids stream's resume cursor follows the same rule, and its business date must be the canonical `YYYY-MM-DD` the server wrote (a malformed cursor is 400 `Invalid cursor`); an attachment token that decodes to anything but a positive safe integer is 400 `Invalid attachment token`.
188
191
  - **`forceRefresh`** (`parseForceRefreshParam`; the `business-date`, `report` and `ids-stream` endpoints): absent reads as `false`, and only `true` and `false` are accepted. Anything else — `yes`, `TRUE`, `1`, the empty value — is `{"error":{"message":"Invalid forceRefresh"}}`; the ids stream answers it before its `HEAD` short-circuit.
192
+ - **The echo id** (`clientTempId`; every intent but `clearCache`): the client makes one id per operation, sends it with the action and recognizes its own operation by it in the answer and in the SSE event. The action accepts exactly the two forms the package's client makes (`parseClientTempId`, server entry, which returns the branded type `DailyReportClientTempId`):
193
+ a lowercase UUID (`crypto.randomUUID()`: 8-4-4-4-12 lowercase hexadecimal digits) and a negative decimal integer without a leading zero of at most 16 digits (`String(-1 * Date.now())`, the optimistic temporary id of a report or a comment). A missing or empty id is `clientTempId required`;
194
+ anything else — an uppercase UUID, `-0`, `tmp-1`, surrounding whitespace, 37 characters, a text of 1 MiB — is `Invalid clientTempId`, before any service call.
195
+ The server writes the id into the persistent SSE event of every intent and echoes it in the answer, so the two forms keep the id from deciding an event's size; the SSE message schema reads every event's id against the same pattern (`DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` in `src/shared/client-temp-id.ts`), so an entry with any other id is dropped on delivery (fail-closed).
196
+ The service's write methods — `setStarStatus`, `setReadStatus`, `addComment`, `deleteComment`, `createDailyReport`, `updateDailyReport`, `publishDailyReport` and `deleteDailyReport` — take only a `DailyReportClientTempId`, which only `parseClientTempId` produces, so a host that calls the service directly (an integration test, a batch) reads its id through the parser first and cannot publish an event that delivery would drop.
189
197
  - **The action's form**: the action reads the body with a cap and parses the whole form before it resolves the internal user, the viewer's visibility or any intent (`readActionCommand` in `src/server/action-form.ts`, the only code that reads the form; the intents run on the parsed command and never see the form).
190
- - **Size**: the body is read up to `ACTION_FORM_MAX_BYTES` (1 MiB, 1,048,576 bytes). A declared `Content-Length` above it is answered without reading the body, and a body without a declared length (chunked) or with a wrong one stops being read where the count of bytes passes the cap: both are 413 `{"error":"Form too large"}`, with no error log line.
191
- The refusals are recorded per handler factory in windows of 60 s (`ACTION_FORM_REFUSAL_LOG_WINDOW_MS`) at `warn`: the window's first refusal as `413 action reason=form_too_large measured=<declared|counted> limit=1048576`, and, when the window counted more, one `form_too_large_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> measured=declared:<n>,counted:<n>` written by the window's own timer at its end.
198
+ - **Rate**: before the body is read, each request spends one token of its viewer's bucket, keyed by the external user id `authenticate` returns: `ACTION_RATE_LIMIT_PER_MINUTE` = 600 per viewer and per handler factory (so per worker process), refilled in proportion to the time over one minute (`ACTION_RATE_WINDOW_MS`). The bucket starts full, so a burst of up to 600 passes at once.
199
+ An empty bucket answers 429 `{"error":"Too many requests"}` with `Retry-After: 60` (one window, after which the bucket is full again); it reads no body and calls no service. The bound is the client's own fastest flow with headroom: a side pane auto-reads the report it shows after 500 ms, so one pane sends at most 60,000 ÷ 500 = 120 `toggleRead` a minute, and 600 is five such panes; stars, comments and saves come at the pace of a person.
200
+ The refusals are recorded per viewer in windows of the rate window at `warn` (through the shared bucket of `src/server/rate-limit.ts`, which the attachment paths use too): the window's first refusal as `429 action reason=rate_limit`, and the rest counted into one `rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> route=action` line written by the window's own timer at its end. No line names the user.
201
+ - **Size**: the body is read up to `ACTION_FORM_MAX_BYTES` (1 MiB, 1,048,576 bytes). A declared `Content-Length` above it is answered 413 `{"error":"Form too large"}` without reading the body.
202
+ A body without a declared length (chunked) or with a wrong one stops being read where the count of bytes passes the cap, and the request's body is cancelled. On Node adapters the cancel destroys the request's socket (`@react-router/node`'s `createReadableStreamFromReadable`, for one), so the sender gets a closed connection, not the 413: delivering the 413 would mean reading the rest of an upload of unbounded size, which is the cost the cap exists to avoid. Neither refusal writes an error line.
203
+ Both are recorded per handler factory in windows of 60 s (`ACTION_FORM_REFUSAL_LOG_WINDOW_MS`) at `warn`: the window's first refusal as `413 action reason=form_too_large measured=<declared|counted> limit=1048576`, and, when the window counted more, one `form_too_large_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> measured=declared:<n>,counted:<n>` written by the window's own timer at its end.
192
204
  The cap is the only bound of a report's body and a comment's text, which have no ceiling of their own.
193
205
  - **Text only**: a body that is no form (no form media type, a broken multipart body, a body cut short) and a form with a file part anywhere are `{"error":"Invalid form"}`, without an error log line.
194
- - **Order**: the business date, the operation timestamp (`operationTimestamp`: the canonical decimal form of a non-negative safe integer, else `Invalid operationTimestamp`), then — for every intent but `clearCache`, which reads nothing more — `clientTempId` (`clientTempId required`), for `create` the business date's presence (`businessDate required`; `create` never reads `reportHubId`), `reportHubId`, and then the intent's own values below, or `Invalid intent` for a name that is no intent.
206
+ - **Order**: the business date, the operation timestamp (`operationTimestamp`: the canonical decimal form of a non-negative safe integer, else `Invalid operationTimestamp`), then — for every intent but `clearCache`, which reads nothing more — `clientTempId` (`clientTempId required` when it is missing or empty, then `Invalid clientTempId` for any other form; **The echo id** above), for `create` the business date's presence (`businessDate required`; `create` never reads `reportHubId`), `reportHubId`, and then the intent's own values below, or `Invalid intent` for a name that is no intent.
195
207
  A `clearCache` that `enableDevCacheClear` does not open is `Invalid intent` as well, also before the user lookup. A star or read toggle echoes the timestamp as it was sent, `null` when none was sent.
196
208
  - **`update`**: `title` and `content` change only the fields that are sent. A field that is not sent keeps its stored value, and the empty text clears the field, which is stored as NULL (one rule, the service's `storedText`).
197
209
  The title is at most the length the injected title column declares, in UTF-16 code units (`service.titleMaxLength`, read from `tables.hub.title`: 200 for `defineDailyReportSchema`'s `nvarchar(200)`, which counts code units as `String.length` does, so a surrogate pair takes 2); a longer one is `Invalid title`. The package's client always sends both fields as typed.
198
210
  - **`toggleStar` / `toggleRead`**: `isStarred` / `isRead` is exactly `true` or `false` (`parseWireBoolean`). Anything else — `TRUE`, `1`, `yes`, the empty text — is `Invalid isStarred` / `Invalid isRead`, and a missing one is `isStarred required` / `isRead required`.
199
211
  - **`addComment`**: `content` is a non-empty text (`Content required` otherwise). **`deleteComment`**: `commentId` (**Ids** above).
212
+ - **One wire contract for the action and the JSON endpoints** (`src/shared/action-wire.ts` and `src/shared/api-endpoints.ts`, shared by the server and the package's client; neither side restates a name): the intents (`DAILY_REPORT_ACTION_INTENTS`, from which the server's form reading and the client's operations derive), the form's field names, the command of each intent with its form encoding (`encodeActionCommand`, which the server's parsing reads back as the same command), the endpoint and query-parameter names, and the 200 answer of each intent (`ActionResult`).
213
+ Every answer carries its `intent` (`deleteComment`'s too) and the report's id as the decimal text the server sends, beside the echo id and the intent's own values (the created or published report, the toggled statuses, the posted comment, the deleted comment's id); a refusal is `{"error":"<message>"}`.
214
+ The package's client reads an answer with the strict `parseActionResult` of the intent it sent — a field of another type, a number where the text id belongs or another intent's answer fails the action, nothing is converted — and reads the `report` and `business-date` endpoints with strict schemas (`{ report }` and `{ reports }` of `dailyReportDetailSchema`); a failure rejects with an `Error`.
200
215
 
201
216
  ### DI ports
202
217
 
@@ -209,7 +224,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
209
224
  - `resolveVisibleSourceTypes(request)` — **Optional row-level authorization port.** Returns the `Hub.source_type` values this request may view. Applies uniformly to every server data path: list (ids stream), business-date list, detail, comments, attachment bytes, and SSE. See [Source-type visibility](#source-type-visibility) below.
210
225
  - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the action answers `400` (`Invalid intent`) and never calls `service.clearCache()`. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user could otherwise flush all caches without limit).
211
226
  - `attachments` — Optional attachment delivery block (`DailyReportAttachmentsConfig`), owned by the service: the id codec and the read port it always carries, the size limit and the original route's tuning, and the optional `thumbnails` with the renderer port and its tuning. See [Attachments](#attachments).
212
- - `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package as it is. Without it each area logs to the console with its own prefix and lowest level, for example `[DailyReportAttachment]` from `info` (see **Log levels** under [Attachments](#attachments)), `[DailyReportIsolation]` from `warn` (the refusal record of [Request isolation](#request-isolation)) and `[DailyReportAction]` from `warn` (the record of the action's 413, **Request values** in [Server wiring](#server-wiring-di)).
227
+ - `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package as it is. Without it each area logs to the console with its own prefix and lowest level, for example `[DailyReportAttachment]` from `info` (see **Log levels** under [Attachments](#attachments)), `[DailyReportIsolation]` from `warn` (the refusal record of [Request isolation](#request-isolation)) and `[DailyReportAction]` from `warn` (the record of the action's 413 and 429, **Request values** in [Server wiring](#server-wiring-di)).
213
228
  - Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
214
229
 
215
230
  **Configuration errors** follow one convention on the server and on the client: the message reads `[daily-report] <path> must be <expectation>`, `<path>` being the public key or argument to fix, and ends with `; got <value>` only when it shows a value.
@@ -387,6 +402,7 @@ type DailyReportReadAttachment = (
387
402
  - Never throw; return a typed failure. `filePath` stays on the server.
388
403
  - **`size` is the size of the whole object.** With `head: true` read metadata only and declare it: it is the HEAD's `Content-Length`, which must match a GET's (RFC 9110 §9.3.2), and a HEAD whose read declares no `size` answers without `Content-Length` rather than claim 0 for an object that has a body.
389
404
  A GET's `Content-Length` is the length of the `bytes` it sends, so a read that declares `size` declares exactly `bytes.byteLength`: any other value — a port that measures the size in a separate call can see another version of the object — is a port contract violation, answered 500 and logged at `error` as `reason=port_contract code=size_mismatch`, with nothing sent and no missing- or present-object record.
405
+ GET and HEAD share one bound as well: a GET read whose bytes exceed `maxBytes` is the violation `code=over_max_bytes`, and a HEAD read, which carries no bytes, is checked by the `size` it declares — a declared size above `maxBytes` is the same 500 with the same `error` line, without `Content-Length`, so no HEAD answers 200 for an object whose GET ends in 500.
390
406
  - **Failure reasons** form one closed union (`DailyReportAttachmentFailure`), and each has the status the handlers answer with; `code` is the raw storage code for the log line and never decides a status. A reason outside the union — which a host outside the type check can return — is a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=failure_reason`, never guessed into another status.
391
407
 
392
408
  | Reason | When | Original | Thumbnail |
@@ -735,11 +751,11 @@ The package never writes the file path itself; a `port_exception` line includes
735
751
  | --- | --- |
736
752
  | `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified, `too_large size=unrecorded` on a row without a recorded size) |
737
753
  | `warn` | 404 `not_visible` (a sign of token enumeration), the storage 404s (`not_found`, `denied`, `invalid_path`), 413 (`db_size`, and the port's `too_large` on an original), the first 429 of a viewer's window per bucket and the window's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`, the storage's `busy`), every 502, the 504 of a storage read past a deadline (`deadline`, `read_timeout`), port settlements after a deadline (`read_overrun`, `render_overrun`), ports still unsettled at twice their stage budget (`read_stuck`, `render_stuck`) and thumbnail reads that do not match the row's size (`size_mismatch`: another length, or a `too_large` on a row whose size is known) |
738
- | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`), an original's `GET` read whose declared `size` is not the length of its bytes (`code=size_mismatch`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
754
+ | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` and an original's `HEAD` that declares a size over it (`code=over_max_bytes`), an original's `GET` read whose declared `size` is not the length of its bytes (`code=size_mismatch`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
739
755
 
740
756
  `error` is left to failures of the server itself, so an alert on `error` does not fire on user traffic or on upstream storage states.
741
757
 
742
- **429 lines**: each rate bucket (the original's, and the thumbnail's generation and revalidation buckets) records its refusals in windows per viewer of `RATE_LIMIT_LOG_WINDOW_MS` (60 s, the rate window and the 429's `Retry-After`), kept apart from the buckets themselves.
758
+ **429 lines**: each rate bucket (the original's, and the thumbnail's generation and revalidation buckets) records its refusals in windows per viewer of its own rate window (`ATTACHMENT_RATE_WINDOW_MS`, 60 s, also the 429's `Retry-After`; one bucket, `createRateLimitBucket` in `src/server/rate-limit.ts`, serves these paths and the action), kept apart from the buckets themselves.
743
759
  A viewer's refusal with no open window writes its `429 … reason=rate_limit` (`reason=revalidation_rate_limit`) line at once and opens the window; later refusals in the window are only counted, and the window's own timer writes, when it counted any, one `rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<id>` (`revalidation_rate_limit_suppressed …`) line (with `variant=thumbnail` on the thumbnail path) and closes the window. The 429 of a revalidation refused after authorization counts in the generation bucket's window.
744
760
  A viewer therefore writes at most two lines per bucket and window, whatever its request rate or the refill; the count is written at the window's end, also when the refusals stop, and a bucket the limiter evicts (it keeps 1,024 users) loses none of it.
745
761
 
@@ -805,7 +821,12 @@ The report id list is **not** part of the loader data: a module-resident NDJSON
805
821
  The client splits the stream into lines as the chunks arrive, examining each decoded character once (a chunk of a 4,000-item line of about 300 KB does not re-read the part of the line already received), and reads a final line without a newline at the end of the stream; a line cut short there is not valid JSON, so it is skipped with one warning and the attempt resumes from its cursor, like any interrupted stream.
806
822
  `createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session before render).
807
823
  If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one wrong-path request on the very first load, self-healed by the page-mount `ensureDailyReportIdsStreamSession`.
808
- `DailyReportPage` subscribes via `useDailyReportIdsStream`, rendering the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 200` first page against the cached full query, so first paint does not wait for the full id scan).
824
+ `DailyReportPage` renders the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 200` first page against the cached full query, so first paint does not wait for the full id scan).
825
+
826
+ - **An items publish re-renders only what it changes, behind the keys.** The package's own components read the session through narrow views (internal), each of which re-renders its reader only when one of its fields changes: the page root and the SSE connection read the status (the phase, the background revalidation, the last error, the SSE anchor and whether any report has arrived), the progress badge the phase, the revalidation and the two counts, and the loaded screen's set size the phase and the declared total.
827
+ `DailyReportActionProvider` follows the list itself through one subscription that re-renders it inside `startTransition` when the published revision or the scan's settledness changes, and reads the revision, the latest changes, the list's accessor and the settledness from the session in the same render, so they always agree.
828
+ The rows derivation and the views' row count therefore render at transition priority: a key pressed while the stream delivers commits before the publish's render, and an items publish re-renders neither the page root nor the SSE connection's host (`useSyncExternalStore`, which the public hook below uses, renders its update at the sync priority even inside `startTransition`, so it cannot carry the transition).
829
+ The public `useDailyReportIdsStream` keeps its contract — the whole state on every publish — so a host component that calls it re-renders on every publish.
809
830
 
810
831
  - **Rows follow the stream by its changes.** Every items publish of the session carries its revision (`itemsRevision`, never reused by another session), the number of reports it holds (`loadedCount`) and the net changes of the latest publishes, one per publish — the reports added, changed and removed — chained by revision (`itemsChanges`, the latest 32).
811
832
  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).
@@ -824,8 +845,10 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
824
845
  Outside such a container the view has no height to fill: its size is contained (below), so its content cannot size it, and it is 0 px tall.
825
846
  - **The fill chain**: every box from the page box down to a view's root — the page box, its centred width-capped wrapper, the view-mode tabs and the active tab panel — fills the rest of its parent column (`flex: 1 1 0%; min-height: 0`, `FILL_COLUMN_CLASS_NAME`) and declares no height. The wrapper's padding is 4 px at the top and the sides and none at the bottom, so the view's box ends exactly at the page's bottom edge.
826
847
  - **One page frame and one column for every screen**: the loading screen, the load-error screen and the loaded screen share the page box (`VIEW_PAGE_FRAME_CLASS_NAME`: the fill rule and the page surface, slate-50 / dark slate-950) and its centred column (`VIEW_COLUMN_CLASS_NAME`: the fill rule, at most `max-w-6xl`, 4 px from the frame's top and side edges, starting on the 4 px lattice). So the page keeps its colour in both schemes while loading ends (a dark page never shows the light surface first) and the content does not move sideways between the screens.
827
- - **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip`): its box is sized as if it were empty, so what the view renders — a `VirtualScroll` given the measured height — never feeds back into the root's size, into the intrinsic size of an ancestor (a host column with a minimum height does not grow) or into the document's scroll height. The clip keeps rows drawn for the previous height from painting outside the box during the one frame before the view renders the new height.
828
- Layout and paint are not contained, so the mobile overlay, a fixed-position descendant, keeps the viewport as its containing block.
848
+ - **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip`, its children in block flow): its box is sized as if it were empty, so what the view renders — a `VirtualScroll` given the measured height — never feeds back into the root's size, into the intrinsic size of an ancestor (a host column with a minimum height does not grow) or into the document's scroll height. The clip keeps rows drawn for the previous height from painting outside the box during the one frame before the view renders the new height.
849
+ The root's layout and paint are not contained: either would make the root the containing block of the mobile overlay, a fixed-position descendant, and as a flex item of its column the root could not be a relayout boundary anyway (Chromium makes no flex or grid item one, contained or not).
850
+ - **The view's body is the relayout boundary** (`VIEW_BODY_CLASS_NAME`: a column flex container at `height: 100%` of the root, with `contain: size layout style`): it fills the root exactly, and since the root lays it out in block flow it is not a flex item, so with its size and layout contained it is a relayout boundary. Everything the view lays out — the list column, the panel group and the side pane, the scroll bar — sits inside it, so a change there (an ids stream publish that changes the row count or the scroll bar's thumb) lays out from the body, never from the document.
851
+ The List's mobile overlay (a fixed-position `<dialog>`) is rendered directly under the root, outside the body: layout containment would make the body the containing block of its fixed descendants, and the dialog, which stays mounted outside the top layer while it slides out after `close()`, keeps the viewport as its containing block. Paint is not contained (the root clips).
829
852
  - **One measurement, in one unit**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so each view takes its root's border-box block size in layout px from one `ResizeObserver` of the root's own window, observing the root alone (`box: "border-box"`; `useViewBoxHeight`). Every value, the first included, is the delivery's `borderBoxSize[0].blockSize`: the layout effect only starts the observation, and no code reads a size from the DOM.
830
853
  The value is `null` until the first delivery, and the view renders no body until then, so no row is ever drawn at a guessed height (the List also waits for its row slot, below). The platform delivers the first observation in the rendering update after the observation starts, after layout and before paint, and that one delivery is committed at once (`flushSync`), so the view's content is drawn before the same frame paints; later deliveries only set React state, which React renders after the delivery.
831
854
  A delivery reads and writes nothing in the DOM, and a value equal to the last one written is not written again.
@@ -931,8 +954,11 @@ The keys are delegated to each view's **list**: the element that holds the view'
931
954
  An insert or a delete that commits between such a change and the next frame therefore keeps the position: `End`, `PageDown` and a held key are never reverted, focus stays on the key's destination, and a re-measured row shifts no report.
932
955
  The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read), so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled.
933
956
  Only the anchor's scroller holds the full handle (the end-to-end test handle gets the read-only part and the scroller, so its one position change, `revealIndex`, records the anchor like a key; see [Test hooks](#test-hooks)), and `src/client/components/view-scroller.spec.ts` also scans the sources for such a call outside the scroller.
934
- - **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, and the second stage of a compensation the pane had clamped — are reported synchronously, once each change is complete, through its `onScrollAdjust` (since 3.9.0).
935
- Both views pass the anchor's `handleScrollAdjust` there, which records the anchor from the handle at once, so every position change `VirtualScroll` makes is in the anchor before a list change can commit.
957
+ - **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, the second stage of a compensation the pane had clamped, and the pane's clamp of the position to a smaller maximum when the content shrinks under the view (`cause: "clamp"`, 3.13.0) — are reported synchronously, once each change is complete, through its `onScrollAdjust` (since 3.9.0).
958
+ Both views pass the anchor's `handleScrollAdjust` there. For every cause but the clamp it records the anchor from the handle at once, so every position change `VirtualScroll` makes is in the anchor before a list change can commit.
959
+ The clamp is the list change itself, seen from the scroll pane: rows before the window deleted near the end of the list shrink the content, and the pane moves the position to the new maximum inside the commit of that change, before the anchor's restoring layout effect.
960
+ The anchor is kept as recorded and only the position it was recorded at moves by the clamp's `delta`, so the restore does not count the clamp as a scroll of the user and puts the anchored report back at its offset, 42 px above the end or exactly at it alike
961
+ (re-recording there would read the clamped position against the previous list's rows, name another report and leave the view displaced by up to the last row's height).
936
962
  - **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range, once per frame. A list change in the frame before that report first advances the anchor by the distance scrolled since it was recorded and restores from there: it neither undoes the user's scroll nor shifts the content by a row.
937
963
  Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the previous list's row heights from the anchor to the report now at the top, so the walk is bounded by the rows passed since the record (one frame's scroll), whatever the list's length or the position.
938
964
  An insert or a delete before or inside the rendered window therefore leaves the first visible report — and the focus inside the rows — where it was. At the start of the list (scroll position 0) no anchor is kept, so reports that arrive at the top are shown;
@@ -1072,11 +1098,14 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1072
1098
  Selection, focus, colours and borders switch instantly, and with reduced motion nothing moves, script included: the side pane's scroll after the viewer posts a comment is smooth only while the pane's own window does not prefer reduced motion and instant under `prefers-reduced-motion: reduce` (`scrollBehaviorFor` in `src/client/ui/motion.ts` reads the preference on every scroll, because browsers do not apply it to a script's `behavior: "smooth"`).
1073
1099
  A touch or pen flick starts no glide under `prefers-reduced-motion: reduce` either: `VirtualScroll` continues a flick with a scripted `requestAnimationFrame` glide (for up to 10 s with the views' fast-scroll options), so each view reads the preference of its own window (`useReducedMotion`, which follows a change from the next render and is `false` on the server) and passes inertia options whose start threshold no velocity reaches (`inertiaOptionsFor(true)`); the content still follows the finger 1:1 while it drags and stops when it is released.
1074
1100
  Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
1075
- The header's total count and the ids stream's counters ease to a new value over 250 ms only while the window of the element that shows the number does not prefer reduced motion; under `prefers-reduced-motion: reduce` they show the new value in the same render and ask for no animation frame (`useAnimatedNumber`, which reads the preference with `useReducedMotion`).
1101
+ The header's total count eases to a new value over 250 ms only while the window of the element that shows the number does not prefer reduced motion; under `prefers-reduced-motion: reduce` it shows the new value in the same render and asks for no animation frame (`useAnimatedNumber`, which reads the preference with `useReducedMotion`).
1102
+ The ids stream's progress badge shows the published counts as they are, with no easing in either case: publishes arrive at most every 50 ms, so a 250 ms ease would never settle and would only change the text on every frame.
1103
+ Both counters write their text into a contained box (`CounterText` in `src/client/ui/counter-text.tsx`): an invisible sizer that holds the widest text at the current number of digits reserves the box's width (with tabular digits every number of that many digits fits it, and the sizer changes only when the number of digits does),
1104
+ and the shown text sits over the sizer in a box with `contain: size layout style`, a relayout boundary (`COUNTER_BOX_CLASS_NAME`, `COUNTER_SIZER_CLASS_NAME`, `COUNTER_TEXT_CLASS_NAME`). A change of the text lays out that box alone, never the document, and neither the badge's width nor the header moves; only the shown text is read out.
1076
1105
  The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
1077
1106
  (the circle and every element inside it, overriding the visual's inline transitions; the bar's circle wrapper; the arrow buttons; the thumb; the scroll-to-edge buttons and their overlay), so the circle follows a drag without easing and the other parts change state at once.
1078
1107
  While the list scrolls (`data-daily-report-scrolling` on the view's scroll container) the hover lift and its shadow are held still. `src/client/ui/motion-policy.spec.ts` checks the shipped CSS, and scans the sources so that no smooth scroll behaviour, no `animate()` call and no inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts`,
1079
- and so that only the files of an allowlist with a written reason ask for an animation frame: the counters' easing, which must read the reduced-motion preference, and two uses that move nothing (the held key's one move per frame and the attachment grid's track width handed to the next frame), so script-driven interpolation can only be written behind that preference.
1108
+ and so that only the files of an allowlist with a written reason ask for an animation frame: the header total's easing, which must read the reduced-motion preference, and two uses that move nothing (the held key's one move per frame and the attachment grid's track width handed to the next frame), so script-driven interpolation can only be written behind that preference.
1080
1109
  That check is lexical — the stylesheets and the literals of the scripts. What actually moves is checked in the browser by the host app's reduced-motion end-to-end guard: under `prefers-reduced-motion: reduce`, after each step of a pointer and a touch tour of both views (the tap-scroll circle pressed and dragged, a card hovered, a flick, the mobile overlay opened and closed, a thumbnail loaded), no animation or transition of a motion property runs in the view, including what `VirtualScroll` renders.
1081
1110
  - **Spacing**: every spacing the package writes is 4 (within a group), 8 (between parts) or 16 (between items), and every box length it writes sits on the 4 px grid, line boxes included: one-line text such as each List preview line has a 20 px line box (`leading-5`, the preview's lines 8 px apart at a 28 px pitch) and the reading text of the side pane, the DetailList content and the comments a 24 px one (`leading-6`); no line height is proportional to the font size.
1082
1111
  These are the values at the default 16 px root: lengths written in rem (type, line boxes, the card's content, most spacing) scale with the root font size, while the CSS-pixel quantities of the state indicators — G and the lengths built on it — are written in px and keep their values at every root (see **Row gutter G** and **List slot P**).
@@ -1094,7 +1123,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1094
1123
  A host that wants the same whole-pixel strokes on the top and bottom edges at the quarter ratios 1.25, 1.5 and 1.75 puts the edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to the lattice unit `DAILY_REPORT_LAYOUT_LATTICE_PX` (`height: calc-size(auto, round(up, size, 4px))`), since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px).
1095
1124
  The top edge then lies on the lattice, and the bottom edge too when the window height is a multiple of the unit; the bottom-aligned surface then sits exactly G from the view's end.
1096
1125
  In any other window, and at other ratios (browser zoom such as 0.9, 1.1 or 1.33), it sits at least G and less than G + 1 device pixel from the end (**G-symmetric frame** in [View height](#view-height-host-layout)).
1097
- - **Side pane** (List, desktop layout): the panel group fills the view's root, and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
1126
+ - **Side pane** (List, desktop layout): the panel group fills the view's body (which fills the view's root), and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
1098
1127
  The List panel ends G after its scroll bar (`LIST_PANEL_END_GUTTER_CLASS_NAME`, `pr-[8px]`), so a 2G = 16 px channel lies between the scroll bar and the pane, and the resize handle that `@aiquants/resize-panels` centres on the panel boundary (a 10 px hit area around a 6 px grip) covers neither the scroll bar nor the pane's edge.
1099
1128
  Two pointer targets there fall short of WCAG 2.5.8 (a target of at least 24 × 24 px, or one whose 24 px circle meets no other target and no other such circle): the resize handle's 10 px hit area, centred in the 16 px channel 3 px from the scroll bar and 3 px from the pane, and the view's 8 px scroll bar.
1100
1129
  The centres of their 24 px circles are 12 px apart (the handle's on the panel boundary, the bar's 12 px before it), so the circles intersect and the spacing exception does not apply either. Both keep the specified frame — the 2G channel and the 8 px scroll bar of both views — and no wider construction has been decided yet.
@@ -1480,9 +1509,13 @@ Every breaking change and its migration is listed per version in `CHANGELOG.md`,
1480
1509
 
1481
1510
  1. Mutation services publish to Redis Stream (`daily-report:sse-stream`) in sequence: `invalidate -> epoch increment -> SSE publish`.
1482
1511
  2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections). `createDailyReportServer` creates it; `createDailyReportHandlers` takes the reader as `sseReader` typed by the port `DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a host's own reader implements: a plain object with the two methods is accepted, while the class type, which has private fields, would refuse one.
1512
+ The reader reads each live entry once per process and hands every subscriber the same frozen value (`StreamEntry`: `{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by `dailyReportSseMessageSchema` — the validated message (`parsed`), the JSON value as published (`published`, with the server-only fields) and its text (`text`) — or `null` when the field is absent, is not JSON or fails the schema (the last two logged once at `error`; such an entry is written to no connection — fail-closed — while the reader's own read position moves past it).
1513
+ Each connection only filters that value (recipient, source-type visibility, comment redaction) and never changes it, and the frame texts of the variants that differ from the published text — without `recipientRawUserId`, and with the embedded report's comments emptied — are built at most once per message and shared by every connection that writes them, so a live entry costs one parse in proportion to its size plus a filter per connection, not one parse per connection. A host's own reader keeps the same contract: it reads an entry once and hands the same value to every subscriber.
1483
1514
  3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its responses carry only `SSE_RESPONSE_HEADERS` plus the forwarded `Set-Cookie` (no `Connection` or other hop-by-hop header, which HTTP/2 forbids). `recipientRawUserId` is filtered server-side and removed before transmission to prevent internal ID leaks. Catch-up and live entries pass through the same filters (recipient, source-type visibility, comment redaction).
1484
1515
  4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the failure count reset only after 60 s of healthy open (the 45 s stale window plus one heartbeat period), one native browser reconnect (which carries the cursor in the `Last-Event-ID` header), and a stale watch of 45 s (three heartbeat periods).
1485
1516
  The action context correlates optimistic updates with SSE echoes using `clientTempId`, and exposes the connection status as `sseStatus` (`DailyReportSseConnectionStatus`: the reopening status, or `{ kind: "resyncing" }` while a `resync-required` waits for a fresh ids anchor).
1517
+ The action context ignores the SSE echo of its own action for 30 s after sending it, holds a message about a report that is not cached yet for up to 30 s (when the report's cache is written within that time the held messages are handled once, by the last committed render's handler; otherwise they are dropped),
1518
+ and keeps a created report locked against older background answers for 1 s; each is a deadline on the monotonic clock read when it matters, with no timer, so nothing runs after the provider unmounts (`src/client/deferred-callback-scope.spec.ts` lists every timer, frame, listener and observer the client arms, each with its reason).
1486
1519
 
1487
1520
  ### SSE wire contract
1488
1521
 
@@ -1510,7 +1543,9 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1510
1543
  When the tail cannot be read, `ready()` rejects and the failed read is not kept, so the next call reads again: the connection that waited for it ends (`producer-failed`, the row above) and the client reopens it, with its cursor when it has one, so the catch-up covers everything since; and a run that cannot fix its position fails like any other failing run (every subscriber's `onError`). A host with its own SSE handler awaits `ready()` the same way and closes the connection when it rejects.
1511
1544
  The same frame is used as an anchor-only frame for entries the viewer's filters drop (addressed to another user, or a source type the viewer cannot see), so the client's cursor keeps advancing without receiving their content.
1512
1545
  Without it, a tab whose visible traffic is quiet while other users' read / star updates flow would keep an old cursor, and its next reconnect would fall outside the retained window (`resync-required`, then an ids rescan that bypasses the server cache).
1513
- - **Catch-up**: entries after the cursor are read in pages of 1,000 (`xRange` is inclusive, so each page skips its start id) until the stream end; every entry is delivered. When a page does not start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window continues).
1546
+ - **Reads within a byte budget**: Redis bounds an `XREAD` or an `XRANGE` only by a count of entries, so the count of both comes from one byte budget (`src/server/payload-limits.ts`): `SSE_READ_BUDGET_BYTES` (64 MiB) divided by the largest event, `SSE_EVENT_MAX_BYTES` (**Event size** below), rounded down and at least 1 — `SSE_READ_ENTRY_COUNT` = 10.
1547
+ The shared reader's live `XREAD` and every catch-up page (`DAILY_REPORT_SSE_CATCH_UP_BATCH`, exported) read that many entries, so one read brings at most 64 MiB into the process, also when every entry is as large as an event can be. A catch-up reads its pages per connection; a catch-up of 1,000 small events takes 113 round trips (one check of the oldest entry and 112 pages, each new page reading 9 entries after its start).
1548
+ - **Catch-up**: entries after the cursor are read in pages of `DAILY_REPORT_SSE_CATCH_UP_BATCH` (`xRange` is inclusive, so each page skips its start id) until the stream end; every entry is delivered. When a page does not start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window continues).
1514
1549
  - **Ids on the wire only increase**: live entries that arrive during the catch-up are queued, not written. The catch-up stops in front of the first queued entry (the fan-out reader delivers every entry after it in order), then the queue is written in id order and later live entries are written as they arrive. Writing a live entry first would let a disconnect move the client's resume position past catch-up entries it never received, and an older `report-update` would overwrite a newer one on the client.
1515
1550
  - **Heartbeat**: `startSseHeartbeat` writes a jittered `retry:` (2,000–10,000 ms), an immediate named `event: heartbeat` (`data: {"serverTime":<ms>}`, no `id:`), then one every 15 s (`DAILY_REPORT_SSE_HEARTBEAT_MS`). Named events are ignored by `onmessage`, so old bundles are unaffected.
1516
1551
  - **Stream anchor (`streamAnchor`)**: the ids stream carries the newest SSE entry id on its first authoritative line
@@ -1523,6 +1558,7 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1523
1558
  - **Delivery is idempotent**: an anchor can be up to one ids-cache TTL old; replaying from it is safe because `report-*` messages upsert and `comment-add` is matched by comment id.
1524
1559
  - **Event size**: the service writes an event to the stream only when its JSON is at most `SSE_EVENT_MAX_BYTES` (6,356,992 bytes, `src/server/payload-limits.ts`): the action's form cap (1 MiB, **Request values**) at the largest growth `JSON.stringify` can give a text of the form (6 times: a control character, one raw byte in a multipart body, becomes the escape `\u00XX`), plus 64 KiB for the rest of the event.
1525
1560
  So no text an accepted action carries can push its event over the bound. A larger event — only a `report-create`, `report-update` or `report-publish` of a report whose accumulated detail (its body and all its comments) exceeds about 6 MiB — is not written, and the service logs `[SSE] Event too large (<operation>): <bytes> bytes > 6356992; not published` at `warn`; the caches were invalidated before, so viewers read the change on their next load.
1561
+ The persistent stream itself is bounded by a count of entries, not by bytes: at most `streamMaxLen` (10,000 by default, trimmed with `MAXLEN ~`) events of at most `SSE_EVENT_MAX_BYTES` each, about 59.2 GiB in the worst case, while a status update is under 200 bytes and a report event carries one report's detail. One read of it is bounded by the byte budget above; bounding the stream's own bytes needs thin events (the type, the ids and a version, with the detail read again through the ETag'd endpoints), which is a change of the client's protocol.
1526
1562
  - **A resync that gives up is retried**: after `resync-required` the hook asks the ids session for a rescan (`resyncDailyReportIdsStream()`) and does not reconnect until an anchor newer than the one it had arrives. When that rescan gives up (the ids phase stays `complete` with an `error`, which happens after repeated failures during an outage), the hook asks again after a full-jitter backoff from 2 s to 30 s while SSE is enabled, until an anchor arrives. An initial ids scan that ends in `failed` is left to the page's manual retry.
1527
1563
 
1528
1564
  ## API Surface (Summary)
@@ -1545,8 +1581,9 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1545
1581
  - **server** (`@aiquants/daily-report/server`, Node.js):
1546
1582
  - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1547
1583
  - 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`.
1548
- - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
1549
- - Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` / `DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1584
+ - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` (the entries of one catch-up page, derived from the read byte budget) / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
1585
+ - Request values: `parseClientTempId` and its branded result type `DailyReportClientTempId` (the action's echo id in its two canonical forms, the only id the service's write methods take; **The echo id** in [Server wiring](#server-wiring-di)).
1586
+ - Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` / `DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` (an entry as the reader hands it to every subscriber: its id and its message read once, or `null`) / `ExternalReportFields`,
1550
1587
  and for attachments `DailyReportAttachmentsConfig` / `DailyReportAttachmentThumbnailsConfig` (the `attachments` block and its `thumbnails`) / `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1551
1588
 
1552
1589
  MIT
package/dist/client.d.mts CHANGED
@@ -1,9 +1,9 @@
1
1
  import * as react from "react";
2
2
  import { RefObject, ReactNode, Context } from "react";
3
- import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail } from "./types-DkERw8NE.mjs";
3
+ import { D as DailyReportItem, a as DailyReportUser, b as DailyReportSseTerminalEvent, c as DailyReportSseMessage, d as DailyReportDetail } from "./sse-schema-DcOgQr_O.mjs";
4
4
  import { VirtualScrollLocale, VirtualScrollLabels, VirtualScrollHandle } from "@aiquants/virtualscroll";
5
5
  import { UseReopeningEventSourceResult } from "@aiquants/sse/react";
6
- import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment, b as DailyReportSseStreamAnchor } from "./ids-stream-DvB2h1dj.mjs";
6
+ import { U as UIComment, D as DailyReportSseStreamAnchor } from "./ids-stream-CyK2upgD.mjs";
7
7
  import { ShouldRevalidateFunction } from "react-router";
8
8
  import "zod";
9
9
  type DailyReportAttachmentIndicatorProps = {
@@ -41,8 +41,20 @@ type DailyReportListProps = {
41
41
  scrollOffsetRef?: RefObject<number>;
42
42
  };
43
43
  declare const DailyReportList: (props: DailyReportListProps) => react.JSX.Element;
44
+ declare const DAILY_REPORT_ACTION_INTENTS: readonly [
45
+ "create",
46
+ "update",
47
+ "publish",
48
+ "delete",
49
+ "toggleStar",
50
+ "toggleRead",
51
+ "addComment",
52
+ "deleteComment",
53
+ "clearCache"
54
+ ];
55
+ type DailyReportActionIntent = (typeof DAILY_REPORT_ACTION_INTENTS)[number];
44
56
  type DailyReportLocale = VirtualScrollLocale;
45
- type DailyReportOperation = "create" | "update" | "publish" | "delete" | "addComment" | "deleteComment" | "toggleStar" | "toggleRead";
57
+ type DailyReportOperation = Exclude<DailyReportActionIntent, "clearCache">;
46
58
  type DailyReportLabels = VirtualScrollLabels & {
47
59
  readonly id: string;
48
60
  readonly businessDate: string;
package/dist/client.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import * as react from "react";
2
2
  import { RefObject, ReactNode, Context } from "react";
3
- import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail } from "./types-DkERw8NE.js";
3
+ import { D as DailyReportItem, a as DailyReportUser, b as DailyReportSseTerminalEvent, c as DailyReportSseMessage, d as DailyReportDetail } from "./sse-schema-DcOgQr_O.js";
4
4
  import { VirtualScrollLocale, VirtualScrollLabels, VirtualScrollHandle } from "@aiquants/virtualscroll";
5
5
  import { UseReopeningEventSourceResult } from "@aiquants/sse/react";
6
- import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment, b as DailyReportSseStreamAnchor } from "./ids-stream-BR5RSj5u.js";
6
+ import { U as UIComment, D as DailyReportSseStreamAnchor } from "./ids-stream-CtlszCHr.js";
7
7
  import { ShouldRevalidateFunction } from "react-router";
8
8
  import "zod";
9
9
  type DailyReportAttachmentIndicatorProps = {
@@ -41,8 +41,20 @@ type DailyReportListProps = {
41
41
  scrollOffsetRef?: RefObject<number>;
42
42
  };
43
43
  declare const DailyReportList: (props: DailyReportListProps) => react.JSX.Element;
44
+ declare const DAILY_REPORT_ACTION_INTENTS: readonly [
45
+ "create",
46
+ "update",
47
+ "publish",
48
+ "delete",
49
+ "toggleStar",
50
+ "toggleRead",
51
+ "addComment",
52
+ "deleteComment",
53
+ "clearCache"
54
+ ];
55
+ type DailyReportActionIntent = (typeof DAILY_REPORT_ACTION_INTENTS)[number];
44
56
  type DailyReportLocale = VirtualScrollLocale;
45
- type DailyReportOperation = "create" | "update" | "publish" | "delete" | "addComment" | "deleteComment" | "toggleStar" | "toggleRead";
57
+ type DailyReportOperation = Exclude<DailyReportActionIntent, "clearCache">;
46
58
  type DailyReportLabels = VirtualScrollLabels & {
47
59
  readonly id: string;
48
60
  readonly businessDate: string;