@aiquants/daily-report 0.6.2 → 0.8.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.
Files changed (67) hide show
  1. package/README.md +13 -6
  2. package/dist/client.d.mts +443 -23
  3. package/dist/client.d.ts +443 -23
  4. package/dist/client.js +5 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -4
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +77 -3
  9. package/dist/index.d.ts +77 -3
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/server.d.mts +436 -6
  15. package/dist/server.d.ts +436 -6
  16. package/dist/server.js +8 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +8 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
  21. package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
  24. package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
  25. package/package.json +3 -3
  26. package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
  27. package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
  28. package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
  29. package/src/client/components/daily-report-attachment-list.tsx +112 -0
  30. package/src/client/components/daily-report-detail-list.tsx +14 -7
  31. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  32. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  33. package/src/client/components/daily-report-list.tsx +19 -11
  34. package/src/client/components/daily-report-page.spec.tsx +119 -0
  35. package/src/client/components/daily-report-page.tsx +56 -68
  36. package/src/client/components/daily-report-resolved-content.tsx +31 -7
  37. package/src/client/components/report-views.spec.tsx +340 -0
  38. package/src/client/config-context.tsx +8 -0
  39. package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
  40. package/src/client/contexts/daily-report-action-context.tsx +151 -52
  41. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  42. package/src/client/hooks/use-animated-number.ts +69 -0
  43. package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
  44. package/src/client/hooks/use-responsive-layout.ts +56 -0
  45. package/src/client/route-helpers.spec.ts +100 -0
  46. package/src/client/route-helpers.ts +20 -41
  47. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
  48. package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
  49. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  50. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  51. package/src/client/utils/constants.spec.ts +96 -0
  52. package/src/client/utils/constants.ts +47 -2
  53. package/src/client.ts +6 -0
  54. package/src/index.ts +1 -0
  55. package/src/server/handlers.attachment.spec.ts +470 -0
  56. package/src/server/handlers.ids-stream.spec.ts +362 -0
  57. package/src/server/handlers.ts +671 -6
  58. package/src/server/ports.ts +74 -0
  59. package/src/server/schema.ts +80 -5
  60. package/src/server/service.spec.ts +280 -14
  61. package/src/server/service.ts +348 -25
  62. package/src/server/test-helpers/handlers-config.ts +181 -0
  63. package/src/server.ts +16 -1
  64. package/src/shared/ids-stream.ts +92 -0
  65. package/src/shared/sse-schema.spec.ts +50 -0
  66. package/src/shared/sse-schema.ts +27 -0
  67. package/src/shared/types.ts +31 -0
