@aiquants/daily-report 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/dist/client.d.mts +443 -23
- package/dist/client.d.ts +443 -23
- 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 +77 -3
- package/dist/index.d.ts +77 -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 +436 -6
- package/dist/server.d.ts +436 -6
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
- package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
- package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
- package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
- package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
- package/src/client/components/daily-report-attachment-list.tsx +112 -0
- package/src/client/components/daily-report-detail-list.tsx +14 -7
- 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 +19 -11
- 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/components/report-views.spec.tsx +340 -0
- package/src/client/config-context.tsx +8 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +151 -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/hooks/use-responsive-layout.spec.ts +74 -0
- package/src/client/hooks/use-responsive-layout.ts +56 -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/utils/constants.spec.ts +96 -0
- package/src/client/utils/constants.ts +47 -2
- package/src/client.ts +6 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.attachment.spec.ts +470 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +671 -6
- package/src/server/ports.ts +74 -0
- package/src/server/schema.ts +80 -5
- package/src/server/service.spec.ts +280 -14
- package/src/server/service.ts +348 -25
- package/src/server/test-helpers/handlers-config.ts +181 -0
- package/src/server.ts +16 -1
- package/src/shared/ids-stream.ts +92 -0
- package/src/shared/sse-schema.spec.ts +50 -0
- package/src/shared/sse-schema.ts +27 -0
- package/src/shared/types.ts +31 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-continuation gating tests for the action provider's deferred mutations.
|
|
3
|
+
* アクションプロバイダーの遅延ミューテーション継続に対する門番テスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件:
|
|
6
|
+
*
|
|
7
|
+
* - **POST /action の待機中にリロード無しのユーザー切替が起きた場合、解決した継続は
|
|
8
|
+
* モジュール常駐状態 (常駐ストリームセッション・DailyReportCache) へ書き込まない。**
|
|
9
|
+
* 門番を欠くと、旧ユーザーの日報行が新ユーザーの一覧へ注入され (観測集合に刻まれて
|
|
10
|
+
* 完走剪定も免れる)、切替時に破棄した詳細キャッシュへ旧ユーザーの下書き本文が
|
|
11
|
+
* 再シードされる (第 6 ラウンド敵対的レビューで実測再現された漏洩)。
|
|
12
|
+
* - **同一ユーザーのまま解決した継続は従来どおり反映される** (門番が正常経路を壊さない)。
|
|
13
|
+
*
|
|
14
|
+
* セッション・キャッシュはモジュール常駐シングルトンのため、テスト間で明示的に
|
|
15
|
+
* リセットする (このファイル内で直列実行)。
|
|
16
|
+
*/
|
|
17
|
+
import { act, render, renderHook, waitFor } from "@testing-library/react"
|
|
18
|
+
import { useEffect } from "react"
|
|
19
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
20
|
+
import type { DailyReportActionContextType } from "./daily-report-action-context"
|
|
21
|
+
|
|
22
|
+
// SSE 接続は EventSource を jsdom に持ち込まず、onMessage を横取りしてテストから直接叩けるようにする
|
|
23
|
+
const capturedOnMessage: { current: ((payload: unknown) => void) | null } = { current: null }
|
|
24
|
+
vi.mock("../hooks/use-daily-report-sse-connection", () => ({
|
|
25
|
+
useDailyReportSseConnection: ({ onMessage }: { onMessage: (payload: unknown) => void }) => {
|
|
26
|
+
capturedOnMessage.current = onMessage
|
|
27
|
+
},
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
import { clearDailyReportCache, getCachedReport } from "../hooks/use-daily-report"
|
|
31
|
+
import { ensureDailyReportIdsStreamSession, resetDailyReportIdsStreamSessionForTesting, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
32
|
+
import { DailyReportActionProvider, useDailyReportActionContext } from "./daily-report-action-context"
|
|
33
|
+
|
|
34
|
+
/** NDJSON 1 行の ids-stream 応答を組み立てる。 */
|
|
35
|
+
const idsStreamResponse = (reportHubIds: number[]): Response => {
|
|
36
|
+
const line = JSON.stringify({
|
|
37
|
+
items: reportHubIds.map((reportHubId) => ({ reportHubId, businessDate: "2026-08-01", sourceType: "Internal" })),
|
|
38
|
+
nextCursor: null,
|
|
39
|
+
isLast: true,
|
|
40
|
+
total: reportHubIds.length,
|
|
41
|
+
})
|
|
42
|
+
return new Response(`${line}\n`, { status: 200, headers: { "Content-Type": "application/x-ndjson" } })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** /action 応答 (create 成功) を組み立てる。clientTempId は要求からエコーする。 */
|
|
46
|
+
const createActionResponse = (clientTempId: string, newId: number): Response =>
|
|
47
|
+
new Response(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
status: "OK",
|
|
50
|
+
reportHubId: newId,
|
|
51
|
+
clientTempId,
|
|
52
|
+
report: {
|
|
53
|
+
reportHubId: newId,
|
|
54
|
+
date: "2026-08-05",
|
|
55
|
+
createdAt: "2026-08-05 10:00:00",
|
|
56
|
+
author: "User A",
|
|
57
|
+
userId: "ENC_A",
|
|
58
|
+
sourceType: "Internal",
|
|
59
|
+
employeeName: null,
|
|
60
|
+
updatedBy: "User A",
|
|
61
|
+
updatedAt: "2026-08-05 10:00:00",
|
|
62
|
+
category: null,
|
|
63
|
+
creationCategory: null,
|
|
64
|
+
visitTimeFrom: null,
|
|
65
|
+
visitTimeTo: null,
|
|
66
|
+
customerName: null,
|
|
67
|
+
interviewers: [],
|
|
68
|
+
subject: "USER_A confidential draft subject",
|
|
69
|
+
content: "secret",
|
|
70
|
+
comments: [],
|
|
71
|
+
isRead: true,
|
|
72
|
+
isStarred: false,
|
|
73
|
+
labels: [],
|
|
74
|
+
commentItems: [],
|
|
75
|
+
attachments: [],
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
/** コンテキストを外へ取り出すだけの消費者。 */
|
|
82
|
+
const contextRef: { current: DailyReportActionContextType | null } = { current: null }
|
|
83
|
+
const ContextProbe = () => {
|
|
84
|
+
const ctx = useDailyReportActionContext()
|
|
85
|
+
/**
|
|
86
|
+
* コンテキスト参照の同期副作用。
|
|
87
|
+
* 依存配列: ctx (プロバイダー再レンダーごと)。クリーンアップ: なし。
|
|
88
|
+
*/
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
contextRef.current = ctx
|
|
91
|
+
}, [ctx])
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* fetch スタブ: ids-stream は即時応答、/action は解放関数で遅延解決する。
|
|
97
|
+
* 解放時の clientTempId エコーのため要求 FormData を記録する。
|
|
98
|
+
*/
|
|
99
|
+
const installFetchStub = () => {
|
|
100
|
+
let releaseAction: ((newId: number) => void) | null = null
|
|
101
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
102
|
+
const url = String(input)
|
|
103
|
+
if (url.includes("/ids-stream")) {
|
|
104
|
+
// A のセッションは [101]、B のセッションも [101] を正とする (999 は誰の全量にも無い)
|
|
105
|
+
return idsStreamResponse([101])
|
|
106
|
+
}
|
|
107
|
+
if (url.includes("/action")) {
|
|
108
|
+
const clientTempId = String((init?.body as FormData).get("clientTempId"))
|
|
109
|
+
return new Promise<Response>((resolve) => {
|
|
110
|
+
releaseAction = (newId: number) => resolve(createActionResponse(clientTempId, newId))
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`unexpected fetch: ${url}`)
|
|
114
|
+
})
|
|
115
|
+
vi.stubGlobal("fetch", fetchMock)
|
|
116
|
+
return { release: (newId: number) => releaseAction?.(newId) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
resetDailyReportIdsStreamSessionForTesting()
|
|
121
|
+
clearDailyReportCache()
|
|
122
|
+
contextRef.current = null
|
|
123
|
+
capturedOnMessage.current = null
|
|
124
|
+
vi.unstubAllGlobals()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe("遅延ミューテーション継続の門番 (ユーザー切替汚染防止)", () => {
|
|
128
|
+
it("切替後に解決した createReport はセッションにもキャッシュにも書き込まない", async () => {
|
|
129
|
+
const stub = installFetchStub()
|
|
130
|
+
|
|
131
|
+
// ユーザー A のセッション確立 + プロバイダー描画
|
|
132
|
+
act(() => {
|
|
133
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_A" })
|
|
134
|
+
})
|
|
135
|
+
const { unmount } = render(
|
|
136
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A" initialItems={[]}>
|
|
137
|
+
<ContextProbe />
|
|
138
|
+
</DailyReportActionProvider>,
|
|
139
|
+
)
|
|
140
|
+
await waitFor(() => expect(contextRef.current).not.toBeNull())
|
|
141
|
+
|
|
142
|
+
// A が作成を発行 (POST は保留)
|
|
143
|
+
let createPromise: Promise<number> | undefined
|
|
144
|
+
act(() => {
|
|
145
|
+
createPromise = contextRef.current?.createReport("2026-08-05")
|
|
146
|
+
})
|
|
147
|
+
createPromise?.catch(() => {})
|
|
148
|
+
|
|
149
|
+
// リロード無しのユーザー切替: プロバイダー破棄 + セッション B へ
|
|
150
|
+
unmount()
|
|
151
|
+
act(() => {
|
|
152
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_B" })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// B のストリーム完走を待ってから A の POST を解決させる
|
|
156
|
+
const { result: streamResult } = renderHook(() => useDailyReportIdsStream())
|
|
157
|
+
await waitFor(() => expect(streamResult.current.phase).toBe("complete"))
|
|
158
|
+
act(() => {
|
|
159
|
+
stub.release(999)
|
|
160
|
+
})
|
|
161
|
+
await expect(createPromise).rejects.toThrow()
|
|
162
|
+
|
|
163
|
+
// B のセッションに A の新規日報 999 が注入されていない
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(streamResult.current.items.map((item) => item.reportHubId)).toEqual([101])
|
|
166
|
+
})
|
|
167
|
+
// 切替時に破棄した詳細キャッシュへ A の下書きが再シードされていない
|
|
168
|
+
expect(getCachedReport(999, { ignoreExpiry: true })).toBeFalsy()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("切替〜アンマウント間の隙間で届いた旧ユーザーの SSE report-create はセッションにもキャッシュにも書き込まない", async () => {
|
|
172
|
+
installFetchStub()
|
|
173
|
+
|
|
174
|
+
// ユーザー A のセッション + プロバイダー (SSE onMessage を横取り)
|
|
175
|
+
act(() => {
|
|
176
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_A" })
|
|
177
|
+
})
|
|
178
|
+
render(
|
|
179
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A" initialItems={[]}>
|
|
180
|
+
<ContextProbe />
|
|
181
|
+
</DailyReportActionProvider>,
|
|
182
|
+
)
|
|
183
|
+
await waitFor(() => expect(capturedOnMessage.current).not.toBeNull())
|
|
184
|
+
|
|
185
|
+
// 隙間状態の再現: A のプロバイダー (EventSource) はマウントされたまま、
|
|
186
|
+
// モジュールセッションだけが B へ差し替わる
|
|
187
|
+
act(() => {
|
|
188
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_B" })
|
|
189
|
+
})
|
|
190
|
+
const { result: streamResult } = renderHook(() => useDailyReportIdsStream())
|
|
191
|
+
await waitFor(() => expect(streamResult.current.phase).toBe("complete"))
|
|
192
|
+
|
|
193
|
+
// A 宛の下書き report-create が旧接続へ届く
|
|
194
|
+
act(() => {
|
|
195
|
+
capturedOnMessage.current?.({
|
|
196
|
+
type: "report-create",
|
|
197
|
+
clientTempId: "other-tab-op",
|
|
198
|
+
report: {
|
|
199
|
+
reportHubId: 999,
|
|
200
|
+
date: "2026-08-05",
|
|
201
|
+
createdAt: "2026-08-05 10:00:00",
|
|
202
|
+
author: "User A",
|
|
203
|
+
userId: "ENC_A",
|
|
204
|
+
sourceType: "Internal",
|
|
205
|
+
employeeName: null,
|
|
206
|
+
updatedBy: "User A",
|
|
207
|
+
updatedAt: "2026-08-05 10:00:00",
|
|
208
|
+
category: null,
|
|
209
|
+
creationCategory: null,
|
|
210
|
+
visitTimeFrom: null,
|
|
211
|
+
visitTimeTo: null,
|
|
212
|
+
customerName: null,
|
|
213
|
+
interviewers: [],
|
|
214
|
+
subject: "USER_A private draft via SSE",
|
|
215
|
+
content: "secret",
|
|
216
|
+
comments: [],
|
|
217
|
+
isRead: false,
|
|
218
|
+
isStarred: false,
|
|
219
|
+
labels: [],
|
|
220
|
+
commentItems: [],
|
|
221
|
+
attachments: [],
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// B のセッションに 999 が注入されず、破棄済みキャッシュにも再シードされない
|
|
227
|
+
expect(streamResult.current.items.map((item) => item.reportHubId)).toEqual([101])
|
|
228
|
+
expect(getCachedReport(999, { ignoreExpiry: true })).toBeFalsy()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("同一ユーザーのまま解決した createReport は従来どおりセッションとキャッシュへ反映される", async () => {
|
|
232
|
+
const stub = installFetchStub()
|
|
233
|
+
|
|
234
|
+
act(() => {
|
|
235
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_A" })
|
|
236
|
+
})
|
|
237
|
+
render(
|
|
238
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A" initialItems={[]}>
|
|
239
|
+
<ContextProbe />
|
|
240
|
+
</DailyReportActionProvider>,
|
|
241
|
+
)
|
|
242
|
+
await waitFor(() => expect(contextRef.current).not.toBeNull())
|
|
243
|
+
|
|
244
|
+
const { result: streamResult } = renderHook(() => useDailyReportIdsStream())
|
|
245
|
+
await waitFor(() => expect(streamResult.current.phase).toBe("complete"))
|
|
246
|
+
|
|
247
|
+
let createPromise: Promise<number> | undefined
|
|
248
|
+
act(() => {
|
|
249
|
+
createPromise = contextRef.current?.createReport("2026-08-05")
|
|
250
|
+
})
|
|
251
|
+
act(() => {
|
|
252
|
+
stub.release(999)
|
|
253
|
+
})
|
|
254
|
+
await expect(createPromise).resolves.toBe(999)
|
|
255
|
+
|
|
256
|
+
// 門番が正常経路を壊していない: 常駐セッションへ 999 が中継され、キャッシュも温まる
|
|
257
|
+
await waitFor(() => {
|
|
258
|
+
expect(streamResult.current.items.map((item) => item.reportHubId)).toContain(999)
|
|
259
|
+
})
|
|
260
|
+
expect(getCachedReport(999, { ignoreExpiry: true })?.subject).toBe("USER_A confidential draft subject")
|
|
261
|
+
})
|
|
262
|
+
})
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
* 日報の楽観的 CRUD と SSE 同期を提供するアクションコンテキスト。
|
|
4
4
|
*/
|
|
5
5
|
import { type Context, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"
|
|
6
|
-
import { useRevalidator } from "react-router"
|
|
7
6
|
import { createLogger, LogLevel } from "../../shared/logger"
|
|
8
7
|
import type { DailyReportSseMessage } from "../../shared/sse-schema"
|
|
9
8
|
import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
|
|
10
9
|
import { useDailyReportConfig } from "../config-context"
|
|
11
10
|
import { acquireMutationLock, applyDailyReportServerUpdates, clearDailyReportCache, DailyReportCache, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
|
|
12
11
|
import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
|
|
12
|
+
import { getDailyReportIdsStreamSessionUserKey, refreshDailyReportIdsStream, removeDailyReportIdsStreamItem, upsertDailyReportIdsStreamItem, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
13
13
|
|
|
14
14
|
const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
|
|
15
15
|
|
|
@@ -25,7 +25,7 @@ type ActionResponse = {
|
|
|
25
25
|
[key: string]: unknown
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
type DailyReportActionContextType = {
|
|
28
|
+
export type DailyReportActionContextType = {
|
|
29
29
|
/** 表示中ユーザー (楽観的コメントの作者名等に使用)。 */
|
|
30
30
|
user?: DailyReportUser
|
|
31
31
|
/** reportHubId -> Map<tempCommentId, content> (追加コメントの一時IDと内容) */
|
|
@@ -137,18 +137,35 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
137
137
|
/** 自分が発行したアクションのID(SSEエコー無視用) */
|
|
138
138
|
const processedClientTempIds = useRef<Set<string>>(new Set())
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
// トゥームストーン掃除のゲート参照。ストリーミング途中の部分配列で掃除すると、
|
|
141
|
+
// 後続チャンク到着前に削除された ID のトゥームストーンが失われ、遅れて届いた
|
|
142
|
+
// 削除前スナップショットのチャンクで日報が復活する (effect の依存を増やさないため ref 経由)
|
|
143
|
+
const streamState = useDailyReportIdsStream()
|
|
144
|
+
const isStreamSettledRef = useRef(false)
|
|
145
|
+
isStreamSettledRef.current = streamState.phase === "complete" && !streamState.isRevalidating
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 遅延解決するミューテーション継続の門番。POST /action の待機中にリロード無しの
|
|
149
|
+
* ユーザー切替が起きた場合、この判定が偽になり、継続はモジュール常駐状態
|
|
150
|
+
* (常駐ストリームセッション・DailyReportCache・ミューテーションロック) への
|
|
151
|
+
* 書き込みを放棄する。放棄しないと、旧ユーザーの日報行が新ユーザーの一覧へ
|
|
152
|
+
* 注入され (観測集合に刻まれて完走剪定も免れる)、切替時に破棄したはずの
|
|
153
|
+
* 詳細キャッシュへ旧ユーザーの下書き本文が再シードされる (実測再現済み)。
|
|
154
|
+
* 同一ユーザーの通常ナビゲーション (アンマウント) ではセッションが生き続けるため
|
|
155
|
+
* 真のままであり、作成直後の日報が常駐データから欠ける round-3 の退行は起こさない。
|
|
156
|
+
*/
|
|
157
|
+
const isSessionStillOurs = useCallback(() => getDailyReportIdsStreamSessionUserKey() === (userId ?? null), [userId])
|
|
141
158
|
|
|
142
159
|
const refetchData = useCallback(async () => {
|
|
143
160
|
logger.info("[DailyReportActionProvider] Refetching data without page reload...")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
161
|
+
// ID 一覧はストリームセッションのバックグラウンド再検証で更新する
|
|
162
|
+
// (ルーター再検証は dailyReportShouldRevalidate に抑止されるため使わない)
|
|
163
|
+
refreshDailyReportIdsStream()
|
|
147
164
|
setVersion((v) => v + 1)
|
|
148
165
|
DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
|
|
149
166
|
listener(-1)
|
|
150
167
|
})
|
|
151
|
-
}, [
|
|
168
|
+
}, [])
|
|
152
169
|
|
|
153
170
|
const clearCacheAndRefetch = useCallback(async () => {
|
|
154
171
|
logger.info("[DailyReportActionProvider] Clearing client & server/DB cache and refetching data without page reload...")
|
|
@@ -175,15 +192,16 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
175
192
|
logger.warn("[DailyReportActionProvider] Failed to clear server-side cache:", e)
|
|
176
193
|
}
|
|
177
194
|
|
|
178
|
-
// 3.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
195
|
+
// 3. ID 一覧ストリームをサーバーキャッシュバイパスで再検証 & UI 再描画。
|
|
196
|
+
// POST 待機中にユーザー切替が起きた場合は放棄する (新ユーザーのストリームを
|
|
197
|
+
// 冗長に強制再ダウンロードさせない)
|
|
198
|
+
if (!isSessionStillOurs()) return
|
|
199
|
+
refreshDailyReportIdsStream({ forceRefresh: true })
|
|
182
200
|
setVersion((v) => v + 1)
|
|
183
201
|
DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
|
|
184
202
|
listener(-1)
|
|
185
203
|
})
|
|
186
|
-
}, [apiBasePath,
|
|
204
|
+
}, [apiBasePath, isSessionStillOurs])
|
|
187
205
|
|
|
188
206
|
/**
|
|
189
207
|
* Queue for SSE messages that arrived before the target report was cached.
|
|
@@ -281,6 +299,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
281
299
|
* レポートアイテムをリストへ upsert し、businessDate 降順を維持します。
|
|
282
300
|
*/
|
|
283
301
|
const upsertItem = useCallback((reportHubId: number, businessDate: string | null | undefined) => {
|
|
302
|
+
// モジュール常駐ストリームセッションへも反映する (鮮度窓内の再訪で作成直後の日報が
|
|
303
|
+
// 消えないように)。businessDate 未指定は「既存値を保持」の意図なので中継しない
|
|
304
|
+
if (businessDate !== undefined) {
|
|
305
|
+
upsertDailyReportIdsStreamItem({ reportHubId, businessDate: businessDate ?? null })
|
|
306
|
+
}
|
|
284
307
|
setItems((prev) => {
|
|
285
308
|
const nextBusinessDate = businessDate ?? prev.find((item) => item.reportHubId === reportHubId)?.businessDate ?? null
|
|
286
309
|
const exists = prev.some((item) => item.reportHubId === reportHubId)
|
|
@@ -304,59 +327,101 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
304
327
|
* Remove a stale item from the list as a safety net.
|
|
305
328
|
* フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
|
|
306
329
|
*/
|
|
307
|
-
const removeStaleItem = useCallback(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
330
|
+
const removeStaleItem = useCallback(
|
|
331
|
+
(reportHubId: number) => {
|
|
332
|
+
// ユーザー切替後の遅延発火は新ユーザーのセッションへトゥームストーンを刻んでしまう
|
|
333
|
+
if (!isSessionStillOurs()) return
|
|
334
|
+
removedIds.current.add(reportHubId)
|
|
335
|
+
// 常駐ストリームデータからも除去する (再訪時に安全弁が再発動し続けるのを防ぐ)
|
|
336
|
+
removeDailyReportIdsStreamItem(reportHubId)
|
|
337
|
+
setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
|
|
338
|
+
},
|
|
339
|
+
[isSessionStillOurs],
|
|
340
|
+
)
|
|
311
341
|
|
|
312
342
|
/**
|
|
313
|
-
* Merge initialItems with prevItems instead of replacing.
|
|
343
|
+
* Merge initialItems with prevItems instead of replacing (render-phase derived state).
|
|
314
344
|
* Items already in the list are kept even if temporarily absent from the API response.
|
|
315
345
|
* Removal is handled exclusively by the report-delete SSE handler.
|
|
316
346
|
*
|
|
317
|
-
* initialItems と prevItems
|
|
318
|
-
*
|
|
319
|
-
*
|
|
347
|
+
* initialItems と prevItems をマージする (全置換ではない)。render-phase の
|
|
348
|
+
* 派生状態パターン (prev 比較 + render 中の setState) で行うことが重要 —
|
|
349
|
+
* effect で行うと「最初のチャンクのコミットは items が空 (VirtualScroll が
|
|
350
|
+
* 一瞬 No items を描画) → effect 後の 2 コミット目で行が出る」という
|
|
351
|
+
* 1 コミット分の遅延と空表示フラッシュが生まれる。render 中の setState は
|
|
352
|
+
* React が子のコミット前に同一コンポーネントを再レンダーする公式パターン。
|
|
353
|
+
* マージは純粋 (ref は読み取りのみ)。StrictMode の二重レンダーにも冪等。
|
|
320
354
|
*/
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// initialItems
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
removedIds.current.delete(id)
|
|
355
|
+
const [prevInitialItems, setPrevInitialItems] = useState(initialItems)
|
|
356
|
+
if (initialItems !== prevInitialItems) {
|
|
357
|
+
setPrevInitialItems(initialItems)
|
|
358
|
+
// initialItems をベースにマップを構築 (removedIds を除外)
|
|
359
|
+
const mergedMap = new Map<number, DailyReportItem>()
|
|
360
|
+
for (const item of initialItems) {
|
|
361
|
+
if (!removedIds.current.has(item.reportHubId)) {
|
|
362
|
+
mergedMap.set(item.reportHubId, item)
|
|
330
363
|
}
|
|
331
364
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
for (const item of initialItems) {
|
|
337
|
-
if (!removedIds.current.has(item.reportHubId)) {
|
|
338
|
-
mergedMap.set(item.reportHubId, item)
|
|
339
|
-
}
|
|
365
|
+
// 現在の items にのみ存在する ID を保持 (削除は report-delete SSE で行う)
|
|
366
|
+
for (const item of items) {
|
|
367
|
+
if (!(mergedMap.has(item.reportHubId) || removedIds.current.has(item.reportHubId))) {
|
|
368
|
+
mergedMap.set(item.reportHubId, item)
|
|
340
369
|
}
|
|
370
|
+
}
|
|
371
|
+
// businessDate 降順でソート。正規化済み YYYY-MM-DD は辞書順 = 時系列順なので
|
|
372
|
+
// 文字列比較で足りる (Date 構築はチャンク到着ごとの全件ソートで比較 1 回あたり
|
|
373
|
+
// 2 個生成され、数万件 × 数十チャンクで数百万回の構築になる)。null は末尾。
|
|
374
|
+
// ストリームはソート順どおりに届き末尾へ追記されるため、通常は整列済み —
|
|
375
|
+
// O(n) の検査で確認できたらソートを丸ごと省略する (20 万件級で毎 publish の
|
|
376
|
+
// O(n log n) を O(n) へ落とす)
|
|
377
|
+
const merged = Array.from(mergedMap.values())
|
|
378
|
+
let isSorted = true
|
|
379
|
+
for (let i = 1; i < merged.length; i++) {
|
|
380
|
+
if ((merged[i - 1].businessDate ?? "") < (merged[i].businessDate ?? "")) {
|
|
381
|
+
isSorted = false
|
|
382
|
+
break
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!isSorted) {
|
|
386
|
+
merged.sort((a, b) => {
|
|
387
|
+
const left = a.businessDate ?? ""
|
|
388
|
+
const right = b.businessDate ?? ""
|
|
389
|
+
if (left === right) return 0
|
|
390
|
+
return left < right ? 1 : -1
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
setItems(merged)
|
|
394
|
+
}
|
|
341
395
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
396
|
+
/**
|
|
397
|
+
* トゥームストーン (removedIds) のクリーンアップ副作用。
|
|
398
|
+
* ストリームの全量が出揃っている (complete かつ再検証中でない) ときにのみ行う。
|
|
399
|
+
* 部分配列からの欠落は「サーバーに存在しない」証拠にならない。
|
|
400
|
+
* 依存配列: initialItems (チャンク到着・再検証ごと)。クリーンアップ: なし。
|
|
401
|
+
* ref の変異を伴うため render-phase ではなく effect に置く。
|
|
402
|
+
*/
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
if (isStreamSettledRef.current) {
|
|
405
|
+
const initialIds = new Set(initialItems.map((item) => item.reportHubId))
|
|
406
|
+
// Set のイテレーション中の delete は JS 仕様で安全 (visited は再訪問しない)
|
|
407
|
+
for (const id of removedIds.current) {
|
|
408
|
+
if (!initialIds.has(id)) {
|
|
409
|
+
removedIds.current.delete(id)
|
|
346
410
|
}
|
|
347
411
|
}
|
|
348
|
-
|
|
349
|
-
// businessDate 降順でソート
|
|
350
|
-
return Array.from(mergedMap.values()).sort((a, b) => {
|
|
351
|
-
const dateA = a.businessDate ? new Date(a.businessDate).getTime() : 0
|
|
352
|
-
const dateB = b.businessDate ? new Date(b.businessDate).getTime() : 0
|
|
353
|
-
return dateB - dateA
|
|
354
|
-
})
|
|
355
|
-
})
|
|
412
|
+
}
|
|
356
413
|
}, [initialItems])
|
|
357
414
|
|
|
358
415
|
const handleSseMessage = useCallback(
|
|
359
416
|
(payload: Exclude<DailyReportSseMessage, { type: "connected" }>) => {
|
|
417
|
+
// セッション切替〜プロバイダーアンマウントの隙間で届いた旧ユーザーの SSE は破棄する。
|
|
418
|
+
// clientLoader の bootstrap は React のコミット前にモジュールセッションを新ユーザーへ
|
|
419
|
+
// 差し替えるため、旧プロバイダーの EventSource が閉じるまでの間に届いたメッセージ
|
|
420
|
+
// (下書きの recipient 宛配信を含む) が、破棄済みキャッシュと新セッションへ
|
|
421
|
+
// 旧ユーザーのデータを書き込んでしまう (POST 継続と同じ汚染シグネチャ・実測再現済み)
|
|
422
|
+
if (!isSessionStillOurs()) {
|
|
423
|
+
return
|
|
424
|
+
}
|
|
360
425
|
// Ignore echo of own actions
|
|
361
426
|
// 自分が発行したアクションのエコーは無視する
|
|
362
427
|
// clientTempId は connected 以外すべてのメッセージに存在する(型で保証済み)
|
|
@@ -463,6 +528,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
463
528
|
manuallyTrackingIds.current.delete(reportHubId)
|
|
464
529
|
// マージロジックでの復活を防止するために削除済み ID を記録
|
|
465
530
|
removedIds.current.add(reportHubId)
|
|
531
|
+
// 常駐ストリームデータからも除去 (鮮度窓内の再訪で削除済み日報が蘇らないように)
|
|
532
|
+
removeDailyReportIdsStreamItem(reportHubId)
|
|
466
533
|
|
|
467
534
|
// 削除されたレポートへの待機中 SSE メッセージを破棄
|
|
468
535
|
const pendingEntry = pendingSseQueue.current.get(reportHubId)
|
|
@@ -495,7 +562,7 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
495
562
|
}
|
|
496
563
|
}
|
|
497
564
|
},
|
|
498
|
-
[isProcessedId, upsertItem, user, enqueuePendingSseMessage],
|
|
565
|
+
[isProcessedId, isSessionStillOurs, upsertItem, user, enqueuePendingSseMessage],
|
|
499
566
|
)
|
|
500
567
|
|
|
501
568
|
// 常に最新の handleSseMessage をキューのリプレイから参照できるようにする
|
|
@@ -574,10 +641,20 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
574
641
|
if (typeof data.clientTempId !== "string" || data.clientTempId !== clientTempId) {
|
|
575
642
|
throw new Error(`Invalid clientTempId for ${intent}`)
|
|
576
643
|
}
|
|
644
|
+
// ユーザー切替後に解決した継続は、モジュール常駐状態 (常駐セッション・詳細キャッシュ)
|
|
645
|
+
// を旧ユーザーのデータで汚染しないよう onSuccess を放棄する
|
|
646
|
+
if (!isSessionStillOurs()) {
|
|
647
|
+
logger.warn(`[executeAction] ${intent} resolved after a user switch; discarding continuation`)
|
|
648
|
+
throw new Error(`Stale continuation for ${intent}`)
|
|
649
|
+
}
|
|
577
650
|
return (await callbacks.onSuccess?.(data)) as T
|
|
578
651
|
} catch (e) {
|
|
579
652
|
logger.error(`${intent} failed:`, e)
|
|
580
|
-
|
|
653
|
+
// 切替後の失敗も同様: onFailure のロールバックが旧値をモジュールキャッシュへ
|
|
654
|
+
// 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)
|
|
655
|
+
if (isSessionStillOurs()) {
|
|
656
|
+
callbacks.onFailure?.()
|
|
657
|
+
}
|
|
581
658
|
throw e
|
|
582
659
|
} finally {
|
|
583
660
|
if (!(options?.keepLock || options?.skipLock)) {
|
|
@@ -773,6 +850,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
773
850
|
isStarred: false,
|
|
774
851
|
labels: [{ id: 0, name: draftLabelName, color: "#9ca3af" }],
|
|
775
852
|
commentItems: [],
|
|
853
|
+
// 楽観更新の仮レポート。作成直後に添付は存在しない
|
|
854
|
+
attachments: [],
|
|
776
855
|
}
|
|
777
856
|
writeCache(dummyReport, Date.now() + 300000, false)
|
|
778
857
|
|
|
@@ -803,6 +882,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
803
882
|
|
|
804
883
|
const newId = Number(res.reportHubId)
|
|
805
884
|
|
|
885
|
+
// 常駐ストリームセッションへも反映する。自分の SSE エコーは clientTempId で
|
|
886
|
+
// 抑止されるため upsertItem 経由の中継は届かず、ここで直接中継しないと
|
|
887
|
+
// 鮮度窓内の再訪で作成直後の日報が一覧から消える
|
|
888
|
+
upsertDailyReportIdsStreamItem({ reportHubId: newId, businessDate })
|
|
889
|
+
|
|
806
890
|
// newId を一時的にロックし、createReport 以前に開始された背景 prefetch の
|
|
807
891
|
// cacheMany による stale データ上書きを防止する
|
|
808
892
|
acquireMutationLock(newId)
|
|
@@ -881,6 +965,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
881
965
|
applyDailyReportServerUpdates(id, data.report)
|
|
882
966
|
setVersion((v) => v + 1)
|
|
883
967
|
}
|
|
968
|
+
// 成功確定後に常駐ストリームセッションへも反映する (自分の SSE エコーは
|
|
969
|
+
// 抑止されるため)。失敗時に走らせると、並行削除済みの日報を誤って
|
|
970
|
+
// 蘇らせトゥームストーンまで解除してしまう
|
|
971
|
+
upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate })
|
|
884
972
|
},
|
|
885
973
|
onFailure: () => {
|
|
886
974
|
// ロールバック
|
|
@@ -900,6 +988,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
900
988
|
|
|
901
989
|
// マージロジックでの復活を防止するために削除済み ID を記録
|
|
902
990
|
removedIds.current.add(id)
|
|
991
|
+
// 常駐ストリームデータからも除去 (鮮度窓内の再訪で削除済み日報が蘇らないように)
|
|
992
|
+
removeDailyReportIdsStreamItem(id)
|
|
903
993
|
// Optimistic UI update
|
|
904
994
|
setItems((prev) => prev.filter((item) => item.reportHubId !== id))
|
|
905
995
|
|
|
@@ -924,8 +1014,17 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
924
1014
|
return
|
|
925
1015
|
}
|
|
926
1016
|
|
|
927
|
-
//
|
|
1017
|
+
// それ以外のエラーの場合は、楽観的更新をロールバックする。
|
|
1018
|
+
// 常駐ストリームデータの除去とトゥームストーンも upsert で巻き戻す
|
|
1019
|
+
// (巻き戻さないと、実在する日報が次の全量走査まで再訪時の一覧から消える)。
|
|
1020
|
+
// ユーザー切替後に解決した継続は巻き戻さない — 旧ユーザーの行を新ユーザーの
|
|
1021
|
+
// セッションへ upsert しトゥームストーンまで解除してしまうため
|
|
1022
|
+
// (元の catch と同様に握り潰す。呼び出し元 UI は既に破棄済み)
|
|
1023
|
+
if (!isSessionStillOurs()) {
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
928
1026
|
removedIds.current.delete(id)
|
|
1027
|
+
upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate: targetItem?.businessDate ?? businessDate })
|
|
929
1028
|
if (targetItem) {
|
|
930
1029
|
setItems((prev) => {
|
|
931
1030
|
if (prev.some((i) => i.reportHubId === id)) return prev
|