@aiquants/daily-report 0.4.2 → 0.5.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.
Files changed (37) hide show
  1. package/README.md +59 -0
  2. package/dist/client.d.mts +90 -5
  3. package/dist/client.d.ts +90 -5
  4. package/dist/client.js +4 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +4 -4
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +2 -2
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/server.d.mts +13 -1
  15. package/dist/server.d.ts +13 -1
  16. package/dist/server.js +6 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +6 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-BnQQh_Cc.d.ts → sse-schema-ChCypDPj.d.ts} +34 -1
  21. package/dist/{sse-schema-DRNjDRDy.d.mts → sse-schema-Cs1oC2DP.d.mts} +34 -1
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/dist/{types-DAyE_3R1.d.mts → types-BNhYC2j9.d.mts} +1 -1
  24. package/dist/{types-DAyE_3R1.d.ts → types-BNhYC2j9.d.ts} +1 -1
  25. package/package.json +1 -1
  26. package/src/client/components/daily-report-detail-list.tsx +62 -50
  27. package/src/client/components/daily-report-list.tsx +15 -3
  28. package/src/client/components/daily-report-resolved-content.tsx +33 -7
  29. package/src/client/config-context.tsx +21 -8
  30. package/src/client/contexts/daily-report-action-context.tsx +53 -1
  31. package/src/client/hooks/use-daily-report.ts +23 -1
  32. package/src/server/cache.spec.ts +47 -0
  33. package/src/server/cache.ts +27 -0
  34. package/src/server/handlers.ts +4 -0
  35. package/src/server/service.ts +18 -1
  36. package/src/shared/sse-schema.ts +1 -0
  37. package/src/shared/types.ts +1 -1
@@ -3,11 +3,12 @@
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"
6
7
  import { createLogger, LogLevel } from "../../shared/logger"
7
8
  import type { DailyReportSseMessage } from "../../shared/sse-schema"
8
9
  import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
9
10
  import { useDailyReportConfig } from "../config-context"
