@aiquants/daily-report 0.9.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/daily-report",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Reusable daily-report feature package: shared types/schemas, React (Router v7) UI with virtual scrolling + optimistic updates + SSE sync, and a drizzle (mssql) server layer with DI ports (auth, user resolution, redis, external sources).",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -276,3 +276,61 @@ describe("initialItems の既定値 (無限レンダー防止)", () => {
276
276
  expect(contextRef.current?.items).toEqual([])
277
277
  })
278
278
  })
279
+
280
+ describe("SSE 自動接続の遅延 (初回描画へ接続枠を譲る)", () => {
281
+ // isSseEnabled は render 時点で読む (effect 経由の contextRef はタイマー flush と 1 ティックずれる)
282
+ const renderState: { current: DailyReportActionContextType | null } = { current: null }
283
+ const RenderCapture = () => {
284
+ renderState.current = useDailyReportActionContext()
285
+ return null
286
+ }
287
+
288
+ it("初期は未接続で、遅延後に自動接続する", async () => {
289
+ vi.useFakeTimers()
290
+ try {
291
+ // セッションは張らない (このテストは SSE 遅延だけを見る)。userId ありで自動接続対象
292
+ render(
293
+ <DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A">
294
+ <RenderCapture />
295
+ </DailyReportActionProvider>,
296
+ )
297
+ await act(async () => {
298
+ await vi.advanceTimersByTimeAsync(0)
299
+ })
300
+ // 初回描画時点では未接続 (HTTP/1.1 の接続枠を初回の中身フェッチへ譲る)
301
+ expect(renderState.current?.isSseEnabled).toBe(false)
302
+ // 遅延経過で自動接続する
303
+ await act(async () => {
304
+ await vi.advanceTimersByTimeAsync(1000)
305
+ })
306
+ expect(renderState.current?.isSseEnabled).toBe(true)
307
+ } finally {
308
+ vi.useRealTimers()
309
+ }
310
+ })
311
+
312
+ it("手動トグルは自動接続タイマーに上書きされない", async () => {
313
+ vi.useFakeTimers()
314
+ try {
315
+ render(
316
+ <DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A">
317
+ <RenderCapture />
318
+ </DailyReportActionProvider>,
319
+ )
320
+ await act(async () => {
321
+ await vi.advanceTimersByTimeAsync(0)
322
+ })
323
+ // ユーザーが手動で ON → OFF した後、遅延タイマーが発火しても手動 OFF を尊重する
324
+ act(() => renderState.current?.toggleSse())
325
+ expect(renderState.current?.isSseEnabled).toBe(true)
326
+ act(() => renderState.current?.toggleSse())
327
+ expect(renderState.current?.isSseEnabled).toBe(false)
328
+ await act(async () => {
329
+ await vi.advanceTimersByTimeAsync(1000)
330
+ })
331
+ expect(renderState.current?.isSseEnabled).toBe(false)
332
+ } finally {
333
+ vi.useRealTimers()
334
+ }
335
+ })
336
+ })
@@ -24,6 +24,7 @@ import {
24
24
  } from "../hooks/use-daily-report"
25
25
  import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
