@aiquants/daily-report 0.2.0 → 0.3.1
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 +21 -0
- package/dist/client.js +2 -2
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +2 -2
- package/dist/client.mjs.map +1 -1
- package/dist/server.d.mts +5 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +4 -4
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +4 -4
- package/dist/server.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client/components/daily-report-detail-list.tsx +14 -13
- package/src/client/components/daily-report-list.tsx +5 -5
- package/src/server/cache.spec.ts +47 -0
- package/src/server/cache.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiquants/daily-report",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Reusable daily-report feature package: shared types/schemas, React (Router v7) UI with virtual scrolling + optimistic updates + SSE sync, and a drizzle (mssql) server layer with DI ports (auth, user resolution, redis, external sources).",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -454,14 +454,11 @@ const DailyReportHeader = ({
|
|
|
454
454
|
sourceTypeConfigs?: Record<string, SourceTypeConfig>
|
|
455
455
|
}) => (
|
|
456
456
|
<div className="flex flex-wrap items-center gap-2 font-medium text-slate-600 text-xs">
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
className="flex
|
|
461
|
-
|
|
462
|
-
data-detail-read-button={report.reportHubId}>
|
|
463
|
-
{isRead ? <Check className="h-3.5 w-3.5 text-blue-500" /> : <UnreadIndicator className="text-xs" />}
|
|
464
|
-
</button>
|
|
457
|
+
{(() => {
|
|
458
|
+
const sourceType = report.sourceType
|
|
459
|
+
const sourceConfig = sourceType ? sourceTypeConfigs?.[sourceType] : undefined
|
|
460
|
+
return sourceConfig ? <span className={cn("inline-flex items-center rounded px-2 py-0.5 font-semibold text-[10px] tracking-wide", sourceConfig.badgeClassName)}>{sourceConfig.label}</span> : null
|
|
461
|
+
})()}
|
|
465
462
|
<button
|
|
466
463
|
type="button"
|
|
467
464
|
onClick={onToggleStar}
|
|
@@ -470,6 +467,14 @@ const DailyReportHeader = ({
|
|
|
470
467
|
data-detail-star-button={report.reportHubId}>
|
|
471
468
|
{isStarred ? <Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" /> : <Star className="h-3.5 w-3.5 text-slate-300 dark:text-slate-500" />}
|
|
472
469
|
</button>
|
|
470
|
+
<button
|
|
471
|
+
type="button"
|
|
472
|
+
onClick={onToggleRead}
|
|
473
|
+
className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-slate-200 focus:outline-none dark:hover:bg-slate-700"
|
|
474
|
+
title={isRead ? "未読にする" : "既読にする"}
|
|
475
|
+
data-detail-read-button={report.reportHubId}>
|
|
476
|
+
{isRead ? <Check className="h-3.5 w-3.5 text-blue-500" /> : <UnreadIndicator className="text-xs" />}
|
|
477
|
+
</button>
|
|
473
478
|
{isMine && (
|
|
474
479
|
<button type="button" onClick={onDelete} data-detail-delete-button={report.reportHubId} className="text-lg leading-none transition-transform focus:outline-none active:scale-125" title="削除する">
|
|
475
480
|
<Trash2 className="h-4 w-4 text-slate-300 hover:text-red-500" />
|
|
@@ -479,11 +484,7 @@ const DailyReportHeader = ({
|
|
|
479
484
|
<span className="text-[10px] text-blue-500 uppercase tracking-wide">{fieldLabels.id}</span>
|
|
480
485
|
{report.reportHubId}
|
|
481
486
|
</span>
|
|
482
|
-
|
|
483
|
-
const sourceType = report.sourceType
|
|
484
|
-
const sourceConfig = sourceType ? sourceTypeConfigs?.[sourceType] : undefined
|
|
485
|
-
return sourceConfig ? <span className={cn("inline-flex items-center rounded px-2 py-0.5 font-semibold text-[10px] tracking-wide", sourceConfig.badgeClassName)}>{sourceConfig.label}</span> : null
|
|
486
|
-
})()}
|
|
487
|
+
|
|
487
488
|
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-3 py-1">
|
|
488
489
|
<span className="text-[10px] text-slate-400 uppercase tracking-wide">{fieldLabels.businessDate}</span>
|
|
489
490
|
{formatToIsoDate(report.date)}
|
|
@@ -995,12 +995,12 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
|
|
|
995
995
|
<span className="inline-flex min-w-0 max-w-full items-center rounded-full bg-slate-100 px-3 py-1 font-semibold text-xs dark:bg-slate-800 dark:text-slate-200">
|
|
996
996
|
<span className="truncate font-medium text-slate-900 dark:text-slate-100">{creator}</span>
|
|
997
997
|
</span>
|
|
998
|
-
{(() => {
|
|
999
|
-
const sourceType = report?.sourceType
|
|
1000
|
-
const sourceConfig = sourceType ? sourceTypeConfigs?.[sourceType] : undefined
|
|
1001
|
-
return sourceConfig ? <span className={cn("inline-flex shrink-0 items-center rounded px-2 py-0.5 font-semibold text-[10px] tracking-wide", sourceConfig.badgeClassName)}>{sourceConfig.label}</span> : null
|
|
1002
|
-
})()}
|
|
1003
998
|
<div className="ml-auto flex items-center gap-1.5">
|
|
999
|
+
{(() => {
|
|
1000
|
+
const sourceType = report?.sourceType
|
|
1001
|
+
const sourceConfig = sourceType ? sourceTypeConfigs?.[sourceType] : undefined
|
|
1002
|
+
return sourceConfig ? <span className={cn("inline-flex shrink-0 items-center rounded px-2 py-0.5 font-semibold text-[10px] tracking-wide", sourceConfig.badgeClassName)}>{sourceConfig.label}</span> : null
|
|
1003
|
+
})()}
|
|
1004
1004
|
<button
|
|
1005
1005
|
type="button"
|
|
1006
1006
|
title={report.isStarred ? "スターを外す" : "スターを付ける"}
|
|
@@ -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
|
+
})
|
package/src/server/cache.ts
CHANGED
|
@@ -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
|
})()
|
|
@@ -162,4 +166,18 @@ export class SqlResultCache {
|
|
|
162
166
|
}
|
|
163
167
|
}
|
|
164
168
|
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Cleans up all expired cache buckets to prevent memory accumulation.
|
|
172
|
+
* メモリー蓄積を防ぐため、期限切れのキャッシュバケットをすべてクリーンアップする処理。
|
|
173
|
+
*/
|
|
174
|
+
private cleanExpired(): void {
|
|
175
|
+
const now = Date.now()
|
|
176
|
+
// 期限切れのキーを削除
|
|
177
|
+
for (const [key, bucket] of this.buckets.entries()) {
|
|
178
|
+
if (bucket.expireAt <= now) {
|
|
179
|
+
this.buckets.delete(key)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
165
183
|
}
|