@aiquants/daily-report 0.7.0 → 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.
- package/README.md +13 -6
- package/dist/client.d.mts +363 -21
- package/dist/client.d.ts +363 -21
- package/dist/client.js +5 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +76 -2
- package/dist/index.d.ts +76 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +2 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +7 -5
- package/dist/server.mjs.map +1 -1
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
- package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +56 -68
- package/src/client/components/daily-report-resolved-content.tsx +31 -7
- package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +149 -52
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/route-helpers.spec.ts +100 -0
- package/src/client/route-helpers.ts +20 -41
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
- package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
- package/src/client.ts +4 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +245 -5
- package/src/server/service.ts +40 -0
- package/src/server/test-helpers/handlers-config.ts +40 -1
- package/src/shared/ids-stream.ts +92 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NDJSON streaming tests for the `ids-stream` endpoint.
|
|
3
|
+
* `ids-stream` エンドポイントの NDJSON 配信テスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件のうち、特に重要なもの:
|
|
6
|
+
*
|
|
7
|
+
* - **旧 `ids` JSON エンドポイントは存在しない** (クリーンカットオーバーの回帰防止)。
|
|
8
|
+
* - **チャンク順序とカーソル連鎖が SQL の並び順 (営業日降順・ID 降順・NULL 営業日末尾) と
|
|
9
|
+
* 厳密に一致する。** ズレると再開時に行の欠落または重複が起きる。
|
|
10
|
+
* - **`total` は接続ごとの先頭行にのみ載る** (進捗分母の契約)。
|
|
11
|
+
* - **不正カーソルは fail-closed で 400** (DB アクセスなし)。
|
|
12
|
+
* - **`Cache-Control: no-transform`** — compression ミドルウェアのバッファリング防止が
|
|
13
|
+
* ストリーミングの成立条件そのもの。
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from "vitest"
|
|
16
|
+
import type { DailyReportIdsStreamChunkLine, DailyReportIdsStreamLine } from "../shared/ids-stream"
|
|
17
|
+
import type { DailyReportItem } from "../shared/types"
|
|
18
|
+
import { createDailyReportHandlers } from "./handlers"
|
|
19
|
+
import { makeApiArgs, makeHandlersFixture, TEST_EXTERNAL_USER_ID } from "./test-helpers/handlers-config"
|
|
20
|
+
|
|
21
|
+
/** ストリーム順 (営業日降順・ID 降順) の合成アイテムを count 件生成する。 */
|
|
22
|
+
const makeItems = (count: number): DailyReportItem[] => {
|
|
23
|
+
// 10 件ごとに営業日を 1 日下げ、同日内は ID 降順になる (日付演算で常に正規形 YYYY-MM-DD を保つ)
|
|
24
|
+
const baseUtc = Date.UTC(2026, 6, 31)
|
|
25
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
26
|
+
reportHubId: count - index,
|
|
27
|
+
businessDate: new Date(baseUtc - Math.floor(index / 10) * 86_400_000).toISOString().slice(0, 10),
|
|
28
|
+
sourceType: "Internal",
|
|
29
|
+
}))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** レスポンス全体をドレインし、NDJSON 行の配列へパースする。 */
|
|
33
|
+
const readNdjsonLines = async (response: Response): Promise<DailyReportIdsStreamLine[]> => {
|
|
34
|
+
const text = await response.text()
|
|
35
|
+
return text
|
|
36
|
+
.split("\n")
|
|
37
|
+
.filter((line) => line.trim() !== "")
|
|
38
|
+
.map((line) => JSON.parse(line) as DailyReportIdsStreamLine)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** チャンク行だけを取り出す (エラー行が混じればテストを落とす)。 */
|
|
42
|
+
const asChunks = (lines: DailyReportIdsStreamLine[]): DailyReportIdsStreamChunkLine[] => {
|
|
43
|
+
return lines.map((line) => {
|
|
44
|
+
expect(line).toHaveProperty("items")
|
|
45
|
+
return line as DailyReportIdsStreamChunkLine
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("ids-stream / 入口の検証", () => {
|
|
50
|
+
it("未認証は 401 を返し、サービスを呼ばない", async () => {
|
|
51
|
+
const { config, calls } = makeHandlersFixture({ user: null })
|
|
52
|
+
const { api } = createDailyReportHandlers(config)
|
|
53
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
54
|
+
expect(response.status).toBe(401)
|
|
55
|
+
expect(calls.getDailyReportIds).toHaveLength(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("GET 以外は 405 を返す", async () => {
|
|
59
|
+
const { config } = makeHandlersFixture()
|
|
60
|
+
const { api } = createDailyReportHandlers(config)
|
|
61
|
+
const response = await api.loader(makeApiArgs("ids-stream", { method: "POST" }))
|
|
62
|
+
expect(response.status).toBe(405)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("旧 ids エンドポイントは存在せず 404 を返す (クリーンカットオーバーの固定)", async () => {
|
|
66
|
+
const { config } = makeHandlersFixture()
|
|
67
|
+
const { api } = createDailyReportHandlers(config)
|
|
68
|
+
const response = await api.loader(makeApiArgs("ids"))
|
|
69
|
+
expect(response.status).toBe(404)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("ストリーミング応答のヘッダー契約 (x-ndjson / no-transform / nosniff / Set-Cookie 伝搬)", async () => {
|
|
73
|
+
const { config } = makeHandlersFixture({ cookie: "session=abc", idsStreamItems: makeItems(1) })
|
|
74
|
+
const { api } = createDailyReportHandlers(config)
|
|
75
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
76
|
+
expect(response.status).toBe(200)
|
|
77
|
+
expect(response.headers.get("Content-Type")).toBe("application/x-ndjson; charset=utf-8")
|
|
78
|
+
expect(response.headers.get("Cache-Control")).toBe("private, no-cache, no-transform")
|
|
79
|
+
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff")
|
|
80
|
+
expect(response.headers.get("Set-Cookie")).toBe("session=abc")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("新規接続でサービス例外 (全量・先頭ページとも失敗) は定型エラー行 + 正常クローズ", async () => {
|
|
84
|
+
const { config } = makeHandlersFixture({ idsStreamError: new Error("db down: SanwaPython secret") })
|
|
85
|
+
const { api } = createDailyReportHandlers(config)
|
|
86
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
87
|
+
// ストリーミングは開始済みなので 200 のままエラー行で通知する (クライアントは再開する)
|
|
88
|
+
expect(response.status).toBe(200)
|
|
89
|
+
const body = await response.text()
|
|
90
|
+
expect(body).not.toContain("SanwaPython")
|
|
91
|
+
const lines = body
|
|
92
|
+
.split("\n")
|
|
93
|
+
.filter((line) => line.trim() !== "")
|
|
94
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
95
|
+
expect(lines).toHaveLength(1)
|
|
96
|
+
expect(lines[0]).toEqual({ error: "stream interrupted by server error" })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("再開接続でサービス例外はストリーム開始前に 500 JSON へ落ちる", async () => {
|
|
100
|
+
const { config } = makeHandlersFixture({ idsStreamError: new Error("db down: SanwaPython secret") })
|
|
101
|
+
const { api } = createDailyReportHandlers(config)
|
|
102
|
+
const response = await api.loader(makeApiArgs("ids-stream", { query: "?cursor_report_hub_id=10&cursor_business_date=2026-07-31" }))
|
|
103
|
+
expect(response.status).toBe(500)
|
|
104
|
+
expect(response.headers.get("Content-Type")).toContain("application/json")
|
|
105
|
+
expect(await response.text()).not.toContain("SanwaPython")
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe("ids-stream / 高速先頭ページ (キャッシュミス時の初回描画短縮)", () => {
|
|
110
|
+
it("全量クエリが遅い場合、先頭ページが先に届き、全量確定後の行に total が載る", async () => {
|
|
111
|
+
const items = makeItems(1100)
|
|
112
|
+
let releaseFull: (() => void) | null = null
|
|
113
|
+
const fullGate = new Promise<void>((resolve) => {
|
|
114
|
+
releaseFull = resolve
|
|
115
|
+
})
|
|
116
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: fullGate })
|
|
117
|
+
const { api } = createDailyReportHandlers(config)
|
|
118
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
119
|
+
const reader = response.body?.getReader()
|
|
120
|
+
if (!reader) throw new Error("body must be a stream")
|
|
121
|
+
const decoder = new TextDecoder()
|
|
122
|
+
|
|
123
|
+
// 1 行目: 全量を待たずに先頭ページ (200 件・total 無し) が届く
|
|
124
|
+
const first = await reader.read()
|
|
125
|
+
const firstLine = JSON.parse(decoder.decode(first.value)) as DailyReportIdsStreamChunkLine
|
|
126
|
+
expect(firstLine.items).toEqual(items.slice(0, 200))
|
|
127
|
+
expect(firstLine.total).toBeUndefined()
|
|
128
|
+
expect(firstLine.isLast).toBe(false)
|
|
129
|
+
expect(firstLine.nextCursor).toEqual({ businessDate: items[199].businessDate, reportHubId: items[199].reportHubId })
|
|
130
|
+
expect(calls.getDailyReportIdsFirstPage).toEqual([[TEST_EXTERNAL_USER_ID, 200]])
|
|
131
|
+
|
|
132
|
+
// 全量を解放すると、続きの行 (先頭ページの続きから) に total が載る
|
|
133
|
+
releaseFull?.()
|
|
134
|
+
let rest = ""
|
|
135
|
+
while (true) {
|
|
136
|
+
const { done, value } = await reader.read()
|
|
137
|
+
if (done) break
|
|
138
|
+
rest += decoder.decode(value, { stream: true })
|
|
139
|
+
}
|
|
140
|
+
const restLines = rest
|
|
141
|
+
.split("\n")
|
|
142
|
+
.filter((line) => line.trim() !== "")
|
|
143
|
+
.map((line) => JSON.parse(line) as DailyReportIdsStreamChunkLine)
|
|
144
|
+
expect(restLines[0].total).toBe(1100)
|
|
145
|
+
expect(restLines.slice(1).every((line) => line.total === undefined)).toBe(true)
|
|
146
|
+
// 先頭ページ + 続きの連結が全量と厳密一致 (欠落・重複なし)
|
|
147
|
+
expect([...firstLine.items, ...restLines.flatMap((line) => line.items)]).toEqual(items)
|
|
148
|
+
expect(restLines[restLines.length - 1].isLast).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("先頭ページが上限未満なら全量クエリを待たずに完結する (小規模データセットの最速経路)", async () => {
|
|
152
|
+
const items = makeItems(5)
|
|
153
|
+
// 全量は永遠に解決しない (待っていないことの証明)
|
|
154
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: new Promise<void>(() => {}) })
|
|
155
|
+
const { api } = createDailyReportHandlers(config)
|
|
156
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
157
|
+
expect(lines).toHaveLength(1)
|
|
158
|
+
expect(lines[0]).toEqual({ items, nextCursor: null, isLast: true, total: 5 })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("先頭ページ送出後に全量クエリが失敗したら、定型エラー行 + 正常クローズ (受信済みの続きから再開できる)", async () => {
|
|
162
|
+
const items = makeItems(1100)
|
|
163
|
+
// 全量クエリだけを失敗させる (先頭ページは成功して 200 件が届いている)。
|
|
164
|
+
// 生成直後に catch を付けて未処理拒否警告を防ぐ (await 側では正しく throw される)
|
|
165
|
+
const failedGate = Promise.reject(new Error("full query down: SanwaPython secret"))
|
|
166
|
+
failedGate.catch(() => {})
|
|
167
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: failedGate })
|
|
168
|
+
const { api } = createDailyReportHandlers(config)
|
|
169
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
170
|
+
expect(response.status).toBe(200)
|
|
171
|
+
const body = await response.text()
|
|
172
|
+
expect(body).not.toContain("SanwaPython")
|
|
173
|
+
const lines = body
|
|
174
|
+
.split("\n")
|
|
175
|
+
.filter((line) => line.trim() !== "")
|
|
176
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
177
|
+
// 1 行目: 先頭ページ (200 件・total 無し・isLast 無し)
|
|
178
|
+
expect((lines[0] as DailyReportIdsStreamChunkLine).items).toHaveLength(200)
|
|
179
|
+
expect((lines[0] as DailyReportIdsStreamChunkLine).isLast).toBe(false)
|
|
180
|
+
expect((lines[0] as DailyReportIdsStreamChunkLine).total).toBeUndefined()
|
|
181
|
+
// 2 行目: 内部情報を含まない定型エラー行 (クライアントはカーソル再開する)
|
|
182
|
+
expect(lines[1]).toEqual({ error: "stream interrupted by server error" })
|
|
183
|
+
expect(lines).toHaveLength(2)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("キャッシュヒット相当 (全量が即時) では従来どおり先頭行に total が載る", async () => {
|
|
187
|
+
const items = makeItems(5)
|
|
188
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
189
|
+
const { api } = createDailyReportHandlers(config)
|
|
190
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
191
|
+
expect(lines[0].total).toBe(5)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("再開接続は先頭ページクエリを発行しない (受信済みの続きだけを読む)", async () => {
|
|
195
|
+
const items = makeItems(5)
|
|
196
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: items })
|
|
197
|
+
const { api } = createDailyReportHandlers(config)
|
|
198
|
+
await (await api.loader(makeApiArgs("ids-stream", { query: `?cursor_report_hub_id=${items[1].reportHubId}&cursor_business_date=${items[1].businessDate}` }))).text()
|
|
199
|
+
expect(calls.getDailyReportIdsFirstPage).toHaveLength(0)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe("ids-stream / チャンク分割と total", () => {
|
|
204
|
+
it("空データセットは total 0 の単一終端行になる", async () => {
|
|
205
|
+
const { config } = makeHandlersFixture({ idsStreamItems: [] })
|
|
206
|
+
const { api } = createDailyReportHandlers(config)
|
|
207
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
208
|
+
expect(lines).toHaveLength(1)
|
|
209
|
+
expect(lines[0]).toEqual({ items: [], nextCursor: null, isLast: true, total: 0 })
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("小規模データは 1 行で全件 + total + isLast", async () => {
|
|
213
|
+
const items = makeItems(5)
|
|
214
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: items })
|
|
215
|
+
const { api } = createDailyReportHandlers(config)
|
|
216
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
217
|
+
expect(lines).toHaveLength(1)
|
|
218
|
+
expect(lines[0].items).toEqual(items)
|
|
219
|
+
expect(lines[0].total).toBe(5)
|
|
220
|
+
expect(lines[0].isLast).toBe(true)
|
|
221
|
+
expect(lines[0].nextCursor).toBeNull()
|
|
222
|
+
expect(calls.getDailyReportIds).toEqual([[TEST_EXTERNAL_USER_ID, false]])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("9000 件は 200/4000/4000/800 の 4 チャンクに割れ、total は先頭行のみ、カーソルが連鎖する", async () => {
|
|
226
|
+
const items = makeItems(9000)
|
|
227
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
228
|
+
const { api } = createDailyReportHandlers(config)
|
|
229
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
230
|
+
|
|
231
|
+
expect(lines.map((line) => line.items.length)).toEqual([200, 4000, 4000, 800])
|
|
232
|
+
expect(lines[0].total).toBe(9000)
|
|
233
|
+
expect(lines.slice(1).every((line) => line.total === undefined)).toBe(true)
|
|
234
|
+
|
|
235
|
+
// カーソルは各チャンク末尾アイテムのキーと一致する
|
|
236
|
+
expect(lines[0].nextCursor).toEqual({ businessDate: items[199].businessDate, reportHubId: items[199].reportHubId })
|
|
237
|
+
expect(lines[1].nextCursor).toEqual({ businessDate: items[4199].businessDate, reportHubId: items[4199].reportHubId })
|
|
238
|
+
expect(lines[2].nextCursor).toEqual({ businessDate: items[8199].businessDate, reportHubId: items[8199].reportHubId })
|
|
239
|
+
expect(lines[3].nextCursor).toBeNull()
|
|
240
|
+
expect(lines.map((line) => line.isLast)).toEqual([false, false, false, true])
|
|
241
|
+
|
|
242
|
+
// 全チャンクの連結が元配列と厳密一致する (欠落・重複なし)
|
|
243
|
+
expect(lines.flatMap((line) => line.items)).toEqual(items)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("forceRefresh=true はサービスへ透過する", async () => {
|
|
247
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: makeItems(1) })
|
|
248
|
+
const { api } = createDailyReportHandlers(config)
|
|
249
|
+
await (await api.loader(makeApiArgs("ids-stream", { query: "?forceRefresh=true" }))).text()
|
|
250
|
+
expect(calls.getDailyReportIds).toEqual([[TEST_EXTERNAL_USER_ID, true]])
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe("ids-stream / カーソル再開", () => {
|
|
255
|
+
it("中間カーソルからの再開は「厳密に後」の行から欠落・重複なく続く", async () => {
|
|
256
|
+
const items = makeItems(1100)
|
|
257
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
258
|
+
const { api } = createDailyReportHandlers(config)
|
|
259
|
+
|
|
260
|
+
const cursorItem = items[199]
|
|
261
|
+
const query = `?cursor_report_hub_id=${cursorItem.reportHubId}&cursor_business_date=${cursorItem.businessDate}`
|
|
262
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
263
|
+
|
|
264
|
+
const resumed = lines.flatMap((line) => line.items)
|
|
265
|
+
expect(resumed).toEqual(items.slice(200))
|
|
266
|
+
// 再開接続でも先頭行に最新の total が載り直す
|
|
267
|
+
expect(lines[0].total).toBe(1100)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("NULL 営業日領域は cursor_report_hub_id 単独で再開できる", async () => {
|
|
271
|
+
const dated = makeItems(3)
|
|
272
|
+
const nullRegion: DailyReportItem[] = [
|
|
273
|
+
{ reportHubId: 900, businessDate: null, sourceType: "NI" },
|
|
274
|
+
{ reportHubId: 800, businessDate: null, sourceType: "NI" },
|
|
275
|
+
{ reportHubId: 700, businessDate: null, sourceType: "NI" },
|
|
276
|
+
]
|
|
277
|
+
const { config } = makeHandlersFixture({ idsStreamItems: [...dated, ...nullRegion] })
|
|
278
|
+
const { api } = createDailyReportHandlers(config)
|
|
279
|
+
|
|
280
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query: "?cursor_report_hub_id=900" }))))
|
|
281
|
+
expect(lines.flatMap((line) => line.items)).toEqual([
|
|
282
|
+
{ reportHubId: 800, businessDate: null, sourceType: "NI" },
|
|
283
|
+
{ reportHubId: 700, businessDate: null, sourceType: "NI" },
|
|
284
|
+
])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("末尾以降を指すカーソルは空チャンク + isLast で正常終端する", async () => {
|
|
288
|
+
const items = makeItems(5)
|
|
289
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
290
|
+
const { api } = createDailyReportHandlers(config)
|
|
291
|
+
const last = items[items.length - 1]
|
|
292
|
+
const query = `?cursor_report_hub_id=${last.reportHubId}&cursor_business_date=${last.businessDate}`
|
|
293
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
294
|
+
expect(lines).toHaveLength(1)
|
|
295
|
+
expect(lines[0]).toEqual({ items: [], nextCursor: null, isLast: true, total: 5 })
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it.each([
|
|
299
|
+
["営業日のみ (位置を決められない)", "?cursor_business_date=2026-07-31"],
|
|
300
|
+
["日付形式違反 (スラッシュ)", "?cursor_report_hub_id=10&cursor_business_date=2026/07/31"],
|
|
301
|
+
["日付形式違反 (桁欠け)", "?cursor_report_hub_id=10&cursor_business_date=2026-7-31"],
|
|
302
|
+
["非整数 ID", "?cursor_report_hub_id=abc"],
|
|
303
|
+
["小数 ID", "?cursor_report_hub_id=12.5"],
|
|
304
|
+
["ゼロ ID", "?cursor_report_hub_id=0"],
|
|
305
|
+
["負数 ID", "?cursor_report_hub_id=-1"],
|
|
306
|
+
["先頭ゼロ ID (正規形でない)", "?cursor_report_hub_id=007"],
|
|
307
|
+
])("不正カーソル (%s) は 400 で、サービスを呼ばない", async (_label, query) => {
|
|
308
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: makeItems(3) })
|
|
309
|
+
const { api } = createDailyReportHandlers(config)
|
|
310
|
+
const response = await api.loader(makeApiArgs("ids-stream", { query }))
|
|
311
|
+
expect(response.status).toBe(400)
|
|
312
|
+
expect(calls.getDailyReportIds).toHaveLength(0)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe("ids-stream / 逐次配信", () => {
|
|
317
|
+
it("チャンクは 1 つの巨大な書き込みではなく複数回の enqueue で届く (pull 単位のバックプレッシャー)", async () => {
|
|
318
|
+
const items = makeItems(9000)
|
|
319
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
320
|
+
const { api } = createDailyReportHandlers(config)
|
|
321
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
322
|
+
const body = response.body
|
|
323
|
+
if (!body) throw new Error("body must be a stream")
|
|
324
|
+
|
|
325
|
+
// reader で読み、read() の回数 (= enqueue の粒度) を数える
|
|
326
|
+
const reader = body.getReader()
|
|
327
|
+
const decoder = new TextDecoder()
|
|
328
|
+
let readCount = 0
|
|
329
|
+
let text = ""
|
|
330
|
+
while (true) {
|
|
331
|
+
const { done, value } = await reader.read()
|
|
332
|
+
if (done) break
|
|
333
|
+
readCount += 1
|
|
334
|
+
text += decoder.decode(value, { stream: true })
|
|
335
|
+
}
|
|
336
|
+
// 200/4000/4000/800 の 4 チャンク = 4 回以上の read で届く (全体一括 1 回ではない)
|
|
337
|
+
expect(readCount).toBeGreaterThanOrEqual(4)
|
|
338
|
+
expect(text.split("\n").filter((line) => line.trim() !== "")).toHaveLength(4)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it("reader.cancel() は例外なく受理される (切断安全)", async () => {
|
|
342
|
+
const { config } = makeHandlersFixture({ idsStreamItems: makeItems(1100) })
|
|
343
|
+
const { api } = createDailyReportHandlers(config)
|
|
344
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
345
|
+
const reader = response.body?.getReader()
|
|
346
|
+
if (!reader) throw new Error("body must be a stream")
|
|
347
|
+
await reader.read()
|
|
348
|
+
await expect(reader.cancel()).resolves.toBeUndefined()
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe("ids-stream / 中断", () => {
|
|
353
|
+
it("送出前に中断済みのリクエストは 1 行も流さずクローズする", async () => {
|
|
354
|
+
const { config } = makeHandlersFixture({ idsStreamItems: makeItems(50) })
|
|
355
|
+
const { api } = createDailyReportHandlers(config)
|
|
356
|
+
const abortController = new AbortController()
|
|
357
|
+
abortController.abort()
|
|
358
|
+
const response = await api.loader(makeApiArgs("ids-stream", { signal: abortController.signal }))
|
|
359
|
+
expect(response.status).toBe(200)
|
|
360
|
+
expect(await response.text()).toBe("")
|
|
361
|
+
})
|
|
362
|
+
})
|
package/src/server/handlers.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* - sse.loader: Redis Streams ベースのリアルタイム更新 SSE エンドポイント
|
|
8
8
|
*/
|
|
9
9
|
import { normalizeBusinessDateKey } from "../shared/business-date"
|
|
10
|
+
import { type DailyReportIdsStreamChunkLine, type DailyReportIdsStreamCursor, findIdsStreamResumeIndex } from "../shared/ids-stream"
|
|
10
11
|
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
11
12
|
import { dailyReportSseMessageSchema } from "../shared/sse-schema"
|
|
13
|
+
import type { DailyReportItem } from "../shared/types"
|
|
12
14
|
import type { DailyReportAttachmentFailure, DailyReportAuthenticate, DailyReportEncodeUserId, DailyReportIdCodec, DailyReportReadAttachment, DailyReportRedisProvider } from "./ports"
|
|
13
15
|
import { jsonResponseWithETag } from "./response"
|
|
14
16
|
import type { DailyReportService } from "./service"
|
|
@@ -24,6 +26,171 @@ const jsonData = (payload: unknown, init?: { status?: number }): Response =>
|
|
|
24
26
|
headers: { "Content-Type": "application/json" },
|
|
25
27
|
})
|
|
26
28
|
|
|
29
|
+
// ---------------- ids NDJSON ストリームの定数とヘルパー (純粋・モジュールスコープ) ----------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* First-chunk size, tuned for time-to-first-render.
|
|
33
|
+
* 初回チャンクのアイテム数。最初の描画までの時間を優先して小さく保つ。
|
|
34
|
+
*
|
|
35
|
+
* 一覧の可視行 + オーバースキャンを一度に満たせる件数であればよい。
|
|
36
|
+
*/
|
|
37
|
+
const IDS_STREAM_FIRST_CHUNK_SIZE = 200
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 2 チャンク目以降のアイテム数。1 アイテムが数十バイト (id/日付/種別のみ) と軽いため
|
|
41
|
+
* 大きめに運ぶ。実データは 20 万件超に達するため、小さすぎると行数 (= pull 往復と
|
|
42
|
+
* クライアント側 publish 回数) が肥大する (4000 件 ≈ 240 KB/行、20 万件で約 50 行)。
|
|
43
|
+
*/
|
|
44
|
+
const IDS_STREAM_NEXT_CHUNK_SIZE = 4000
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sanitized message for mid-stream failures (no internal details).
|
|
48
|
+
* ストリーム送出中の失敗をクライアントへ伝える定型文 (内部情報は載せない)。
|
|
49
|
+
*/
|
|
50
|
+
const IDS_STREAM_ERROR_CLIENT_MESSAGE = "stream interrupted by server error"
|
|
51
|
+
|
|
52
|
+
/** 再開カーソルの営業日として受け付ける唯一の形式 (正規化済み YYYY-MM-DD)。 */
|
|
53
|
+
const IDS_STREAM_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parses and validates resume-cursor query params (fail-closed).
|
|
57
|
+
* 再開カーソルのクエリパラメータを検証付きで解釈する処理 (不正は fail-closed)。
|
|
58
|
+
*
|
|
59
|
+
* - `cursor_report_hub_id` 単独 = 「営業日なし領域」のカーソル (businessDate: null)。
|
|
60
|
+
* - `cursor_business_date` は `cursor_report_hub_id` 無しでは意味を持たないため 400。
|
|
61
|
+
*
|
|
62
|
+
* @param url Request URL. リクエスト URL。
|
|
63
|
+
* @returns Cursor (null = 先頭から) か、検証失敗の印。
|
|
64
|
+
*/
|
|
65
|
+
const parseIdsStreamCursor = (url: URL): { cursor: DailyReportIdsStreamCursor | null } | { invalid: true } => {
|
|
66
|
+
const rawId = url.searchParams.get("cursor_report_hub_id")
|
|
67
|
+
const rawDate = url.searchParams.get("cursor_business_date")
|
|
68
|
+
|
|
69
|
+
if (rawId === null) {
|
|
70
|
+
// 営業日だけのカーソルは並び順上の位置を決められない
|
|
71
|
+
return rawDate === null ? { cursor: null } : { invalid: true }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const reportHubId = Number.parseInt(rawId, 10)
|
|
75
|
+
if (!Number.isSafeInteger(reportHubId) || String(reportHubId) !== rawId || reportHubId <= 0) {
|
|
76
|
+
return { invalid: true }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (rawDate === null) {
|
|
80
|
+
return { cursor: { businessDate: null, reportHubId } }
|
|
81
|
+
}
|
|
82
|
+
if (!IDS_STREAM_DATE_PATTERN.test(rawDate)) {
|
|
83
|
+
return { invalid: true }
|
|
84
|
+
}
|
|
85
|
+
return { cursor: { businessDate: rawDate, reportHubId } }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Emits a finalized id array as chunked NDJSON lines from a resume position.
|
|
90
|
+
* 確定済み ID 配列を再開位置からチャンク行として送出するジェネレーター。
|
|
91
|
+
*
|
|
92
|
+
* `total` は最初に yield する行へ載せる (= 接続内で全量が確定した最初の行。
|
|
93
|
+
* キャッシュヒットの新規接続と再開接続では先頭行、高速先頭ページ経路では 2 行目)。
|
|
94
|
+
*
|
|
95
|
+
* @param items Stream-ordered full array. ストリーム順の全量配列。
|
|
96
|
+
* @param resumeKey Resume position (null = 先頭から)。
|
|
97
|
+
* @param firstChunkSize このジェネレーターの最初のチャンクサイズ。
|
|
98
|
+
*/
|
|
99
|
+
async function* streamLinesFromFullArray(items: DailyReportItem[], resumeKey: DailyReportIdsStreamCursor | null, firstChunkSize = IDS_STREAM_FIRST_CHUNK_SIZE): AsyncGenerator<DailyReportIdsStreamChunkLine> {
|
|
100
|
+
let position = resumeKey ? findIdsStreamResumeIndex(items, resumeKey) : 0
|
|
101
|
+
let isFirstYield = true
|
|
102
|
+
while (true) {
|
|
103
|
+
const chunkItems = items.slice(position, position + (isFirstYield ? firstChunkSize : IDS_STREAM_NEXT_CHUNK_SIZE))
|
|
104
|
+
position += chunkItems.length
|
|
105
|
+
const isLast = position >= items.length
|
|
106
|
+
const lastItem = chunkItems[chunkItems.length - 1]
|
|
107
|
+
yield {
|
|
108
|
+
items: chunkItems,
|
|
109
|
+
nextCursor: isLast || !lastItem ? null : { businessDate: lastItem.businessDate, reportHubId: lastItem.reportHubId },
|
|
110
|
+
isLast,
|
|
111
|
+
...(isFirstYield ? { total: items.length } : {}),
|
|
112
|
+
}
|
|
113
|
+
isFirstYield = false
|
|
114
|
+
if (isLast) return
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Wraps a line iterator into a pull-based NDJSON streaming Response.
|
|
120
|
+
* 行イテレーターを pull 駆動の NDJSON ストリーミング Response へ包む処理。
|
|
121
|
+
*
|
|
122
|
+
* - 切断検知時はイテレーターを畳んで (`return()`) DB 待ちを解放し、close する
|
|
123
|
+
* (素の return は desiredSize > 0 のまま pull が再帰する)。
|
|
124
|
+
* - イテレーターの throw は内部情報を含まない定型エラー行 + 正常クローズへ倒す
|
|
125
|
+
* (クライアントは「isLast 無し終端」をカーソル再開のシグナルとして扱う)。
|
|
126
|
+
*/
|
|
127
|
+
const buildIdsStreamResponse = (cookie: string | null, lines: AsyncGenerator<DailyReportIdsStreamChunkLine>, request: Request, logger: DailyReportLogger): Response => {
|
|
128
|
+
const encoder = new TextEncoder()
|
|
129
|
+
// cancel() の lines.return() は「pull が DB 待ちの next() を保留中」だと先取りできない。
|
|
130
|
+
// 待ちが明けた後の enqueue が閉じたストリームへ落ちて catch に入るため、切断は
|
|
131
|
+
// フラグで決定的に識別し、通常イベントとして静かに畳む (ERROR ログにしない —
|
|
132
|
+
// 全量クエリ中の離脱は日常イベントで、本物の送出失敗が雑音に埋もれる)
|
|
133
|
+
let cancelled = false
|
|
134
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
135
|
+
async pull(controller) {
|
|
136
|
+
if (cancelled || request.signal.aborted) {
|
|
137
|
+
await lines.return(undefined).catch(() => {})
|
|
138
|
+
try {
|
|
139
|
+
controller.close()
|
|
140
|
+
} catch {
|
|
141
|
+
// cancel 済みコントローラーの close は失敗してよい
|
|
142
|
+
}
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const { value, done } = await lines.next()
|
|
147
|
+
if (done) {
|
|
148
|
+
controller.close()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
// 保留中の next() の間に切断された場合はここで検知して静かに終える
|
|
152
|
+
if (cancelled || request.signal.aborted) {
|
|
153
|
+
await lines.return(undefined).catch(() => {})
|
|
154
|
+
try {
|
|
155
|
+
controller.close()
|
|
156
|
+
} catch {
|
|
157
|
+
// cancel 済みコントローラーの close は失敗してよい
|
|
158
|
+
}
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`))
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (cancelled || request.signal.aborted) {
|
|
164
|
+
// 切断起因の enqueue/close 失敗はエラーではない
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
logger.error(`ids-stream emission failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
168
|
+
try {
|
|
169
|
+
controller.enqueue(encoder.encode(`${JSON.stringify({ error: IDS_STREAM_ERROR_CLIENT_MESSAGE })}\n`))
|
|
170
|
+
controller.close()
|
|
171
|
+
} catch {
|
|
172
|
+
// 既に切断済みなら何もできない
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
cancel() {
|
|
177
|
+
cancelled = true
|
|
178
|
+
void lines.return(undefined).catch(() => {})
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const headers = new Headers({
|
|
183
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
184
|
+
// no-transform: compression ミドルウェア/中間プロキシによるバッファリングを禁止する (SSE と同じ理由)
|
|
185
|
+
"Cache-Control": "private, no-cache, no-transform",
|
|
186
|
+
"X-Content-Type-Options": "nosniff",
|
|
187
|
+
})
|
|
188
|
+
if (cookie) {
|
|
189
|
+
headers.append("Set-Cookie", cookie)
|
|
190
|
+
}
|
|
191
|
+
return new Response(stream, { status: 200, headers })
|
|
192
|
+
}
|
|
193
|
+
|
|
27
194
|
// ---------------- 添付配信の定数とヘルパー (純粋・モジュールスコープ) ----------------
|
|
28
195
|
|
|
29
196
|
/** 添付 1 件あたりの既定上限。サービス側のワイヤ上限 (既定 64 MiB) より必ず低く保つ。 */
|
|
@@ -370,13 +537,86 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
370
537
|
return jsonResponseWithETag(request, cookie, { businessDate: normalizedBusinessDate, reports }, 200)
|
|
371
538
|
},
|
|
372
539
|
/**
|
|
373
|
-
*
|
|
374
|
-
*
|
|
540
|
+
* Streams the full daily report id list as NDJSON chunks.
|
|
541
|
+
* 日報 ID 一覧全件を NDJSON チャンクとして逐次配信する。
|
|
542
|
+
*
|
|
543
|
+
* 全量クエリ (`SqlResultCache` の TTL / Redis epoch / in-flight コアレッシングを
|
|
544
|
+
* 素通しで維持) は常に即時に開始する。全量確定を待ってから送出するのは再開接続のみで、
|
|
545
|
+
* その場合の DB エラーはストリーム開始前に throw されて apiLoader の catch が
|
|
546
|
+
* 500 JSON を返す。新規接続はキャッシュ無しの TOP 200 高速先頭ページと競争させ、
|
|
547
|
+
* キャッシュミス時は全量確定前に最初のチャンクを送出する (最初の描画までの時間が
|
|
548
|
+
* 全体データ量に依存しない)。キャッシュヒット時は全量が即勝ちし、従来どおり
|
|
549
|
+
* 確定済み配列のメモリスライス送出になる。
|
|
375
550
|
*/
|
|
376
|
-
ids: async (url, cookie, request, user) => {
|
|
551
|
+
"ids-stream": async (url, cookie, request, user) => {
|
|
552
|
+
const parsed = parseIdsStreamCursor(url)
|
|
553
|
+
if ("invalid" in parsed) {
|
|
554
|
+
return jsonResponseWithETag(request, cookie, { error: { message: "Invalid cursor" } }, 400)
|
|
555
|
+
}
|
|
377
556
|
const forceRefresh = url.searchParams.get("forceRefresh") === "true"
|
|
378
|
-
|
|
379
|
-
|
|
557
|
+
|
|
558
|
+
// 全量 (SqlResultCache / epoch / コアレッシング維持) は常に即時に走らせ始める
|
|
559
|
+
const fullArrayPromise = service.getDailyReportIdsByExternalId(user.id, { forceRefresh })
|
|
560
|
+
// 拒否はイテレーターの await 側で扱う (ここで未処理拒否警告を出さない)
|
|
561
|
+
fullArrayPromise.catch(() => {})
|
|
562
|
+
|
|
563
|
+
if (parsed.cursor) {
|
|
564
|
+
// 再開接続: クライアントは高速先頭を受信済みなので全量確定を待つ (エラーは 500)
|
|
565
|
+
return buildIdsStreamResponse(cookie, streamLinesFromFullArray(await fullArrayPromise, parsed.cursor), request, apiLogger)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 新規接続: 「高速先頭ページ (TOP n・キャッシュ無し)」と「全量 (キャッシュ経由)」を
|
|
570
|
+
* 競争させ、先に確定した方で最初のチャンクを出す。キャッシュヒット時は全量が即勝ち
|
|
571
|
+
* (従来どおり先頭行に total)。キャッシュミス時は先頭ページが先に届き、最初の描画は
|
|
572
|
+
* 全量クエリの確定を待たない。total は全量確定後の最初の行に載る。
|
|
573
|
+
*/
|
|
574
|
+
// キャッシュヒット時もこの TOP 200 クエリは走って結果が捨てられる (二重クエリ)。
|
|
575
|
+
// インデックス順の部分走査で ms 級と実測見積もりされており、ヒット判定のための
|
|
576
|
+
// キャッシュ同期 peek API を足す複雑さに見合わないため意図的に許容する
|
|
577
|
+
const firstPagePromise = service.getDailyReportIdsFirstPageByExternalId(user.id, IDS_STREAM_FIRST_CHUNK_SIZE)
|
|
578
|
+
firstPagePromise.catch(() => {})
|
|
579
|
+
|
|
580
|
+
const lineIterator = (async function* (): AsyncGenerator<DailyReportIdsStreamChunkLine> {
|
|
581
|
+
const winner = await Promise.race([
|
|
582
|
+
fullArrayPromise.then(
|
|
583
|
+
(items) => ({ kind: "full" as const, items }),
|
|
584
|
+
(error) => ({ kind: "full-error" as const, error }),
|
|
585
|
+
),
|
|
586
|
+
firstPagePromise.then(
|
|
587
|
+
(items) => ({ kind: "page" as const, items }),
|
|
588
|
+
() => ({ kind: "page-error" as const }),
|
|
589
|
+
),
|
|
590
|
+
])
|
|
591
|
+
|
|
592
|
+
let resumeKey: DailyReportIdsStreamCursor | null = null
|
|
593
|
+
let fullItems: DailyReportItem[] | null = null
|
|
594
|
+
|
|
595
|
+
if (winner.kind === "full") {
|
|
596
|
+
fullItems = winner.items
|
|
597
|
+
} else if (winner.kind === "page" || winner.kind === "full-error") {
|
|
598
|
+
// 全量が先に死んでいても先頭ページが生きていれば、まずそれを届ける
|
|
599
|
+
const pageItems = winner.kind === "page" ? winner.items : await firstPagePromise
|
|
600
|
+
if (pageItems.length < IDS_STREAM_FIRST_CHUNK_SIZE) {
|
|
601
|
+
// 先頭ページが上限未満 = データセット全量。全量クエリを待たずに完結する
|
|
602
|
+
yield { items: pageItems, nextCursor: null, isLast: true, total: pageItems.length }
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
const lastItem = pageItems[pageItems.length - 1]
|
|
606
|
+
resumeKey = { businessDate: lastItem.businessDate, reportHubId: lastItem.reportHubId }
|
|
607
|
+
// total は未知 (全量確定後の行に載せる)
|
|
608
|
+
yield { items: pageItems, nextCursor: resumeKey, isLast: false }
|
|
609
|
+
}
|
|
610
|
+
// page-error は全量へフォールバック。full-error は上の分岐でページを出してから、ここで throw する
|
|
611
|
+
if (fullItems === null) {
|
|
612
|
+
fullItems = await fullArrayPromise
|
|
613
|
+
}
|
|
614
|
+
// 高速先頭ページ経路 (resumeKey あり) はクライアントが既に 200 件を描画済み
|
|
615
|
+
// なので、続きは大きいチャンクで運ぶ。キャッシュヒット経路は従来どおり 200 件先頭
|
|
616
|
+
yield* streamLinesFromFullArray(fullItems, resumeKey, resumeKey ? IDS_STREAM_NEXT_CHUNK_SIZE : IDS_STREAM_FIRST_CHUNK_SIZE)
|
|
617
|
+
})()
|
|
618
|
+
|
|
619
|
+
return buildIdsStreamResponse(cookie, lineIterator, request, apiLogger)
|
|
380
620
|
},
|
|
381
621
|
/**
|
|
382
622
|
* Retrieves details for a specific daily report.
|