26
26
  import { getDailyReportIdsStreamSessionUserKey, refreshDailyReportIdsStream, removeDailyReportIdsStreamItem, upsertDailyReportIdsStreamItem, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
27
+ import { SSE_INITIAL_CONNECT_DEFER_MS } from "../utils/constants"
27
28
  import { useDailyReportErrorSurface } from "./daily-report-error-context"
28
29
 
29
30
  const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
@@ -181,7 +182,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY
181
182
  const resolvedIdMap = useRef<Map<number, number>>(new Map())
182
183
  const [version, setVersion] = useState(0)
183
184
  const [items, setItems] = useState<DailyReportItem[]>(initialItems)
184
- const [isSseEnabled, setIsSseEnabled] = useState(!!userId)
185
+ // SSE は初回描画とその最初の中身フェッチに接続枠を譲るため、少し遅らせて自動接続する
186
+ // (HTTP/1.1 の 6 接続のうち 1 枠を初回描画へ回す)。手動トグル済みなら自動接続しない
187
+ const [isSseEnabled, setIsSseEnabled] = useState(false)
188
+ const hasManualSseToggleRef = useRef(false)
185
189
  const pendingAddPromises = useRef<Map<number, Promise<number>>>(new Map())
186
190
  const manuallyTrackingIds = useRef<Set<number>>(new Set())
187
191
  /** 削除済み ID を記録し、マージロジックでの復活を防止する */
@@ -369,9 +373,27 @@ export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY
369
373
  // 未登録ユーザーは SSE を有効化できない
370
374
  const toggleSse = () => {
371
375
  if (!userId) return
376
+ // 手動操作後は自動接続タイマーに上書きさせない
377
+ hasManualSseToggleRef.current = true
372
378
  setIsSseEnabled((prev) => !prev)
373
379
  }
374
380
 
381
+ /**
382
+ * Defers the initial SSE auto-connect until just after first paint.
383
+ * 初回 SSE 自動接続を初回描画直後まで遅らせる副作用。
384
+ *
385
+ * 依存配列: userId (ユーザーごとに provider が key リマウントされるため、切替で張り直す)。
386
+ * クリーンアップ: タイマー解除 (アンマウント/再マウントでのリークと二重発火を防ぐ)。
387
+ */
388
+ useEffect(() => {
389
+ if (!userId) return
390
+ const timer = setTimeout(() => {
391
+ // 手動でトグルされていなければ自動接続する
392
+ if (!hasManualSseToggleRef.current) setIsSseEnabled(true)
393
+ }, SSE_INITIAL_CONNECT_DEFER_MS)
394
+ return () => clearTimeout(timer)
395
+ }, [userId])
396
+
375
397
  /**
376
398
  * Remove a stale item from the list as a safety net.
377
399
  * フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
@@ -201,3 +201,60 @@ describe("getGlobal behavior (window グローバル変数の読み取り)", ()
201
201
  expect(mod.getCachedReport(1)).toBeNull()
202
202
  })
203
203
  })
204
+
205
+ describe("useDailyReportDetail の初回フェッチ (デバウンス適用範囲)", () => {
206
+ let mod: typeof import("./use-daily-report")
207
+
208
+ beforeEach(async () => {
209
+ vi.resetModules()
210
+ mod = await import("./use-daily-report")
211
+ vi.useFakeTimers()
212
+ })
213
+
214
+ afterEach(() => {
215
+ vi.useRealTimers()
216
+ vi.unstubAllGlobals()
217
+ })
218
+
219
+ /** business-date 応答を 1 件返す fetch スタブ。呼び出し回数を記録する。 */
220
+ const stubFetch = () => {
221
+ const calls: string[] = []
222
+ vi.stubGlobal(
223
+ "fetch",
224
+ vi.fn(async (input: RequestInfo | URL) => {
225
+ calls.push(String(input))
226
+ return new Response(JSON.stringify({ reports: [makeMockReport({ reportHubId: 7, date: "2026-01-01" })] }), { status: 200, headers: { "Content-Type": "application/json" } })
227
+ }),
228
+ )
229
+ return calls
230
+ }
231
+
232
+ it("未キャッシュの初回は即フェッチする (120ms のデバウンスを掛けない)", async () => {
233
+ const { renderHook, act } = await import("@testing-library/react")
234
+ const calls = stubFetch()
235
+ renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
236
+ // タイマーを進めずマイクロタスクだけ流す → 即時 (0ms) 発火の検証
237
+ await act(async () => {
238
+ await vi.advanceTimersByTimeAsync(0)
239
+ })
240
+ expect(calls.length).toBe(1)
241
+ expect(calls[0]).toContain("business-date?businessDate=2026-01-01")
242
+ })
243
+
244
+ it("キャッシュ済み (stale) の再取得はデバウンスされる", async () => {
245
+ const { renderHook, act } = await import("@testing-library/react")
246
+ const calls = stubFetch()
247
+ // 期限切れのキャッシュを用意 → 表示は即、再取得はデバウンス対象
248
+ mod.writeCache(makeMockReport({ reportHubId: 7, date: "2026-01-01" }), Date.now() - 1)
249
+ renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
250
+ await act(async () => {
251
+ await vi.advanceTimersByTimeAsync(0)
252
+ })
253
+ // 0ms 時点では発火しない (スクロール churn 吸収のためのデバウンス)
254
+ expect(calls.length).toBe(0)
255
+ await act(async () => {
256
+ await vi.advanceTimersByTimeAsync(120)
257
+ })
258
+ expect(calls.length).toBe(1)
259
+ })
260
+ })
@@ -7,6 +7,7 @@ import { normalizeBusinessDateKey } from "../../shared/business-date"
7
7
  import { createLogger, LogLevel } from "../../shared/logger"
