@aiquants/daily-report 0.1.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/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/client.d.mts +449 -0
- package/dist/client.d.ts +449 -0
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -0
- package/dist/client.mjs +7 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +42 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/dist/logger-D3krZrNK.d.mts +29 -0
- package/dist/logger-D3krZrNK.d.ts +29 -0
- package/dist/server.d.mts +1515 -0
- package/dist/server.d.ts +1515 -0
- package/dist/server.js +10 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +10 -0
- package/dist/server.mjs.map +1 -0
- package/dist/sse-schema-CK7cUnEo.d.ts +1986 -0
- package/dist/sse-schema-yl5AaSsj.d.mts +1986 -0
- package/dist/types-CVhwLhSN.d.mts +76 -0
- package/dist/types-CVhwLhSN.d.ts +76 -0
- package/package.json +108 -0
- package/src/client/components/business-day-thumb-overlay.tsx +19 -0
- package/src/client/components/daily-report-comment-item.tsx +81 -0
- package/src/client/components/daily-report-comment-section.tsx +166 -0
- package/src/client/components/daily-report-detail-list.tsx +676 -0
- package/src/client/components/daily-report-edit-form.tsx +81 -0
- package/src/client/components/daily-report-list.tsx +1024 -0
- package/src/client/components/daily-report-page.tsx +147 -0
- package/src/client/components/daily-report-resolved-content.tsx +139 -0
- package/src/client/components/unread-indicator.tsx +13 -0
- package/src/client/config-context.tsx +129 -0
- package/src/client/contexts/daily-report-action-context.tsx +910 -0
- package/src/client/hooks/use-daily-report-comments.ts +73 -0
- package/src/client/hooks/use-daily-report-sse-connection.ts +86 -0
- package/src/client/hooks/use-daily-report.spec.ts +155 -0
- package/src/client/hooks/use-daily-report.ts +426 -0
- package/src/client/hooks/use-dynamic-viewport-height.ts +127 -0
- package/src/client/route-helpers.ts +76 -0
- package/src/client/ui/button.tsx +42 -0
- package/src/client/ui/cn.ts +14 -0
- package/src/client/ui/input.tsx +21 -0
- package/src/client/ui/label.tsx +16 -0
- package/src/client/ui/switch.tsx +19 -0
- package/src/client/ui/tabs.tsx +40 -0
- package/src/client/ui/textarea.tsx +19 -0
- package/src/client/utils/constants.ts +29 -0
- package/src/client.ts +21 -0
- package/src/index.ts +10 -0
- package/src/server/cache.ts +165 -0
- package/src/server/etag.ts +18 -0
- package/src/server/external-source.ts +60 -0
- package/src/server/handlers.ts +543 -0
- package/src/server/ports.ts +68 -0
- package/src/server/response.ts +37 -0
- package/src/server/schema.ts +266 -0
- package/src/server/service.spec.ts +97 -0
- package/src/server/service.ts +1308 -0
- package/src/server/sse-reader.spec.ts +55 -0
- package/src/server/sse-reader.ts +223 -0
- package/src/server.ts +83 -0
- package/src/shared/business-date.spec.ts +61 -0
- package/src/shared/business-date.ts +84 -0
- package/src/shared/comment-adapter.ts +78 -0
- package/src/shared/logger.ts +47 -0
- package/src/shared/sse-schema.ts +147 -0
- package/src/shared/text-utils.ts +57 -0
- package/src/shared/types.ts +76 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "./cn"
|
|
4
|
+
|
|
5
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
6
|
+
return (
|
|
7
|
+
<input
|
|
8
|
+
type={type}
|
|
9
|
+
data-slot="input"
|
|
10
|
+
className={cn(
|
|
11
|
+
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
|
12
|
+
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
|
13
|
+
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { Input }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as LabelPrimitive from "@radix-ui/react-label"
|
|
2
|
+
import type * as React from "react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "./cn"
|
|
5
|
+
|
|
6
|
+
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
7
|
+
return (
|
|
8
|
+
<LabelPrimitive.Root
|
|
9
|
+
data-slot="label"
|
|
10
|
+
className={cn("flex select-none items-center gap-2 font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50", className)}
|
|
11
|
+
{...props}
|
|
12
|
+
/>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { Label }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "./cn"
|
|
5
|
+
|
|
6
|
+
const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>>(({ className, ...props }, ref) => (
|
|
7
|
+
<SwitchPrimitives.Root
|
|
8
|
+
className={cn(
|
|
9
|
+
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
10
|
+
className,
|
|
11
|
+
)}
|
|
12
|
+
{...props}
|
|
13
|
+
ref={ref}>
|
|
14
|
+
<SwitchPrimitives.Thumb className={cn("pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0")} />
|
|
15
|
+
</SwitchPrimitives.Root>
|
|
16
|
+
))
|
|
17
|
+
Switch.displayName = SwitchPrimitives.Root.displayName
|
|
18
|
+
|
|
19
|
+
export { Switch }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "./cn"
|
|
5
|
+
|
|
6
|
+
const Tabs = TabsPrimitive.Root
|
|
7
|
+
|
|
8
|
+
const TabsList = React.forwardRef<React.ElementRef<typeof TabsPrimitive.List>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>>(({ className, ...props }, ref) => (
|
|
9
|
+
<TabsPrimitive.List ref={ref} className={cn("inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", className)} {...props} />
|
|
10
|
+
))
|
|
11
|
+
TabsList.displayName = TabsPrimitive.List.displayName
|
|
12
|
+
|
|
13
|
+
const TabsTrigger = React.forwardRef<React.ElementRef<typeof TabsPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>>(({ className, ...props }, ref) => (
|
|
14
|
+
<TabsPrimitive.Trigger
|
|
15
|
+
ref={ref}
|
|
16
|
+
className={cn(
|
|
17
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 font-medium text-sm ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-2xs",
|
|
18
|
+
className,
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
))
|
|
23
|
+
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
|
24
|
+
|
|
25
|
+
// https://github.com/radix-ui/primitives/issues/2359
|
|
26
|
+
const TabsContent = React.forwardRef<React.ElementRef<typeof TabsPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>>(({ className, ...props }, ref) => (
|
|
27
|
+
<TabsPrimitive.Content
|
|
28
|
+
ref={ref}
|
|
29
|
+
// forceMount={true} //<=======Add this line
|
|
30
|
+
className={cn(
|
|
31
|
+
"data-[state=inactive]:hidden", //<=========Add this line
|
|
32
|
+
"mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
))
|
|
38
|
+
TabsContent.displayName = TabsPrimitive.Content.displayName
|
|
39
|
+
|
|
40
|
+
export { Tabs, TabsContent, TabsList, TabsTrigger }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "./cn"
|
|
4
|
+
|
|
5
|
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
6
|
+
return (
|
|
7
|
+
<textarea
|
|
8
|
+
data-slot="textarea"
|
|
9
|
+
className={cn(
|
|
10
|
+
"flex min-h-15 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
|
11
|
+
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { Textarea }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ScrollBarTapCircleOptions, type ScrollPaneInertiaOptions, tapScrollCircleSampleVisual } from "@aiquants/virtualscroll"
|
|
2
|
+
import type { DailyReportItem } from "../../shared/types"
|
|
3
|
+
import type { DailyReportDetailResource } from "../hooks/use-daily-report"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared constants for Daily Report data fetching parameters.
|
|
7
|
+
* 日報データ取得で利用する共通パラメーター。
|
|
8
|
+
*/
|
|
9
|
+
export const DAILY_REPORT_FETCH_DEBOUNCE_MS = 180
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_ITEM_HEIGHT = 160
|
|
12
|
+
export const ITEM_VERTICAL_PADDING = 6
|
|
13
|
+
export const ITEM_CONTAINER_HEIGHT = DEFAULT_ITEM_HEIGHT - ITEM_VERTICAL_PADDING
|
|
14
|
+
export const FAST_SCROLL_WHEEL_MULTIPLIER = 0.8
|
|
15
|
+
export const FAST_SCROLL_INERTIA_OPTIONS: ScrollPaneInertiaOptions = { maxVelocity: 18, deceleration: 0.0018, startVelocityThreshold: 0.03 }
|
|
16
|
+
export const PREFETCH_LOOKAHEAD = 20
|
|
17
|
+
export const PREFETCH_MAX_DATES = 6
|
|
18
|
+
export const EMPTY_ITEM: DailyReportItem = { reportHubId: -1, businessDate: null }
|
|
19
|
+
export const EMPTY_DETAIL: DailyReportDetailResource = { report: null, error: null, isLoading: false, isRefetching: false }
|
|
20
|
+
export const WHEEL_RESET_TIMEOUT_MS = 240
|
|
21
|
+
export const VIRTUAL_SCROLL_OVERSCAN_COUNT = 15
|
|
22
|
+
export const SCROLL_BAR_WIDTH = 8
|
|
23
|
+
export const MAX_PREVIEW_LINES = 3
|
|
24
|
+
export const TAP_SCROLL_CIRCLE_OPTIONS: ScrollBarTapCircleOptions = {
|
|
25
|
+
enabled: true,
|
|
26
|
+
renderVisual: tapScrollCircleSampleVisual,
|
|
27
|
+
maxSpeedCurve: { exponentialSteepness: 7.5, exponentialScale: 14, easedOffset: 0.18 },
|
|
28
|
+
maxSpeedMultiplier: 18,
|
|
29
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client entry of @aiquants/daily-report: React components, hooks, and route helpers.
|
|
3
|
+
* @aiquants/daily-report の client エントリ。React コンポーネント・フック・ルートヘルパーを公開。
|
|
4
|
+
*/
|
|
5
|
+
export * from "./client/components/business-day-thumb-overlay"
|
|
6
|
+
export * from "./client/components/daily-report-comment-item"
|
|
7
|
+
export * from "./client/components/daily-report-comment-section"
|
|
8
|
+
export * from "./client/components/daily-report-detail-list"
|
|
9
|
+
export * from "./client/components/daily-report-edit-form"
|
|
10
|
+
export * from "./client/components/daily-report-list"
|
|
11
|
+
export * from "./client/components/daily-report-page"
|
|
12
|
+
export * from "./client/components/daily-report-resolved-content"
|
|
13
|
+
export * from "./client/components/unread-indicator"
|
|
14
|
+
export * from "./client/config-context"
|
|
15
|
+
export * from "./client/contexts/daily-report-action-context"
|
|
16
|
+
export * from "./client/hooks/use-daily-report"
|
|
17
|
+
export * from "./client/hooks/use-daily-report-comments"
|
|
18
|
+
export * from "./client/hooks/use-daily-report-sse-connection"
|
|
19
|
+
export * from "./client/hooks/use-dynamic-viewport-height"
|
|
20
|
+
export * from "./client/route-helpers"
|
|
21
|
+
export * from "./client/utils/constants"
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared (isomorphic) entry of @aiquants/daily-report: types, SSE schemas, and pure utilities.
|
|
3
|
+
* @aiquants/daily-report の共有 (isomorphic) エントリ。型・SSE スキーマ・純粋ユーティリティを公開。
|
|
4
|
+
*/
|
|
5
|
+
export * from "./shared/business-date"
|
|
6
|
+
export * from "./shared/comment-adapter"
|
|
7
|
+
export * from "./shared/logger"
|
|
8
|
+
export * from "./shared/sse-schema"
|
|
9
|
+
export * from "./shared/text-utils"
|
|
10
|
+
export * from "./shared/types"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory SQL result cache with TTL, snapshot, and cross-worker epoch (via redis) support.
|
|
3
|
+
* TTL・スナップショット・クロスワーカー epoch (redis 経由) を備えた SQL 結果のインメモリキャッシュ。
|
|
4
|
+
*
|
|
5
|
+
* epoch は任意注入の redis ポート経由で取得・更新する (未注入時は常に 0 = epoch 無効)。
|
|
6
|
+
*/
|
|
7
|
+
import type { DailyReportRedisProvider } from "./ports"
|
|
8
|
+
|
|
9
|
+
export type SqlResultCacheQueryOptions = {
|
|
10
|
+
forceRefresh?: boolean
|
|
11
|
+
snapshot?: boolean
|
|
12
|
+
ttlMsOverride?: number
|
|
13
|
+
epochKey?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates epoch helpers bound to an optional redis provider.
|
|
18
|
+
* 任意の redis プロバイダーに束縛された epoch ヘルパーを生成する処理。
|
|
19
|
+
*/
|
|
20
|
+
export const createEpochStore = (redis?: DailyReportRedisProvider) => ({
|
|
21
|
+
/**
|
|
22
|
+
* Gets the current epoch value from Redis.
|
|
23
|
+
* Redis からエポック値を取得する。エラー時は 0 を返す (常に re-fetch = safe 方向)。
|
|
24
|
+
*/
|
|
25
|
+
async getEpoch(epochKey: string): Promise<number> {
|
|
26
|
+
try {
|
|
27
|
+
const client = await redis?.getClient()
|
|
28
|
+
if (!client) return 0
|
|
29
|
+
const val = await client.get(epochKey)
|
|
30
|
+
return val ? Number(val) : 0
|
|
31
|
+
} catch {
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
/**
|
|
36
|
+
* Increments the epoch counter in Redis.
|
|
37
|
+
* Redis のエポックカウンターをインクリメントする。
|
|
38
|
+
*/
|
|
39
|
+
async incrementEpoch(epochKey: string): Promise<void> {
|
|
40
|
+
try {
|
|
41
|
+
const client = await redis?.getClient()
|
|
42
|
+
if (!client) return
|
|
43
|
+
await client.incr(epochKey)
|
|
44
|
+
} catch {
|
|
45
|
+
// Redis エラー時は epoch 更新をスキップ (次回 GET で 0 → stale 扱い → re-fetch)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export type EpochStore = ReturnType<typeof createEpochStore>
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* In-memory cache for SQL query results with TTL and snapshot support.
|
|
54
|
+
* TTLとスナップショット機能を備えたSQLクエリ結果のインメモリキャッシュ。
|
|
55
|
+
*/
|
|
56
|
+
export class SqlResultCache {
|
|
57
|
+
private readonly buckets = new Map<string, { records: readonly unknown[]; expireAt: number; epoch: number }>()
|
|
58
|
+
private readonly inFlight = new Map<string, Promise<readonly unknown[]>>()
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly config: { defaultTtlMs: number },
|
|
62
|
+
private readonly epochStore: EpochStore,
|
|
63
|
+
) {}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Invalidates a specific cache bucket.
|
|
67
|
+
* 指定されたキャッシュバケットを無効化します。
|
|
68
|
+
*/
|
|
69
|
+
invalidate = (cacheKey: string): void => {
|
|
70
|
+
this.buckets.delete(cacheKey)
|
|
71
|
+
this.inFlight.delete(cacheKey)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Retrieves records from cache or fetches them if expired/missing.
|
|
76
|
+
* キャッシュからレコードを取得するか、期限切れや未存在の場合はフェッチします。
|
|
77
|
+
*/
|
|
78
|
+
getOrFetch = async <T>(
|
|
79
|
+
opts: {
|
|
80
|
+
cacheKey: string
|
|
81
|
+
fetcher: () => Promise<readonly T[]>
|
|
82
|
+
} & SqlResultCacheQueryOptions,
|
|
83
|
+
): Promise<readonly T[]> => {
|
|
84
|
+
const { cacheKey, forceRefresh, snapshot } = opts
|
|
85
|
+
const bucket = this.buckets.get(cacheKey)
|
|
86
|
+
|
|
87
|
+
// 強制リフレッシュ時はキャッシュと進行中のリクエストをクリア
|
|
88
|
+
if (forceRefresh) {
|
|
89
|
+
this.buckets.delete(cacheKey)
|
|
90
|
+
this.inFlight.delete(cacheKey)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 有効なキャッシュがあれば返却 (epoch check 込み)
|
|
94
|
+
if (!forceRefresh && bucket && bucket.expireAt > Date.now()) {
|
|
95
|
+
if (opts.epochKey) {
|
|
96
|
+
// epochKey 指定時は Redis の epoch と比較して stale を検出
|
|
97
|
+
const currentEpoch = await this.epochStore.getEpoch(opts.epochKey)
|
|
98
|
+
if (bucket.epoch === currentEpoch) {
|
|
99
|
+
return snapshot ? (bucket.records.map((r) => structuredClone(r)) as T[]) : (bucket.records as T[])
|
|
100
|
+
}
|
|
101
|
+
// epoch 不一致 → stale bucket を除去して re-fetch へ
|
|
102
|
+
this.buckets.delete(cacheKey)
|
|
103
|
+
} else {
|
|
104
|
+
return snapshot ? (bucket.records.map((r) => structuredClone(r)) as T[]) : (bucket.records as T[])
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 進行中のリクエストがあれば相乗り
|
|
109
|
+
if (!forceRefresh && this.inFlight.has(cacheKey)) {
|
|
110
|
+
const shared = (await this.inFlight.get(cacheKey)) as readonly T[]
|
|
111
|
+
return snapshot ? shared.map((r) => structuredClone(r)) : shared
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 新規フェッチとキャッシュ更新
|
|
115
|
+
// 自己参照 (this.inFlight.get(cacheKey) === promise) のため、定義前参照を回避する確定代入アサーション
|
|
116
|
+
let promise!: Promise<readonly T[]>
|
|
117
|
+
promise = (async () => {
|
|
118
|
+
const records = await opts.fetcher()
|
|
119
|
+
// invalidation 中に完了したリクエストはキャッシュに書き込まない(レース防止)
|
|
120
|
+
// ただし forceRefresh の場合は常に書き込む
|
|
121
|
+
if (forceRefresh || this.inFlight.get(cacheKey) === promise) {
|
|
122
|
+
const expireAt = Date.now() + (opts.ttlMsOverride ?? this.config.defaultTtlMs)
|
|
123
|
+
// キャッシュ保存時に現在の epoch を記録
|
|
124
|
+
const epoch = opts.epochKey ? await this.epochStore.getEpoch(opts.epochKey) : 0
|
|
125
|
+
this.buckets.set(cacheKey, { records, expireAt, epoch })
|
|
126
|
+
}
|
|
127
|
+
return records
|
|
128
|
+
})()
|
|
129
|
+
|
|
130
|
+
if (!forceRefresh) this.inFlight.set(cacheKey, promise as Promise<readonly unknown[]>)
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const result = await promise
|
|
134
|
+
return snapshot ? result.map((r) => structuredClone(r)) : result
|
|
135
|
+
} finally {
|
|
136
|
+
this.inFlight.delete(cacheKey)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Clears the cache for a specific key.
|
|
142
|
+
* 指定されたキーのキャッシュをクリアします。
|
|
143
|
+
*/
|
|
144
|
+
flush = (cacheKey: string): void => {
|
|
145
|
+
this.buckets.delete(cacheKey)
|
|
146
|
+
this.inFlight.delete(cacheKey)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Invalidates cache buckets matching a prefix.
|
|
151
|
+
* 指定されたプレフィックスに一致するキャッシュバケットを無効化します。
|
|
152
|
+
*/
|
|
153
|
+
invalidatePrefix = (prefix: string): void => {
|
|
154
|
+
for (const key of this.buckets.keys()) {
|
|
155
|
+
if (key.startsWith(prefix)) {
|
|
156
|
+
this.buckets.delete(key)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const key of this.inFlight.keys()) {
|
|
160
|
+
if (key.startsWith(prefix)) {
|
|
161
|
+
this.inFlight.delete(key)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ETag generation helper for JSON payloads.
|
|
3
|
+
* JSON ペイロード向けの ETag 生成ヘルパー。
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "node:crypto"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generates a SHA-256 ETag for the given data.
|
|
9
|
+
* 指定されたデータの SHA-256 ETag を生成します。
|
|
10
|
+
*
|
|
11
|
+
* @param data The data to hash (will be JSON stringified).
|
|
12
|
+
* @returns The ETag string (wrapped in quotes).
|
|
13
|
+
*/
|
|
14
|
+
export const generateETag = (data: unknown): string => {
|
|
15
|
+
const json = JSON.stringify(data)
|
|
16
|
+
const hash = createHash("sha256").update(json).digest("hex")
|
|
17
|
+
return `"${hash}"`
|
|
18
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External source adapter contract for legacy/foreign daily-report tables.
|
|
3
|
+
* レガシー・外部由来の日報テーブルを取り込む外部ソースアダプタ契約。
|
|
4
|
+
*
|
|
5
|
+
* DailyReportHub.source_type がアダプタの `sourceType` に一致する行は、
|
|
6
|
+
* `table` を `Hub.source_id_num = idColumn` で LEFT JOIN し、その行を
|
|
7
|
+
* `mapRow` で表示フィールドへ変換する (例: 別システムのレガシー日報テーブルの取り込み)。
|
|
8
|
+
*/
|
|
9
|
+
import type { AnyMsSqlColumn } from "drizzle-orm/mssql-core"
|
|
10
|
+
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
11
|
+
import type { DailyReportComment, DailyReportInterviewer } from "../shared/types"
|
|
12
|
+
|
|
13
|
+
/** 外部ソース行から詳細表示へ供給するフィールド群。 */
|
|
14
|
+
export type ExternalReportFields = {
|
|
15
|
+
content?: string | null
|
|
16
|
+
employeeName?: string | null
|
|
17
|
+
category?: string | null
|
|
18
|
+
creationCategory?: string | null
|
|
19
|
+
visitTimeFrom?: string | null
|
|
20
|
+
visitTimeTo?: string | null
|
|
21
|
+
customerName?: string | null
|
|
22
|
+
interviewers?: DailyReportInterviewer[]
|
|
23
|
+
comments?: DailyReportComment[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 外部ソースアダプタ。 */
|
|
27
|
+
export type DailyReportExternalSource = {
|
|
28
|
+
/** DailyReportHub.source_type の一致値 (例 "legacy")。 */
|
|
29
|
+
sourceType: string
|
|
30
|
+
/** LEFT JOIN する drizzle テーブル。 */
|
|
31
|
+
table: unknown
|
|
32
|
+
/** Hub.source_id_num と突き合わせる ID 列。 */
|
|
33
|
+
idColumn: AnyMsSqlColumn
|
|
34
|
+
/** 結合行を表示フィールドへ変換する処理。 */
|
|
35
|
+
mapRow: (row: Record<string, unknown>) => ExternalReportFields
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const defaultLogger = createLogger(LogLevel.INFO, "[DailyReportExternalSource]")
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parses JSON array payloads and maps each element (fail-soft: returns [] on error).
|
|
42
|
+
* JSON 配列のペイロードを解析し各要素を変換する処理 (エラー時は空配列)。
|
|
43
|
+
*/
|
|
44
|
+
export const transformJsonArray = <TRaw, TResult>(payload: string | null, label: string, mapper: (raw: TRaw) => TResult | null, logger: DailyReportLogger = defaultLogger): TResult[] => {
|
|
45
|
+
if (!payload) {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(payload)
|
|
51
|
+
if (!Array.isArray(parsed)) {
|
|
52
|
+
logger.warn(`Unexpected ${label} format: not an array`)
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
return (parsed as TRaw[]).map(mapper).filter((entry): entry is TResult => entry !== null)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
logger.warn(`Failed to parse ${label}:`, error)
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
}
|