@aiquants/daily-report 0.20.0 → 0.21.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 +229 -8
- package/dist/client.d.mts +201 -739
- package/dist/client.d.ts +201 -739
- package/dist/client.js +5 -6
- package/dist/client.mjs +5 -6
- package/dist/index.d.mts +5 -197
- package/dist/index.d.ts +5 -197
- package/dist/index.js +1 -2
- package/dist/index.mjs +1 -2
- package/dist/logger-D3krZrNK.d.mts +0 -14
- package/dist/logger-D3krZrNK.d.ts +0 -14
- package/dist/server.d.mts +114 -744
- package/dist/server.d.ts +114 -744
- package/dist/server.js +1 -2
- package/dist/server.mjs +1 -2
- package/dist/{sse-schema-ChSFm-4q.d.mts → sse-schema-DCxpdETM.d.mts} +1106 -1090
- package/dist/{sse-schema-BdUGThiz.d.ts → sse-schema-DoQw8_om.d.ts} +1106 -1090
- package/dist/types-CqgYQgbH.d.mts +0 -34
- package/dist/types-CqgYQgbH.d.ts +0 -34
- package/package.json +9 -10
- package/dist/client.js.map +0 -1
- package/dist/client.mjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/server.mjs.map +0 -1
- package/src/client/components/business-day-thumb-overlay.tsx +0 -19
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +0 -51
- package/src/client/components/daily-report-attachment-indicator.tsx +0 -45
- package/src/client/components/daily-report-attachment-list.spec.tsx +0 -94
- package/src/client/components/daily-report-attachment-list.tsx +0 -112
- package/src/client/components/daily-report-comment-item.tsx +0 -89
- package/src/client/components/daily-report-comment-section.tsx +0 -166
- package/src/client/components/daily-report-detail-list.tsx +0 -842
- package/src/client/components/daily-report-edit-form.tsx +0 -87
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +0 -93
- package/src/client/components/daily-report-ids-stream-status.tsx +0 -67
- package/src/client/components/daily-report-list.tsx +0 -1232
- package/src/client/components/daily-report-page.spec.tsx +0 -119
- package/src/client/components/daily-report-page.tsx +0 -140
- package/src/client/components/daily-report-resize-panels.spec.tsx +0 -307
- package/src/client/components/daily-report-resolved-content.tsx +0 -207
- package/src/client/components/daily-report-scroll-restoration.spec.tsx +0 -422
- package/src/client/components/report-views.spec.tsx +0 -375
- package/src/client/components/unread-indicator.tsx +0 -13
- package/src/client/config-context.tsx +0 -184
- package/src/client/contexts/daily-report-action-context.spec.tsx +0 -433
- package/src/client/contexts/daily-report-action-context.tsx +0 -1147
- package/src/client/contexts/daily-report-error-context.spec.tsx +0 -66
- package/src/client/contexts/daily-report-error-context.tsx +0 -98
- package/src/client/hooks/use-animated-number.spec.ts +0 -103
- package/src/client/hooks/use-animated-number.ts +0 -69
- package/src/client/hooks/use-daily-report-comments.ts +0 -73
- package/src/client/hooks/use-daily-report-sse-connection.ts +0 -86
- package/src/client/hooks/use-daily-report.spec.ts +0 -546
- package/src/client/hooks/use-daily-report.ts +0 -549
- package/src/client/hooks/use-dynamic-viewport-height.ts +0 -127
- package/src/client/hooks/use-responsive-layout.spec.ts +0 -74
- package/src/client/hooks/use-responsive-layout.ts +0 -63
- package/src/client/route-helpers.spec.ts +0 -100
- package/src/client/route-helpers.ts +0 -61
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +0 -910
- package/src/client/streaming/daily-report-ids-stream-client.ts +0 -730
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +0 -401
- package/src/client/streaming/daily-report-ids-stream-session.ts +0 -319
- package/src/client/ui/button.tsx +0 -42
- package/src/client/ui/cn.ts +0 -14
- package/src/client/ui/input.tsx +0 -21
- package/src/client/ui/label.tsx +0 -16
- package/src/client/ui/switch.tsx +0 -19
- package/src/client/ui/tabs.tsx +0 -40
- package/src/client/ui/textarea.tsx +0 -19
- package/src/client/utils/constants.spec.ts +0 -96
- package/src/client/utils/constants.ts +0 -84
- package/src/client.ts +0 -28
- package/src/index.ts +0 -11
- package/src/lucide-icons.d.ts +0 -6
- package/src/server/authz.spec.ts +0 -57
- package/src/server/authz.ts +0 -99
- package/src/server/cache.spec.ts +0 -556
- package/src/server/cache.ts +0 -303
- package/src/server/etag.ts +0 -18
- package/src/server/external-source.ts +0 -60
- package/src/server/handlers.action.spec.ts +0 -44
- package/src/server/handlers.attachment.spec.ts +0 -470
- package/src/server/handlers.ids-stream.spec.ts +0 -618
- package/src/server/handlers.ts +0 -1638
- package/src/server/handlers.unauthorized-cookie.spec.ts +0 -187
- package/src/server/handlers.visibility.spec.ts +0 -774
- package/src/server/ports.ts +0 -222
- package/src/server/response.ts +0 -37
- package/src/server/schema.spec.ts +0 -30
- package/src/server/schema.ts +0 -344
- package/src/server/service.spec.ts +0 -1130
- package/src/server/service.ts +0 -2045
- package/src/server/sse-reader.spec.ts +0 -302
- package/src/server/sse-reader.ts +0 -267
- package/src/server/test-helpers/handlers-config.ts +0 -321
- package/src/server/transaction-retry.spec.ts +0 -63
- package/src/server/visibility.spec.ts +0 -403
- package/src/server/visibility.ts +0 -373
- package/src/server.spec.ts +0 -160
- package/src/server.ts +0 -131
- package/src/shared/business-date.spec.ts +0 -82
- package/src/shared/business-date.ts +0 -104
- package/src/shared/comment-adapter.spec.ts +0 -38
- package/src/shared/comment-adapter.ts +0 -80
- package/src/shared/ids-stream.spec.ts +0 -154
- package/src/shared/ids-stream.ts +0 -279
- package/src/shared/logger.ts +0 -47
- package/src/shared/sse-schema.spec.ts +0 -50
- package/src/shared/sse-schema.ts +0 -198
- package/src/shared/text-utils.spec.ts +0 -42
- package/src/shared/text-utils.ts +0 -65
- package/src/shared/types.ts +0 -109
- package/src/styles/standalone.entry.css +0 -51
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest"
|
|
2
|
-
import { createEpochStore, SqlResultCache } from "./cache"
|
|
3
|
-
import type { DailyReportRedisProvider } from "./ports"
|
|
4
|
-
import { DailyReportSseReader, isStreamIdLte, type StreamEntry } from "./sse-reader"
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Robustness tests for the SSE fan-out reader (createClient failure recovery + id compare).
|
|
8
|
-
* SSE Fan-Out リーダーの堅牢性テスト (createClient 失敗時の復帰 + Stream ID 比較)。
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const makeCache = () => new SqlResultCache({ defaultTtlMs: 1000 }, createEpochStore(undefined))
|
|
12
|
-
|
|
13
|
-
describe("isStreamIdLte", () => {
|
|
14
|
-
it("数値比較で ms / seq を正しく順序付ける (辞書順の罠を回避)", () => {
|
|
15
|
-
expect(isStreamIdLte("100-0", "100-0")).toBe(true)
|
|
16
|
-
expect(isStreamIdLte("100-2", "100-10")).toBe(true) // 辞書順なら false になる罠
|
|
17
|
-
expect(isStreamIdLte("101-0", "100-9")).toBe(false)
|
|
18
|
-
})
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
describe("DailyReportSseReader createClient failure recovery", () => {
|
|
22
|
-
it("createClient が reject しても _state が stuck せず、再購読でループを再開できる", async () => {
|
|
23
|
-
let attempts = 0
|
|
24
|
-
const redis: DailyReportRedisProvider = {
|
|
25
|
-
getClient: async () => undefined,
|
|
26
|
-
// 1 回目は例外を投げ、2 回目は undefined を返す (どちらも復帰可能であること)
|
|
27
|
-
createClient: async () => {
|
|
28
|
-
attempts++
|
|
29
|
-
if (attempts === 1) throw new Error("connection refused")
|
|
30
|
-
return undefined
|
|
31
|
-
},
|
|
32
|
-
}
|
|
33
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: { debug() {}, info() {}, warn() {}, error() {} } })
|
|
34
|
-
|
|
35
|
-
const errors: Error[] = []
|
|
36
|
-
// 1 回目の購読 → createClient throw → error emit → _state idle へ復帰
|
|
37
|
-
const unsub1 = reader.subscribe(
|
|
38
|
-
() => {},
|
|
39
|
-
(e) => errors.push(e),
|
|
40
|
-
)
|
|
41
|
-
// マイクロタスク/ループ完了を待つ
|
|
42
|
-
await vi.waitFor(() => expect(errors.length).toBeGreaterThanOrEqual(1))
|
|
43
|
-
expect(errors[0].message).toBe("connection refused")
|
|
44
|
-
unsub1()
|
|
45
|
-
|
|
46
|
-
// 2 回目の購読 → createClient は再度呼ばれる (state-stuck していない証拠)
|
|
47
|
-
const unsub2 = reader.subscribe(
|
|
48
|
-
() => {},
|
|
49
|
-
() => {},
|
|
50
|
-
)
|
|
51
|
-
await vi.waitFor(() => expect(attempts).toBe(2))
|
|
52
|
-
unsub2()
|
|
53
|
-
await reader.destroy()
|
|
54
|
-
})
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Fake blocking client that scripts xRead results and records every requested start id.
|
|
59
|
-
* xRead の結果をスクリプト化し、要求された開始 ID をすべて記録する偽ブロッキングクライアント。
|
|
60
|
-
*
|
|
61
|
-
* スクリプト要素 null は BLOCK タイムアウト (新着なしの null 返し) を 1 回分模す。
|
|
62
|
-
* スクリプトが尽きたら BLOCK を模して未解決 Promise で待機し、quit() で null 解決して
|
|
63
|
-
* ループを正常終了させる (実クライアントの BLOCK + 切断挙動の最小モデル)。
|
|
64
|
-
*/
|
|
65
|
-
class FakeBlockingClient {
|
|
66
|
-
isOpen = true
|
|
67
|
-
/** xRead が要求された開始 ID の記録 (履歴再生の有無を判定する証拠)。 */
|
|
68
|
-
readonly requestedIds: string[] = []
|
|
69
|
-
private _pendingResolve: ((v: null) => void) | undefined
|
|
70
|
-
constructor(private readonly script: Array<Array<{ id: string; message: Record<string, string> }> | null>) {}
|
|
71
|
-
|
|
72
|
-
on(_event: "error", _listener: (err: Error) => void): void {}
|
|
73
|
-
|
|
74
|
-
async xRead(streams: Array<{ key: string; id: string }>): Promise<Array<{ name: string; messages: Array<{ id: string; message: Record<string, string> }> }> | null> {
|
|
75
|
-
this.requestedIds.push(streams[0].id)
|
|
76
|
-
if (this.script.length > 0) {
|
|
77
|
-
const batch = this.script.shift()
|
|
78
|
-
// null = BLOCK タイムアウト 1 回分
|
|
79
|
-
if (batch === null || batch === undefined) return null
|
|
80
|
-
return [{ name: streams[0].key, messages: batch }]
|
|
81
|
-
}
|
|
82
|
-
// スクリプト枯渇 = 新着なし。BLOCK を模して quit まで待機する
|
|
83
|
-
return new Promise((resolve) => {
|
|
84
|
-
this._pendingResolve = resolve
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async quit(): Promise<void> {
|
|
89
|
-
this.isOpen = false
|
|
90
|
-
this._pendingResolve?.(null)
|
|
91
|
-
this._pendingResolve = undefined
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
describe("DailyReportSseReader history-replay poisoning prevention", () => {
|
|
96
|
-
/**
|
|
97
|
-
* 偽クライアント列を払い出す redis プロバイダーを組み立てるヘルパー。
|
|
98
|
-
* tailId: 共有クライアントの xRevRange が返す末尾 ID ("empty" は空ストリーム、
|
|
99
|
-
* undefined は共有クライアント自体が取得不能 = "$" 縮退経路)。
|
|
100
|
-
*/
|
|
101
|
-
const makeRedis = (clients: FakeBlockingClient[], tailId?: string | "empty" | "reject"): { redis: DailyReportRedisProvider; created: FakeBlockingClient[] } => {
|
|
102
|
-
const created: FakeBlockingClient[] = []
|
|
103
|
-
const queue = [...clients]
|
|
104
|
-
const sharedClient =
|
|
105
|
-
tailId === undefined
|
|
106
|
-
? undefined
|
|
107
|
-
: ({
|
|
108
|
-
xRevRange: async () => {
|
|
109
|
-
// "reject" はアンカー解決の失敗 (Redis エラー) を模す
|
|
110
|
-
if (tailId === "reject") throw new Error("xRevRange failed")
|
|
111
|
-
return tailId === "empty" ? [] : [{ id: tailId, message: {} }]
|
|
112
|
-
},
|
|
113
|
-
} as unknown as Awaited<ReturnType<DailyReportRedisProvider["getClient"]>>)
|
|
114
|
-
return {
|
|
115
|
-
created,
|
|
116
|
-
redis: {
|
|
117
|
-
getClient: async () => sharedClient,
|
|
118
|
-
createClient: async () => {
|
|
119
|
-
const client = queue.shift()
|
|
120
|
-
if (!client) throw new Error("script exhausted: unexpected createClient call")
|
|
121
|
-
created.push(client)
|
|
122
|
-
return client
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const silentLogger = { debug() {}, info() {}, warn() {}, error() {} }
|
|
129
|
-
|
|
130
|
-
it("ループ開始時に xRevRange でストリーム末尾へアンカーし、初回 xRead から具体 ID で読む", async () => {
|
|
131
|
-
const client = new FakeBlockingClient([])
|
|
132
|
-
const { redis } = makeRedis([client], "50-3")
|
|
133
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
134
|
-
|
|
135
|
-
const unsub = reader.subscribe(() => {})
|
|
136
|
-
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
137
|
-
// "$" のまま読むと BLOCK タイムアウトごとにサーバー側で末尾へ再解決され、
|
|
138
|
-
// 再武装の合間に届いたエントリを永続的に取りこぼす。必ず具体 ID に固定されていること
|
|
139
|
-
expect(client.requestedIds[0]).toBe("50-3")
|
|
140
|
-
unsub()
|
|
141
|
-
await reader.destroy()
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
it("BLOCK タイムアウト (null) 後も同じ具体 ID で再武装し、その後の新着を取りこぼさない", async () => {
|
|
145
|
-
const client = new FakeBlockingClient([null, [{ id: "100-1", message: { data: JSON.stringify({ type: "report-create" }) } }]])
|
|
146
|
-
const { redis } = makeRedis([client], "50-3")
|
|
147
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
148
|
-
|
|
149
|
-
const entries: StreamEntry[] = []
|
|
150
|
-
const unsub = reader.subscribe((entry) => entries.push(entry))
|
|
151
|
-
await vi.waitFor(() => expect(entries.length).toBe(1))
|
|
152
|
-
expect(entries[0].id).toBe("100-1")
|
|
153
|
-
// タイムアウト前後とも具体 ID ("$" の再武装が残っていれば 2 回目が "$" になり失敗する)
|
|
154
|
-
expect(client.requestedIds.slice(0, 2)).toEqual(["50-3", "50-3"])
|
|
155
|
-
// エントリ処理後は位置が前進する
|
|
156
|
-
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(3))
|
|
157
|
-
expect(client.requestedIds[2]).toBe("100-1")
|
|
158
|
-
unsub()
|
|
159
|
-
await reader.destroy()
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
it('空ストリームは "0-0" にアンカーする (履歴が無いので毒殺は構造的に不能)', async () => {
|
|
163
|
-
const client = new FakeBlockingClient([])
|
|
164
|
-
const { redis } = makeRedis([client], "empty")
|
|
165
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
166
|
-
|
|
167
|
-
const unsub = reader.subscribe(() => {})
|
|
168
|
-
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
169
|
-
expect(client.requestedIds[0]).toBe("0-0")
|
|
170
|
-
unsub()
|
|
171
|
-
await reader.destroy()
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
it('共有クライアント取得不能時は "$" のまま縮退する ("0-0" の全履歴再生には決して戻らない)', async () => {
|
|
175
|
-
const client = new FakeBlockingClient([])
|
|
176
|
-
const { redis } = makeRedis([client], undefined)
|
|
177
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
178
|
-
|
|
179
|
-
const unsub = reader.subscribe(() => {})
|
|
180
|
-
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
181
|
-
expect(client.requestedIds[0]).toBe("$")
|
|
182
|
-
unsub()
|
|
183
|
-
await reader.destroy()
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
it("新着エントリは従来どおり配信され、report-create でキャッシュ無効化も発火する (陽性確認)", async () => {
|
|
187
|
-
const client = new FakeBlockingClient([[{ id: "100-1", message: { data: JSON.stringify({ type: "report-create" }) } }]])
|
|
188
|
-
const { redis } = makeRedis([client], "50-3")
|
|
189
|
-
const cache = makeCache()
|
|
190
|
-
const invalidateSpy = vi.spyOn(cache, "invalidatePrefix")
|
|
191
|
-
// 実プロセスのヒット状況を模して ids バケットを播種しておく
|
|
192
|
-
await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher: async () => ["seed"] })
|
|
193
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache, logger: silentLogger })
|
|
194
|
-
|
|
195
|
-
const entries: StreamEntry[] = []
|
|
196
|
-
const unsub = reader.subscribe((entry) => entries.push(entry))
|
|
197
|
-
await vi.waitFor(() => expect(entries.length).toBe(1))
|
|
198
|
-
expect(entries[0].id).toBe("100-1")
|
|
199
|
-
expect(invalidateSpy).toHaveBeenCalledWith("daily-report:ids")
|
|
200
|
-
unsub()
|
|
201
|
-
await reader.destroy()
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
it('stop → start で前回位置 (_lastId) を保持し、"0-0" へは決して戻らない', async () => {
|
|
205
|
-
const first = new FakeBlockingClient([[{ id: "100-1", message: { data: JSON.stringify({ type: "report-create" }) } }]])
|
|
206
|
-
const second = new FakeBlockingClient([])
|
|
207
|
-
const third = new FakeBlockingClient([])
|
|
208
|
-
const { redis } = makeRedis([first, second, third], "50-3")
|
|
209
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
210
|
-
|
|
211
|
-
// 1 回目の購読: 末尾アンカーから開始し、"100-1" を配信して位置を進める
|
|
212
|
-
const entries: StreamEntry[] = []
|
|
213
|
-
const unsub1 = reader.subscribe((entry) => entries.push(entry))
|
|
214
|
-
await vi.waitFor(() => expect(entries.length).toBe(1))
|
|
215
|
-
unsub1() // 最後の購読者解除 → ループ停止
|
|
216
|
-
|
|
217
|
-
// 2 回目の購読: 前回位置 "100-1" から再開する (停止中の実イベントを追える)
|
|
218
|
-
const unsub2 = reader.subscribe(() => {})
|
|
219
|
-
await vi.waitFor(() => expect(second.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
220
|
-
expect(second.requestedIds[0]).toBe("100-1")
|
|
221
|
-
unsub2()
|
|
222
|
-
|
|
223
|
-
// destroy 後の再購読はテスト専用リセット ("$") を経て、再び末尾アンカーへ解決される
|
|
224
|
-
await reader.destroy()
|
|
225
|
-
const unsub3 = reader.subscribe(() => {})
|
|
226
|
-
await vi.waitFor(() => expect(third.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
227
|
-
expect(third.requestedIds[0]).toBe("50-3")
|
|
228
|
-
unsub3()
|
|
229
|
-
await reader.destroy()
|
|
230
|
-
|
|
231
|
-
// 全ライフサイクルを通じて "0-0" (全履歴再生) を一度も要求していないこと
|
|
232
|
-
const allIds = [first, second, third].flatMap((c) => c.requestedIds)
|
|
233
|
-
expect(allIds).not.toContain("0-0")
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
it("停止待ちの間に購読者が全解除されたら起動しない (購読者 0 の孤児ループ防止)", async () => {
|
|
237
|
-
const first = new FakeBlockingClient([])
|
|
238
|
-
const second = new FakeBlockingClient([])
|
|
239
|
-
const { redis, created } = makeRedis([first, second], "50-3")
|
|
240
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
241
|
-
|
|
242
|
-
// 購読 → ループ稼働
|
|
243
|
-
const unsub1 = reader.subscribe(() => {})
|
|
244
|
-
await vi.waitFor(() => expect(created.length).toBe(1))
|
|
245
|
-
// 解除 (stopping 開始) → stopping 中に再購読 (起動待ち) → 即解除 (購読者 0)
|
|
246
|
-
unsub1()
|
|
247
|
-
const unsub2 = reader.subscribe(() => {})
|
|
248
|
-
unsub2()
|
|
249
|
-
|
|
250
|
-
// 停止が完了しても 2 本目のクライアントは生成されないこと (旧実装は購読者 0 でも起動した)
|
|
251
|
-
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
252
|
-
expect(created.length).toBe(1)
|
|
253
|
-
expect((reader as unknown as { _state: string })._state).toBe("idle")
|
|
254
|
-
await reader.destroy()
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
it('アンカー解決の失敗は warn して "$" のまま縮退し、ループは通常稼働する ("0-0" へは落ちない)', async () => {
|
|
258
|
-
const client = new FakeBlockingClient([])
|
|
259
|
-
const { redis } = makeRedis([client], "reject")
|
|
260
|
-
const warns: unknown[][] = []
|
|
261
|
-
const logger = { debug() {}, info() {}, warn: (...args: unknown[]) => warns.push(args), error() {} }
|
|
262
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger })
|
|
263
|
-
|
|
264
|
-
const unsub = reader.subscribe(() => {})
|
|
265
|
-
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
266
|
-
// 失敗時は "$" 縮退 (rethrow で state-stuck したり "0-0" の全履歴再生へ落ちたりしない)
|
|
267
|
-
expect(client.requestedIds[0]).toBe("$")
|
|
268
|
-
expect(warns.length).toBeGreaterThanOrEqual(1)
|
|
269
|
-
expect(String(warns[0][0])).toContain("tail anchor")
|
|
270
|
-
unsub()
|
|
271
|
-
await reader.destroy()
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
it("onError 省略の購読者しか居なくてもエラーはプロセスを落とさず logger.error へ落ちる", async () => {
|
|
275
|
-
// リスナー 0 の "error" emit は EventEmitter 仕様で throw し、void 呼びの
|
|
276
|
-
// _startLoop 内では unhandledRejection = プロセス危険になる。_emitError の
|
|
277
|
-
// ガードが素の emit へ退行していないことを固定する
|
|
278
|
-
let attempts = 0
|
|
279
|
-
const redis: DailyReportRedisProvider = {
|
|
280
|
-
getClient: async () => undefined,
|
|
281
|
-
createClient: async () => {
|
|
282
|
-
attempts++
|
|
283
|
-
throw new Error("connection refused")
|
|
284
|
-
},
|
|
285
|
-
}
|
|
286
|
-
const errors: unknown[][] = []
|
|
287
|
-
const logger = { debug() {}, info() {}, warn() {}, error: (...args: unknown[]) => errors.push(args) }
|
|
288
|
-
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger })
|
|
289
|
-
|
|
290
|
-
// onError を渡さない購読 → createClient 失敗 → ガード済みフォールバックが発火する
|
|
291
|
-
const unsub1 = reader.subscribe(() => {})
|
|
292
|
-
await vi.waitFor(() => expect(errors.length).toBeGreaterThanOrEqual(1))
|
|
293
|
-
expect(String(errors[0][0])).toContain("no error listeners")
|
|
294
|
-
unsub1()
|
|
295
|
-
|
|
296
|
-
// state-stuck していないこと (再購読で再試行される)
|
|
297
|
-
const unsub2 = reader.subscribe(() => {})
|
|
298
|
-
await vi.waitFor(() => expect(attempts).toBeGreaterThanOrEqual(2))
|
|
299
|
-
unsub2()
|
|
300
|
-
await reader.destroy()
|
|
301
|
-
})
|
|
302
|
-
})
|
package/src/server/sse-reader.ts
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fan-Out shared reader for the daily report SSE Redis Stream.
|
|
3
|
-
* 日報 SSE 用 Redis Stream の共有リーダーモジュール。
|
|
4
|
-
*
|
|
5
|
-
* 単一の xRead BLOCK ループで全接続にメッセージをブロードキャストし、
|
|
6
|
-
* 接続ごとに Redis TCP を張る問題を解消する。
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { EventEmitter } from "node:events"
|
|
10
|
-
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
11
|
-
import type { SqlResultCache } from "./cache"
|
|
12
|
-
import type { DailyReportRedisBlockingClient, DailyReportRedisProvider } from "./ports"
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Compares two Redis Stream IDs numerically.
|
|
16
|
-
* Redis Stream ID を数値比較し、a <= b なら true を返す。
|
|
17
|
-
*
|
|
18
|
-
* Stream ID は `<timestamp_ms>-<sequence>` 形式。
|
|
19
|
-
* 辞書順比較では sequence が可変長のとき不正確になるため、数値分割で比較する。
|
|
20
|
-
*/
|
|
21
|
-
export const isStreamIdLte = (a: string, b: string): boolean => {
|
|
22
|
-
const [aMs, aSeq] = a.split("-").map(Number)
|
|
23
|
-
const [bMs, bSeq] = b.split("-").map(Number)
|
|
24
|
-
if (aMs !== bMs) return aMs < bMs
|
|
25
|
-
return aSeq <= bSeq
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** xRead 結果の 1 エントリに相当する型。 */
|
|
29
|
-
export type StreamEntry = {
|
|
30
|
-
id: string
|
|
31
|
-
message: Record<string, string>
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type DailyReportSseReaderConfig = {
|
|
35
|
-
/** ブロッキング xRead 用の専用クライアントを生成する redis プロバイダー。 */
|
|
36
|
-
redis?: DailyReportRedisProvider
|
|
37
|
-
/** SSE Redis Stream キー。 */
|
|
38
|
-
streamKey: string
|
|
39
|
-
/** 受信メッセージに応じてローカル SQL キャッシュを無効化する対象キャッシュ。 */
|
|
40
|
-
cache: SqlResultCache
|
|
41
|
-
/** ロガー (既定は console ベース)。 */
|
|
42
|
-
logger?: DailyReportLogger
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Shared xRead loop that fans out entries to all active SSE connections.
|
|
47
|
-
* 全 SSE 接続に対してメッセージをファンアウトする共有 xRead ループ。
|
|
48
|
-
*
|
|
49
|
-
* - "entry" イベント: 新着エントリ (StreamEntry) をブロードキャスト
|
|
50
|
-
* - "error" イベント: 回復不能エラーをブロードキャスト
|
|
51
|
-
* */
|
|
52
|
-
export class DailyReportSseReader {
|
|
53
|
-
private _emitter = new EventEmitter()
|
|
54
|
-
private _state: "idle" | "running" | "stopping" = "idle"
|
|
55
|
-
private _refCount = 0
|
|
56
|
-
// "$" = 「購読開始以降の新着のみ」の番兵。ループ開始時に一度だけ具体 ID (ストリーム末尾) へ
|
|
57
|
-
// 解決される (_startLoop 参照)。❗ "0-0" を初期値にしてはならない: 初回購読でストリーム全履歴を
|
|
58
|
-
// 再生し、過去イベントの invalidatePrefix が初回コールドフェッチの書き込みガードを不成立にして
|
|
59
|
-
// キャッシュ保存を毒殺する (実測: 履歴 10,006 件の再生で ids バケットが保存されず、次のロードも
|
|
60
|
-
// 全量コールド)。起動直後のプロセスのキャッシュは空で、過去イベントの無効化は無意味。
|
|
61
|
-
// 過去分の取りこぼし補償は接続単位の catch-up (Last-Event-ID + xRange) の責務。
|
|
62
|
-
// 通常の stop/start では従来どおり前回位置を保持し、停止中の実イベントを再開時に追う
|
|
63
|
-
private _lastId = "$"
|
|
64
|
-
private _client: DailyReportRedisBlockingClient | undefined
|
|
65
|
-
private readonly _logger: DailyReportLogger
|
|
66
|
-
|
|
67
|
-
constructor(private readonly config: DailyReportSseReaderConfig) {
|
|
68
|
-
// リスナー上限を緩和 (接続数分)
|
|
69
|
-
this._emitter.setMaxListeners(0)
|
|
70
|
-
this._logger = config.logger ?? createLogger(LogLevel.INFO, "[SSE Reader]")
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Subscribes to the shared reader. Starts the loop on first subscriber.
|
|
75
|
-
* 共有リーダーを購読する。最初の購読者でループを開始する。
|
|
76
|
-
*
|
|
77
|
-
* @param onEntry - 新着エントリのコールバック
|
|
78
|
-
* @param onError - 回復不能エラーのコールバック (省略可)
|
|
79
|
-
* @returns unsubscribe 関数。呼び出すと両リスナーを解除し、最後の購読者解除でループを停止する。
|
|
80
|
-
*/
|
|
81
|
-
subscribe(onEntry: (entry: StreamEntry) => void, onError?: (err: Error) => void): () => void {
|
|
82
|
-
this._emitter.on("entry", onEntry)
|
|
83
|
-
if (onError) {
|
|
84
|
-
this._emitter.on("error", onError)
|
|
85
|
-
}
|
|
86
|
-
this._refCount++
|
|
87
|
-
|
|
88
|
-
// 最初の購読者でループ開始
|
|
89
|
-
if (this._refCount === 1) {
|
|
90
|
-
void this._startLoop()
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// unsubscribe
|
|
94
|
-
return () => {
|
|
95
|
-
this._emitter.removeListener("entry", onEntry)
|
|
96
|
-
if (onError) {
|
|
97
|
-
this._emitter.removeListener("error", onError)
|
|
98
|
-
}
|
|
99
|
-
this._refCount--
|
|
100
|
-
if (this._refCount <= 0) {
|
|
101
|
-
this._refCount = 0
|
|
102
|
-
void this._stopLoop()
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Destroys the reader for test cleanup.
|
|
109
|
-
* テストクリーンアップ用のデストラクタ。
|
|
110
|
-
*/
|
|
111
|
-
async destroy(): Promise<void> {
|
|
112
|
-
this._refCount = 0
|
|
113
|
-
await this._stopLoop()
|
|
114
|
-
this._emitter.removeAllListeners()
|
|
115
|
-
this._lastId = "$"
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Internal xRead BLOCK loop.
|
|
120
|
-
* 内部 xRead BLOCK ループ。
|
|
121
|
-
*/
|
|
122
|
-
private async _startLoop(): Promise<void> {
|
|
123
|
-
// stopping 中なら完了を待つ
|
|
124
|
-
while (this._state === "stopping") {
|
|
125
|
-
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
126
|
-
}
|
|
127
|
-
if (this._state === "running") return
|
|
128
|
-
// 停止完了を待つ間に最後の購読者が解除されていたら起動しない
|
|
129
|
-
// (購読者 0 の孤児ループが専用 Redis 接続を握り続けるのを防ぐ)
|
|
130
|
-
if (this._refCount === 0) return
|
|
131
|
-
|
|
132
|
-
this._state = "running"
|
|
133
|
-
|
|
134
|
-
// 専用クライアント取得。createClient が reject した場合も _state を idle へ戻し、
|
|
135
|
-
// 再購読でループを再開できるようにする (注入 redis 実装が例外を投げても state-stuck しない)。
|
|
136
|
-
try {
|
|
137
|
-
this._client = await this.config.redis?.createClient()
|
|
138
|
-
} catch (err) {
|
|
139
|
-
this._state = "idle"
|
|
140
|
-
this._emitError(err instanceof Error ? err : new Error("Failed to create Redis client for SSE reader"))
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
if (!this._client) {
|
|
144
|
-
this._state = "idle"
|
|
145
|
-
this._emitError(new Error("Failed to create Redis client for SSE reader"))
|
|
146
|
-
return
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// エラーリスナー
|
|
150
|
-
this._client.on("error", (err) => {
|
|
151
|
-
this._logger.error("Redis client error:", err)
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
// ❗ "$" は xRead ごとにサーバー側で「その時点の末尾」へ再解決されるため、BLOCK タイムアウトで
|
|
155
|
-
// 再武装する合間に届いたエントリを永続的に取りこぼす ("$" が有効なのは最初の 1 回だけ、が
|
|
156
|
-
// Redis の規約)。ループへ入る前に一度だけ具体 ID へ固定する。空ストリームの "0-0" は
|
|
157
|
-
// 履歴が存在しないので安全 (毒殺は「履歴があるのに 0-0」の場合のみ)。
|
|
158
|
-
// 解決に失敗したら "$" のまま縮退する (取りこぼし窓が残るだけで、履歴再生よりは安全)
|
|
159
|
-
if (this._lastId === "$") {
|
|
160
|
-
try {
|
|
161
|
-
const shared = await this.config.redis?.getClient()
|
|
162
|
-
if (shared) {
|
|
163
|
-
const latest = await shared.xRevRange(this.config.streamKey, "+", "-", { COUNT: 1 })
|
|
164
|
-
this._lastId = latest.length > 0 ? latest[0].id : "0-0"
|
|
165
|
-
}
|
|
166
|
-
} catch (err) {
|
|
167
|
-
this._logger.warn("Failed to resolve stream tail anchor; falling back to $:", err)
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
while (this._state === "running" && this._client?.isOpen) {
|
|
173
|
-
const results = await this._client.xRead([{ key: this.config.streamKey, id: this._lastId }], { BLOCK: 5000, COUNT: 100 })
|
|
174
|
-
if (!results) continue
|
|
175
|
-
|
|
176
|
-
for (const stream of results) {
|
|
177
|
-
for (const msg of stream.messages) {
|
|
178
|
-
this._lastId = msg.id
|
|
179
|
-
// epoch check の補完: SSE 経由でもローカルキャッシュを即座にクリア
|
|
180
|
-
// (epoch check が主防御線。SSE は Redis ダウン時や非 epoch 対象キャッシュへのフォールバック)
|
|
181
|
-
try {
|
|
182
|
-
const parsed = msg.message?.data ? JSON.parse(msg.message.data) : null
|
|
183
|
-
const msgType = parsed?.type
|
|
184
|
-
if (msgType === "report-create" || msgType === "report-delete" || msgType === "report-publish") {
|
|
185
|
-
this.config.cache.invalidatePrefix("daily-report:ids")
|
|
186
|
-
}
|
|
187
|
-
// コメント・ステータス変更時は対象レポートの詳細キャッシュを無効化
|
|
188
|
-
if (msgType === "comment-add" || msgType === "comment-delete" || msgType === "status-update") {
|
|
189
|
-
const reportHubId = parsed?.reportHubId
|
|
190
|
-
if (typeof reportHubId === "number") {
|
|
191
|
-
this.config.cache.invalidate(`daily-report:detail:${reportHubId}`)
|
|
192
|
-
this.config.cache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
} catch {
|
|
196
|
-
// JSON パースエラーは無視 (invalidation スキップ = safe 方向)
|
|
197
|
-
}
|
|
198
|
-
this._emitter.emit("entry", { id: msg.id, message: msg.message as Record<string, string> })
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
} catch (err) {
|
|
203
|
-
// ClientClosedError は正常停止時に発生する
|
|
204
|
-
const isClientClosed = (err as Error)?.constructor?.name === "ClientClosedError"
|
|
205
|
-
if (!isClientClosed && this._state === "running") {
|
|
206
|
-
this._logger.error("xRead loop error:", err)
|
|
207
|
-
this._emitError(err instanceof Error ? err : new Error(String(err)))
|
|
208
|
-
}
|
|
209
|
-
} finally {
|
|
210
|
-
// クライアント切断
|
|
211
|
-
if (this._client?.isOpen) {
|
|
212
|
-
try {
|
|
213
|
-
await this._client.quit()
|
|
214
|
-
} catch (_e) {
|
|
215
|
-
// 切断エラーは無視
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
this._client = undefined
|
|
219
|
-
this._state = "idle"
|
|
220
|
-
|
|
221
|
-
// ループ終了後に購読者が残っていれば自動再起動
|
|
222
|
-
if (this._refCount > 0) {
|
|
223
|
-
void this._startLoop()
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Stops the xRead loop by closing the client.
|
|
230
|
-
* クライアントを閉じて xRead ループを停止する。
|
|
231
|
-
*/
|
|
232
|
-
private async _stopLoop(): Promise<void> {
|
|
233
|
-
if (this._state !== "running") return
|
|
234
|
-
this._state = "stopping"
|
|
235
|
-
|
|
236
|
-
if (this._client?.isOpen) {
|
|
237
|
-
try {
|
|
238
|
-
await this._client.quit()
|
|
239
|
-
} catch (_e) {
|
|
240
|
-
// 切断エラーは無視
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// 自分が仕掛けた停止フェーズの完了だけを待つ。"idle" 限定で待つと、停止直後の同期的な
|
|
245
|
-
// 自動再起動 (finally 内の idle → running 遷移には await が挟まらない) に追い越された場合に
|
|
246
|
-
// このポーラが永遠に "idle" を観測できず、50ms タイマーが恒久的に残留する。
|
|
247
|
-
// "stopping" を抜けたら (idle でも running でも) 停止処理としての責務は完了している。
|
|
248
|
-
// _state は別の非同期ループが書き換えるが、TS は await を跨いだクラスフィールドの変化を
|
|
249
|
-
// 追えず "stopping" に過剰 narrowing するため、宣言型へキャストして比較する。
|
|
250
|
-
while ((this._state as "idle" | "running" | "stopping") === "stopping") {
|
|
251
|
-
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Emits an "error" event only when a listener exists (bare "error" emit throws).
|
|
257
|
-
* リスナーが居るときだけ "error" イベントを emit する処理 (リスナー 0 の "error" emit は
|
|
258
|
-
* EventEmitter 仕様で throw し、非同期文脈では unhandled rejection = プロセス危険になるため)。
|
|
259
|
-
*/
|
|
260
|
-
private _emitError(err: Error): void {
|
|
261
|
-
if (this._emitter.listenerCount("error") > 0) {
|
|
262
|
-
this._emitter.emit("error", err)
|
|
263
|
-
} else {
|
|
264
|
-
this._logger.error("SSE reader error (no error listeners):", err)
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|