10
- import { acquireMutationLock, applyDailyReportServerUpdates, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
11
+ import { acquireMutationLock, applyDailyReportServerUpdates, clearDailyReportCache, DailyReportCache, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
11
12
  import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
12
13
 
13
14
  const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
@@ -80,6 +81,10 @@ type DailyReportActionContextType = {
80
81
  deleteReport: (reportHubId: number, businessDate: string) => Promise<void>
81
82
  /** stale アイテムをリストから除去する安全弁 */
82
83
  removeStaleItem: (reportHubId: number) => void
84
+ /** データのみリロード (ページリロードなし) */
85
+ refetchData: () => Promise<void>
86
+ /** キャッシュを破棄してデータのみ再読み込み (ページリロードなし) */
87
+ clearCacheAndRefetch: () => Promise<void>
83
88
  }
84
89
 
85
90
  // HMR / dual-bundle (ESM+CJS) でもコンテキスト identity を一意に保つため、
@@ -132,6 +137,50 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
132
137
  /** 自分が発行したアクションのID(SSEエコー無視用) */
133
138
  const processedClientTempIds = useRef<Set<string>>(new Set())
134
139
 
140
+ const revalidator = useRevalidator()
141
+
142
+ const refetchData = useCallback(async () => {
143
+ logger.info("[DailyReportActionProvider] Refetching data without page reload...")
144
+ if (revalidator.state === "idle") {
145
+ revalidator.revalidate()
146
+ }
147
+ setVersion((v) => v + 1)
148
+ DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
149
+ listener(-1)
150
+ })
151
+ }, [revalidator])
152
+
153
+ const clearCacheAndRefetch = useCallback(async () => {
154
+ logger.info("[DailyReportActionProvider] Clearing client & server/DB cache and refetching data without page reload...")
155
+ // 1. クライアント側キャッシュ破棄
156
+ clearDailyReportCache()
157
+ removedIds.current.clear()
158
+ try {
159
+ sessionStorage.clear()
160
+ } catch {}
161
+
162
+ // 2. サーバー/DB側キャッシュ破棄 API 呼び出し
163
+ try {
164
+ const formData = new FormData()
165
+ formData.append("intent", "clearCache")
166
+ await fetch(`${apiBasePath}/action`, {
167
+ method: "POST",
168
+ body: formData,
169
+ })
170
+ } catch (e) {
171
+ logger.warn("[DailyReportActionProvider] Failed to clear server-side cache:", e)
172
+ }
173
+
174
+ // 3. ルートローダーの再フェッチ & UI 再描画
175
+ if (revalidator.state === "idle") {
176
+ revalidator.revalidate()
177
+ }
178
+ setVersion((v) => v + 1)
179
+ DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
180
+ listener(-1)
181
+ })
182
+ }, [apiBasePath, revalidator])
183
+
135
184
  /**
136
185
  * Queue for SSE messages that arrived before the target report was cached.
137
186
  * キャッシュ未取得時に到着した SSE メッセージの待機キュー。
@@ -703,6 +752,7 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
703
752
  createdAt: now,
704
753
  author: getUserDisplayName(user),
705
754
  userId: user?.id ?? "unknown",
755
+ sourceType: "Internal",
706
756
  employeeName: getUserDisplayName(user),
707
757
  updatedBy: getUserDisplayName(user),
708
758
  updatedAt: now,
@@ -903,6 +953,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
903
953
  publishReport,
904
954
  deleteReport,
905
955
  removeStaleItem,
956
+ refetchData,
957
+ clearCacheAndRefetch,
906
958
  }}>
907
959
  {children}
908
960
  </DailyReportActionContext.Provider>
@@ -27,7 +27,7 @@ const FETCH_DEBOUNCE_MS = 120
27
27
  // --- Cache Store (Centralized State) ---
28
28
  type CacheEntry<T> = { data: T; expiresAt: number }
29
29
 
30
- const DailyReportCache = {
30
+ export const DailyReportCache = {
31
31
  reports: new Map<number, CacheEntry<DailyReportDetail>>(),
32
32
  lists: new Map<string, CacheEntry<DailyReportDetail[]>>(),
33
33
  pendingLists: new Map<string, Promise<DailyReportDetail[]>>(),
@@ -58,6 +58,23 @@ const DailyReportCache = {
58
58
  }
59
59
  },
60
60
 
61
+ /**
62
+ * Clear all cached entries and pending requests.
63
+ * すべてのキャッシュエントリおよび待機中リクエストをクリアする。
64
+ */
65
+ clear() {
66
+ this.reports.clear()
67
+ this.lists.clear()
68
+ this.pendingLists.clear()
69
+ this.pendingReports.clear()
70
+ this.locks.clear()
71
+ this.lastMutations.clear()
72
+ this.recentDeletes.clear()
73
+ this.listeners.forEach((listener) => {
74
+ listener(-1)
75
+ })
76
+ },
77
+
61
78
  /**
62
79
  * Check if the cache entry is still valid.
63
80
  * キャッシュエントリがまだ有効かを確認する。
@@ -218,6 +235,11 @@ export const updateDailyReportCache = (id: number, u: Partial<DailyReportDetail>
218
235
  * キャッシュからレポートを削除する。
219
236
  */
220
237
  export const deleteDailyReportCache = (id: number, d: string) => DailyReportCache.delete(id, d)
238
+ /**
239
+ * Clear all daily report cache store entries.
240
+ * 日報の全キャッシュストアを消去する。
241
+ */
242
+ export const clearDailyReportCache = () => DailyReportCache.clear()
221
243
 