8
8
  import type { DailyReportDetail } from "../../shared/types"
9
9
  import { useDailyReportConfig } from "../config-context"
10
+ import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
10
11
 
11
12
  const logger = createLogger(LogLevel.INFO, "use-daily-report")
12
13
 
@@ -22,7 +23,6 @@ const getGlobal = (k: string, d: number): number => {
22
23
  }
23
24
  const TTL = getGlobal("__DAILY_REPORT_CACHE_TTL__", 300_000)
24
25
  const REVIVE_MS = getGlobal("__DAILY_REPORT_CACHE_REVIVE_MS__", 60_000)
25
- const FETCH_DEBOUNCE_MS = 120
26
26
 
27
27
  // --- Cache Store (Centralized State) ---
28
28
  type CacheEntry<T> = { data: T; expiresAt: number }
@@ -430,6 +430,11 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
430
430
  setIsLoading(!cached)
431
431
  setIsRefetching(true)
432
432
 
433
+ // 未キャッシュ (骨組み表示中) の初回取得は即時に走らせる。デバウンスはスクロール連打の
434
+ // 無駄取りを抑える目的なので、スクロール churn の無い初回に効かせると純粋な遅延になる。
435
+ // キャッシュ済み (内容表示中) の stale 再取得だけデバウンスして churn を吸収する
436
+ const debounceMs = cached ? DAILY_REPORT_FETCH_DEBOUNCE_MS : 0
437
+
433
438
  const timer = setTimeout(async () => {
434
439
  if (reqRef.current.token !== token) return
435
440
  try {
@@ -453,7 +458,7 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
453
458
  setIsRefetching(false)
454
459
  }
455
460
  }
456
- }, FETCH_DEBOUNCE_MS)
461
+ }, debounceMs)
457
462
 
458
463
  return () => clearTimeout(timer)
459
464
  }, [reportHubId, businessDate, apiBasePath])
@@ -6,7 +6,17 @@ import type { DailyReportDetailResource } from "../hooks/use-daily-report"
6
6
  * Shared constants for Daily Report data fetching parameters.
7
7
  * 日報データ取得で利用する共通パラメーター。
8
8
  */
9
- export const DAILY_REPORT_FETCH_DEBOUNCE_MS = 180
9
+ export const DAILY_REPORT_FETCH_DEBOUNCE_MS = 120
10
+
11
+ /**
12
+ * Delay before the provider auto-connects SSE, freeing a browser connection slot for first paint.
13
+ * SSE を自動接続するまでの遅延。初回描画とその中身フェッチへ接続枠を譲るための猶予。
14
+ *
15
+ * HTTP/1.1 のオリジン当たり 6 接続のうち、ids ストリームと SSE で 2 枠を常時占有する。
16
+ * SSE 接続を初回描画直後まで遅らせ、その 1 枠を初回の中身フェッチ (`/business-date`) に回す。
17
+ * リアルタイム更新の初動がこの分だけ遅れるが、その間の状態はストリーム / SWR が担保する。
18
+ */
19
+ export const SSE_INITIAL_CONNECT_DEFER_MS = 1000
10
20
 
11
21
  export const DEFAULT_ITEM_HEIGHT = 160
12
22
  export const ITEM_VERTICAL_PADDING = 6
