@aiquants/daily-report 0.30.0 → 0.32.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,135 @@
2
2
 
3
3
  All notable changes to `@aiquants/daily-report` are documented here.
4
4
 
5
+ ## 0.32.0 (2026-10-07)
6
+
7
+ 0.31.0 の続き: 添付の設定を 1 つの任意の入れ子のブロック (`attachments`) にまとめること、action の本文の上限と、送られた値 (件名・本文・コメント・真偽値) と `forceRefresh` を 1 つの厳格な解釈で読むこと、SSE のイベント 1 件の上限、ids ストリームのスナップショットトークンを取得ごとに 1 回だけ作ることと publish が一覧を写さないこと、アクションのプロバイダーの操作の値を描画をまたいで同じに保つこと、削除の印の付いた行の外し漏れ、件数の補間が動きを減らす設定に従うこと。どれも README の該当の節が今の契約を記す。
8
+
9
+ ### Fixed
10
+
11
+ - **action の本文は 1 MiB まで**: action はフォームの本文を `ACTION_FORM_MAX_BYTES` (1,048,576 バイト) までしか読まない。宣言した長さ (`Content-Length`) がそれを超える本文は読まずに、宣言の無い本文 (チャンク転送) と長さを偽る本文は読んだバイトを数えて上限を超えたところで読むのをやめて、413 (`{"error":"Form too large"}`) で答える。どのサービスも呼ばず、error の行も書かない。
12
+ 記録は 60 秒の窓ごとに warn の 2 行まで (既定のロガーの接頭辞は `[DailyReportAction]`): 窓の最初の拒否の `413 action reason=form_too_large measured=<declared|counted> limit=1048576` と、窓の終わりに数えた拒否があれば `form_too_large_suppressed count=<N> since=<窓の始まりの ISO 8601> measured=declared:<n>,counted:<n>`。
13
+ SSE のストリームへ書くイベント 1 件の JSON は `SSE_EVENT_MAX_BYTES` (6,356,992 バイト: フォームの上限を JSON の最大の伸びの 6 倍に伸ばし、包みの 64 KiB を足した大きさ) までで、それより大きいイベントは書かずに warn の `[SSE] Event too large (<操作>): <バイト数> bytes > 6356992; not published` を残す。
14
+ 受け付けた action の文字がこの上限を超えるイベントを作ることは無く、超えうるのは日報の詳細を丸ごと運ぶイベント (作成・更新・公開) のうち、積み上がった詳細 (本文とコメントの全件) が約 6 MiB を超えた日報だけで、キャッシュの無効化は書く前に済むので、閲覧者は次の読み込みで最新を読む。
15
+ 以前は本文に上限が無く (前段のリバースプロキシの上限まで)、8 MiB のコメントも 200 で格納され、永続の SSE のストリームへ書かれて、接続中のすべての閲覧者と再接続の追いつきへ届いた。
16
+ - **action の送られた値も 1 つの厳格な解釈で読む**: フォームを読むのは `src/server/action-form.ts` だけで、操作の実行はフォームではなく解釈し終えた指示を受け取る。
17
+ - 更新 (`update`) は送られた欄だけを変える: 送られない欄は格納した値のまま、空文字はその欄を消して NULL で格納する。件名は注入した件名の列が宣言した長さ (パッケージのスキーマでは `nvarchar(200)`: UTF-16 の符号単位で 200) までで、超えれば 400 (`Invalid title`)。
18
+ - スターと既読の切り替えの `isStarred` / `isRead` は `true` か `false` だけで、ほかの値 (`TRUE`・`1`・`yes`・空文字) は 400 (`Invalid isStarred` / `Invalid isRead`)、無ければ 400 (`isStarred required` / `isRead required`)。コメントの追加の `content` は空でない文字 (無いか空なら `Content required`)。
19
+ - フォームのどこかにファイルの部分があれば 400 (`Invalid form`)。フォームは作りからテキストだけになる。
20
+ - API の `business-date`・`report`・`ids-stream` の `forceRefresh` は、無ければ偽で、`true` と `false` だけを受け、ほかの値は 400 (`{"error":{"message":"Invalid forceRefresh"}}`。`ids-stream` は HEAD の短絡より前に答える)。
21
+ - action のどの 400 も、内部ユーザーの解決と可視集合の解決より前に答える (封鎖された `clearCache` の 400 も)。
22
+ 以前は件名・本文・コメントを `form.get()` のまま読んだので、送られない件名は NULL で格納され、ファイルの部分は文字列の型のままサービスへ届いた。`isStarred` / `isRead` と `forceRefresh` は `true` 以外のどの値も偽と読み (`TRUE`・`1`・`yes`・空文字がスターを外し、未読に戻した)、201 文字の件名はドライバーの切り詰めの失敗で 500 になり、`isStarred required` と `Content required` は利用者と可視集合の問い合わせの後に答えた。
23
+ - **削除の印の付いた行は、ストリームの正味の変化が値の変化でも見せない**: 行の導き (`deriveReportRows`) は、見せている行に削除の印があれば、ストリームの変化がその日報の値の変化 (同じ合体窓の削除と中継で正味が値の変化になったもの、印が付いた後に導いた値の変化) でも行を外す。
24
+ 以前は印を見ると値の変化を当てはめずに戻ったので、一覧全体から導けば隠れる行が見えたまま残り、印の後の値の変化では前の値の行が残って、後から届いた削除がその行を見つけられず、行は二度と外れなかった。
25
+ - **件数の補間は動きを減らす設定に従う**: ヘッダーの総件数と ids ストリームの走査中の件数は、件数を描く要素のウィンドウが `prefers-reduced-motion: reduce` のとき、値が変わったその描画で目標の値を描き、アニメーションのフレームを 1 つも求めない (`useAnimatedNumber`)。以前は設定を読まずに 250 ms かけて補間した (README の「動きを減らす設定では、スクリプトの動きも含めて何も動かない」に反していた)。
26
+
27
+ ### Changed
28
+
29
+ - **アクションのプロバイダーの操作は 1 つの同じ値**: `DailyReportActionProvider` は操作 (関数・楽観的な状態の台帳・表示中ユーザー) を 1 つの値として 1 回だけ作り、その同一性は表示中ユーザーが替わるまで変わらない。関数はどれも最後に確定した描画の実装を呼び (実装はレイアウトの副作用で入れ替え、描画中に ref を書かない)、状態 (行・台帳の版・SSE の有効と接続の状態) は別のコンテキストで配る。
30
+ パッケージの行・List のカード・DetailList の行と本文カード・側面ペインの中身・編集のフォームは操作だけを読むので、ストリームの publish・SSE の知らせ・接続の状態の変化で描き直さない。台帳の版の変化で描き直すのは日報のコメントの部品 (`ReportComments`) だけ。
31
+ 公開のフック `useDailyReportActionContext` は今までどおり操作と状態を 1 つの値で返し、状態が変わらない描画では同じ値を返す (状態が変わるたびに呼び出し側は描き直す)。
32
+ 以前はプロバイダーが描画のたびに新しいオブジェクトを配り、操作の関数もほとんどが描画ごとの閉包だったので、ストリームの publish のたびに、描かれているすべての行・カード・側面ペインの中身・編集のフォームが memo を越えて描き直された。
33
+ - **ids ストリームのスナップショットトークンは取得ごとに 1 回**: サービスは ID 一覧の全量を取得したとき (キャッシュのフェッチャーの中、整列の治癒の後) に 1 回だけトークンを作り、一覧と同じ包みに持たせる (`getDailyReportIdsByExternalId` の戻り値 `{ items, streamAnchor, snapshot }`。行の無い包みは空の一覧のトークン)。ids ストリームの配信はそれを読む。
34
+ 以前は要求ごとに全件をたどって作り直したので、228,222 件では要求ごとにイベントループを p50 7 ms (静かなホスト)、負荷の下で 11〜26 ms 止めた。
35
+ - **ids ストリームの publish は一覧を写さない**: クライアントの publish が配るスナップショットは一覧そのものを持たず、publish の仕事は変化の大きさに比例する。一覧全体が要る購読者は読むときに作る (`DailyReportIdsStreamClient.readItems()`: 最後の publish の時点の一覧で、合体窓の中のまだ publish していない変化を戻す)。
36
+ プロバイダーは一覧全体から導き直すときだけ読み、ページは中身を見せるかを `loadedCount > 0` で決める。以前は publish ごとに一覧全体を写し (`Array.from`)、228,222 件では publish ごとに p50 1.29 ms、最初の訪問の 59 回の publish で約 56 ms を、publish の描画と同じタスクで使った。
37
+ - **開発者向け**: 添付の設定の解決は `src/server/attachment-delivery/settings.ts` の `resolveAttachmentDelivery` 1 つ (ブロックの検証と既定値の解決) と、平たいキーの拒否 `rejectFlatAttachmentKeys` (`FLAT_ATTACHMENT_KEYS` の表) にある。添付の経路は `service.attachmentDelivery` の解決済みの値だけを読み、ブロックが無ければ配信の状態を作らず、サムネイルが無ければサムネイルのバケット・ゲート・キャッシュを作らない。
38
+ action のフォームの読み取りと解釈は `src/server/action-form.ts` (`readActionCommand`) にあり、ハンドラーの操作の実行 (`runActionCommand`) はフォームを受け取らない。2 つの上限は `src/server/payload-limits.ts` (`ACTION_FORM_MAX_BYTES`・`SSE_EVENT_MAX_BYTES`)。`src/server/wire-params.ts` の `formFieldText` は無く、`parseWireBoolean`・`parseForceRefreshParam`・`readFormTextFields` が加わった。件名と本文の空文字を NULL で格納する規則はサービスの `storedText` 1 か所。
39
+ プロバイダーの描画中の行の導きは `useDerivedReportRows` (`src/client/contexts/daily-report-rows-derivation.ts`) 1 つで、本物のクライアントの publish ごとに触る行の数を試験が数える。行の導きは、乱数の操作列の性質の試験 (8 つの種 × 2 通り × 400 の操作列) で一覧全体から導いた行と比べる。両ビューの描画の回数の試験は本物のプロバイダーを描く (アクションのフックを代役にしない)。
40
+ アニメーションのフレームを求めてよいのは理由を添えた許可の一覧のファイルだけ (`src/client/ui/motion-policy.spec.ts`: 件数の補間は動きを減らす設定を読むこと、キーの長押しのまとめと添付の格子の列幅の受け渡しは動きではない用途)。ビューの Tab の順を全部たどる重い jsdom の試験の時間の上限は 1 つの定数 `HEAVY_JSDOM_TEST_TIMEOUT_MS` (60 秒。`src/client/test-helpers/heavy-jsdom-timeout.ts`) で、公開の verify が負荷の高いホストで Vitest の既定の 5 秒を越えて落ちない。
41
+ 配置の格子の画素密度の一覧 (`LAYOUT_LATTICE_DEVICE_PIXEL_RATIOS`) は試験用の部品 (`src/client/test-helpers/layout-lattice-ratios.ts`) へ移り、`dist/client.mjs` に入らない。使われていない `upsertInIdsStreamOrder` は無い。クライアントは ids ストリームの次のカーソルの営業日を、サーバーと同じ `isCanonicalBusinessDateKey` で確かめる (暦に無い日を受けない)。ページはホストの `onSessionExpired` を描画中ではなくレイアウトの副作用で写す。
42
+
43
+ ### Added
44
+
45
+ - server の型 `DailyReportAttachmentsConfig` と `DailyReportAttachmentThumbnailsConfig` (添付の設定のブロックとそのサムネイル。下の Breaking)。型だけの公開なので、バンドルは増えない。
46
+ - サービスの `titleMaxLength` (件名の上限: 注入した hub テーブルの件名の列が宣言した長さ) と、`getDailyReportIdsByExternalId` の戻り値の `snapshot` (上の Changed)。
47
+ - client の `DailyReportIdsStreamClient` の `readItems()` (上の Changed)。
48
+
49
+ ### Breaking
50
+
51
+ - 添付の設定は 1 つの任意のブロック `attachments` (`DailyReportAttachmentsConfig`) にまとまった: サービスの設定 (と、それを受け継ぐ `createDailyReportServer` の設定) の `attachments` は、ID コーデック (`idCodec`) と読み取りポート (`read`) を必ず持ち、大きさの上限と原本の経路の調整値、任意の `thumbnails` (`DailyReportAttachmentThumbnailsConfig`: 描画ポート `renderer` と調整値) を持つ。ブロックを省けば添付機能の無い配備。
52
+ 効かない組み合わせ (読み取りの無いコーデック、読み取りの無い描画、ポートの無い調整値) は型で書けず、型の外 (JS のホスト・読み込んだ設定) から来た誤りは、入れ子のパスを名乗る `TypeError` (欠けたポート: `[daily-report] attachments.read must be injected when attachments is given`) か `RangeError` (不正な値: `[daily-report] attachments.thumbnails.cacheBytes must be an integer >= 0; got -1`) で生成を止める。
53
+ ブロックの外の平たい 9 つのキーは受けない: どれか 1 つでも持つ設定は (値が `undefined` でも)、`createDailyReportService`・`createDailyReportHandlers`・`createDailyReportServer` の生成を、値を置くパスとキーを名乗る `TypeError` で止める (`[daily-report] attachments.concurrency must be given in place of the flat key attachmentConcurrency`)。ポートを渡したのに添付が一覧に出ない・調整値が効かない、といった黙った縮退にはならない。ハンドラーの設定 (`DailyReportHandlersConfig`) は添付のキーを持たない。
54
+ サービスの `attachmentDelivery` (`DailyReportAttachmentDelivery`) は、ブロックの既定値を解決して凍結した `{ idCodec, read, maxBytes, rateLimitPerMinute, concurrency, thumbnails }` (`thumbnails` はサムネイルの無い配備で `null`、ほかは `{ renderer, rateLimitPerMinute, concurrency, cacheBytes }`) で、ブロックの無い配備で `null` (以前は `{ idCodec, readAttachment, thumbnailRenderer, maxBytes }` で、注入しないポートが `undefined`)。
55
+ Migration: 平たいキーをブロックの中のパスへ移す。
56
+
57
+ | 平たいキー (0.31.0 まで) | 以前の持ち主 | ブロックの中のパス |
58
+ | --- | --- | --- |
59
+ | `attachmentIdCodec` | サービス | `attachments.idCodec` |
60
+ | `readAttachment` | サービス | `attachments.read` |
61
+ | `attachmentMaxBytes` | サービス | `attachments.maxBytes` |
62
+ | `attachmentRateLimitPerMinute` | ハンドラー (ファサードが転送) | `attachments.rateLimitPerMinute` |
63
+ | `attachmentConcurrency` | ハンドラー (ファサードが転送) | `attachments.concurrency` |
64
+ | `attachmentThumbnailRenderer` | サービス | `attachments.thumbnails.renderer` |
65
+ | `attachmentThumbnailRateLimitPerMinute` | ハンドラー (ファサードが転送) | `attachments.thumbnails.rateLimitPerMinute` |
66
+ | `attachmentThumbnailConcurrency` | ハンドラー (ファサードが転送) | `attachments.thumbnails.concurrency` |
67
+ | `attachmentThumbnailCacheBytes` | ハンドラー (ファサードが転送) | `attachments.thumbnails.cacheBytes` |
68
+
69
+ 原本だけの配備は `attachments: { idCodec, read }` (上限と原本の調整値は任意)、サムネイルも出す配備はそれに `thumbnails: { renderer }` (調整値は任意) を足す。既定値と下限は変わらない。`createDailyReportHandlers` へ手書きのサービスの代役を渡すホストの試験は、代役の `attachmentDelivery` を新しい形か `null` にする。
70
+ - action の更新の契約: 送らなかった欄は格納した値のまま、空文字は欄を消す (NULL で格納。上の Fixed)。以前は送らなかった欄も NULL で格納した。パッケージのクライアントはいつも件名と本文の両方を送り、client の `updateReport(reportHubId, data, businessDate)` (`useDailyReportActionContext` の操作) の `data` は `{ title: string; content: string }` で両方が必須 (以前は `{ title?: string; content?: string }` で、空にした欄を送らなかった)。
71
+ Migration: 欄を消すときは空文字を送る (欄を省くと格納した値のまま)。`updateReport` には件名と本文の両方を、打たれたとおりに渡す。
72
+ - action と API の値の解釈が厳しくなった (上の Fixed): `isStarred` / `isRead` と `forceRefresh` は `true` と `false` だけ、件名は件名の列の長さまで、フォームのファイルの部分は 400、本文は 1 MiB まで (413)。
73
+ Migration: 真偽値は `true` / `false` で送る。`forceRefresh` は省くか `true` / `false` で送る。action のフォームにファイルを入れない。
74
+ - client の `useDailyReportIdsStream` の状態 (`DailyReportIdsStreamState`) は `items` を持たない (上の Changed)。
75
+ Migration: 件数は `loadedCount`、届いた行があるかは `loadedCount > 0` で読む。見せている行はプロバイダーの `items` (`useDailyReportActionContext().items`) で読み、自分で作った `DailyReportIdsStreamClient` の一覧は `readItems()` で読む。
76
+ - server のサービスは、注入した hub テーブルの件名の列が長さを宣言していなければ、生成時に `RangeError` (`[daily-report] tables.hub.title must be a character column with a declared length; got <値>`) を投げる。`createDailyReportHandlers` は、サービスの `titleMaxLength` が 1 以上の整数でなければ生成時に `RangeError` (`[daily-report] service.titleMaxLength must be an integer >= 1; got <値>`) を投げる。ids ストリームの配信はサービスの `getDailyReportIdsByExternalId` が返す `snapshot` を読む。
77
+ Migration: 件名の列は長さを宣言する (`defineDailyReportSchema` の列は宣言済み)。`createDailyReportHandlers` へ渡す手書きのサービスの代役は `titleMaxLength` を持ち、`getDailyReportIdsByExternalId` から `snapshot` を返す。
78
+ - ピアの `@aiquants/virtualscroll` の下限を 3.11.5 へ上げた (`peer-floors.json`)。両ビューが 3.11.5 の変更に頼る所は無いが、この版の試験はすべて 3.11.5 の上で通したので、公開する範囲 (`^3.11.5`) と文書の下限をそれに合わせた。
79
+ Migration: `@aiquants/virtualscroll` を 3.11.5 以降へ上げる。
80
+
81
+ ## 0.31.0 (2026-10-06)
82
+
83
+ 0.30.0 の続き: 編集中の入力が描画の窓の外や選択の移動を越えて残ること、要求の値 (営業日・ID・操作の時刻・フォーム) の厳格な解釈と作成した日報の営業日を UTC の 0 時で格納すること、原本の `Content-Length` を渡す本文から決めること、一覧の行を ids ストリームの変化だけで導くことと SSE の知らせの合体、合体窓を単調な時計で測ること、行の集合の大きさ (`aria-setsize`) と見出しの総件数を申告した総数で決めること、ホバーの浮き上がりの定義域を 1 から 3 までの 1/4 の倍数のすべてへ広げること、要求の隔離の結線を型で決めること。どれも README の該当の節が今の契約を記す。
84
+
85
+ ### Fixed
86
+
87
+ - **編集中の入力は、行が描画の窓を外れても選択を移しても残る**: 編集の状態 (開いているか・自分で開いたか) と入力中の件名と本文は、行やペインの状態ではなく、アクションのプロバイダーが 1 つ持つ編集の置き場 (日報ごと。`src/client/contexts/daily-report-draft-store.ts`) にある。DetailList の行が仮想スクロールの描画の窓を外れて戻っても (ホイール・ドラッグ・スクロールバー・タップスクロール・別の行からのキー・ホストの選択)、List の側面ペインとモバイルのオーバーレイが別の日報を表示して戻っても、編集モードと入力中の値のまま描く。入力で描き直すのはフォームだけ (行・ペインの中身・一覧は描き直さない)。
88
+ 同じ日報の SSE の更新は入力中の値を上書きせず、保存の成功は値を残し、キャンセルは値を捨てる。公開の成功と日報の削除 (利用者の削除・SSE の `report-delete`・読み込めない日報の除去) は日報の項目を捨て、サーバーが拒んだ削除は日報と一緒に項目も残す。置き場の寿命はプロバイダーと同じで、ページを離れる・再読み込み・利用者の切り替えで空になる。
89
+ 以前は編集中かどうかを行の状態に、入力をフォームの状態に持ったので、行が窓を外れるか選択が移ると、入力中の件名と本文が消えた (下書きは保存済みの値で開き直し、公開済みの日報は表示に戻った)。
90
+ - **作成した日報の営業日は、どの時間帯のサーバーでもその日**: 作成の営業日はその日の UTC の 0 時として格納し、日報の `date` に正準のキー (`YYYY-MM-DD`) を返す。以前は送られた文字列を `new Date()` の自由な解釈へ渡したので、`2026/10/06` をサーバーのローカルの 0 時と読み、UTC より東の時間帯では前日 (`2026-10-05T15:00:00.000Z`) で格納した。`2026-02-30` は 2026-03-02、`1` は 2001-01-01 の日報になった。
91
+ - **要求の値は 1 つの厳格な解釈で読み、誤りはどのクエリよりも前に 400**: API の経路と action は、営業日を `YYYY-MM-DD` か `YYYY/MM/DD` (区切りは 2 か所とも同じ) の 0001-01-01 から 9999-12-31 までの暦にある日だけ (`parseBusinessDateKey`)、日報とコメントの ID を正の安全な整数の正準の 10 進の形だけ (`parseCanonicalPositiveId`。先頭に 0 を持たない数字で 1 から 2^53 − 1)、操作の時刻 (`operationTimestamp`) を 0 以上の安全な整数の正準の 10 進の形だけで読む。
92
+ `business-date` は暦にある日でも前後の空白・時刻付きの ISO 8601・日付の文章を 400 (`Invalid business date`) で答え、`report` は `12abc`・`1e3`・`1.9`・` 7 ` を 400 (`Invalid reportHubId`) で答える。action は送られた営業日をどの操作でも検証し (`Invalid businessDate`)、ID と時刻の誤りを `Invalid reportHubId`・`Invalid commentId`・`Invalid operationTimestamp` で答え、フォームとして読めない本文を error の行を残さない 400 (`Invalid form`) で答える。テキストを求める欄のファイルの部分は無い欄ではなく不正な値。action はフォームを丸ごと解釈してから内部ユーザー・可視集合・操作を解決する。
93
+ 以前は `Number.parseInt` と `Number()` の寛容な解釈で読んだので、`report?reportHubId=12abc` が日報 12 を返し、action の `reportHubId=1e3` は日報 1000 に、`-3` はサービスまで届き、2^53 + 1 は隣の整数に丸まって別の日報を名指しえた。営業日は自由な日付の解釈を通り (`business-date` は `Tue Oct 06 2026` も受けた)、読めない本文は error の行 2 本の 500 になった。
94
+ - **原本の `Content-Length` は渡す本文から決める**: GET の `Content-Length` は渡すバイト列の長さで、読み取りポートが申告した `size` がそれと違えばポートの契約違反として 500 (`reason=port_contract code=size_mismatch`、error) で答え、本文を送らず実体の有無も記録しない。HEAD の `Content-Length` はポートが申告した `size` で、申告が無ければ付けない (RFC 9110 §8.6)。
95
+ 以前は GET も申告した `size` を優先したので、大きさを別の呼び出しで測るポートの申告が本文と食い違うと、短い本文は読み手を待たせ続け、長い本文は切れた。申告の無い HEAD は本文のある原本に `Content-Length: 0` を名乗った。
96
+ - **行の集合の大きさは申告した総数**: 両ビューの行の `aria-setsize`、List のカードの読み上げる位置 (全 N 件中 M 件目) と見出しの総件数は、ビューの行が属する集合の大きさを読む (`DailyReportResolvedContent` が 1 か所で決める)。ids ストリームが一覧を届けている間はサーバーが最初の行で申告した総数 (見せている行の数より小さくはしない)、申告の前は分からない (`aria-setsize="-1"`、見出しは `labels.totalCountLoading`、位置は新しいキー `listRowPositionInUnknownTotal` の「M 件目」)、完走した後は見せている行の数。
97
+ 以前は届いた行の数だったので、228,222 件の一覧の走査の途中で「全 4,200 件中 3 件目」と読み、チャンクが届くたびに集合の大きさと見出しの総件数が変わった。
98
+ - **ホバーの浮き上がりは 1 から 3 までの 1/4 の倍数のどの画素密度でも整数の装置の画素**: 浮き上がり L = ⌊2·dpr⌋ / dpr の上書きを、2·dpr が整数でない 1.25・1.75 に加えて 2.25 (16/9 px、4 装置画素) と 2.75 (20/11 px、5 装置画素) にも置く。以前の 2.25 と 2.75 では 2 px が 4.5 と 5.5 装置画素になり、ホバーした面が装置の画素の半分に乗った (1 px の枠線・輪・アウトラインが隣の画素の行へにじんだ)。
99
+ - **ids ストリームの publish の合体窓は単調な時計で測る**: 合体窓の経過は `performance.now()` で測る (完走の時刻 `completedAt` は時刻なので壁時計のまま)。以前は壁時計で測ったので、時刻合わせで時計が戻ると、戻った分だけ次の publish が遅れた (60 秒戻れば 60 秒)。
100
+
101
+ ### Changed
102
+
103
+ - **一覧の行は ids ストリームの変化だけを当てはめて導く**: ストリームのアイテムの publish は、一覧と一緒にその版 (`itemsRevision`。どのセッションの間でも重ならない) と、publish ごとの正味の変化 (入った・値が変わった・離れた日報) を版の鎖で運ぶ (`itemsChanges`。最新の 32 件)。アクションのプロバイダーは行を正準の順 (営業日の降順 → id の降順、営業日の無い日報は末尾) に保ち、最後に当てはめた版からの変化だけを、1 回の描画が何回の publish を受けても 1 つに畳んで当てはめる。
104
+ 前の末尾の後ろへ入る行は 1 回の比較とネイティブの連結で済み (Δ 件を運ぶ publish が触る行は、ストリームのクライアントを含めて Δ + 1 まで)、順を外れて届いた行は二分探索で差し込み、値の変わった行はその場で置き換え (⌈log2 n⌉)、一覧全体を 1 回たどるのは削除だけ。一覧全体から導き直すのは、最初の描画・セッションの置き換わり・32 回より多い publish の遅れのときだけ。
105
+ 以前は publish ごとに一覧を作り直したので、228,222 件の走査では publish のたびに届いた行の数に比例して触る行が増えた (4,000 件のチャンクの 2 回目で 8,000 行、以後も増え続けた)。
106
+ - **SSE の作成・公開・削除の知らせもチャンクと同じ合体窓を通る**: プロバイダーが常駐のストリームへ中継する変化 (SSE の `report-create`・`report-publish`・`report-delete` と、利用者自身の作成と削除) は、チャンクと同じ 50 ms の窓で 1 回の publish にまとまる (窓の外の最初の変化はすぐ、窓の中の変化は窓の終わりに 1 回、走査の完走は保留中の変化を待たずに運ぶ)。SSE の知らせが連なって届いても一覧は 1 回で変わり、行は最初の知らせから 50 ms 以内に現れる (以前は知らせごとにすぐ)。利用者自身の作成と削除は今までどおりすぐ見える (プロバイダーがその行を自分で足し、外す)。
107
+ - **自動で開く編集は表示中ユーザーの下書きだけ、キャンセルした下書きは開き直さない**: List の側面ペインも DetailList の行と同じ規則で、表示中ユーザーの下書きだけを編集モードで開く (以前の側面ペインはどの下書きも開いた)。キャンセルした下書きは、選択を移して戻っても、行が窓を外れて戻っても表示のまま (置き場が空になるまで)。
108
+ - **開発者向け**: 要求の隔離の結線を型で決める。経路の方針 (`RequestIsolationPolicy`) は拒否の記録の名前 (`route`) も持ち、包みは方針とハンドラーだけを受け取る (`isolated(policy, handler)`)。共通の土台 `NOT_NAVIGABLE` は `route` を持たないので、名前を書かない方針は型検査で落ちる。API の経路のエンドポイントの名前と答える本体の種類は `src/server/api-endpoint.ts` の表 (`API_ENDPOINT_BODY_KINDS`) にだけ書き、メソッド (`API_ENDPOINT_METHODS`) と API の経路のデータの要求の断り (`API_LOADER_ISOLATION_POLICY`) はそこから導く。action の方針は `ACTION_ISOLATION_POLICY` (記録の名前は `action`)。答えと記録の行は変わらない。
109
+ 要求の値の解釈は `src/shared/business-date.ts` (`parseBusinessDateKey`・`isCanonicalBusinessDateKey`) と `src/server/wire-params.ts` (`parseCanonicalPositiveId`・`parseCanonicalNonNegativeInteger`・`isPositiveSafeInteger`・`formFieldText`) に 1 つずつある。ids のストリームの再開カーソルと添付のトークンの ID も同じ解釈で読む (カーソルの営業日は正準のキーだけ)。
110
+
111
+ ### Added
112
+
113
+ - client のラベルのキー `listRowPositionInUnknownTotal(position)`: 集合の大きさが分からない間の List のカードの位置 (en "Item 3"、ja 「3 件目」)。固有のキーは 85 (全体は 96) で、上書きは関数でなければならない。
114
+ - `DailyReportList` と `DailyReportDetailList` の prop `rowSetSize`: 行が属する集合の大きさ (分からない間は −1)。省けば渡した行が集合そのもの。`VirtualScroll` の行の数はいつも渡した行の数。
115
+ - `DailyReportIdsStreamClient` の `has(reportHubId)` (常駐の一覧がまだ publish していない変化を含めてその日報を持つか) と、合体窓の単調な時計を差し替える `now` オプション (既定は `performance.now()`)。`useDailyReportIdsStream` の状態の `itemsRevision` と `itemsChanges` (上の Changed)。
116
+
117
+ ### Breaking
118
+
119
+ - shared の `normalizeBusinessDateKey` は文字列を厳格に読む: `YYYY-MM-DD` か `YYYY/MM/DD` で 0001-01-01 から 9999-12-31 までの暦にある日だけをキーにし、それ以外 (前後の空白・時刻付きの ISO 8601・日付の文章・暦に無い日・0 年) は `null`。`Date` は今までどおりローカルの暦日。以前は空白を削り、形の合う `YYYY-MM-DD` を暦を見ずに受け (`2026-02-30`)、ほかの文字列は `new Date()` の自由な解釈で読んだ。
120
+ Migration: 時刻付きの文字列は `Date` にしてから渡すか、日付の部分 (`YYYY-MM-DD`) を切り出して渡す。
121
+ - client の `DailyReportActionProvider` は `initialItems` を受け取らない: 行はモジュール常駐の ids ストリームのセッションから自分で導く。
122
+ Migration: `initialItems` を外し、プロバイダーを描く前にセッションを確立する (経路の `createDailyReportClientLoader`、またはマウントのときの `ensureDailyReportIdsStreamSession({ apiBasePath, userKey })`。`DailyReportPage` は自分で確立する)。利用者ごとにプロバイダーをマウントし直す (`key={userId}`)。
123
+ - API の経路と action は、寛容な解釈なら読めた値を 400 で答える (上の Fixed): 営業日は `YYYY-MM-DD` か `YYYY/MM/DD` の暦にある日だけ (action は作成以外の操作でも、送られた営業日を検証する)、ID は正準の 10 進の正の整数だけ、`operationTimestamp` は 0 以上の整数だけ。action が送り返す `operationTimestamp` は、送られなければ `null` (以前は 0)。
124
+ Migration: 営業日は `YYYY-MM-DD` で送る (時刻付きの値は日付の部分を切り出す)。ID と時刻は 10 進の整数の文字列で送り、時刻が無ければ欄ごと省く。
125
+ - server の `service.createDailyReport(userId, businessDate, clientTempId)` は正準のキー (`YYYY-MM-DD`) だけを受け、それ以外にはどのクエリよりも前に `RangeError` (`[daily-report] createDailyReport: businessDate must be a business-date key in the canonical form YYYY-MM-DD naming a day from 0001-01-01 to 9999-12-31; got "<値>"`) を投げる。格納するのはその日の UTC の 0 時。
126
+ Migration: 斜線の形は公開の `normalizeBusinessDateKey` (文字列は同じ厳格な解釈で読む) で正準のキーにしてから渡し、時刻付きの値は日付の部分を切り出してから渡す (action は自分で解釈してから渡す)。
127
+ - client の型 `DailyReportLabels` に必須のキー `listRowPositionInUnknownTotal(position)` が増えた (上の Added)。
128
+ Migration: `DailyReportLabels` を丸ごと組むホスト (試験の代役など) はこのキーも書く。上書き (`DailyReportLabelOverrides`) は部分のままでよい。
129
+ - 読み取りポートの `size` の契約: GET の読み取りで申告するなら `bytes.byteLength` と等しくなければならず、違えば 500 (上の Fixed)。HEAD で申告しなければ、HEAD の応答は `Content-Length` を持たない (以前は `0`)。
130
+ Migration: GET では `size` を省くか `bytes.byteLength` を返し、HEAD では実体の大きさを返す (README の Read port の例はどちらもそうしている)。
131
+ - ピアの `@aiquants/virtualscroll` の下限を 3.11.4 へ上げた (`peer-floors.json`)。両ビューが 3.11.2〜3.11.4 の変更に頼る所は無いが、この版の試験はすべて 3.11.4 の上で通したので、公開する範囲 (`^3.11.4`) と文書の下限をそれに合わせた。
132
+ Migration: `@aiquants/virtualscroll` を 3.11.4 以降へ上げる。
133
+
5
134
  ## 0.30.0 (2026-10-06)
6
135
 
7
136
  0.29.0 の続き: 本体をストリームで返す 2 つの経路のフレームワークのデータの要求をパッケージが断ること (ホストの経路は薄いマウントに戻る)、原本の本文の 1 切れずつの受け渡しと無通信の締め切りと閲覧者ごとのスロットの上限、間隔を単調な時計で測ること、読み取りの予算切れの 504、暦に無い営業日の 400、行とカードのフォーカスのアウトラインをキーボードのフォーカスの属性の下だけで描くこと、整数の装置の画素のホバーの浮き上がり、List のカードの位置の読み上げ、キーの移動の `tabindex` をフォーカスの受け渡しで書くこと、ハンドラーの設定と戻り値の型の公開。どれも README の該当の節が今の契約を記す。