222
244
  export const acquireMutationLock = (id: number) => {
223
245
  DailyReportCache.locks.add(id)
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import { createEpochStore, SqlResultCache } from "./cache"
3
+
4
+ describe("SqlResultCache Memory Cleanup", () => {
5
+ it("should clean up expired cache entries when map size exceeds 500", async () => {
6
+ // Mock epoch store
7
+ const epochStore = createEpochStore(undefined)
8
+ vi.spyOn(epochStore, "getEpoch").mockResolvedValue(0)
9
+
10
+ const cache = new SqlResultCache({ defaultTtlMs: 1000 }, epochStore)
11
+
12
+ // 1. 期限切れのキャッシュを 500 件作成 (expireAt を過去に設定)
13
+ // 直接プライベートフィールド buckets に流し込むことでモック状態を作る
14
+ const buckets = (cache as any).buckets
15
+ for (let i = 0; i < 505; i++) {
16
+ buckets.set(`expired-key-${i}`, {
17
+ records: [i],
18
+ expireAt: Date.now() - 1000, // 過去時間
19
+ epoch: 0,
20
+ })
21
+ }
22
+
23
+ // 2. 有効なキャッシュを 10 件作成 (expireAt を未来に設定)
24
+ for (let i = 0; i < 10; i++) {
25
+ buckets.set(`valid-key-${i}`, {
26
+ records: [i],
27
+ expireAt: Date.now() + 100000, // 未来時間
28
+ epoch: 0,
29
+ })
30
+ }
31
+
32
+ expect(buckets.size).toBe(515)
33
+
34
+ // 3. getOrFetch を実行して 500 件超えの閾値トリガーを引く
35
+ const fetcher = vi.fn().mockResolvedValue(["new-value"])
36
+ await cache.getOrFetch({
37
+ cacheKey: "trigger-key",
38
+ fetcher,
39
+ })
40
+
41
+ // 4. 検証: 期限切れの 505 件のデータが cleanExpired で削除され、有効な 10 件 + 新規の 1 件のみ残っていること
42
+ expect(buckets.size).toBe(11) // 10 (valid) + 1 (trigger-key)
43
+ expect(buckets.has("trigger-key")).toBe(true)
44
+ expect(buckets.has("valid-key-0")).toBe(true)
45
+ expect(buckets.has("expired-key-0")).toBe(false)
46
+ })
47
+ })
@@ -123,6 +123,10 @@ export class SqlResultCache {
123
123
  // キャッシュ保存時に現在の epoch を記録
124
124
  const epoch = opts.epochKey ? await this.epochStore.getEpoch(opts.epochKey) : 0
125
125
  this.buckets.set(cacheKey, { records, expireAt, epoch })
126
+ // 蓄積防止のため、サイズが一定値を超えたら期限切れキャッシュを一括クリーンアップ
127
+ if (this.buckets.size > 500) {
128
+ this.cleanExpired()
129
+ }
126
130
  }
127
131
  return records
128
132
  })()
@@ -146,6 +150,15 @@ export class SqlResultCache {
146
150
  this.inFlight.delete(cacheKey)
147
151
  }
148
152
 
153
+ /**
154
+ * Clears all cached buckets and in-flight requests.
155
+ * すべてのキャッシュバケットと進行中のリクエストを全クリアします。
156
+ */
157
+ clearAll = (): void => {
158
+ this.buckets.clear()
159
+ this.inFlight.clear()
160
+ }
161
+
149
162
  /**
150
163
  * Invalidates cache buckets matching a prefix.
151
164
  * 指定されたプレフィックスに一致するキャッシュバケットを無効化します。
@@ -162,4 +175,18 @@ export class SqlResultCache {
162
175
  }
163
176
  }
164
177
  }
178
+
179
+ /**
180
+ * Cleans up all expired cache buckets to prevent memory accumulation.
181
+ * メモリー蓄積を防ぐため、期限切れのキャッシュバケットをすべてクリーンアップする処理。
182
+ */
183
+ private cleanExpired(): void {
184
+ const now = Date.now()
185
+ // 期限切れのキーを削除
186
+ for (const [key, bucket] of this.buckets.entries()) {
187
+ if (bucket.expireAt <= now) {
188
+ this.buckets.delete(key)
189
+ }
190
+ }
191
+ }
165
192
  }
