@aiquants/daily-report 0.9.1 → 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/dist/server.d.mts +43 -1
- package/dist/server.d.ts +43 -1
- package/dist/server.js +9 -9
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +9 -9
- package/dist/server.mjs.map +1 -1
- package/package.json +3 -3
- package/src/server/cache.spec.ts +365 -1
- package/src/server/cache.ts +79 -3
- package/src/server/service.spec.ts +103 -1
- package/src/server/service.ts +48 -22
- package/src/server/sse-reader.spec.ts +248 -1
- package/src/server/sse-reader.ts +52 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiquants/daily-report",
|
|
3
|
-
"version": "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"
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"react-dom": ">=18",
|
|
82
82
|
"react-router": "^7.0.0",
|
|
83
83
|
"zod": ">=3.25.0 <5.0.0",
|
|
84
|
-
"@aiquants/
|
|
85
|
-
"@aiquants/
|
|
84
|
+
"@aiquants/swipe-overlay": "^1.2.4",
|
|
85
|
+
"@aiquants/virtualscroll": "^1.23.0"
|
|
86
86
|
},
|
|
87
87
|
"peerDependenciesMeta": {
|
|
88
88
|
"drizzle-orm": {
|
package/src/server/cache.spec.ts
CHANGED
|
@@ -11,7 +11,7 @@ describe("SqlResultCache Memory Cleanup", () => {
|
|
|
11
11
|
|
|
12
12
|
// 1. 期限切れのキャッシュを 500 件作成 (expireAt を過去に設定)
|
|
13
13
|
// 直接プライベートフィールド buckets に流し込むことでモック状態を作る
|
|
14
|
-
const buckets = (cache as
|
|
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
|
+
})
|
package/src/server/cache.ts
CHANGED
|
@@ -11,6 +11,14 @@ export type SqlResultCacheQueryOptions = {
|
|
|
11
11
|
snapshot?: boolean
|
|
12
12
|
ttlMsOverride?: number
|
|
13
13
|
epochKey?: string
|
|
14
|
+
/**
|
|
15
|
+
* ヒット時にバケット年齢が ttl × ratio を超えていたら、キャッシュ値を即返しつつ
|
|
16
|
+
* 裏で 1 本だけ再フェッチして置き換える (未指定は従来動作 = 何もしない)。
|
|
17
|
+
* 有効域は 0 <= ratio < 1: ヒット判定が「未失効」を要求するため年齢は常に ttl 未満であり、
|
|
18
|
+
* ratio 1 は事実上発火しない。ratio 0 は毎ヒット裏更新 (undefined とは区別される)。
|
|
19
|
+
* 定期的にアクセスされるキーの「TTL 切れ直後のコールド遭遇」を対話リクエストから排除する。
|
|
20
|
+
*/
|
|
21
|
+
refreshAheadRatio?: number
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
/**
|
|
@@ -71,6 +79,66 @@ export class SqlResultCache {
|
|
|
71
79
|
this.inFlight.delete(cacheKey)
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Kicks a single background refetch when the bucket has aged past ttl * ratio.
|
|
84
|
+
* バケット年齢が ttl × ratio を超えたヒット時に、裏で 1 本だけ再フェッチを起動する処理。
|
|
85
|
+
*
|
|
86
|
+
* 応答は誰も待たない。完了までは旧バケットがそのまま生きているため、並行するヒットは
|
|
87
|
+
* 従来どおりキャッシュから即答する (バケットを先に消す forceRefresh 委譲はスタンピードに
|
|
88
|
+
* なるため使わない)。バケットが有効な間は失敗しても旧バケットが残るだけ (safe 方向)。
|
|
89
|
+
* 裏フェッチ中に TTL が完全に切れて in-flight へ相乗りした前面リクエストには、通常の
|
|
90
|
+
* in-flight 共有と同じく失敗が伝播する (相乗りしなくても自前フェッチが同様に失敗する状況)。
|
|
91
|
+
* in-flight が既に居れば起動しない (前面リクエストの結果で足りる)。
|
|
92
|
+
*/
|
|
93
|
+
private maybeRefreshAhead = <T>(opts: { cacheKey: string; fetcher: () => Promise<readonly T[]> } & SqlResultCacheQueryOptions): void => {
|
|
94
|
+
const ratio = opts.refreshAheadRatio
|
|
95
|
+
if (ratio === undefined) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const { cacheKey } = opts
|
|
99
|
+
// 年齢は呼び出し元のスナップショットではなく生きているバケットで測る。epoch 照合の
|
|
100
|
+
// Redis await 中に進行中の裏フェッチが完了していると、古い expireAt のまま余分な
|
|
101
|
+
// 2 本目を起動してしまう (置換直後のバケットは若く、再フェッチ不要)
|
|
102
|
+
const bucket = this.buckets.get(cacheKey)
|
|
103
|
+
if (!bucket) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const ttl = opts.ttlMsOverride ?? this.config.defaultTtlMs
|
|
107
|
+
const age = ttl - (bucket.expireAt - Date.now())
|
|
108
|
+
if (age < ttl * ratio) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (this.inFlight.has(cacheKey)) {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 旧バケットを残したまま裏で取得し、完了時に差し替える。無効化レースの規約は
|
|
116
|
+
// 前面経路と同一: 進行中に invalidate されたら (inFlight の同一性が崩れたら) 書かない
|
|
117
|
+
let promise!: Promise<readonly T[]>
|
|
118
|
+
promise = (async () => {
|
|
119
|
+
// ❗ epoch はフェッチ「開始前」に採取する。完了後に採取すると、フェッチ中に他ワーカーが
|
|
120
|
+
// 更新 (incrementEpoch) した後の値で旧データに新 epoch を刻んでしまい、epoch 照合
|
|
121
|
+
// (staleness の主防御線) が丸ごと無効化される。開始前採取なら不一致 → 再フェッチで自己回復
|
|
122
|
+
const epoch = opts.epochKey ? await this.epochStore.getEpoch(opts.epochKey) : 0
|
|
123
|
+
const records = await opts.fetcher()
|
|
124
|
+
// ガード判定と書き込みの間に await を挟まない (この間に落ちた invalidate の取りこぼし防止)
|
|
125
|
+
if (this.inFlight.get(cacheKey) === promise) {
|
|
126
|
+
this.buckets.set(cacheKey, { records, expireAt: Date.now() + ttl, epoch })
|
|
127
|
+
}
|
|
128
|
+
return records
|
|
129
|
+
})()
|
|
130
|
+
// 登録することで、裏フェッチ中に TTL 切れした前面リクエストは相乗りできる
|
|
131
|
+
this.inFlight.set(cacheKey, promise as Promise<readonly unknown[]>)
|
|
132
|
+
promise
|
|
133
|
+
.catch(() => {})
|
|
134
|
+
.finally(() => {
|
|
135
|
+
// 無効化で消されたり別の登録に置き換わっていたら触らない
|
|
136
|
+
if (this.inFlight.get(cacheKey) === promise) {
|
|
137
|
+
this.inFlight.delete(cacheKey)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
74
142
|
/**
|
|
75
143
|
* Retrieves records from cache or fetches them if expired/missing.
|
|
76
144
|
* キャッシュからレコードを取得するか、期限切れや未存在の場合はフェッチします。
|
|
@@ -96,11 +164,13 @@ export class SqlResultCache {
|
|
|
96
164
|
// epochKey 指定時は Redis の epoch と比較して stale を検出
|
|
97
165
|
const currentEpoch = await this.epochStore.getEpoch(opts.epochKey)
|
|
98
166
|
if (bucket.epoch === currentEpoch) {
|
|
167
|
+
this.maybeRefreshAhead(opts)
|
|
99
168
|
return snapshot ? (bucket.records.map((r) => structuredClone(r)) as T[]) : (bucket.records as T[])
|
|
100
169
|
}
|
|
101
170
|
// epoch 不一致 → stale bucket を除去して re-fetch へ
|
|
102
171
|
this.buckets.delete(cacheKey)
|
|
103
172
|
} else {
|
|
173
|
+
this.maybeRefreshAhead(opts)
|
|
104
174
|
return snapshot ? (bucket.records.map((r) => structuredClone(r)) as T[]) : (bucket.records as T[])
|
|
105
175
|
}
|
|
106
176
|
}
|
|
@@ -115,13 +185,14 @@ export class SqlResultCache {
|
|
|
115
185
|
// 自己参照 (this.inFlight.get(cacheKey) === promise) のため、定義前参照を回避する確定代入アサーション
|
|
116
186
|
let promise!: Promise<readonly T[]>
|
|
117
187
|
promise = (async () => {
|
|
188
|
+
// ❗ epoch はフェッチ「開始前」に採取する (理由は maybeRefreshAhead と同一:
|
|
189
|
+
// フェッチ中の他ワーカー更新を新 epoch で偽装しない。ガードと書き込みも同期隣接に保つ)
|
|
190
|
+
const epoch = opts.epochKey ? await this.epochStore.getEpoch(opts.epochKey) : 0
|
|
118
191
|
const records = await opts.fetcher()
|
|
119
192
|
// invalidation 中に完了したリクエストはキャッシュに書き込まない(レース防止)
|
|
120
193
|
// ただし forceRefresh の場合は常に書き込む
|
|
121
194
|
if (forceRefresh || this.inFlight.get(cacheKey) === promise) {
|
|
122
195
|
const expireAt = Date.now() + (opts.ttlMsOverride ?? this.config.defaultTtlMs)
|
|
123
|
-
// キャッシュ保存時に現在の epoch を記録
|
|
124
|
-
const epoch = opts.epochKey ? await this.epochStore.getEpoch(opts.epochKey) : 0
|
|
125
196
|
this.buckets.set(cacheKey, { records, expireAt, epoch })
|
|
126
197
|
// 蓄積防止のため、サイズが一定値を超えたら期限切れキャッシュを一括クリーンアップ
|
|
127
198
|
if (this.buckets.size > 500) {
|
|
@@ -137,7 +208,12 @@ export class SqlResultCache {
|
|
|
137
208
|
const result = await promise
|
|
138
209
|
return snapshot ? result.map((r) => structuredClone(r)) : result
|
|
139
210
|
} finally {
|
|
140
|
-
|
|
211
|
+
// ❗ identity チェック必須: 無効化が本リクエストの登録を消した後に後続リクエストが
|
|
212
|
+
// 登録し直していた場合、無条件 delete は「後続」の書き込みガードまで壊してしまう
|
|
213
|
+
// (2 本のフェッチが完走したのにバケットが空、という毒殺の温床)
|
|
214
|
+
if (this.inFlight.get(cacheKey) === promise) {
|
|
215
|
+
this.inFlight.delete(cacheKey)
|
|
216
|
+
}
|
|
141
217
|
}
|
|
142
218
|
}
|
|
143
219
|
|
|
@@ -3,7 +3,7 @@ import { bigint, MsSqlDialect, mssqlSchema, nvarchar, primaryKey } from "drizzle
|
|
|
3
3
|
import { describe, expect, it } from "vitest"
|
|
4
4
|
import { createEpochStore, SqlResultCache } from "./cache"
|
|
5
5
|
import { defineDailyReportSchema } from "./schema"
|
|
6
|
-
import { createDailyReportService, type DailyReportDb, type DailyReportHubRow } from "./service"
|
|
6
|
+
import { createDailyReportService, type DailyReportDb, type DailyReportHubRow, formatDateValue } from "./service"
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Security regression tests for audit-actor masking in mapHubRecord.
|
|
@@ -437,3 +437,105 @@ describe("コメント操作の整合性チェック", () => {
|
|
|
437
437
|
expect(add?.recipientRawUserId).toBe(8)
|
|
438
438
|
})
|
|
439
439
|
})
|
|
440
|
+
|
|
441
|
+
describe("formatDateValue (Intl-free rewrite equivalence)", () => {
|
|
442
|
+
/**
|
|
443
|
+
* Reference implementation: the pre-fix Intl-based formatter, copied verbatim.
|
|
444
|
+
* 参照実装: 修正前の Intl ベース整形処理の逐語コピー。
|
|
445
|
+
*
|
|
446
|
+
* 等価性テストの oracle として使う。呼び出しごとに Intl.DateTimeFormat を生成するため
|
|
447
|
+
* 本体からは根絶済 (227k 行 map で 15〜20 秒の CPU 洪水になる)。テスト内 oracle 用途に限る。
|
|
448
|
+
*/
|
|
449
|
+
const referenceFormatDateValue = (value: Date | string | null | undefined, pattern: "YYYY-MM-DD HH:mm:ss" | "YYYY-MM-DD"): string | null => {
|
|
450
|
+
if (!value) {
|
|
451
|
+
return null
|
|
452
|
+
}
|
|
453
|
+
const dateValue = value instanceof Date ? value : new Date(value)
|
|
454
|
+
if (Number.isNaN(dateValue.getTime())) {
|
|
455
|
+
return typeof value === "string" ? value : null
|
|
456
|
+
}
|
|
457
|
+
const opts: Intl.DateTimeFormatOptions = pattern === "YYYY-MM-DD HH:mm:ss" ? { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" } : { year: "numeric", month: "2-digit", day: "2-digit" }
|
|
458
|
+
const formatted = pattern === "YYYY-MM-DD HH:mm:ss" ? dateValue.toLocaleString("ja-JP", opts) : dateValue.toLocaleDateString("ja-JP", opts)
|
|
459
|
+
return formatted.replace(/\//g, "-")
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** 再現可能な乱数列 (LCG)。テストを決定的にするため Math.random は使わない。 */
|
|
463
|
+
const makeLcg = (seed: number) => {
|
|
464
|
+
let state = seed >>> 0
|
|
465
|
+
return () => {
|
|
466
|
+
state = (state * 1664525 + 1013904223) >>> 0
|
|
467
|
+
return state / 0x1_0000_0000
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
it("ランダム 500 日時 × 両パターンで旧 Intl 実装と完全一致する (1900-2100 年域)", () => {
|
|
472
|
+
const rand = makeLcg(20260810)
|
|
473
|
+
const minMs = Date.UTC(1900, 0, 1)
|
|
474
|
+
const spanMs = Date.UTC(2100, 0, 1) - minMs
|
|
475
|
+
for (let i = 0; i < 500; i++) {
|
|
476
|
+
const d = new Date(minMs + Math.floor(rand() * spanMs))
|
|
477
|
+
for (const pattern of ["YYYY-MM-DD", "YYYY-MM-DD HH:mm:ss"] as const) {
|
|
478
|
+
expect(formatDateValue(d, pattern), `${d.toISOString()} / ${pattern}`).toBe(referenceFormatDateValue(d, pattern))
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it("境界値 (月替わり・年跨ぎ・一桁時分秒・真夜中・閏日・秒 59) で旧実装と完全一致する", () => {
|
|
484
|
+
const edges = [
|
|
485
|
+
new Date(2026, 0, 1, 0, 0, 0), // 年始・真夜中 (h23 で 00 になること)
|
|
486
|
+
new Date(2025, 11, 31, 23, 59, 59), // 年末・全フィールド最大
|
|
487
|
+
new Date(2026, 7, 31, 1, 2, 3), // 一桁時分秒 (ゼロ詰め)
|
|
488
|
+
new Date(2024, 1, 29, 12, 0, 0), // 閏日
|
|
489
|
+
new Date(2026, 8, 30, 9, 5, 0), // 月末 30 日
|
|
490
|
+
new Date(2026, 2, 1, 0, 0, 1), // 月初・秒 1
|
|
491
|
+
]
|
|
492
|
+
for (const d of edges) {
|
|
493
|
+
for (const pattern of ["YYYY-MM-DD", "YYYY-MM-DD HH:mm:ss"] as const) {
|
|
494
|
+
expect(formatDateValue(d, pattern), `${d.toISOString()} / ${pattern}`).toBe(referenceFormatDateValue(d, pattern))
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it("文字列入力 (ISO 日付・ISO 日時・スペース区切り) でも旧実装と完全一致する", () => {
|
|
500
|
+
// "YYYY-MM-DD" 形式は Date 仕様上 UTC 深夜として解釈されローカルへずれるが、
|
|
501
|
+
// その挙動ごと旧実装と一致していることが等価性の要件 (挙動変更ではないことの証明)
|
|
502
|
+
const inputs = ["2026-08-10", "2026-08-10T15:30:00", "2026-08-10 15:30:00", "2026-01-01T00:00:00.000Z"]
|
|
503
|
+
for (const s of inputs) {
|
|
504
|
+
for (const pattern of ["YYYY-MM-DD", "YYYY-MM-DD HH:mm:ss"] as const) {
|
|
505
|
+
expect(formatDateValue(s, pattern), `${s} / ${pattern}`).toBe(referenceFormatDateValue(s, pattern))
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it("1000 年未満は旧実装 (年ゼロ詰めなし) から意図的に乖離し、ISO 準拠の 4 桁ゼロ詰めで出力する", () => {
|
|
511
|
+
// 旧 Intl 実装は "202-08-10" を返す。新実装はより正しい "0202-08-10" に固定する
|
|
512
|
+
// (仕様 02 §8.1 に明示した意図的乖離。業務ドメイン 1900-2100 年域の等価性とは独立)
|
|
513
|
+
const ancient = new Date(202, 7, 10, 1, 2, 3)
|
|
514
|
+
expect(formatDateValue(ancient, "YYYY-MM-DD")).toBe("0202-08-10")
|
|
515
|
+
expect(formatDateValue(ancient, "YYYY-MM-DD HH:mm:ss")).toBe("0202-08-10 01:02:03")
|
|
516
|
+
// 負の年 (ISO 拡張表記の文字列入力のみで到達) は符号を除いた絶対値を 4 桁詰め。
|
|
517
|
+
// 素朴な String(-5).padStart(4, "0") は "00-5" と符号ごと詰める崩壊を起こすため固定する
|
|
518
|
+
const bce = new Date(Date.UTC(2000, 5, 15, 12))
|
|
519
|
+
bce.setFullYear(-5)
|
|
520
|
+
expect(formatDateValue(bce, "YYYY-MM-DD")).toMatch(/^-0005-/)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it("不変挙動: null / undefined / 空文字は null、解釈不能文字列は原文、Invalid Date は null を返す", () => {
|
|
524
|
+
expect(formatDateValue(null, "YYYY-MM-DD")).toBeNull()
|
|
525
|
+
expect(formatDateValue(undefined, "YYYY-MM-DD")).toBeNull()
|
|
526
|
+
expect(formatDateValue("", "YYYY-MM-DD HH:mm:ss")).toBeNull()
|
|
527
|
+
expect(formatDateValue("not-a-date", "YYYY-MM-DD")).toBe("not-a-date")
|
|
528
|
+
expect(formatDateValue(new Date(Number.NaN), "YYYY-MM-DD HH:mm:ss")).toBeNull()
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it("性能ガード: 100,000 回の整形が 2 秒未満で完了する (Intl 逆行の煙探知機)", () => {
|
|
532
|
+
// 手組み実装は約 0.1μs/呼。旧 Intl 実装は約 66μs/呼で 100k 回 ≈ 6.6 秒かかるため、
|
|
533
|
+
// 2 秒の粗い閾値でも逆行 (toLocale* への差し戻し) を確実に検知できる
|
|
534
|
+
const d = new Date(2026, 7, 10, 15, 30, 45)
|
|
535
|
+
const startMs = performance.now()
|
|
536
|
+
for (let i = 0; i < 100_000; i++) {
|
|
537
|
+
formatDateValue(d, "YYYY-MM-DD HH:mm:ss")
|
|
538
|
+
}
|
|
539
|
+
expect(performance.now() - startMs).toBeLessThan(2000)
|
|
540
|
+
})
|
|
541
|
+
})
|