@@ -11,7 +11,7 @@ describe("SqlResultCache Memory Cleanup", () => {
11
11
 
12
12
  // 1. 期限切れのキャッシュを 500 件作成 (expireAt を過去に設定)
13
13
  // 直接プライベートフィールド buckets に流し込むことでモック状態を作る
14
- const buckets = (cache as any).buckets
14
+ const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[]; expireAt: number; epoch: number }> }).buckets
15
15
  for (let i = 0; i < 505; i++) {
16
16
  buckets.set(`expired-key-${i}`, {
17
17
  records: [i],
@@ -45,3 +45,367 @@ describe("SqlResultCache Memory Cleanup", () => {
45
45
  expect(buckets.has("expired-key-0")).toBe(false)
46
46
  })
47
47
  })
48
+
49
+ describe("SqlResultCache refresh-ahead", () => {
50
+ /** テスト用のキャッシュと覗き穴 (プライベート buckets / inFlight) を組み立てるヘルパー。 */
51
+ const makeCache = (ttlMs = 200_000, epochValue: { value: number } = { value: 0 }) => {
52
+ const epochStore = createEpochStore(undefined)
53
+ // 可変 epoch: フェッチ中の他ワーカー更新 (incrementEpoch) を模擬できるようにする
54
+ vi.spyOn(epochStore, "getEpoch").mockImplementation(async () => epochValue.value)
55
+ const cache = new SqlResultCache({ defaultTtlMs: ttlMs }, epochStore)
56
+ const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[]; expireAt: number; epoch: number }> }).buckets
57
+ const inFlight = (cache as unknown as { inFlight: Map<string, Promise<readonly unknown[]>> }).inFlight
58
+ return { cache, buckets, inFlight }
59
+ }
60
+
61
+ /**
62
+ * バケットの残余 TTL を書き換えて「年齢」を偽装する (expireAt = now + remainingMs)。
63
+ * CI ストール耐性のため残余は大きく取る (残余が小さいとテスト中に本当に失効し、
64
+ * 手動解決フェッチへ相乗りしてデッドロックする)。
65
+ */
66
+ const ageBucket = (buckets: Map<string, { expireAt: number }>, key: string, remainingMs: number) => {
67
+ const bucket = buckets.get(key)
68
+ if (!bucket) throw new Error(`bucket not found: ${key}`)
69
+ bucket.expireAt = Date.now() + remainingMs
70
+ }
71
+
72
+ it("refreshAheadRatio 未指定なら、老化したヒットでも裏フェッチは走らない (従来動作)", async () => {
73
+ const { cache, buckets, inFlight } = makeCache()
74
+ const fetcher = vi.fn().mockResolvedValue(["v1"])
75
+ await cache.getOrFetch({ cacheKey: "k", fetcher })
76
+ ageBucket(buckets, "k", 60_000) // 年齢 140 秒相当 (ratio 0.5 なら発火する老化度)
77
+
78
+ const hit = await cache.getOrFetch({ cacheKey: "k", fetcher })
79
+ expect(hit).toEqual(["v1"])
80
+ expect(fetcher).toHaveBeenCalledTimes(1)
81
+ expect(inFlight.size).toBe(0)
82
+ })
83
+
84
+ it("若いヒット (年齢 < ttl × ratio) では裏フェッチは走らない", async () => {
85
+ const { cache, inFlight } = makeCache()
86
+ const fetcher = vi.fn().mockResolvedValue(["v1"])
87
+ await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
88
+
89
+ // 直後のヒット = 年齢ほぼ 0
90
+ const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
91
+ expect(hit).toEqual(["v1"])
92
+ expect(fetcher).toHaveBeenCalledTimes(1)
93
+ expect(inFlight.size).toBe(0)
94
+ })
95
+
96
+ it("refreshAheadRatio 0 は毎ヒット裏更新 (undefined との区別 = falsy 判定への退行を検知)", async () => {
97
+ const { cache, inFlight } = makeCache()
98
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockResolvedValue(["v2"])
99
+ await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0 })
100
+
101
+ // 年齢 0 でも ratio 0 なら発火する (`if (!ratio)` へ書き換えると発火せず失敗する)
102
+ const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0 })
103
+ expect(hit).toEqual(["v1"])
104
+ await vi.waitFor(() => expect(inFlight.size).toBe(0))
105
+ expect(fetcher).toHaveBeenCalledTimes(2)
106
+ })
107
+
108
+ it("老化したヒットは旧値を即返しつつ、裏フェッチはちょうど 1 本だけ走ってバケットを差し替える", async () => {
109
+ const { cache, buckets, inFlight } = makeCache()
110
+ // 2 回目以降のフェッチは手動解決 (裏フェッチ進行中の並行ヒットを再現するため)
111
+ let releaseSecond!: (v: readonly unknown[]) => void
112
+ const second = new Promise<readonly unknown[]>((resolve) => {
113
+ releaseSecond = resolve
114
+ })
115
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
116
+ await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
117
+ ageBucket(buckets, "k", 60_000)
118
+
119
+ // ヒット A: 旧値を即返し + 裏フェッチ起動
120
+ const hitA = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
121
+ expect(hitA).toEqual(["v1"])
122
+ expect(inFlight.has("k")).toBe(true)
123
+
124
+ // ヒット B: 裏フェッチ進行中でも旧値を即返し、2 本目は起動しない (in-flight ガード)
125
+ const hitB = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
126
+ expect(hitB).toEqual(["v1"])
127
+ expect(fetcher).toHaveBeenCalledTimes(2) // 初回 + 裏フェッチ 1 本のみ
128
+
129
+ // 裏フェッチ完了 → バケットが新値へ差し替わり、inFlight は掃除される
130
+ const backgroundPromise = inFlight.get("k")
131
+ releaseSecond(["v2"])
132
+ await backgroundPromise
133
+ await vi.waitFor(() => expect(inFlight.size).toBe(0))
134
+ expect(buckets.get("k")?.records).toEqual(["v2"])
135
+ const hitC = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
136
+ expect(hitC).toEqual(["v2"])
137
+ expect(fetcher).toHaveBeenCalledTimes(2)
138
+ })
139
+
140
+ it("ttl は ttlMsOverride を優先する: 発火閾値も差し替え後の expireAt も override 基準 (本番構成)", async () => {
141
+ // 本番は defaultTtlMs より大きい ttlMsOverride を渡す構成 (60s default / 180s override)。
142
+ // ここでは逆向きの大小関係 (default 200s ≫ override 1s) にして、defaultTtlMs へ
143
+ // 退行すると (a) 発火せず (b) 寿命が伸びる、の両方を検知できるようにする
144
+ const { cache, buckets, inFlight } = makeCache(200_000)
145
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockResolvedValue(["v2"])
146
+ await cache.getOrFetch({ cacheKey: "k", fetcher, ttlMsOverride: 1000, refreshAheadRatio: 0.5 })
147
+
148
+ // 残余 300ms = override 基準の年齢 700ms (> 500ms 閾値)。default 基準なら年齢は負で発火しない
149
+ ageBucket(buckets, "k", 300)
150
+ const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, ttlMsOverride: 1000, refreshAheadRatio: 0.5 })
151
+ expect(hit).toEqual(["v1"])
152
+ await vi.waitFor(() => expect(inFlight.size).toBe(0))
153
+ expect(fetcher).toHaveBeenCalledTimes(2)
154
+ // 差し替え後の寿命は override 基準 (default の 200 秒ではない)
155
+ const bucket = buckets.get("k")
156
+ expect(bucket?.records).toEqual(["v2"])
157
+ expect((bucket?.expireAt ?? 0) - Date.now()).toBeLessThanOrEqual(1000)
158
+ })
159
+
160
+ it("裏フェッチが失敗しても、バケットが有効な間は旧値で即答し続ける (safe 方向)", async () => {
161
+ const { cache, buckets, inFlight } = makeCache()
162
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockRejectedValue(new Error("db down"))
163
+ await cache.getOrFetch({ cacheKey: "k", fetcher })
164
+ ageBucket(buckets, "k", 60_000)
165
+
166
+ const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
167
+ expect(hit).toEqual(["v1"])
168
+ // 裏フェッチの失敗を消化させる (unhandled rejection にならないことも暗黙に検証)
169
+ await vi.waitFor(() => expect(inFlight.size).toBe(0))
170
+ expect(buckets.get("k")?.records).toEqual(["v1"])
171
+ const hitAfterFailure = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
172
+ expect(hitAfterFailure).toEqual(["v1"])
173
+ await vi.waitFor(() => expect(inFlight.size).toBe(0))
174
+ })
175
+
176
+ it("裏フェッチ進行中に invalidate されたら結果を書き込まない (前面経路と同じ無効化レース規約)", async () => {
177
+ const { cache, buckets, inFlight } = makeCache()
178
+ let releaseSecond!: (v: readonly unknown[]) => void
179
+ const second = new Promise<readonly unknown[]>((resolve) => {
180
+ releaseSecond = resolve
181
+ })
182
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
183
+ await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher, refreshAheadRatio: 0.5 })
184
+ ageBucket(buckets, "daily-report:ids", 60_000)
185
+
186
+ await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher, refreshAheadRatio: 0.5 })
187
+ const backgroundPromise = inFlight.get("daily-report:ids")
188
+ expect(backgroundPromise).toBeDefined()
189
+
190
+ // 進行中に SSE 由来の無効化が走ったと想定
191
+ cache.invalidatePrefix("daily-report:ids")
192
+ releaseSecond(["stale-write"])
193
+ await backgroundPromise
194
+
195
+ // 無効化後のバケットへ古い結果を書き戻さないこと
196
+ expect(buckets.has("daily-report:ids")).toBe(false)
197
+ })
198
+
199
+ it("epochKey 併用 (本番構成) でも同様: getEpoch 中に落ちた無効化でも書き込まない", async () => {
200
+ // 本番の ids 経路は必ず epochKey 併用。ガード判定と書き込みの間に epoch の Redis await が
201
+ // 挟まると、その間の invalidate を取りこぼしてバケットを復活させてしまう退行を検知する
202
+ const { cache, buckets, inFlight } = makeCache(200_000, { value: 0 })
203
+ let releaseSecond!: (v: readonly unknown[]) => void
204
+ const second = new Promise<readonly unknown[]>((resolve) => {
205
+ releaseSecond = resolve
206
+ })
207
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
208
+ await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
209
+ ageBucket(buckets, "daily-report:ids", 60_000)
210
+
211
+ await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
212
+ expect(inFlight.has("daily-report:ids")).toBe(true)
213
+ const backgroundPromise = inFlight.get("daily-report:ids")
214
+ cache.invalidatePrefix("daily-report:ids")
215
+ releaseSecond(["stale-write"])
216
+ await backgroundPromise
217
+ expect(buckets.has("daily-report:ids")).toBe(false)
218
+ })
219
+
220
+ it("epoch はフェッチ開始前に採取する: フェッチ中の他ワーカー更新を新 epoch で偽装しない", async () => {
221
+ const epochValue = { value: 7 }
222
+ const { cache, buckets, inFlight } = makeCache(200_000, epochValue)
223
+ let releaseSecond!: (v: readonly unknown[]) => void
224
+ const second = new Promise<readonly unknown[]>((resolve) => {
225
+ releaseSecond = resolve
226
+ })
227
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
228
+ await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
229
+ expect(buckets.get("k")?.epoch).toBe(7)
230
+ ageBucket(buckets, "k", 60_000)
231
+
232
+ // 老化ヒット → 裏フェッチ起動 (epoch 7 を開始前に採取)
233
+ const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
234
+ expect(hit).toEqual(["v1"])
235
+ // フェッチ中に他ワーカーが更新 → epoch 9 へ (incrementEpoch 相当)
236
+ epochValue.value = 9
237
+ releaseSecond(["pre-mutation-data"])
238
+ await inFlight.get("k")
239
+ await vi.waitFor(() => expect(inFlight.size).toBe(0))
240
+
241
+ // ❗ 差し替えバケットの epoch は「開始前」の 7 であること。完了後採取 (9) だと
242
+ // 旧データが新 epoch を纏い、次のヒットの epoch 照合をすり抜けて stale を配ってしまう
243
+ expect(buckets.get("k")?.epoch).toBe(7)
244
+ // 次のヒットは epoch 不一致 (7 ≠ 9) を検出して再フェッチする (自己回復)
245
+ const fetcherAfter = vi.fn().mockResolvedValue(["post-mutation-data"])
246
+ const healed = await cache.getOrFetch({ cacheKey: "k", fetcher: fetcherAfter, epochKey: "e", refreshAheadRatio: 0.5 })
247
+ expect(healed).toEqual(["post-mutation-data"])
248
+ expect(fetcherAfter).toHaveBeenCalledTimes(1)
249
+ })
250
+
251
+ it("epochKey 併用のヒット経路でも老化ヒットで裏フェッチが起動する", async () => {
252
+ const { cache, buckets, inFlight } = makeCache()
253
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockResolvedValue(["v2"])
254
+ await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "epoch:k", refreshAheadRatio: 0.5 })
255
+ ageBucket(buckets, "k", 60_000)
256
+
257
+ const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "epoch:k", refreshAheadRatio: 0.5 })
258
+ expect(hit).toEqual(["v1"])
259
+ await vi.waitFor(() => expect(inFlight.size).toBe(0))
260
+ expect(buckets.get("k")?.records).toEqual(["v2"])
261
+ expect(fetcher).toHaveBeenCalledTimes(2)
262
+ })
263
+
264
+ it("裏フェッチ進行中に TTL が切れた前面リクエストは裏フェッチへ相乗りする (二重クエリ防止)", async () => {
265
+ const { cache, buckets, inFlight } = makeCache()
266
+ let releaseSecond!: (v: readonly unknown[]) => void
267
+ const second = new Promise<readonly unknown[]>((resolve) => {
268
+ releaseSecond = resolve
269
+ })
270
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
271
+ await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
272
+ ageBucket(buckets, "k", 60_000)
273
+ await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 }) // 裏フェッチ起動
274
+
275
+ // 裏フェッチ完了前に TTL が完全に切れる (意図的な失効。相乗りするので手動解決とデッドロックしない)
276
+ ageBucket(buckets, "k", -1)
277
+ const ridePromise = cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
278
+ releaseSecond(["v2"])
279
+ expect(await ridePromise).toEqual(["v2"])
280
+ expect(fetcher).toHaveBeenCalledTimes(2) // 相乗りできており 3 本目は走らない
281
+ await vi.waitFor(() => expect(inFlight.size).toBe(0))
282
+ expect(buckets.get("k")?.records).toEqual(["v2"])
283
+ })
284
+ })
285
+
286
+ describe("SqlResultCache foreground write-guard integrity", () => {
287
+ it("前面 finally の inFlight 掃除は identity チェック付き: 先行フェッチの完了が後続の登録を壊さない", async () => {
288
+ // シーケンス: F1 コールド開始 → 無効化 (F1 の登録を正しく削除) → F2 開始 (登録)
289
+ // → F1 完了 (finally が F2 の登録を消してはならない) → F2 完了 (バケット保存成功)
290
+ const epochStore = createEpochStore(undefined)
291
+ vi.spyOn(epochStore, "getEpoch").mockResolvedValue(0)
292
+ const cache = new SqlResultCache({ defaultTtlMs: 200_000 }, epochStore)
293
+ const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[] }> }).buckets
294
+
295
+ let releaseF1!: (v: readonly unknown[]) => void
296
+ const f1Gate = new Promise<readonly unknown[]>((resolve) => {
297
+ releaseF1 = resolve
298
+ })
299
+ let releaseF2!: (v: readonly unknown[]) => void
300
+ const f2Gate = new Promise<readonly unknown[]>((resolve) => {
301
+ releaseF2 = resolve
302
+ })
303
+ const fetcher = vi.fn().mockReturnValueOnce(f1Gate).mockReturnValueOnce(f2Gate)
304
+
305
+ const p1 = cache.getOrFetch({ cacheKey: "k", fetcher })
306
+ cache.invalidatePrefix("k") // F1 進行中に SSE 由来の無効化
307
+ const p2 = cache.getOrFetch({ cacheKey: "k", fetcher }) // 後続 F2 が登録し直す
308
+ releaseF1(["f1-stale"])
309
+ await p1 // F1 完了 (旧実装: ここで無条件 delete が F2 の登録を clobber していた)
310
+ releaseF2(["f2-fresh"])
311
+ expect(await p2).toEqual(["f2-fresh"])
312
+
313
+ // F2 の結果が正しくバケットへ保存されていること (毒殺されていないこと)
314
+ expect(buckets.get("k")?.records).toEqual(["f2-fresh"])
315
+ })
316
+
317
+ it("前面経路も epoch はフェッチ開始前に採取する (フェッチ中の他ワーカー更新を新 epoch で偽装しない)", async () => {
318
+ const epochValue = { value: 7 }
319
+ const epochStore = createEpochStore(undefined)
320
+ vi.spyOn(epochStore, "getEpoch").mockImplementation(async () => epochValue.value)
321
+ const cache = new SqlResultCache({ defaultTtlMs: 200_000 }, epochStore)
322
+ const buckets = (cache as unknown as { buckets: Map<string, { epoch: number }> }).buckets
323
+
324
+ let release!: (v: readonly unknown[]) => void
325
+ const gate = new Promise<readonly unknown[]>((resolve) => {
326
+ release = resolve
327
+ })
328
+ const fetchPromise = cache.getOrFetch({ cacheKey: "k", fetcher: () => gate, epochKey: "e" })
329
+ // コールドフェッチ中に他ワーカーが更新 → epoch 9 へ
330
+ epochValue.value = 9
331
+ release(["v1"])
332
+ await fetchPromise
333
+ // 開始前の 7 で刻まれていること (9 だと次ヒットの照合をすり抜けて stale を配る)
334
+ expect(buckets.get("k")?.epoch).toBe(7)
335
+ })
336
+
337
+ it("裏フェッチ finally の掃除も identity チェック付き: 無効化後に登録し直した後続を壊さない", async () => {
338
+ // 前面テストの鏡像。裏フェッチ (refresh-ahead) の finally が無条件 delete へ退行すると、
339
+ // 無効化 → 後続コールド F2 登録 → 裏フェッチ完了、の順で F2 の登録が消え書き込みガードが壊れる
340
+ const epochStore = createEpochStore(undefined)
341
+ vi.spyOn(epochStore, "getEpoch").mockResolvedValue(0)
342
+ const cache = new SqlResultCache({ defaultTtlMs: 200_000 }, epochStore)
343
+ const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[]; expireAt: number }> }).buckets
344
+ const inFlight = (cache as unknown as { inFlight: Map<string, Promise<readonly unknown[]>> }).inFlight
345
+
346
+ let releaseBg!: (v: readonly unknown[]) => void
347
+ const bgGate = new Promise<readonly unknown[]>((resolve) => {
348
+ releaseBg = resolve
349
+ })
350
+ let releaseF2!: (v: readonly unknown[]) => void
351
+ const f2Gate = new Promise<readonly unknown[]>((resolve) => {
352
+ releaseF2 = resolve
353
+ })
354
+ const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValueOnce(bgGate).mockReturnValueOnce(f2Gate)
355
+ await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
356
+ const bucket = buckets.get("k")
357
+ if (!bucket) throw new Error("bucket not found")
358
+ bucket.expireAt = Date.now() + 60_000 // 老化偽装
359
+
360
+ await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 }) // 裏フェッチ起動 (bgGate)
361
+ cache.invalidatePrefix("k") // 裏フェッチの登録を正しく削除
362
+ const p2 = cache.getOrFetch({ cacheKey: "k", fetcher }) // 後続コールド F2 が登録し直す
363
+ const f2Promise = inFlight.get("k")
364
+ expect(f2Promise).toBeDefined()
365
+
366
+ releaseBg(["bg-stale"])
367
+ await bgGate
368
+ await new Promise((resolve) => setImmediate(resolve))
369
+ // 裏フェッチの finally が F2 の登録を消していないこと
370
+ expect(inFlight.get("k")).toBe(f2Promise)
371
+
372
+ releaseF2(["f2-fresh"])
373
+ expect(await p2).toEqual(["f2-fresh"])
374
+ expect(buckets.get("k")?.records).toEqual(["f2-fresh"])
375
+ })
376
+
377
+ it("epoch 照合の await 中にバケットが無効化されたら refresh-ahead は静かに何もしない (生きバケット再読)", async () => {
378
+ // ヒット判定はエントリ時点のバケット参照で行われるが、epoch 照合の Redis await 中に
379
+ // invalidatePrefix が落ちるとバケットは消えている。maybeRefreshAhead が呼び出し元の
380
+ // スナップショットに触れると TypeError や不要な裏フェッチになるため、生きバケットの
381
+ // 再読 + null ガードで無反応になることを固定する
382
+ const epochStore = createEpochStore(undefined)
383
+ let releaseEpoch!: (v: number) => void
384
+ const epochGate = new Promise<number>((resolve) => {
385
+ releaseEpoch = resolve
386
+ })
387
+ const getEpoch = vi.spyOn(epochStore, "getEpoch")
388
+ getEpoch.mockResolvedValueOnce(0) // 初回保存時
389
+ getEpoch.mockReturnValueOnce(epochGate) // ヒット時の照合 (ゲート)
390
+ const cache = new SqlResultCache({ defaultTtlMs: 200_000 }, epochStore)
391
+ const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[]; expireAt: number }> }).buckets
392
+ const inFlight = (cache as unknown as { inFlight: Map<string, Promise<readonly unknown[]>> }).inFlight
393
+
394
+ const fetcher = vi.fn().mockResolvedValue(["v1"])
395
+ await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
396
+ const bucket = buckets.get("k")
397
+ if (!bucket) throw new Error("bucket not found")
398
+ bucket.expireAt = Date.now() + 60_000 // 老化偽装
399
+
400
+ const hitPromise = cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
401
+ cache.invalidatePrefix("k") // 照合 await 中の無効化
402
+ releaseEpoch(0) // epoch は一致 → ヒット継続 → maybeRefreshAhead 到達
403
+ const hit = await hitPromise
404
+ // エントリ時点の参照から旧値は返る (この挙動は従来どおり)
405
+ expect(hit).toEqual(["v1"])
406
+ // 消えたバケットに対して裏フェッチを起動しない (TypeError にもならない)
407
+ expect(fetcher).toHaveBeenCalledTimes(1)
408
+ expect(inFlight.size).toBe(0)
409
+ expect(buckets.has("k")).toBe(false)
410
+ })
411
+ })