@@ -317,6 +317,10 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
317
317
  throw e
318
318
  }
319
319
  }
320
+ case "clearCache": {
321
+ await service.clearCache()
322
+ return jsonData({ status: "OK", intent: "clearCache" })
323
+ }
320
324
  default:
321
325
  return jsonData({ error: "Invalid intent" }, { status: 400 })
322
326
  }
@@ -326,6 +326,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
326
326
  date: formatDateValue(hub.businessDate, "YYYY-MM-DD"),
327
327
  author: row.creatorName ?? maskAuditActor(hub.createdBy) ?? "",
328
328
  userId: hub.userId ? encodeUserId(hub.userId) : "",
329
+ sourceType: hub.sourceType ?? "Internal",
329
330
  createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
330
331
  updatedAt: formatDateValue(hub.updatedAt, "YYYY-MM-DD HH:mm:ss"),
331
332
  updatedBy: maskAuditActor(hub.updatedBy),
@@ -351,7 +352,6 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
351
352
  userId: encodeUserId(c.userId),
352
353
  isMine: currentUserId ? c.userId === currentUserId : false,
353
354
  })),
354
- sourceType: hub.sourceType,
355
355
  }
356
356
  }
357
357
 
@@ -1036,6 +1036,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1036
1036
  createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
1037
1037
  author: userName,
1038
1038
  userId: encodeUserId(userId),
1039
+ sourceType: "Internal",
1039
1040
  employeeName: userName,
1040
1041
  // 監査列と同様、クライアント公開時は生内部 ID を難読化する (mapHubRecord と整合)
1041
1042
  updatedBy: encodeUserId(userId),
@@ -1279,10 +1280,26 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1279
1280
  return fullDetail ?? null
1280
1281
  }
1281
1282
 
1283
+ /**
1284
+ * Clears all server-side SQL result and user ID caches.
1285
+ * サーバー側のすべての SQL 結果キャッシュおよびユーザー ID キャッシュを全消去する。
1286
+ */
1287
+ const clearCache = async (): Promise<void> => {
1288
+ sqlResultCache.clearAll()
1289
+ userIdCache.clear()
1290
+ cachedDraftLabelId = null
1291
+ if (epochs) {
1292
+ await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
1293
+ }
1294
+ logger.info("[DailyReportService] Server-side DB/SQL caches cleared successfully.")
1295
+ }
1296
+
1282
1297
  return {
1283
1298
  // 定数 (アプリ側の互換 export 用)
1284
1299
  streamKey: DAILY_REPORT_SSE_STREAM_KEY,
1285
1300
  streamMaxLen: DAILY_REPORT_SSE_STREAM_MAXLEN,
1301
+ // キャッシュクリア
1302
+ clearCache,
1286
1303
  // ユーザー解決
1287
1304
  getUserIdByExternalId,
1288
1305
  // 参照系
@@ -39,6 +39,7 @@ export const dailyReportDetailSchema = z.object({
39
39
  createdAt: z.string().nullable(),
40
40
  author: z.string(),
41
41
  userId: z.string(),
42
+ sourceType: z.string().nullable(),
42
43
  employeeName: z.string().nullable(),
43
44
  updatedBy: z.string().nullable(),
44
45
  updatedAt: z.string().nullable(),
@@ -47,6 +47,7 @@ export type DailyReportDetail = {
47
47
  createdAt: string | null
48
48
  author: string
49
49
  userId: string
50
+ sourceType: string | null
50
51
  employeeName: string | null
51
52
  updatedBy: string | null
52
53
  updatedAt: string | null
@@ -63,7 +64,6 @@ export type DailyReportDetail = {
63
64
  isStarred: boolean
64
65
  labels: DailyReportLabelDef[]
65
66
  commentItems: DailyReportCommentItem[]
66
- sourceType?: string
67
67
  }
68
68
 
69
69
  /**