@@ -0,0 +1,599 @@
1
+ /**
2
+ * Resilient NDJSON stream client for the daily-report ids feed (React-free).
3
+ * 日報 ID 一覧 NDJSON フィードの耐障害ストリームクライアント (React 非依存)。
4
+ *
5
+ * fetch + ReadableStream で `{apiBasePath}/ids-stream` を読み、チャンク到着ごとに
6
+ * 蓄積スナップショットを publish する。切断・サーバーエラー行・未完終端は
7
+ * カーソル再開付きの指数バックオフで自己回復し、受信済みデータは常に保持する。
8
+ * React へは `subscribe` / `getState` (安定参照スナップショット) だけを公開し、
9
+ * `useSyncExternalStore` でそのまま束縛できる形にする。
10
+ */
11
+ import { type DailyReportIdsStreamCursor, type DailyReportIdsStreamLine, isIdsStreamChunkLine } from "../../shared/ids-stream"
12
+ import type { DailyReportItem } from "../../shared/types"
13
+
14
+ /** ストリームのライフサイクル局面。 */
15
+ export type DailyReportIdsStreamPhase = "idle" | "streaming" | "retrying" | "complete" | "failed" | "auth-required"
16
+
17
+ /** publish されるスナップショット。publish 間で参照が安定している。 */
18
+ export type DailyReportIdsStreamState = {
19
+ /** 受信済みアイテム (reportHubId で重複排除済み)。 */
20
+ items: DailyReportItem[]
21
+ /** 一意アイテム件数 (`items.length` と同値。表示用の別名)。 */
22
+ loadedCount: number
23
+ /** サーバー申告の全体件数。先頭行未着なら null。 */
24
+ totalCount: number | null
25
+ /** ライフサイクル局面。 */
26
+ phase: DailyReportIdsStreamPhase
27
+ /** complete 後のバックグラウンド再検証が走っているか。 */
28
+ isRevalidating: boolean
29
+ /** 一度でも完走したか (再検証中の UI 判定に使う)。 */
30
+ hasCompletedOnce: boolean
31
+ /**
32
+ * 直近の「実際の完走」時刻 (未完走は null)。鮮度判定 (SWR 窓) の唯一の根拠。
33
+ * 完走の publish でのみ更新される — 再検証の諦めなど phase が complete のままの
34
+ * publish で更新すると、古いデータが新鮮扱いされ再検証が永久に走らなくなる。
35
+ */
36
+ completedAt: number | null
37
+ /** 直近の失敗 (成功で null に戻る)。 */
38
+ error: Error | null
39
+ }
40
+
41
+ /** 1 接続試行の結末。 */
42
+ type StreamAttemptOutcome = { kind: "complete" } | { kind: "aborted" } | { kind: "auth-required" } | { kind: "retryable"; error: Error }
43
+
44
+ /** テスト注入用の依存一式。 */
45
+ export type DailyReportIdsStreamClientOptions = {
46
+ /** API ベースパス (既定 `/daily_report/api`)。 */
47
+ apiBasePath?: string
48
+ /** fetch 実装 (既定 globalThis.fetch)。 */
49
+ fetchFn?: (input: string, init: { signal: AbortSignal; headers: Record<string, string>; cache: RequestCache }) => Promise<Response>
50
+ /** タイマー実装 (既定 setTimeout / clearTimeout)。 */
51
+ setTimeoutFn?: (handler: () => void, ms: number) => ReturnType<typeof setTimeout>
52
+ clearTimeoutFn?: (id: ReturnType<typeof setTimeout>) => void
53
+ /** 警告ロガー (既定 console.warn)。 */
54
+ warn?: (...args: unknown[]) => void
55
+ /**
56
+ * チャンク由来 publish の合体窓 (ms)。0 で無効 (毎チャンク即時 publish)。
57
+ * 高速回線ではネットワーク read が密集し、publish ごとに全件ソート + 全行再レンダーが
58
+ * 走るため、先頭は即時 (初回描画優先)・後続は窓内で合体するスロットルを掛ける。
59
+ * 完走 publish と外部ミューテーション (ユーザー操作の即時フィードバック) は対象外。
60
+ */
61
+ publishCoalesceMs?: number
62
+ }
63
+
64
+ /** チャンク由来 publish の既定合体窓 (ms)。 */
65
+ const DEFAULT_PUBLISH_COALESCE_MS = 50
66
+
67
+ /** 初回リトライ遅延。 */
68
+ const INITIAL_RETRY_DELAY_MS = 1_000
69
+
70
+ /** リトライ遅延の上限。 */
71
+ const MAX_RETRY_DELAY_MS = 30_000
72
+
73
+ /** 連続失敗の上限。到達で failed へ落ち、手動 retry 待ちになる。 */
74
+ const MAX_CONSECUTIVE_FAILURES = 6
75
+
76
+ /** 既定の API ベースパス (config-context の既定と一致させる)。 */
77
+ const DEFAULT_API_BASE_PATH = "/daily_report/api"
78
+
79
+ /** 再開カーソルとして採用する営業日の唯一の形式 (サーバー側の検証契約と一致)。 */
80
+ const CURSOR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
81
+
82
+ /**
83
+ * NDJSON ids stream client with dedupe, cursor resume, and exponential backoff.
84
+ * 重複排除・カーソル再開・指数バックオフを備えた NDJSON ids ストリームクライアント。
85
+ */
86
+ export class DailyReportIdsStreamClient {
87
+ private readonly apiBasePath: string
88
+ private readonly fetchFn: NonNullable<DailyReportIdsStreamClientOptions["fetchFn"]>
89
+ private readonly setTimeoutFn: NonNullable<DailyReportIdsStreamClientOptions["setTimeoutFn"]>
90
+ private readonly clearTimeoutFn: NonNullable<DailyReportIdsStreamClientOptions["clearTimeoutFn"]>
91
+ private readonly warn: NonNullable<DailyReportIdsStreamClientOptions["warn"]>
92
+
93
+ /** reportHubId → アイテム。挿入順を保ち、再開の重複到着を吸収する。 */
94
+ private readonly itemMap = new Map<number, DailyReportItem>()
95
+ private readonly listeners = new Set<() => void>()
96
+
97
+ private state: DailyReportIdsStreamState = {
98
+ items: [],
99
+ loadedCount: 0,
100
+ totalCount: null,
101
+ phase: "idle",
102
+ isRevalidating: false,
103
+ hasCompletedOnce: false,
104
+ completedAt: null,
105
+ error: null,
106
+ }
107
+
108
+ private lastCursor: DailyReportIdsStreamCursor | null = null
109
+ private consecutiveFailures = 0
110
+ private started = false
111
+ private disposed = false
112
+ /** 実行中ループの世代。refresh/dispose で進め、旧ループの続行を無効化する。 */
113
+ private loopGeneration = 0
114
+ private activeAbortController: AbortController | null = null
115
+ /**
116
+ * 先頭 (cursor 無し) から始まった走査で観測した ID の集合。
117
+ * 完走時にこの集合へ含まれない Map エントリを剪定し、サーバー側で削除された
118
+ * 日報がモジュール常駐データに永久残留するのを防ぐ。途中失敗のカーソル再開は
119
+ * 同じ走査の続きなので集合を引き継ぐ (全区間の和 = サーバーの全量)。
120
+ */
121
+ private activeSeenIds: Set<number> | null = null
122
+ /**
123
+ * 外部削除のトゥームストーン。サーバーは配列全体を先に確定させてから送出するため、
124
+ * 走査中に届いた削除の後、同じ削除前スナップショットの後続チャンクが同 ID を
125
+ * 再配送してくる。これを吸収せずに素通しすると、削除済み日報が Map へ戻り
126
+ * 完走剪定も免れてしまう (観測集合に再記録されるため)。新しい走査の開始
127
+ * (削除コミット後のスナップショット) でクリアする。
128
+ */
129
+ private readonly externalRemovalTombstones = new Set<number>()
130
+ /**
131
+ * 走行中の再検証中に届いた明示的キャッシュバイパス要求の持ち越しフラグ。
132
+ * 走行中の再検証は中断しない方針のため、要求を黙って捨てず完走後に連結実行する。
133
+ */
134
+ private pendingForceRefresh = false
135
+ private wakeRetryWait: (() => void) | null = null
136
+ private readonly handleOnline = () => this.wakeRetryWait?.()
137
+
138
+ /** チャンク由来 publish の合体窓 (ms)。 */
139
+ private readonly publishCoalesceMs: number
140
+ /** 合体窓のトレーリングタイマー。 */
141
+ private publishCoalesceTimer: ReturnType<typeof setTimeout> | null = null
142
+ /** 直近のアイテム publish 時刻 (leading edge 判定用)。 */
143
+ private lastItemsPublishAt = 0
144
+
145
+ constructor(options: DailyReportIdsStreamClientOptions = {}) {
146
+ this.apiBasePath = options.apiBasePath ?? DEFAULT_API_BASE_PATH
147
+ this.fetchFn = options.fetchFn ?? ((input, init) => fetch(input, init))
148
+ this.setTimeoutFn = options.setTimeoutFn ?? ((handler, ms) => setTimeout(handler, ms))
149
+ this.clearTimeoutFn = options.clearTimeoutFn ?? ((id) => clearTimeout(id))
150
+ this.warn = options.warn ?? ((...args) => console.warn(...args))
151
+ this.publishCoalesceMs = options.publishCoalesceMs ?? DEFAULT_PUBLISH_COALESCE_MS
152
+ if (typeof window !== "undefined") {
153
+ // オフライン復帰でバックオフ待機を即時解除する
154
+ window.addEventListener("online", this.handleOnline)
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Returns the current snapshot (stable reference between publishes).
160
+ * 現在のスナップショットを返す処理 (publish 間で参照が安定)。
161
+ */
162
+ getState(): DailyReportIdsStreamState {
163
+ return this.state
164
+ }
165
+
166
+ /**
167
+ * Subscribes to snapshot updates.
168
+ * スナップショット更新の購読を登録する処理。
169
+ *
170
+ * @param listener Change callback. 変更通知コールバック。
171
+ * @returns Unsubscribe function. 購読解除関数。
172
+ */
173
+ subscribe(listener: () => void): () => void {
174
+ this.listeners.add(listener)
175
+ return () => this.listeners.delete(listener)
176
+ }
177
+
178
+ /**
179
+ * Starts the initial stream (idempotent).
180
+ * 初回ストリーミングを開始する処理 (冪等)。
181
+ */
182
+ start(): void {
183
+ if (this.started || this.disposed) return
184
+ this.started = true
185
+ void this.runStreamLoop({ revalidate: false, forceRefresh: false })
186
+ }
187
+
188
+ /**
189
+ * Restarts after a terminal failure (no-op unless phase is "failed").
190
+ * failed からの手動再開処理 (failed 以外では何もしない)。
191
+ *
192
+ * 受信済みデータと再開カーソルは保持したまま続きから読む。
193
+ */
194
+ retry(): void {
195
+ if (this.disposed || this.state.phase !== "failed") return
196
+ this.consecutiveFailures = 0
197
+ void this.runStreamLoop({ revalidate: this.state.hasCompletedOnce, forceRefresh: false })
198
+ }
199
+
200
+ /**
201
+ * Revalidates a completed stream in the background (SWR-style).
202
+ * 完走済みストリームをバックグラウンドで再検証する処理 (SWR)。
203
+ *
204
+ * カーソルを捨てて先頭から読み直し、Map の重複排除で差分を上書き吸収し、
205
+ * 完走時に「今回の走査で観測されなかった行」を剪定する (サーバー側削除の反映)。
206
+ * failed のときはカーソルを保持した再開に切り替える (forceRefresh 指定は失わない)。
207
+ * streaming/retrying 中は何もしない。
208
+ *
209
+ * @param options.forceRefresh サーバー側 SqlResultCache をバイパスするか。
210
+ */
211
+ refresh(options: { forceRefresh?: boolean } = {}): void {
212
+ if (this.disposed) return
213
+ if (this.state.phase === "failed") {
214
+ // retry() 相当だが forceRefresh の指定を落とさない (キャッシュクリア意図の保存)
215
+ this.consecutiveFailures = 0
216
+ void this.runStreamLoop({ revalidate: this.state.hasCompletedOnce, forceRefresh: options.forceRefresh ?? false })
217
+ return
218
+ }
219
+ if (this.state.phase !== "complete") return
220
+ // 走行中の再検証を別の refresh が中断して先頭からやり直すと、ensure の多重呼び出し
221
+ // (clientLoader の prime / ensure / ページ effect) だけで再訪ごとに 3 連ダウンロードになる
222
+ if (this.state.isRevalidating) {
223
+ // ただし明示的なキャッシュバイパス要求 (キャッシュクリアボタン) は黙って捨てず、
224
+ // 走行中の再検証の完走後に連結実行する
225
+ if (options.forceRefresh) {
226
+ this.pendingForceRefresh = true
227
+ }
228
+ return
229
+ }
230
+ this.lastCursor = null
231
+ this.consecutiveFailures = 0
232
+ void this.runStreamLoop({ revalidate: true, forceRefresh: options.forceRefresh ?? false })
233
+ }
234
+
235
+ /**
236
+ * Applies an externally-known upsert (SSE / local mutation) to the resident data.
237
+ * 外部由来 (SSE / ローカル操作) の upsert を常駐データへ反映する処理。
238
+ *
239
+ * これを欠くと、マウント中に作成された日報がストリームセッションに存在せず、
240
+ * 鮮度窓内の再訪 (再検証なし) で一覧から消える。アクティブな走査中は観測集合にも
241
+ * 刻み、完走時の剪定で「生きていると分かっている行」を誤って落とさないようにする。
242
+ */
243
+ applyExternalUpsert(item: DailyReportItem): void {
244
+ if (this.disposed || !item || typeof item.reportHubId !== "number") return
245
+ // 明示的な upsert は「生きている」の権威シグナルなので削除トゥームストーンを解除する
246
+ this.externalRemovalTombstones.delete(item.reportHubId)
247
+ this.activeSeenIds?.add(item.reportHubId)
248
+ const existing = this.itemMap.get(item.reportHubId)
249
+ // sourceType 未指定の upsert (プロバイダーは知らない) は既存値を保持する
250
+ const next: DailyReportItem = { reportHubId: item.reportHubId, businessDate: item.businessDate, sourceType: item.sourceType ?? existing?.sourceType }
251
+ if (!existing || existing.businessDate !== next.businessDate || existing.sourceType !== next.sourceType) {
252
+ this.itemMap.set(item.reportHubId, next)
253
+ this.publishItems()
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Applies an externally-known removal (SSE report-delete / stale valve) to the resident data.
259
+ * 外部由来 (SSE report-delete / stale 安全弁) の削除を常駐データへ反映する処理。
260
+ *
261
+ * 走査中の後続チャンク (削除前スナップショット) による同一行の再配送は、
262
+ * クライアント側トゥームストーンが次の新しい走査までブロックする。
263
+ */
264
+ applyExternalRemoval(reportHubId: number): void {
265
+ if (this.disposed) return
266
+ this.activeSeenIds?.delete(reportHubId)
267
+ // 削除前スナップショットの後続チャンクによる再配送を、次の新しい走査までブロックする
268
+ this.externalRemovalTombstones.add(reportHubId)
269
+ if (this.itemMap.delete(reportHubId)) {
270
+ this.publishItems()
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Aborts everything and detaches listeners (terminal).
276
+ * すべてを中断しリスナーを外す終了処理 (以後再利用不可)。
277
+ */
278
+ dispose(): void {
279
+ this.disposed = true
280
+ this.loopGeneration += 1
281
+ this.activeAbortController?.abort()
282
+ this.cancelScheduledPublishItems()
283
+ // 保留中のバックオフ待機を起こす (finish が自分のタイマーを片付ける)
284
+ this.wakeRetryWait?.()
285
+ if (typeof window !== "undefined") {
286
+ window.removeEventListener("online", this.handleOnline)
287
+ }
288
+ this.listeners.clear()
289
+ }
290
+
291
+ /**
292
+ * Connection-retry loop: stream → classify → backoff → resume.
293
+ * 接続リトライループ。ストリーム → 結果分類 → バックオフ → カーソル再開。
294
+ */
295
+ private async runStreamLoop(mode: { revalidate: boolean; forceRefresh: boolean }): Promise<void> {
296
+ const generation = ++this.loopGeneration
297
+ // 進行中の旧接続 (再検証開始時など) を中断し、バックオフ待機中の旧ループも
298
+ // 即時に起こして退場させる (放置すると旧タイマーが残り続ける)
299
+ this.activeAbortController?.abort()
300
+ this.wakeRetryWait?.()
301
+
302
+ // 先頭からの走査 (初回/再検証) は削除剪定用の観測集合を新調し、外部削除の
303
+ // トゥームストーンをクリアする (これから受けるスナップショットは削除コミット後)。
304
+ // カーソル再開 (途中失敗からの retry) は同じ走査の続きなので両方引き継ぐ
305
+ if (this.lastCursor === null) {
306
+ this.activeSeenIds = new Set()
307
+ this.externalRemovalTombstones.clear()
308
+ }
309
+
310
+ // このループ自体が forced run なら、持ち越し要求はここで満たされたことにする
311
+ // (満たされないまま残ると、無関係な後続の再検証に古い強制走査が連鎖する)
312
+ if (mode.forceRefresh) {
313
+ this.pendingForceRefresh = false
314
+ }
315
+
316
+ this.publish({
317
+ phase: mode.revalidate ? this.state.phase : "streaming",
318
+ isRevalidating: mode.revalidate,
319
+ error: null,
320
+ })
321
+
322
+ while (!this.disposed && generation === this.loopGeneration) {
323
+ // forceRefresh はループ全体で維持する。試行がサーバーへ届く前に失敗した場合、
324
+ // 1 回で降ろすと後続リトライが古いサーバーキャッシュを掴む
325
+ const outcome = await this.streamOnce(mode.forceRefresh)
326
+ if (this.disposed || generation !== this.loopGeneration) return
327
+
328
+ if (outcome.kind === "complete") {
329
+ this.pruneUnseenItems()
330
+ // 完走 = 成功。失敗予算をリセットする (前進ゼロの完走もあり得るため、
331
+ // 進行時リセットだけに頼ると連結実行される次のループへ古い予算が漏れる)
332
+ this.consecutiveFailures = 0
333
+ // 完走 publish が全量を運ぶため、保留中のトレーリング publish は不要
334
+ this.cancelScheduledPublishItems()
335
+ this.lastItemsPublishAt = Date.now()
336
+ this.publish({
337
+ phase: "complete",
338
+ isRevalidating: false,
339
+ hasCompletedOnce: true,
340
+ completedAt: Date.now(),
341
+ error: null,
342
+ items: Array.from(this.itemMap.values()),
343
+ loadedCount: this.itemMap.size,
344
+ })
345
+ // 走行中に持ち越された明示的キャッシュバイパス要求を連結実行する
346
+ if (this.pendingForceRefresh && !this.disposed) {
347
+ this.pendingForceRefresh = false
348
+ this.lastCursor = null
349
+ void this.runStreamLoop({ revalidate: true, forceRefresh: true })
350
+ }
351
+ return
352
+ }
353
+ if (outcome.kind === "aborted") return
354
+ if (outcome.kind === "auth-required") {
355
+ this.publish({ phase: "auth-required", isRevalidating: false })
356
+ return
357
+ }
358
+
359
+ this.consecutiveFailures += 1
360
+ if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
361
+ // 再検証中の力尽きは既存データを守って complete のまま静かに諦める
362
+ // (completedAt は更新しない — 次の再訪で再検証が確実に走るように)
363
+ if (mode.revalidate && this.state.hasCompletedOnce) {
364
+ this.warn("[DailyReportIdsStream] revalidation gave up:", outcome.error.message)
365
+ // 持ち越し要求も破棄する (死んだ再検証の後に古い強制走査を連鎖させない)
366
+ this.pendingForceRefresh = false
367
+ this.publish({ isRevalidating: false, error: outcome.error })
368
+ } else {
369
+ this.publish({ phase: "failed", isRevalidating: false, error: outcome.error })
370
+ }
371
+ return
372
+ }
373
+
374
+ this.publish({ phase: mode.revalidate ? this.state.phase : "retrying", error: outcome.error })
375
+ await this.waitBeforeRetry(Math.min(INITIAL_RETRY_DELAY_MS * 2 ** (this.consecutiveFailures - 1), MAX_RETRY_DELAY_MS))
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Prunes map entries not observed by the completed full scan.
381
+ * 完走した全量走査で観測されなかった Map エントリを剪定する処理。
382
+ *
383
+ * サーバー側で削除された日報の残留 (再訪時の亡霊復活・loadedCount > totalCount) を防ぐ。
384
+ * 観測集合が無い場合 (理論上到達しない防御) は何も消さない。
385
+ */
386
+ private pruneUnseenItems(): void {
387
+ const seen = this.activeSeenIds
388
+ this.activeSeenIds = null
389
+ if (!seen) return
390
+ for (const id of Array.from(this.itemMap.keys())) {
391
+ if (!seen.has(id)) {
392
+ this.itemMap.delete(id)
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Runs one streaming attempt and classifies its outcome.
399
+ * 1 回のストリーミング試行を実行し、結末を分類する処理。
400
+ */
401
+ private async streamOnce(forceRefresh: boolean): Promise<StreamAttemptOutcome> {
402
+ const abortController = new AbortController()
403
+ this.activeAbortController = abortController
404
+ try {
405
+ const response = await this.fetchFn(this.buildStreamUrl(forceRefresh), {
406
+ signal: abortController.signal,
407
+ headers: { Accept: "application/x-ndjson" },
408
+ cache: "no-store",
409
+ })
410
+ if (response.status === 401 || response.status === 403) {
411
+ return { kind: "auth-required" }
412
+ }
413
+ if (!(response.ok && response.body)) {
414
+ return { kind: "retryable", error: new Error(`ids stream HTTP ${response.status}`) }
415
+ }
416
+
417
+ const reader = response.body.getReader()
418
+ const decoder = new TextDecoder()
419
+ let partialLine = ""
420
+ let sawErrorLine = false
421
+ let sawIsLast = false
422
+
423
+ while (true) {
424
+ const { done, value } = await reader.read()
425
+ if (done) break
426
+ // ネットワークチャンクは行境界と無関係に割れるため、末尾の欠け行を持ち越す
427
+ const lines = (partialLine + decoder.decode(value, { stream: true })).split("\n")
428
+ partialLine = lines.pop() ?? ""
429
+
430
+ let mutated = false
431
+ for (const rawLine of lines) {
432
+ if (rawLine.trim() === "") continue
433
+ let parsed: DailyReportIdsStreamLine
434
+ try {
435
+ parsed = JSON.parse(rawLine) as DailyReportIdsStreamLine
436
+ } catch {
437
+ // 単一行の破損はスキップし、接続レベルの再開に委ねる
438
+ this.warn("[DailyReportIdsStream] skipped malformed line")
439
+ continue
440
+ }
441
+ if (!isIdsStreamChunkLine(parsed)) {
442
+ sawErrorLine = true
443
+ continue
444
+ }
445
+ mutated = this.absorbChunkItems(parsed.items) || mutated
446
+ if (typeof parsed.total === "number" && Number.isFinite(parsed.total) && parsed.total !== this.state.totalCount) {
447
+ // total 単独の変化でも必ず購読者へ通知する (無通知のスナップショット
448
+ // 置換は useSyncExternalStore のスナップショット安定契約を破る)
449
+ this.publish({ totalCount: parsed.total })
450
+ }
451
+ if (parsed.nextCursor && (parsed.nextCursor.businessDate === null || CURSOR_DATE_PATTERN.test(parsed.nextCursor.businessDate))) {
452
+ // 契約外の日付 (混入データ等) を持つカーソルは採用しない。
453
+ // 日付を欠いた id だけの再開は NULL 営業日領域へスキップして欠落を生むため、
454
+ // 直前の正当なカーソル (無ければ先頭) から読み直す方が安全
455
+ this.lastCursor = parsed.nextCursor
456
+ }
457
+ if (parsed.isLast) {
458
+ sawIsLast = true
459
+ }
460
+ }
461
+ if (mutated) {
462
+ // 前進があった時点で失敗カウンタを戻す (断続的な不調で failed へ落とさない)
463
+ this.consecutiveFailures = 0
464
+ this.schedulePublishItems()
465
+ }
466
+ }
467
+
468
+ if (sawErrorLine) {
469
+ return { kind: "retryable", error: new Error("Stream interrupted by server error") }
470
+ }
471
+ if (!sawIsLast) {
472
+ // サーバー再起動や中間切断。カーソルから再開する
473
+ return { kind: "retryable", error: new Error("Stream ended before completion") }
474
+ }
475
+ return { kind: "complete" }
476
+ } catch (error) {
477
+ // DOMException (ブラウザー/undici) と Error 派生 (テスト DI) の両形を受ける
478
+ if (error instanceof Error && error.name === "AbortError") {
479
+ return { kind: "aborted" }
480
+ }
481
+ return { kind: "retryable", error: error instanceof Error ? error : new Error(String(error)) }
482
+ } finally {
483
+ if (this.activeAbortController === abortController) {
484
+ this.activeAbortController = null
485
+ }
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Merges chunk items into the dedupe map.
491
+ * チャンクのアイテム群を重複排除 Map へ取り込む処理。
492
+ *
493
+ * @returns True when anything was added or changed. 追加または内容変化があれば true。
494
+ */
495
+ private absorbChunkItems(items: DailyReportItem[]): boolean {
496
+ let mutated = false
497
+ for (const item of items) {
498
+ if (!item || typeof item.reportHubId !== "number") continue
499
+ // 走査中に外部削除された ID は、同じ削除前スナップショットの後続チャンクが
500
+ // 再配送してきても受け入れない (受けると剪定も免れて再訪時に蘇る)
501
+ if (this.externalRemovalTombstones.has(item.reportHubId)) continue
502
+ // 内容が既知でも「観測した」事実は剪定用集合へ必ず刻む
503
+ this.activeSeenIds?.add(item.reportHubId)
504
+ const existing = this.itemMap.get(item.reportHubId)
505
+ if (!existing || existing.businessDate !== item.businessDate || existing.sourceType !== item.sourceType) {
506
+ this.itemMap.set(item.reportHubId, item)
507
+ mutated = true
508
+ }
509
+ }
510
+ return mutated
511
+ }
512
+
513
+ /** アイテム配列を再スナップショットして即時 publish する。 */
514
+ private publishItems(): void {
515
+ this.lastItemsPublishAt = Date.now()
516
+ this.publish({ items: Array.from(this.itemMap.values()), loadedCount: this.itemMap.size })
517
+ }
518
+
519
+ /**
520
+ * チャンク由来のアイテム publish をスロットルする (leading edge 即時 + trailing 合体)。
521
+ * 最初のチャンクは即時に描画へ届け、窓内に密集した後続チャンクは 1 回の publish
522
+ * (= 1 回の全件ソートと再レンダー) へ合体する。合体窓 0 は無効 (常に即時)。
523
+ */
524
+ private schedulePublishItems(): void {
525
+ if (this.publishCoalesceMs <= 0) {
526
+ this.publishItems()
527
+ return
528
+ }
529
+ const elapsed = Date.now() - this.lastItemsPublishAt
530
+ if (elapsed >= this.publishCoalesceMs) {
531
+ this.publishItems()
532
+ return
533
+ }
534
+ if (this.publishCoalesceTimer !== null) return
535
+ this.publishCoalesceTimer = this.setTimeoutFn(() => {
536
+ this.publishCoalesceTimer = null
537
+ this.publishItems()
538
+ }, this.publishCoalesceMs - elapsed)
539
+ }
540
+
541
+ /** 保留中のトレーリング publish を破棄する (完走 publish が全量を運ぶ場合など)。 */
542
+ private cancelScheduledPublishItems(): void {
543
+ if (this.publishCoalesceTimer !== null) {
544
+ this.clearTimeoutFn(this.publishCoalesceTimer)
545
+ this.publishCoalesceTimer = null
546
+ }
547
+ }
548
+
549
+ /** 部分状態をマージした新スナップショットを作り、購読者へ通知する。 */
550
+ private publish(partial: Partial<DailyReportIdsStreamState>): void {
551
+ this.state = { ...this.state, ...partial }
552
+ for (const listener of this.listeners) {
553
+ listener()
554
+ }
555
+ }
556
+
557
+ /** 再開カーソル付きのストリーム URL を構築する。 */
558
+ private buildStreamUrl(forceRefresh: boolean): string {
559
+ const params = new URLSearchParams()
560
+ if (this.lastCursor) {
561
+ params.set("cursor_report_hub_id", String(this.lastCursor.reportHubId))
562
+ // null 営業日領域の再開は日付パラメータを省略する (サーバー側の契約)
563
+ if (this.lastCursor.businessDate !== null) {
564
+ params.set("cursor_business_date", this.lastCursor.businessDate)
565
+ }
566
+ }
567
+ if (forceRefresh) {
568
+ params.set("forceRefresh", "true")
569
+ }
570
+ const query = params.toString()
571
+ return `${this.apiBasePath}/ids-stream${query ? `?${query}` : ""}`
572
+ }
573
+
574
+ /**
575
+ * バックオフ待機。online イベント・dispose・後続ループの開始で早期解除される。
576
+ *
577
+ * タイマー ID は待機ごとのローカルに閉じ、共有スロット (wakeRetryWait) は
578
+ * 「まだ自分を指しているときだけ」外す。単一スロットへ素朴に書くと、
579
+ * 追い越された旧待機の finish が新しいループのタイマーを消してしまい、
580
+ * 再検証が永久に固まる (実測済みの競合)。
581
+ */
582
+ private waitBeforeRetry(delayMs: number): Promise<void> {
583
+ return new Promise((resolve) => {
584
+ let timerId: ReturnType<typeof setTimeout> | null = null
585
+ const finish = () => {
586
+ if (timerId !== null) {
587
+ this.clearTimeoutFn(timerId)
588
+ timerId = null
589
+ }
590
+ if (this.wakeRetryWait === finish) {
591
+ this.wakeRetryWait = null
592
+ }
593
+ resolve()
594
+ }
595
+ this.wakeRetryWait = finish
596
+ timerId = this.setTimeoutFn(finish, delayMs)
597
+ })
598
+ }
599
+ }