@aiquants/daily-report 0.7.0 → 0.9.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 +438 -26
- package/dist/client.d.ts +438 -26
- 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 +115 -3
- package/dist/index.d.ts +115 -3
- 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 +15 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +9 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +9 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
- package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-comment-item.tsx +6 -1
- package/src/client/components/daily-report-detail-list.tsx +65 -34
- package/src/client/components/daily-report-edit-form.tsx +6 -0
- 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-list.tsx +88 -55
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +62 -69
- package/src/client/components/daily-report-resolved-content.tsx +36 -10
- package/src/client/components/report-views.spec.tsx +37 -2
- package/src/client/config-context.tsx +18 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +278 -0
- package/src/client/contexts/daily-report-action-context.tsx +203 -65
- package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
- package/src/client/contexts/daily-report-error-context.tsx +98 -0
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/hooks/use-daily-report.spec.ts +50 -2
- package/src/client/hooks/use-daily-report.ts +30 -1
- 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 +704 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +677 -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 +5 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.action.spec.ts +44 -0
- package/src/server/handlers.ids-stream.spec.ts +414 -0
- package/src/server/handlers.ts +324 -8
- package/src/server/service.spec.ts +83 -7
- package/src/server/service.ts +100 -3
- package/src/server/test-helpers/handlers-config.ts +48 -1
- package/src/server.spec.ts +49 -0
- package/src/server.ts +7 -0
- package/src/shared/business-date.spec.ts +22 -1
- package/src/shared/business-date.ts +20 -0
- package/src/shared/comment-adapter.spec.ts +38 -0
- package/src/shared/comment-adapter.ts +9 -7
- package/src/shared/ids-stream.spec.ts +65 -0
- package/src/shared/ids-stream.ts +139 -0
- package/src/shared/sse-schema.ts +5 -0
- package/src/shared/text-utils.spec.ts +42 -0
- package/src/shared/text-utils.ts +8 -0
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,220 @@ 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
|
+
* Heartbeat interval while the next chunk is pending (ms).
|
|
57
|
+
* 次チャンク待ちの間に空行ハートビートを送る間隔 (ms)。
|
|
58
|
+
*
|
|
59
|
+
* 全量クエリ (20 万件超) の確定待ちで無音が続くと、クライアントの stall ウォッチドッグ
|
|
60
|
+
* (既定 30s) が生きた接続を half-open と誤認する。それより十分短い間隔で空行を送り、
|
|
61
|
+
* read を解決させて誤検知を防ぐ。空行はクライアント側で読み飛ばされる (`trim() === ""`)。
|
|
62
|
+
*/
|
|
63
|
+
const IDS_STREAM_HEARTBEAT_MS = 15_000
|
|
64
|
+
|
|
65
|
+
/** next() 待ちがハートビート間隔に負けたことを表す番兵。 */
|
|
66
|
+
const IDS_STREAM_HEARTBEAT = Symbol("ids-stream-heartbeat")
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parses and validates resume-cursor query params (fail-closed).
|
|
70
|
+
* 再開カーソルのクエリパラメータを検証付きで解釈する処理 (不正は fail-closed)。
|
|
71
|
+
*
|
|
72
|
+
* - `cursor_report_hub_id` 単独 = 「営業日なし領域」のカーソル (businessDate: null)。
|
|
73
|
+
* - `cursor_business_date` は `cursor_report_hub_id` 無しでは意味を持たないため 400。
|
|
74
|
+
*
|
|
75
|
+
* @param url Request URL. リクエスト URL。
|
|
76
|
+
* @returns Cursor (null = 先頭から) か、検証失敗の印。
|
|
77
|
+
*/
|
|
78
|
+
const parseIdsStreamCursor = (url: URL): { cursor: DailyReportIdsStreamCursor | null } | { invalid: true } => {
|
|
79
|
+
const rawId = url.searchParams.get("cursor_report_hub_id")
|
|
80
|
+
const rawDate = url.searchParams.get("cursor_business_date")
|
|
81
|
+
|
|
82
|
+
if (rawId === null) {
|
|
83
|
+
// 営業日だけのカーソルは並び順上の位置を決められない
|
|
84
|
+
return rawDate === null ? { cursor: null } : { invalid: true }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const reportHubId = Number.parseInt(rawId, 10)
|
|
88
|
+
if (!Number.isSafeInteger(reportHubId) || String(reportHubId) !== rawId || reportHubId <= 0) {
|
|
89
|
+
return { invalid: true }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (rawDate === null) {
|
|
93
|
+
return { cursor: { businessDate: null, reportHubId } }
|
|
94
|
+
}
|
|
95
|
+
if (!IDS_STREAM_DATE_PATTERN.test(rawDate)) {
|
|
96
|
+
return { invalid: true }
|
|
97
|
+
}
|
|
98
|
+
return { cursor: { businessDate: rawDate, reportHubId } }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Emits a finalized id array as chunked NDJSON lines from a resume position.
|
|
103
|
+
* 確定済み ID 配列を再開位置からチャンク行として送出するジェネレーター。
|
|
104
|
+
*
|
|
105
|
+
* `total` は最初に yield する行へ載せる (= 接続内で全量が確定した最初の行。
|
|
106
|
+
* キャッシュヒットの新規接続と再開接続では先頭行、高速先頭ページ経路では 2 行目)。
|
|
107
|
+
*
|
|
108
|
+
* @param items Stream-ordered full array. ストリーム順の全量配列。
|
|
109
|
+
* @param resumeKey Resume position (null = 先頭から)。
|
|
110
|
+
* @param firstChunkSize このジェネレーターの最初のチャンクサイズ。
|
|
111
|
+
*/
|
|
112
|
+
async function* streamLinesFromFullArray(items: DailyReportItem[], resumeKey: DailyReportIdsStreamCursor | null, firstChunkSize = IDS_STREAM_FIRST_CHUNK_SIZE): AsyncGenerator<DailyReportIdsStreamChunkLine> {
|
|
113
|
+
let position = resumeKey ? findIdsStreamResumeIndex(items, resumeKey) : 0
|
|
114
|
+
let isFirstYield = true
|
|
115
|
+
while (true) {
|
|
116
|
+
const chunkItems = items.slice(position, position + (isFirstYield ? firstChunkSize : IDS_STREAM_NEXT_CHUNK_SIZE))
|
|
117
|
+
position += chunkItems.length
|
|
118
|
+
const isLast = position >= items.length
|
|
119
|
+
const lastItem = chunkItems[chunkItems.length - 1]
|
|
120
|
+
yield {
|
|
121
|
+
items: chunkItems,
|
|
122
|
+
nextCursor: isLast || !lastItem ? null : { businessDate: lastItem.businessDate, reportHubId: lastItem.reportHubId },
|
|
123
|
+
isLast,
|
|
124
|
+
...(isFirstYield ? { total: items.length } : {}),
|
|
125
|
+
}
|
|
126
|
+
isFirstYield = false
|
|
127
|
+
if (isLast) return
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wraps a line iterator into a pull-based NDJSON streaming Response.
|
|
133
|
+
* 行イテレーターを pull 駆動の NDJSON ストリーミング Response へ包む処理。
|
|
134
|
+
*
|
|
135
|
+
* - 切断検知時はイテレーターを畳んで (`return()`) DB 待ちを解放し、close する
|
|
136
|
+
* (素の return は desiredSize > 0 のまま pull が再帰する)。
|
|
137
|
+
* - イテレーターの throw は内部情報を含まない定型エラー行 + 正常クローズへ倒す
|
|
138
|
+
* (クライアントは「isLast 無し終端」をカーソル再開のシグナルとして扱う)。
|
|
139
|
+
*/
|
|
140
|
+
const buildIdsStreamResponse = (cookie: string | null, lines: AsyncGenerator<DailyReportIdsStreamChunkLine>, request: Request, logger: DailyReportLogger): Response => {
|
|
141
|
+
const encoder = new TextEncoder()
|
|
142
|
+
// cancel() の lines.return() は「pull が DB 待ちの next() を保留中」だと先取りできない。
|
|
143
|
+
// 待ちが明けた後の enqueue が閉じたストリームへ落ちて catch に入るため、切断は
|
|
144
|
+
// フラグで決定的に識別し、通常イベントとして静かに畳む (ERROR ログにしない —
|
|
145
|
+
// 全量クエリ中の離脱は日常イベントで、本物の送出失敗が雑音に埋もれる)
|
|
146
|
+
let cancelled = false
|
|
147
|
+
// next() の解決はハートビートを跨いで保持する。ハートビートが先着したら空行を出し、
|
|
148
|
+
// 同じ next() を次の pull で await し直す (毎回 next() を呼び直すと二重取得になる)。
|
|
149
|
+
let pendingNext: Promise<IteratorResult<DailyReportIdsStreamChunkLine>> | null = null
|
|
150
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
151
|
+
async pull(controller) {
|
|
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
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
|
|
162
|
+
try {
|
|
163
|
+
if (pendingNext === null) {
|
|
164
|
+
pendingNext = lines.next()
|
|
165
|
+
}
|
|
166
|
+
// 次チャンクの確定待ちが長引く間、接続の生存を示す空行を送る
|
|
167
|
+
const heartbeatPromise = new Promise<typeof IDS_STREAM_HEARTBEAT>((resolve) => {
|
|
168
|
+
heartbeatTimer = setTimeout(() => resolve(IDS_STREAM_HEARTBEAT), IDS_STREAM_HEARTBEAT_MS)
|
|
169
|
+
})
|
|
170
|
+
const raced = await Promise.race([pendingNext, heartbeatPromise])
|
|
171
|
+
if (heartbeatTimer !== null) {
|
|
172
|
+
clearTimeout(heartbeatTimer)
|
|
173
|
+
heartbeatTimer = null
|
|
174
|
+
}
|
|
175
|
+
if (raced === IDS_STREAM_HEARTBEAT) {
|
|
176
|
+
// next() はまだ保留中。空行だけ送って pending は次の pull へ持ち越す
|
|
177
|
+
if (cancelled || request.signal.aborted) {
|
|
178
|
+
await lines.return(undefined).catch(() => {})
|
|
179
|
+
try {
|
|
180
|
+
controller.close()
|
|
181
|
+
} catch {
|
|
182
|
+
// cancel 済みコントローラーの close は失敗してよい
|
|
183
|
+
}
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
controller.enqueue(encoder.encode("\n"))
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
pendingNext = null
|
|
190
|
+
const { value, done } = raced
|
|
191
|
+
if (done) {
|
|
192
|
+
controller.close()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
// 保留中の next() の間に切断された場合はここで検知して静かに終える
|
|
196
|
+
if (cancelled || request.signal.aborted) {
|
|
197
|
+
await lines.return(undefined).catch(() => {})
|
|
198
|
+
try {
|
|
199
|
+
controller.close()
|
|
200
|
+
} catch {
|
|
201
|
+
// cancel 済みコントローラーの close は失敗してよい
|
|
202
|
+
}
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`))
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (heartbeatTimer !== null) {
|
|
208
|
+
clearTimeout(heartbeatTimer)
|
|
209
|
+
}
|
|
210
|
+
// next() が throw した = このイテレーターは畳む。保留参照を残さない
|
|
211
|
+
pendingNext = null
|
|
212
|
+
if (cancelled || request.signal.aborted) {
|
|
213
|
+
// 切断起因の enqueue/close 失敗はエラーではない
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
logger.error(`ids-stream emission failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
217
|
+
try {
|
|
218
|
+
controller.enqueue(encoder.encode(`${JSON.stringify({ error: IDS_STREAM_ERROR_CLIENT_MESSAGE })}\n`))
|
|
219
|
+
controller.close()
|
|
220
|
+
} catch {
|
|
221
|
+
// 既に切断済みなら何もできない
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
cancel() {
|
|
226
|
+
cancelled = true
|
|
227
|
+
void lines.return(undefined).catch(() => {})
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const headers = new Headers({
|
|
232
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
233
|
+
// no-transform: compression ミドルウェア/中間プロキシによるバッファリングを禁止する (SSE と同じ理由)
|
|
234
|
+
"Cache-Control": "private, no-cache, no-transform",
|
|
235
|
+
"X-Content-Type-Options": "nosniff",
|
|
236
|
+
})
|
|
237
|
+
if (cookie) {
|
|
238
|
+
headers.append("Set-Cookie", cookie)
|
|
239
|
+
}
|
|
240
|
+
return new Response(stream, { status: 200, headers })
|
|
241
|
+
}
|
|
242
|
+
|
|
27
243
|
// ---------------- 添付配信の定数とヘルパー (純粋・モジュールスコープ) ----------------
|
|
28
244
|
|
|
29
245
|
/** 添付 1 件あたりの既定上限。サービス側のワイヤ上限 (既定 64 MiB) より必ず低く保つ。 */
|
|
@@ -302,6 +518,13 @@ export type DailyReportHandlersConfig = {
|
|
|
302
518
|
streamKey: string
|
|
303
519
|
/** 未ログイン時のリダイレクト先 (index.loader 用、既定 "/auth/login")。 */
|
|
304
520
|
loginRedirectPath?: string
|
|
521
|
+
/**
|
|
522
|
+
* `intent=clearCache` (全サーバーキャッシュの一括破棄) を許可するか。既定 false。
|
|
523
|
+
* 開発用アフォーダンスであり、有効化すると認証済みユーザーなら誰でも全ワーカーの
|
|
524
|
+
* キャッシュを無制限に破棄できるため、本番では必ず無効のままにする
|
|
525
|
+
* (消費アプリは開発環境でのみ true を注入する)。
|
|
526
|
+
*/
|
|
527
|
+
enableDevCacheClear?: boolean
|
|
305
528
|
/** ロガー (既定は console ベース)。 */
|
|
306
529
|
logger?: DailyReportLogger
|
|
307
530
|
}
|
|
@@ -370,13 +593,88 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
370
593
|
return jsonResponseWithETag(request, cookie, { businessDate: normalizedBusinessDate, reports }, 200)
|
|
371
594
|
},
|
|
372
595
|
/**
|
|
373
|
-
*
|
|
374
|
-
*
|
|
596
|
+
* Streams the full daily report id list as NDJSON chunks.
|
|
597
|
+
* 日報 ID 一覧全件を NDJSON チャンクとして逐次配信する。
|
|
598
|
+
*
|
|
599
|
+
* 全量クエリ (`SqlResultCache` の TTL / Redis epoch / in-flight コアレッシングを
|
|
600
|
+
* 素通しで維持) は常に即時に開始する。全量確定を待ってから送出するのは再開接続のみで、
|
|
601
|
+
* その場合の DB エラーはストリーム開始前に throw されて apiLoader の catch が
|
|
602
|
+
* 500 JSON を返す。新規接続はキャッシュ無しの TOP 200 高速先頭ページと競争させ、
|
|
603
|
+
* キャッシュミス時は全量確定前に最初のチャンクを送出する (最初の描画までの時間が
|
|
604
|
+
* 全体データ量に依存しない)。キャッシュヒット時は全量が即勝ちし、従来どおり
|
|
605
|
+
* 確定済み配列のメモリスライス送出になる。
|
|
375
606
|
*/
|
|
376
|
-
ids: async (url, cookie, request, user) => {
|
|
607
|
+
"ids-stream": async (url, cookie, request, user) => {
|
|
608
|
+
const parsed = parseIdsStreamCursor(url)
|
|
609
|
+
if ("invalid" in parsed) {
|
|
610
|
+
return jsonResponseWithETag(request, cookie, { error: { message: "Invalid cursor" } }, 400)
|
|
611
|
+
}
|
|
377
612
|
const forceRefresh = url.searchParams.get("forceRefresh") === "true"
|
|
378
|
-
|
|
379
|
-
|
|
613
|
+
|
|
614
|
+
// 全量 (SqlResultCache / epoch / コアレッシング維持) は常に即時に走らせ始める
|
|
615
|
+
const fullArrayPromise = service.getDailyReportIdsByExternalId(user.id, { forceRefresh })
|
|
616
|
+
// 拒否はイテレーターの await 側で扱う (ここで未処理拒否警告を出さない)
|
|
617
|
+
fullArrayPromise.catch(() => {})
|
|
618
|
+
|
|
619
|
+
if (parsed.cursor) {
|
|
620
|
+
// 再開接続: クライアントは高速先頭を受信済みなので全量確定を待つ (エラーは 500)。
|
|
621
|
+
// 全量クエリはアプリ側の requestTimeout (既定 60s) で境界づけられ、確定後は
|
|
622
|
+
// スライスだけなので、接続確立後の無音はハートビート対象にならない
|
|
623
|
+
return buildIdsStreamResponse(cookie, streamLinesFromFullArray(await fullArrayPromise, parsed.cursor), request, apiLogger)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* 新規接続: 「高速先頭ページ (TOP n・キャッシュ無し)」と「全量 (キャッシュ経由)」を
|
|
628
|
+
* 競争させ、先に確定した方で最初のチャンクを出す。キャッシュヒット時は全量が即勝ち
|
|
629
|
+
* (従来どおり先頭行に total)。キャッシュミス時は先頭ページが先に届き、最初の描画は
|
|
630
|
+
* 全量クエリの確定を待たない。total は全量確定後の最初の行に載る。
|
|
631
|
+
*/
|
|
632
|
+
// キャッシュヒット時もこの TOP 200 クエリは走って結果が捨てられる (二重クエリ)。
|
|
633
|
+
// インデックス順の部分走査で ms 級と実測見積もりされており、ヒット判定のための
|
|
634
|
+
// キャッシュ同期 peek API を足す複雑さに見合わないため意図的に許容する
|
|
635
|
+
const firstPagePromise = service.getDailyReportIdsFirstPageByExternalId(user.id, IDS_STREAM_FIRST_CHUNK_SIZE)
|
|
636
|
+
firstPagePromise.catch(() => {})
|
|
637
|
+
|
|
638
|
+
const lineIterator = (async function* (): AsyncGenerator<DailyReportIdsStreamChunkLine> {
|
|
639
|
+
const winner = await Promise.race([
|
|
640
|
+
fullArrayPromise.then(
|
|
641
|
+
(items) => ({ kind: "full" as const, items }),
|
|
642
|
+
(error) => ({ kind: "full-error" as const, error }),
|
|
643
|
+
),
|
|
644
|
+
firstPagePromise.then(
|
|
645
|
+
(items) => ({ kind: "page" as const, items }),
|
|
646
|
+
() => ({ kind: "page-error" as const }),
|
|
647
|
+
),
|
|
648
|
+
])
|
|
649
|
+
|
|
650
|
+
let resumeKey: DailyReportIdsStreamCursor | null = null
|
|
651
|
+
let fullItems: DailyReportItem[] | null = null
|
|
652
|
+
|
|
653
|
+
if (winner.kind === "full") {
|
|
654
|
+
fullItems = winner.items
|
|
655
|
+
} else if (winner.kind === "page" || winner.kind === "full-error") {
|
|
656
|
+
// 全量が先に死んでいても先頭ページが生きていれば、まずそれを届ける
|
|
657
|
+
const pageItems = winner.kind === "page" ? winner.items : await firstPagePromise
|
|
658
|
+
if (pageItems.length < IDS_STREAM_FIRST_CHUNK_SIZE) {
|
|
659
|
+
// 先頭ページが上限未満 = データセット全量。全量クエリを待たずに完結する
|
|
660
|
+
yield { items: pageItems, nextCursor: null, isLast: true, total: pageItems.length }
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
const lastItem = pageItems[pageItems.length - 1]
|
|
664
|
+
resumeKey = { businessDate: lastItem.businessDate, reportHubId: lastItem.reportHubId }
|
|
665
|
+
// total は未知 (全量確定後の行に載せる)
|
|
666
|
+
yield { items: pageItems, nextCursor: resumeKey, isLast: false }
|
|
667
|
+
}
|
|
668
|
+
// page-error は全量へフォールバック。full-error は上の分岐でページを出してから、ここで throw する
|
|
669
|
+
if (fullItems === null) {
|
|
670
|
+
fullItems = await fullArrayPromise
|
|
671
|
+
}
|
|
672
|
+
// 高速先頭ページ経路 (resumeKey あり) はクライアントが既に 200 件を描画済み
|
|
673
|
+
// なので、続きは大きいチャンクで運ぶ。キャッシュヒット経路は従来どおり 200 件先頭
|
|
674
|
+
yield* streamLinesFromFullArray(fullItems, resumeKey, resumeKey ? IDS_STREAM_NEXT_CHUNK_SIZE : IDS_STREAM_FIRST_CHUNK_SIZE)
|
|
675
|
+
})()
|
|
676
|
+
|
|
677
|
+
return buildIdsStreamResponse(cookie, lineIterator, request, apiLogger)
|
|
380
678
|
},
|
|
381
679
|
/**
|
|
382
680
|
* Retrieves details for a specific daily report.
|
|
@@ -460,6 +758,11 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
460
758
|
const clientTempId = formData.get("clientTempId") as string | null
|
|
461
759
|
|
|
462
760
|
if (intent === "clearCache") {
|
|
761
|
+
// 開発専用アフォーダンス。既定は封鎖 (認証済みなら誰でも全ワーカーの
|
|
762
|
+
// キャッシュを無制限破棄できてしまうため、注入で明示的に開けた環境のみ許可)
|
|
763
|
+
if (!config.enableDevCacheClear) {
|
|
764
|
+
return jsonData({ error: "Invalid intent" }, { status: 400 })
|
|
765
|
+
}
|
|
463
766
|
await service.clearCache()
|
|
464
767
|
return jsonData({ status: "OK", intent: "clearCache" })
|
|
465
768
|
}
|
|
@@ -567,9 +870,22 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
567
870
|
if (!content) {
|
|
568
871
|
return jsonData({ error: "Content required" }, { status: 400 })
|
|
569
872
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
873
|
+
try {
|
|
874
|
+
const newComment = await service.addComment(userId, reportHubId, content, businessDate, clientTempId)
|
|
875
|
+
const safeComment = { ...newComment, userId: newComment.userId }
|
|
876
|
+
return jsonData({ status: "OK", intent: "addComment", newComment: safeComment, reportHubId: String(reportHubId), clientTempId })
|
|
877
|
+
} catch (e: unknown) {
|
|
878
|
+
if (e instanceof Error) {
|
|
879
|
+
// 削除直後の日報への投稿レース等。500 ではなく意味のある状態コードへ写像する
|
|
880
|
+
if (e.message === "Not Found") {
|
|
881
|
+
return jsonData({ error: "Report not found" }, { status: 404 })
|
|
882
|
+
}
|
|
883
|
+
if (e.message.startsWith("Comments are restricted")) {
|
|
884
|
+
return jsonData({ error: "Comments are restricted for this report" }, { status: 403 })
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
throw e
|
|
888
|
+
}
|
|
573
889
|
}
|
|
574
890
|
case "deleteComment": {
|
|
575
891
|
const commentId = Number(formData.get("commentId"))
|
|
@@ -55,10 +55,14 @@ type RecordedUpdate = {
|
|
|
55
55
|
* @param rows Rows every query resolves to. すべてのクエリが返す行。
|
|
56
56
|
* @returns The db mock and the per-query recordings. db モックとクエリごとの記録。
|
|
57
57
|
*/
|
|
58
|
-
const makeDb = (rows: unknown[]): { db: DailyReportDb; queries: RecordedQuery[]; updates: RecordedUpdate[] } => {
|
|
58
|
+
const makeDb = (rows: unknown[], options: { selectQueue?: unknown[][] } = {}): { db: DailyReportDb; queries: RecordedQuery[]; updates: RecordedUpdate[] } => {
|
|
59
59
|
const queries: RecordedQuery[] = []
|
|
60
60
|
const updates: RecordedUpdate[] = []
|
|
61
|
+
// selectQueue があれば select 呼び出しごとに 1 要素ずつ消費する (枯れたら rows へフォールバック)。
|
|
62
|
+
// 複数 select が異なる結果を返す必要のあるフロー (draft 判定など) の検証に使う
|
|
63
|
+
const selectQueue = options.selectQueue ? [...options.selectQueue] : null
|
|
61
64
|
const select = () => {
|
|
65
|
+
const result = selectQueue && selectQueue.length > 0 ? (selectQueue.shift() as unknown[]) : rows
|
|
62
66
|
const recorded: RecordedQuery = { whereSql: null, whereParams: [], leftJoins: 0, joinParams: [] }
|
|
63
67
|
queries.push(recorded)
|
|
64
68
|
const chain: Record<string, unknown> = {
|
|
@@ -76,11 +80,11 @@ const makeDb = (rows: unknown[]): { db: DailyReportDb; queries: RecordedQuery[];
|
|
|
76
80
|
return chain
|
|
77
81
|
},
|
|
78
82
|
top: () => chain,
|
|
79
|
-
orderBy: () => Promise.resolve(
|
|
83
|
+
orderBy: () => Promise.resolve(result),
|
|
80
84
|
// `.where()` / `.orderBy()` のどちらで終わっても await できるようにする。
|
|
81
85
|
// thenable であること自体が目的なので、この規則はここでは意図的に外す
|
|
82
86
|
// biome-ignore lint/suspicious/noThenProperty: 意図的な thenable (クエリビルダの終端を模擬する)
|
|
83
|
-
then: (resolve: (value: unknown) => unknown) => resolve(
|
|
87
|
+
then: (resolve: (value: unknown) => unknown) => resolve(result),
|
|
84
88
|
}
|
|
85
89
|
return chain
|
|
86
90
|
}
|
|
@@ -99,7 +103,17 @@ const makeDb = (rows: unknown[]): { db: DailyReportDb; queries: RecordedQuery[];
|
|
|
99
103
|
}
|
|
100
104
|
return chain
|
|
101
105
|
}
|
|
102
|
-
|
|
106
|
+
// INSERT: values をそのまま id 付きでエコーする (addComment の inserted 行を模擬)
|
|
107
|
+
const insert = () => {
|
|
108
|
+
const chain: Record<string, unknown> = {
|
|
109
|
+
output: () => chain,
|
|
110
|
+
values: (values: Record<string, unknown>) => Promise.resolve([{ id: 501, ...values }]),
|
|
111
|
+
}
|
|
112
|
+
return chain
|
|
113
|
+
}
|
|
114
|
+
// DELETE: 記録のみ (deleteComment 用)
|
|
115
|
+
const del = () => ({ where: () => Promise.resolve({ rowsAffected: [1] }) })
|
|
116
|
+
return { db: { select, update, insert, delete: del } as unknown as DailyReportDb, queries, updates }
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
const baseHub = (over: Partial<DailyReportHubRow>): DailyReportHubRow => ({
|
|
@@ -120,10 +134,20 @@ const baseHub = (over: Partial<DailyReportHubRow>): DailyReportHubRow => ({
|
|
|
120
134
|
...over,
|
|
121
135
|
})
|
|
122
136
|
|
|
123
|
-
const makeService = (rows: unknown[], withAttachmentCodec = false) => {
|
|
137
|
+
const makeService = (rows: unknown[], withAttachmentCodec = false, options: { selectQueue?: unknown[][] } = {}) => {
|
|
124
138
|
const epochs = createEpochStore(undefined)
|
|
125
139
|
const cache = new SqlResultCache({ defaultTtlMs: 60_000 }, epochs)
|
|
126
|
-
const { db, queries, updates } = makeDb(rows)
|
|
140
|
+
const { db, queries, updates } = makeDb(rows, { selectQueue: options.selectQueue })
|
|
141
|
+
// SSE publish (redis xAdd) の捕捉。宛先フィルタ (recipientRawUserId) の検証に使う
|
|
142
|
+
const sseMessages: Record<string, unknown>[] = []
|
|
143
|
+
const redis = {
|
|
144
|
+
getClient: async () => ({
|
|
145
|
+
xAdd: async (_key: string, _id: string, fields: { data: string }) => {
|
|
146
|
+
sseMessages.push(JSON.parse(fields.data) as Record<string, unknown>)
|
|
147
|
+
return "1-1"
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
}
|
|
127
151
|
const service = createDailyReportService({
|
|
128
152
|
db,
|
|
129
153
|
tables,
|
|
@@ -132,12 +156,13 @@ const makeService = (rows: unknown[], withAttachmentCodec = false) => {
|
|
|
132
156
|
encodeUserId: (id) => `ENC${id}`, // テスト用の識別可能な難読化
|
|
133
157
|
cache,
|
|
134
158
|
epochs,
|
|
159
|
+
redis: redis as unknown as Parameters<typeof createDailyReportService>[0]["redis"],
|
|
135
160
|
draftLabelName: "draft", // 必須注入 (ドラフト可視性フィルタ用のラベル名。テストは中立値で十分)
|
|
136
161
|
// 既定では未注入のまま。既存テストが縮退動作 (添付は常に空) で通ることも同時に確認する。
|
|
137
162
|
// ユーザー ID 用 (`ENC`) とは別の接頭辞にして ID 空間の分離を表現する。
|
|
138
163
|
...(withAttachmentCodec ? { attachmentIdCodec: { encode: (id: number) => `ATT${id}`, decode: (t: string) => (t.startsWith("ATT") ? Number(t.slice(3)) : null) } } : {}),
|
|
139
164
|
})
|
|
140
|
-
return Object.assign(service, { __queries: queries, __updates: updates })
|
|
165
|
+
return Object.assign(service, { __queries: queries, __updates: updates, __sse: sseMessages })
|
|
141
166
|
}
|
|
142
167
|
|
|
143
168
|
describe("mapHubRecord audit-actor masking (security)", () => {
|
|
@@ -361,3 +386,54 @@ describe("実体存在の記録 (三値の書き込み側)", () => {
|
|
|
361
386
|
expect(updates).toHaveLength(0)
|
|
362
387
|
})
|
|
363
388
|
})
|
|
389
|
+
|
|
390
|
+
describe("コメント操作の整合性チェック", () => {
|
|
391
|
+
it("addComment は存在しない日報に対して Not Found を投げる (孤児コメントを作らない)", async () => {
|
|
392
|
+
// hub 取得が空 = 存在しない日報。以前は hub 未取得でもフォールスルーして INSERT していた
|
|
393
|
+
const svc = makeService([])
|
|
394
|
+
await expect(svc.addComment(8, 999, "本文", "2099-01-05", "tmp")).rejects.toThrow("Not Found")
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it("addComment は論理削除済みの日報を拒否する", async () => {
|
|
398
|
+
const svc = makeService([{ sourceType: "Internal", deletedAt: "2099-01-06 00:00:00", userId: 8 }])
|
|
399
|
+
await expect(svc.addComment(8, 1, "本文", "2099-01-05", "tmp")).rejects.toThrow("Not Found")
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it("deleteComment は reportHubId がコメントの実所属と一致しないと Not Found を投げる", async () => {
|
|
403
|
+
// コメントは hubId=100 に属するのに、呼び出しは reportHubId=999 を渡している。
|
|
404
|
+
// 不一致のまま進むと無関係な日報のキャッシュ無効化と SSE 配信を起こすため拒否する
|
|
405
|
+
const svc = makeService([{ id: 5, hubId: 100, userId: 8, body: "x", createdAt: "2099-01-05 00:00:00", createdBy: "8", updatedAt: "2099-01-05 00:00:00", updatedBy: "8" }])
|
|
406
|
+
await expect(svc.deleteComment(8, 999, 5, "2099-01-05", "tmp")).rejects.toThrow("Not Found")
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it("addComment: 公開済み日報のコメントは全員配信 (recipientRawUserId 無し)", async () => {
|
|
410
|
+
const svc = makeService([], false, {
|
|
411
|
+
selectQueue: [
|
|
412
|
+
[{ sourceType: "Internal", deletedAt: null, userId: 8 }], // hub 取得
|
|
413
|
+
[{ id: 10 }], // getDraftLabelJoinIds (下書きラベル定義)
|
|
414
|
+
[], // 下書き関連 (該当なし = 公開済み)
|
|
415
|
+
[{ displayName: "山田" }], // コメント投稿者名
|
|
416
|
+
],
|
|
417
|
+
})
|
|
418
|
+
await svc.addComment(8, 1, "本文", "2099-01-05", "tmp")
|
|
419
|
+
const sse = (svc as unknown as { __sse: Record<string, unknown>[] }).__sse
|
|
420
|
+
const add = sse.find((m) => m.type === "comment-add")
|
|
421
|
+
expect(add).toBeDefined()
|
|
422
|
+
expect(add?.recipientRawUserId).toBeUndefined()
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it("addComment: 下書き日報のコメントは所有者だけに配信 (本文の全員配信を防ぐ)", async () => {
|
|
426
|
+
const svc = makeService([], false, {
|
|
427
|
+
selectQueue: [
|
|
428
|
+
[{ sourceType: "Internal", deletedAt: null, userId: 8 }], // hub (所有者 8)
|
|
429
|
+
[{ id: 10 }], // getDraftLabelJoinIds
|
|
430
|
+
[{ hubId: 1 }], // 下書き関連あり = 下書き
|
|
431
|
+
[{ displayName: "山田" }], // 投稿者名
|
|
432
|
+
],
|
|
433
|
+
})
|
|
434
|
+
await svc.addComment(8, 1, "秘密の下書きコメント", "2099-01-05", "tmp")
|
|
435
|
+
const sse = (svc as unknown as { __sse: Record<string, unknown>[] }).__sse
|
|
436
|
+
const add = sse.find((m) => m.type === "comment-add")
|
|
437
|
+
expect(add?.recipientRawUserId).toBe(8)
|
|
438
|
+
})
|
|
439
|
+
})
|