@aiquants/daily-report 0.6.2 → 0.7.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/dist/client.d.mts +81 -3
- package/dist/client.d.ts +81 -3
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +4 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- 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 +434 -6
- package/dist/server.d.ts +434 -6
- package/dist/server.js +6 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +6 -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-list.tsx +19 -11
- 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.tsx +2 -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/utils/constants.spec.ts +96 -0
- package/src/client/utils/constants.ts +47 -2
- package/src/client.ts +2 -0
- package/src/server/handlers.attachment.spec.ts +470 -0
- package/src/server/handlers.ts +426 -1
- 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 +308 -25
- package/src/server/test-helpers/handlers-config.ts +142 -0
- package/src/server.ts +16 -1
- package/src/shared/sse-schema.spec.ts +50 -0
- package/src/shared/sse-schema.ts +27 -0
- package/src/shared/types.ts +31 -0
package/src/server/handlers.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { normalizeBusinessDateKey } from "../shared/business-date"
|
|
10
10
|
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
11
11
|
import { dailyReportSseMessageSchema } from "../shared/sse-schema"
|
|
12
|
-
import type { DailyReportAuthenticate, DailyReportEncodeUserId, DailyReportRedisProvider } from "./ports"
|
|
12
|
+
import type { DailyReportAttachmentFailure, DailyReportAuthenticate, DailyReportEncodeUserId, DailyReportIdCodec, DailyReportReadAttachment, DailyReportRedisProvider } from "./ports"
|
|
13
13
|
import { jsonResponseWithETag } from "./response"
|
|
14
14
|
import type { DailyReportService } from "./service"
|
|
15
15
|
import type { DailyReportSseReader, StreamEntry } from "./sse-reader"
|
|
@@ -24,6 +24,256 @@ const jsonData = (payload: unknown, init?: { status?: number }): Response =>
|
|
|
24
24
|
headers: { "Content-Type": "application/json" },
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
+
// ---------------- 添付配信の定数とヘルパー (純粋・モジュールスコープ) ----------------
|
|
28
|
+
|
|
29
|
+
/** 添付 1 件あたりの既定上限。サービス側のワイヤ上限 (既定 64 MiB) より必ず低く保つ。 */
|
|
30
|
+
const DEFAULT_ATTACHMENT_MAX_BYTES = 32 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Default per-user rate limit (calls per minute), enforced per process.
|
|
34
|
+
* ユーザー単位の既定レート上限 (1 分あたり)。ただし**プロセス単位**で計上する。
|
|
35
|
+
*
|
|
36
|
+
* バケットはハンドラーのクロージャに置く素の `Map` であり、プロセス間で共有されない。
|
|
37
|
+
* 消費アプリが Node Cluster 等で複数ワーカーを起動する場合、コンテナ全体の実効上限は
|
|
38
|
+
* この値のワーカー数倍になる。全体で厳密に効かせたいなら共有ストア (Redis 等) が要る。
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE = 60
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Default simultaneous-read limit, enforced per process.
|
|
44
|
+
* 既定の同時実行上限。**プロセス単位**で計上する。
|
|
45
|
+
*
|
|
46
|
+
* 1 リクエストが上限バイト数をヒープへ載せるため小さく保つ。ヒープ見積りは
|
|
47
|
+
* 「この値 × `attachmentMaxBytes` × ワーカー数」であり、ワーカー数を掛け忘れると
|
|
48
|
+
* コンテナのメモリ上限を実際の数倍で見誤る。消費アプリはワーカー数を織り込んだ値を
|
|
49
|
+
* `attachmentConcurrency` で明示注入すること。
|
|
50
|
+
*
|
|
51
|
+
* スロットは**本文をクライアントへ送出し終えるまで**保持する。読み取り完了時点で
|
|
52
|
+
* 解放すると、前の応答のバイト列がヒープに載ったまま次の読み取りが始まるため、
|
|
53
|
+
* 上の見積りが成立しなくなる (実際の同時保持数は無制限になる)。
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_ATTACHMENT_CONCURRENCY = 4
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Backstop for releasing a concurrency slot when the client never drains the body.
|
|
59
|
+
* クライアントが本文を読み切らない場合に同時実行スロットを解放するバックストップ。
|
|
60
|
+
*
|
|
61
|
+
* スロットを送出完了まで保持する以上、接続を張ったまま読み止めたクライアントが
|
|
62
|
+
* スロットを永久に占有できてしまう。これは締め切りではなく枯渇防止の保険であり、
|
|
63
|
+
* 超過しても応答は中断しない (単にゲートの会計上、保持されていない扱いにするだけ)。
|
|
64
|
+
*/
|
|
65
|
+
const ATTACHMENT_BODY_FLUSH_TIMEOUT_MS = 120_000
|
|
66
|
+
|
|
67
|
+
/** レート制限テーブルの上限件数。無制限 Map はプロセス寿命の間だけ単調増加するリークになる。 */
|
|
68
|
+
const RATE_LIMITER_MAX_ENTRIES = 1024
|
|
69
|
+
|
|
70
|
+
/** レート上限超過時に提示する再試行間隔 (秒)。 */
|
|
71
|
+
const RATE_LIMIT_RETRY_AFTER_SECONDS = 60
|
|
72
|
+
|
|
73
|
+
/** 同時実行上限に達したときに提示する再試行間隔 (秒)。 */
|
|
74
|
+
const CONCURRENCY_RETRY_AFTER_SECONDS = 5
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Media types allowed to render inline in the browser.
|
|
78
|
+
* ブラウザーへインライン表示してよい media type。
|
|
79
|
+
*
|
|
80
|
+
* これ以外は必ず添付ダウンロードにし、Content-Type も `application/octet-stream` へ落とす。
|
|
81
|
+
* `file_type` は外部システム由来の未検証値であり、`text/html` や `image/svg+xml` を
|
|
82
|
+
* 自オリジンでインライン配信するとセッション Cookie を持つ文脈でスクリプトが動く。
|
|
83
|
+
* `X-Content-Type-Options: nosniff` は「宣言型から外れた推測」を止めるだけで、
|
|
84
|
+
* 宣言された型の実行は止めない。
|
|
85
|
+
*/
|
|
86
|
+
const INLINE_SAFE_MEDIA_TYPES: ReadonlySet<string> = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain"])
|
|
87
|
+
|
|
88
|
+
/** ヘッダー値として安全で、かつ MIME 型の形をしている値だけを通す。 */
|
|
89
|
+
const HEADER_SAFE_PATTERN = /^[\x20-\x7E]+$/
|
|
90
|
+
const MEDIA_TYPE_PATTERN = /^[\w.+-]+\/[\w.+-]+/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Accepts a media type only when it is header-safe and well-formed.
|
|
94
|
+
* ヘッダー安全かつ MIME 型の形をしている場合のみ採用する処理。
|
|
95
|
+
*
|
|
96
|
+
* CR/LF や 0x00-0xFF 外の文字が混ざった値を `new Headers()` へ渡すと TypeError になり、
|
|
97
|
+
* 認可済みのダウンロードが 500 になる。
|
|
98
|
+
*
|
|
99
|
+
* **これは第 2 層である。** 現在の配信経路では、注入値はインライン許可リストに完全一致しないため
|
|
100
|
+
* 必ず `application/octet-stream` へ落ち、ヘッダーには到達しない。つまり本関数を外しても
|
|
101
|
+
* 現状の挙動は変わらない (ミューテーションテストで確認済み)。
|
|
102
|
+
* 許可判定を前方一致や「DB の型を信じる」形へ変えた瞬間に効き始める防御なので、
|
|
103
|
+
* 到達不能なまま放置せず **直接の単体テストで固定する**目的でエクスポートしている。
|
|
104
|
+
*
|
|
105
|
+
* @param value Candidate media type. 候補となる media type。
|
|
106
|
+
* @returns The value when acceptable, otherwise null. 採用可なら値、それ以外は null。
|
|
107
|
+
*/
|
|
108
|
+
export const sanitizeMediaType = (value: string | null | undefined): string | null => (value && HEADER_SAFE_PATTERN.test(value) && MEDIA_TYPE_PATTERN.test(value) ? value : null)
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Percent-encodes a file name for the RFC 8187 `filename*` parameter.
|
|
112
|
+
* RFC 8187 の `filename*` 用にファイル名をパーセントエンコードする処理。
|
|
113
|
+
*
|
|
114
|
+
* `encodeURIComponent` は `'` `(` `)` `*` を残すが、いずれも RFC 8187 の attr-char ではない。
|
|
115
|
+
*
|
|
116
|
+
* @param name Raw file name. 生のファイル名。
|
|
117
|
+
* @returns Encoded value. エンコード済みの値。
|
|
118
|
+
*/
|
|
119
|
+
export const encodeRfc8187 = (name: string): string => encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Builds the ASCII fallback used by the plain `filename` parameter.
|
|
123
|
+
* 素の `filename` パラメータ用の ASCII フォールバックを組み立てる処理。
|
|
124
|
+
*
|
|
125
|
+
* @param name Raw file name. 生のファイル名。
|
|
126
|
+
* @returns ASCII-only, quote-free name. ASCII のみで引用符を含まない名前。
|
|
127
|
+
*/
|
|
128
|
+
export const asciiFallbackFileName = (name: string): string => name.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Maps a storage read failure onto the HTTP status the endpoint returns.
|
|
132
|
+
* ストレージ読み取りの失敗種別を、エンドポイントが返す HTTP ステータスへ写像する処理。
|
|
133
|
+
*
|
|
134
|
+
* `not_found` と `denied` はどちらも 404 にする。認可判定は既に SQL 側で完了しているため
|
|
135
|
+
* 横断的な情報漏洩は無いが、実体の有無を 403/404 で区別すると存在オラクルになる。
|
|
136
|
+
*
|
|
137
|
+
* @param reason Failure kind reported by the port. ポートが報告した失敗種別。
|
|
138
|
+
* @returns HTTP status code. HTTP ステータスコード。
|
|
139
|
+
*/
|
|
140
|
+
const attachmentFailureToStatus = (reason: DailyReportAttachmentFailure): number => {
|
|
141
|
+
switch (reason) {
|
|
142
|
+
case "not_found":
|
|
143
|
+
case "denied":
|
|
144
|
+
case "invalid_path":
|
|
145
|
+
return 404
|
|
146
|
+
case "too_large":
|
|
147
|
+
return 413
|
|
148
|
+
default:
|
|
149
|
+
return 502
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Client-facing messages per failure status.
|
|
155
|
+
* 失敗ステータスごとのクライアント向けメッセージ。
|
|
156
|
+
*
|
|
157
|
+
* 404 は「存在しない」「認可されない」「実体が消えた」の合流点なので、
|
|
158
|
+
* どの経路から来ても同一の文面にする (差分が存在オラクルになるため)。
|
|
159
|
+
*/
|
|
160
|
+
const ATTACHMENT_FAILURE_MESSAGES: Readonly<Record<number, string>> = {
|
|
161
|
+
404: "Attachment not found",
|
|
162
|
+
413: "Attachment too large",
|
|
163
|
+
502: "Attachment storage unavailable",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** レート制限 1 ユーザー分の状態。 */
|
|
167
|
+
type RateBucket = { tokens: number; lastRefillMs: number }
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Creates a bounded per-user token-bucket rate limiter (process-local).
|
|
171
|
+
* ユーザー単位のトークンバケット方式レート制限を、件数上限付きで生成する処理 (プロセス内限定)。
|
|
172
|
+
*
|
|
173
|
+
* 上限を設ける理由: 素の `Map` で保持するとプロセス寿命の間だけ単調増加し、
|
|
174
|
+
* 「サーバー側の状態は上限を持つ」という設計方針 (SqlResultCache の >500 GC) に反する。
|
|
175
|
+
* 上限到達時は挿入順が最も古いエントリから落とす。
|
|
176
|
+
*
|
|
177
|
+
* 状態はプロセス内に閉じるため、マルチワーカー配備では実効上限がワーカー数倍になる
|
|
178
|
+
* (`DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE` 参照)。
|
|
179
|
+
*
|
|
180
|
+
* @param limitPerMinute Allowed calls per minute. 1 分あたりの許可回数。
|
|
181
|
+
* @returns A function that consumes one token and reports whether it was allowed. トークンを 1 つ消費し可否を返す関数。
|
|
182
|
+
*/
|
|
183
|
+
const createRateLimiter = (limitPerMinute: number) => {
|
|
184
|
+
const buckets = new Map<number, RateBucket>()
|
|
185
|
+
return (userId: number, nowMs: number): boolean => {
|
|
186
|
+
const existing = buckets.get(userId)
|
|
187
|
+
// アクセスのたびに再挿入して LRU 順序にする (Map は挿入順を保つ)
|
|
188
|
+
if (existing) buckets.delete(userId)
|
|
189
|
+
|
|
190
|
+
const bucket = existing ?? { tokens: limitPerMinute, lastRefillMs: nowMs }
|
|
191
|
+
const elapsedMs = Math.max(0, nowMs - bucket.lastRefillMs)
|
|
192
|
+
if (elapsedMs > 0) {
|
|
193
|
+
bucket.tokens = Math.min(limitPerMinute, bucket.tokens + (elapsedMs * limitPerMinute) / 60_000)
|
|
194
|
+
bucket.lastRefillMs = nowMs
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const allowed = bucket.tokens >= 1
|
|
198
|
+
if (allowed) bucket.tokens -= 1
|
|
199
|
+
|
|
200
|
+
buckets.set(userId, bucket)
|
|
201
|
+
while (buckets.size > RATE_LIMITER_MAX_ENTRIES) {
|
|
202
|
+
const oldest = buckets.keys().next()
|
|
203
|
+
if (oldest.done) break
|
|
204
|
+
buckets.delete(oldest.value)
|
|
205
|
+
}
|
|
206
|
+
return allowed
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Creates a fail-fast concurrency gate (no queueing, process-local).
|
|
212
|
+
* 待ち行列を持たない即時失敗型の同時実行ゲートを生成する処理 (プロセス内限定)。
|
|
213
|
+
*
|
|
214
|
+
* 待たせるとリクエストを掴んだまま滞留するだけなので、上限超過は即座に 503 を返す。
|
|
215
|
+
* カウンタはプロセス内に閉じるため、マルチワーカー配備ではコンテナ全体の同時読み取り数が
|
|
216
|
+
* 上限のワーカー数倍になる (`DEFAULT_ATTACHMENT_CONCURRENCY` 参照)。
|
|
217
|
+
*
|
|
218
|
+
* @param limit Maximum simultaneous holders. 同時保持数の上限。
|
|
219
|
+
* @returns Acquire/release pair. 取得と解放の組。
|
|
220
|
+
*/
|
|
221
|
+
const createConcurrencyGate = (limit: number) => {
|
|
222
|
+
let active = 0
|
|
223
|
+
return {
|
|
224
|
+
tryAcquire: (): boolean => {
|
|
225
|
+
if (active >= limit) return false
|
|
226
|
+
active += 1
|
|
227
|
+
return true
|
|
228
|
+
},
|
|
229
|
+
release: (): void => {
|
|
230
|
+
if (active > 0) active -= 1
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Streams already-materialized bytes and releases the held slot once the body is drained.
|
|
237
|
+
* materialize 済みのバイト列をストリームとして送出し、送出完了時に保持中のスロットを解放する処理。
|
|
238
|
+
*
|
|
239
|
+
* 読み取り完了時点で解放してはならない。その時点でバイト列はまだヒープ上にあり、
|
|
240
|
+
* 応答が流し切られるまで参照が保持されるため、「同時実行上限 × 上限バイト数」という
|
|
241
|
+
* ヒープ見積りが成立しなくなる。ここで送出完了まで保持することで見積りを実際に成立させる。
|
|
242
|
+
*
|
|
243
|
+
* 解放は必ず 1 回だけ行う (正常終了・キャンセル・バックストップのいずれか最初の 1 回)。
|
|
244
|
+
*
|
|
245
|
+
* @param bytes Body bytes to send. 送出する本文のバイト列。
|
|
246
|
+
* @param release Idempotent slot release. 冪等なスロット解放関数。
|
|
247
|
+
* @param timeoutMs Backstop before force-releasing. 強制解放までのバックストップ時間。
|
|
248
|
+
* @returns A stream that emits the bytes once. バイト列を 1 度だけ流すストリーム。
|
|
249
|
+
*/
|
|
250
|
+
const streamAndRelease = (bytes: Uint8Array<ArrayBuffer>, release: () => void, timeoutMs: number): ReadableStream<Uint8Array> => {
|
|
251
|
+
const timer: ReturnType<typeof setTimeout> = setTimeout(release, timeoutMs)
|
|
252
|
+
// タイマーがプロセス終了を待たせないようにする (Node 以外では unref を持たない)
|
|
253
|
+
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
254
|
+
const finish = () => {
|
|
255
|
+
clearTimeout(timer)
|
|
256
|
+
release()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let sent = false
|
|
260
|
+
return new ReadableStream<Uint8Array>({
|
|
261
|
+
pull(controller) {
|
|
262
|
+
// 1 回目の pull で本文を積み、消費された後の 2 回目の pull で閉じて解放する
|
|
263
|
+
if (sent) {
|
|
264
|
+
controller.close()
|
|
265
|
+
finish()
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
sent = true
|
|
269
|
+
controller.enqueue(bytes)
|
|
270
|
+
},
|
|
271
|
+
cancel() {
|
|
272
|
+
finish()
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
27
277
|
export type DailyReportHandlersConfig = {
|
|
28
278
|
/** リクエスト認証ポート。 */
|
|
29
279
|
authenticate: DailyReportAuthenticate
|
|
@@ -31,6 +281,19 @@ export type DailyReportHandlersConfig = {
|
|
|
31
281
|
service: DailyReportService
|
|
32
282
|
/** 内部数値 ID の難読化ポート。 */
|
|
33
283
|
encodeUserId: DailyReportEncodeUserId
|
|
284
|
+
/**
|
|
285
|
+
* 添付 ID の難読化コーデック。未注入なら添付エンドポイントは常に 404。
|
|
286
|
+
* ユーザー ID 用とは別インスタンスを渡すこと (ID 空間の分離)。
|
|
287
|
+
*/
|
|
288
|
+
attachmentIdCodec?: DailyReportIdCodec
|
|
289
|
+
/** 添付ファイル読み取りポート。未注入なら添付エンドポイントは常に 404。 */
|
|
290
|
+
readAttachment?: DailyReportReadAttachment
|
|
291
|
+
/** 添付ファイルの最大バイト数 (既定 32 MiB)。サービス側のワイヤ上限より低く保つこと。 */
|
|
292
|
+
attachmentMaxBytes?: number
|
|
293
|
+
/** 添付エンドポイントの 1 分あたり呼び出し上限 (ユーザー単位・既定 60)。 */
|
|
294
|
+
attachmentRateLimitPerMinute?: number
|
|
295
|
+
/** 添付読み取りの同時実行上限 (プロセス単位・既定 4)。 */
|
|
296
|
+
attachmentConcurrency?: number
|
|
34
297
|
/** SSE 用 redis プロバイダー (catch-up の xRange / xRevRange に使用)。 */
|
|
35
298
|
redis?: DailyReportRedisProvider
|
|
36
299
|
/** SSE Fan-Out 共有リーダー。 */
|
|
@@ -52,6 +315,12 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
52
315
|
const loginRedirectPath = config.loginRedirectPath ?? "/auth/login"
|
|
53
316
|
const apiLogger = config.logger ?? createLogger(LogLevel.ERROR, "[DailyReportAPI]")
|
|
54
317
|
const sseLogger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportSSE]")
|
|
318
|
+
const attachmentLogger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportAttachment]")
|
|
319
|
+
|
|
320
|
+
// 添付配信のプロセス内状態。ファクトリ 1 インスタンスにつき 1 組。
|
|
321
|
+
const attachmentMaxBytes = config.attachmentMaxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES
|
|
322
|
+
const consumeAttachmentRateToken = createRateLimiter(config.attachmentRateLimitPerMinute ?? DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE)
|
|
323
|
+
const attachmentGate = createConcurrencyGate(config.attachmentConcurrency ?? DEFAULT_ATTACHMENT_CONCURRENCY)
|
|
55
324
|
|
|
56
325
|
// ---------------- index (画面ルート) ----------------
|
|
57
326
|
|
|
@@ -540,9 +809,165 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
540
809
|
})
|
|
541
810
|
}
|
|
542
811
|
|
|
812
|
+
// ---------------- attachment (認可済みバイト配信) ----------------
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Streams an authorized attachment's bytes to the client.
|
|
816
|
+
* 認可済みの添付ファイルをバイト列としてクライアントへ返すローダー。
|
|
817
|
+
*
|
|
818
|
+
* 認可は `service.getAttachmentForUser` の SQL 述語で完結させる。ストレージ側の
|
|
819
|
+
* 資格情報はサービス共通のものであり、利用者間の分離を一切提供しない。
|
|
820
|
+
* したがってこの述語が唯一の認可境界であり、ここに欠陥があれば全テナント横断の読み取りになる。
|
|
821
|
+
*
|
|
822
|
+
* 「存在しない」と「認可されない」は区別せず 404 にする (存在オラクル回避)。
|
|
823
|
+
*
|
|
824
|
+
* 本体は必ず try/catch で包む。ここで漏らした例外はフレームワークの最終防衛線に落ち、
|
|
825
|
+
* JSON ではないプレーンテキストの 500 になるうえ `Set-Cookie` (セッション延長) も失われる。
|
|
826
|
+
* **認証ポートの呼び出しも try の内側**に置くこと。認証実装はセッション復号や warmup の
|
|
827
|
+
* 失敗で素の例外を投げうるため、外に置くと同じ穴が残る。ただしリダイレクトは `Response` を
|
|
828
|
+
* throw する正常な制御フローなので、catch では素通しする。
|
|
829
|
+
*/
|
|
830
|
+
const attachmentLoader = async ({ request, params }: LoaderArgs) => {
|
|
831
|
+
let sanitizedCookie: string | null = null
|
|
832
|
+
|
|
833
|
+
/** エラー応答。`Set-Cookie` はすべての分岐で伝播させる。 */
|
|
834
|
+
const fail = (status: number, message: string, retryAfterSeconds?: number): Response => {
|
|
835
|
+
const headers = new Headers({ "Content-Type": "application/json" })
|
|
836
|
+
if (retryAfterSeconds !== undefined) headers.set("Retry-After", String(retryAfterSeconds))
|
|
837
|
+
if (sanitizedCookie) headers.append("Set-Cookie", sanitizedCookie)
|
|
838
|
+
return new Response(JSON.stringify({ error: { message } }), { status, headers })
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
const { user, cookie } = await authenticate(request, { failureRedirect: null })
|
|
843
|
+
sanitizedCookie = cookie ?? null
|
|
844
|
+
|
|
845
|
+
if (!user) return fail(401, "Unauthorized")
|
|
846
|
+
if (request.method !== "GET" && request.method !== "HEAD") return fail(405, "Method not allowed")
|
|
847
|
+
|
|
848
|
+
const codec = config.attachmentIdCodec
|
|
849
|
+
const readAttachment = config.readAttachment
|
|
850
|
+
// ポート未注入は「この配備に添付機能が無い」であり、認可失敗と区別しない
|
|
851
|
+
if (!(codec && readAttachment)) return fail(404, "Attachment not found")
|
|
852
|
+
|
|
853
|
+
const token = params.token ?? ""
|
|
854
|
+
const attachmentId = codec.decode(token)
|
|
855
|
+
// `Number.isSafeInteger` であること。可逆難読化は入力トークンの正準性を検証しないため、
|
|
856
|
+
// 正規トークンと同じ長さの入力から 2^53 を超える値が復号されうる。それを SQL パラメータへ
|
|
857
|
+
// 渡すとドライバーの範囲検証が TypeError を投げ、400 のはずが 500 になる。
|
|
858
|
+
if (attachmentId === null || !Number.isSafeInteger(attachmentId) || attachmentId <= 0) {
|
|
859
|
+
return fail(400, "Invalid attachment token")
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const internalUserId = await service.getUserIdByExternalId(user.id)
|
|
863
|
+
if (!internalUserId) return fail(403, "Forbidden")
|
|
864
|
+
|
|
865
|
+
// レート制限。トークンは列挙可能なので、これが実効的な唯一の緩和策
|
|
866
|
+
if (!consumeAttachmentRateToken(internalUserId, Date.now())) {
|
|
867
|
+
attachmentLogger.error(`429 attachment=${attachmentId} viewer=${internalUserId} reason=rate_limit`)
|
|
868
|
+
return fail(429, "Too many requests", RATE_LIMIT_RETRY_AFTER_SECONDS)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// 認可判定 (SQL 側で完結)。null は「存在しない」「認可されない」「親が論理削除済み」の合流
|
|
872
|
+
const attachment = await service.getAttachmentForUser(attachmentId, internalUserId)
|
|
873
|
+
if (!attachment) {
|
|
874
|
+
attachmentLogger.error(`404 attachment=${attachmentId} viewer=${internalUserId} reason=not_visible`)
|
|
875
|
+
return fail(404, "Attachment not found")
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// DB が既知サイズを持つ場合は読み取り前に弾く (サイズを報告しない取込元の行はここを通過する)
|
|
879
|
+
if (attachment.fileSize != null && attachment.fileSize > attachmentMaxBytes) {
|
|
880
|
+
attachmentLogger.error(`413 attachment=${attachmentId} viewer=${internalUserId} reason=db_size`)
|
|
881
|
+
return fail(413, "Attachment too large")
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (!attachmentGate.tryAcquire()) {
|
|
885
|
+
attachmentLogger.error(`503 attachment=${attachmentId} viewer=${internalUserId} reason=concurrency`)
|
|
886
|
+
return fail(503, "Too many concurrent downloads", CONCURRENCY_RETRY_AFTER_SECONDS)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// スロットの解放は冪等にする。早期 return・例外・送出完了のどの経路からでも
|
|
890
|
+
// ちょうど 1 回だけ返す必要がある
|
|
891
|
+
let gateReleased = false
|
|
892
|
+
const releaseGate = () => {
|
|
893
|
+
if (gateReleased) return
|
|
894
|
+
gateReleased = true
|
|
895
|
+
attachmentGate.release()
|
|
896
|
+
}
|
|
897
|
+
// 本文の送出完了まで保持する経路に入ったかどうか (入った場合のみ finally での解放を見送る)
|
|
898
|
+
let releaseDeferredToStream = false
|
|
899
|
+
|
|
900
|
+
try {
|
|
901
|
+
const isHead = request.method === "HEAD"
|
|
902
|
+
const result = await readAttachment(attachment.filePath, { maxBytes: attachmentMaxBytes, head: isHead, principal: encodeUserId(internalUserId) })
|
|
903
|
+
|
|
904
|
+
if (!result.ok) {
|
|
905
|
+
const status = attachmentFailureToStatus(result.reason)
|
|
906
|
+
// 実体消失は次回描画へ反映する。記録の失敗は配信結果に影響させない
|
|
907
|
+
// (await しないので、拒否を捕まえないと未処理 Promise 拒否になる)
|
|
908
|
+
if (result.reason === "not_found") {
|
|
909
|
+
void service.markAttachmentMissing(attachmentId).catch((error: unknown) => {
|
|
910
|
+
attachmentLogger.error(`markAttachmentMissing failed attachment=${attachmentId} message=${error instanceof Error ? error.message : "unknown"}`)
|
|
911
|
+
})
|
|
912
|
+
}
|
|
913
|
+
// file_path / URI は絶対にログへ出さない (内部パス非露出の方針と整合させる)
|
|
914
|
+
attachmentLogger.error(`${status} attachment=${attachmentId} viewer=${internalUserId} reason=${result.reason} code=${result.code ?? "-"}`)
|
|
915
|
+
return fail(status, ATTACHMENT_FAILURE_MESSAGES[status] ?? "Attachment storage unavailable")
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// インライン許可リスト外は宣言型ごと octet-stream へ落とす。構文検証だけでは
|
|
919
|
+
// text/html や image/svg+xml の自オリジン実行を止められない
|
|
920
|
+
const declared = sanitizeMediaType(attachment.fileType) ?? sanitizeMediaType(result.contentType) ?? "application/octet-stream"
|
|
921
|
+
const inlineSafe = INLINE_SAFE_MEDIA_TYPES.has(declared)
|
|
922
|
+
const wantsDownload = new URL(request.url).searchParams.get("download") === "1"
|
|
923
|
+
const contentType = inlineSafe ? declared : "application/octet-stream"
|
|
924
|
+
const disposition = inlineSafe && !wantsDownload ? "inline" : "attachment"
|
|
925
|
+
|
|
926
|
+
// HEAD は本体を読まないので `bytes` が空になる。RFC 9110 §9.3.2 が要求する
|
|
927
|
+
// 「GET と同じ Content-Length」を満たすため、ポートが申告した実サイズを優先する
|
|
928
|
+
const contentLength = result.size ?? result.bytes.byteLength
|
|
929
|
+
const headers = new Headers({
|
|
930
|
+
"Content-Type": contentType,
|
|
931
|
+
"Content-Length": String(contentLength),
|
|
932
|
+
"Content-Disposition": `${disposition}; filename="${asciiFallbackFileName(attachment.fileName)}"; filename*=UTF-8''${encodeRfc8187(attachment.fileName)}`,
|
|
933
|
+
"Cache-Control": "private, no-store",
|
|
934
|
+
"X-Content-Type-Options": "nosniff",
|
|
935
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
936
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
937
|
+
})
|
|
938
|
+
if (sanitizedCookie) headers.append("Set-Cookie", sanitizedCookie)
|
|
939
|
+
|
|
940
|
+
// 実体を確認できたので消失記録を解除する。これが唯一の自動回復経路であり、
|
|
941
|
+
// 片方向のままだと一時障害で立った absent を利用者が自力で戻せない
|
|
942
|
+
// (UI は absent の添付にリンクを描画しないため再取得の手段が消える)
|
|
943
|
+
void service.markAttachmentPresent(attachmentId).catch((error: unknown) => {
|
|
944
|
+
attachmentLogger.error(`markAttachmentPresent failed attachment=${attachmentId} message=${error instanceof Error ? error.message : "unknown"}`)
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
attachmentLogger.info(`200 attachment=${attachmentId} viewer=${internalUserId} type=${contentType} disposition=${disposition} bytes=${contentLength}`)
|
|
948
|
+
// HEAD は本文を持たないので即座に解放する。GET は送出完了まで保持し、
|
|
949
|
+
// 「同時実行上限 × 上限バイト数」というヒープ見積りを実際に成立させる
|
|
950
|
+
if (isHead) {
|
|
951
|
+
return new Response(null, { status: 200, headers })
|
|
952
|
+
}
|
|
953
|
+
releaseDeferredToStream = true
|
|
954
|
+
return new Response(streamAndRelease(result.bytes, releaseGate, ATTACHMENT_BODY_FLUSH_TIMEOUT_MS), { status: 200, headers })
|
|
955
|
+
} finally {
|
|
956
|
+
// 送出へ引き渡した場合を除き、早期 return でも例外でも必ずここで返す
|
|
957
|
+
if (!releaseDeferredToStream) releaseGate()
|
|
958
|
+
}
|
|
959
|
+
} catch (error) {
|
|
960
|
+
// 認証ポートはリダイレクトを Response として throw する。これは正常な制御フローなので素通しする
|
|
961
|
+
if (error instanceof Response) throw error
|
|
962
|
+
attachmentLogger.error(`500 attachment=? viewer=? reason=unexpected message=${error instanceof Error ? error.message : "unknown"}`)
|
|
963
|
+
return fail(500, "Attachment request failed")
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
543
967
|
return {
|
|
544
968
|
index: { loader: indexLoader },
|
|
545
969
|
api: { loader: apiLoader, action: apiAction },
|
|
546
970
|
sse: { loader: sseLoader },
|
|
971
|
+
attachment: { loader: attachmentLoader },
|
|
547
972
|
}
|
|
548
973
|
}
|
package/src/server/ports.ts
CHANGED
|
@@ -66,3 +66,77 @@ export type DailyReportResolveUserId = (externalUserId: string) => Promise<numbe
|
|
|
66
66
|
* 内部数値ユーザー ID をクライアント公開用に難読化するポート。
|
|
67
67
|
*/
|
|
68
68
|
export type DailyReportEncodeUserId = (id: number) => string
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reversible obfuscation codec for an internal numeric id exposed to clients.
|
|
72
|
+
* クライアントへ公開する内部数値 ID の可逆難読化コーデック。
|
|
73
|
+
*
|
|
74
|
+
* これは難読化であって認可ではない。トークンを能力 (capability) として扱ってはならず、
|
|
75
|
+
* 認可は必ずサーバー側の述語で行うこと。ID 空間を混ぜないため、リソース種別ごとに
|
|
76
|
+
* 別インスタンス (別 alphabet 等) を注入することを推奨する。
|
|
77
|
+
*/
|
|
78
|
+
export type DailyReportIdCodec = {
|
|
79
|
+
/** 内部数値 ID をトークンへ変換する。 */
|
|
80
|
+
encode: (id: number) => string
|
|
81
|
+
/** トークンを内部数値 ID へ戻す。復号できない場合は null。 */
|
|
82
|
+
decode: (token: string) => number | null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Failure kinds a caller maps onto HTTP statuses.
|
|
87
|
+
* 呼び出し側が HTTP ステータスへ写像する失敗種別。
|
|
88
|
+
*
|
|
89
|
+
* - `not_found`: 実体が存在しない (消失・未正規化 URI)
|
|
90
|
+
* - `denied`: ストレージ側で拒否された (サンドボックス外・ACL)
|
|
91
|
+
* - `too_large`: 上限超過 (事前サイズ判定またはワイヤ上限)
|
|
92
|
+
* - `invalid_path`: 受け入れないスキーム・不正なパス (データ不備)
|
|
93
|
+
* - `unavailable`: ストレージサービスへ到達できない・締め切り超過
|
|
94
|
+
*/
|
|
95
|
+
export type DailyReportAttachmentFailure = "not_found" | "denied" | "too_large" | "invalid_path" | "unavailable"
|
|
96
|
+
|
|
97
|
+
/** 添付ファイル読み取りの成功結果。 */
|
|
98
|
+
export type DailyReportAttachmentBytes = {
|
|
99
|
+
ok: true
|
|
100
|
+
/**
|
|
101
|
+
* Byte view backed by an `ArrayBuffer`.
|
|
102
|
+
* `ArrayBuffer` を裏に持つバイトビュー。
|
|
103
|
+
*
|
|
104
|
+
* 素の `Uint8Array` と書くと `Uint8Array<ArrayBufferLike>` へ広がり `BodyInit` へ代入できない
|
|
105
|
+
* (TypeScript 5.7 以降の既定型引数)。`Response` のボディへそのまま渡すため narrow 型で受ける。
|
|
106
|
+
* `head` が true の呼び出しでは長さ 0 のビューを返してよい (実サイズは `size` で申告する)。
|
|
107
|
+
*/
|
|
108
|
+
bytes: Uint8Array<ArrayBuffer>
|
|
109
|
+
/** バックエンドが申告した MIME 型。判定できない場合は null。 */
|
|
110
|
+
contentType?: string | null
|
|
111
|
+
/**
|
|
112
|
+
* Entity size in bytes, independent of how many bytes `bytes` actually holds.
|
|
113
|
+
* 実体のバイト数。`bytes` が実際に保持している長さとは独立に申告する値。
|
|
114
|
+
*
|
|
115
|
+
* HEAD 応答の `Content-Length` は同じ URI への GET が返す値と一致しなければならない
|
|
116
|
+
* (RFC 9110 §9.3.2)。`head` が true の呼び出しは本体を読まないため `bytes` は空になるので、
|
|
117
|
+
* ここで実サイズを申告しないと HEAD が常に `Content-Length: 0` を返してしまう。
|
|
118
|
+
* 申告できない場合は省略してよく、そのとき呼び出し側は `bytes.byteLength` を使う。
|
|
119
|
+
*/
|
|
120
|
+
size?: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 添付ファイル読み取りの失敗結果。 */
|
|
124
|
+
export type DailyReportAttachmentError = {
|
|
125
|
+
ok: false
|
|
126
|
+
reason: DailyReportAttachmentFailure
|
|
127
|
+
/** 診断ログ用の生のストレージエラーコード。HTTP ステータスの決定には使わない。 */
|
|
128
|
+
code?: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reads attachment bytes from the host application's storage backend.
|
|
133
|
+
* ホストアプリのストレージバックエンドから添付ファイルのバイト列を読み取るポート。
|
|
134
|
+
*
|
|
135
|
+
* `filePath` はサーバー内部にとどまり、クライアントへは決して渡らない。
|
|
136
|
+
* 失敗は種別付きで返すこと (例外を投げない・単一の null へ潰さない)。
|
|
137
|
+
* 種別を潰すと「実体が消えた」「上限超過」「サービス停止」が運用上区別できなくなる。
|
|
138
|
+
*
|
|
139
|
+
* `head` が true のときは実体を読まずメタデータのみ取得する。HEAD 要求で本体を読んで
|
|
140
|
+
* 捨てると、認証済み呼び出し元が任意に転送量とレイテンシを増幅できる経路になる。
|
|
141
|
+
*/
|
|
142
|
+
export type DailyReportReadAttachment = (filePath: string, options: { maxBytes: number; head: boolean; principal?: string }) => Promise<DailyReportAttachmentBytes | DailyReportAttachmentError>
|
package/src/server/schema.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 新規プロジェクトは `defineDailyReportSchema()` で同一構造のテーブル定義を生成できる。
|
|
7
7
|
*/
|
|
8
8
|
import { desc, sql } from "drizzle-orm"
|
|
9
|
-
import { type AnyMsSqlColumn, bigint, bit, date, datetime2, foreignKey, index, int, mssqlSchema, nvarchar, primaryKey } from "drizzle-orm/mssql-core"
|
|
9
|
+
import { type AnyMsSqlColumn, bigint, bit, date, datetime2, foreignKey, index, int, mssqlSchema, nvarchar, primaryKey, uniqueIndex } from "drizzle-orm/mssql-core"
|
|
10
10
|
|
|
11
11
|
/** 注入する外部ユーザーテーブルの最小形 (id / display_name)。 */
|
|
12
12
|
export type DailyReportUserTable = { id: AnyMsSqlColumn; displayName: AnyMsSqlColumn }
|
|
@@ -85,19 +85,50 @@ export type DailyReportUserStatusTable = {
|
|
|
85
85
|
updatedBy: AnyMsSqlColumn
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* DailyReportAttachment テーブルの構造形。
|
|
90
|
+
*
|
|
91
|
+
* `uploadedBy` は外部取込ソース由来の行に投稿者が存在しないため NULL 許容。
|
|
92
|
+
* `sourceKey` は取込の冪等 upsert の自然キー。
|
|
93
|
+
* `objectCheckedAt` / `objectMissingAt` から実体存在の三値 (unknown / present / absent) を導出する。
|
|
94
|
+
*/
|
|
95
|
+
export type DailyReportAttachmentTable = {
|
|
96
|
+
id: AnyMsSqlColumn
|
|
97
|
+
hubId: AnyMsSqlColumn
|
|
98
|
+
uploadedBy: AnyMsSqlColumn
|
|
99
|
+
sourceKey: AnyMsSqlColumn
|
|
100
|
+
fileName: AnyMsSqlColumn
|
|
101
|
+
filePath: AnyMsSqlColumn
|
|
102
|
+
fileType: AnyMsSqlColumn
|
|
103
|
+
fileSize: AnyMsSqlColumn
|
|
104
|
+
objectCheckedAt: AnyMsSqlColumn
|
|
105
|
+
objectMissingAt: AnyMsSqlColumn
|
|
106
|
+
createdAt: AnyMsSqlColumn
|
|
107
|
+
createdBy: AnyMsSqlColumn
|
|
108
|
+
updatedAt: AnyMsSqlColumn
|
|
109
|
+
updatedBy: AnyMsSqlColumn
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* サービスへ注入するテーブル一式 (アプリ既存モデルまたは本ファクトリ生成物)。
|
|
114
|
+
*
|
|
115
|
+
* `attachment` のみ任意。添付テーブルを持たない消費アプリでも本パッケージを使えるようにするため。
|
|
116
|
+
* 未注入時は詳細の `attachments` が常に空配列になり、添付エンドポイントは全トークンで 404 を返す
|
|
117
|
+
* (意図した縮退動作)。
|
|
118
|
+
*/
|
|
89
119
|
export type DailyReportTables = {
|
|
90
120
|
hub: DailyReportHubTable
|
|
91
121
|
internal: DailyReportInternalTable
|
|
92
122
|
comment: DailyReportCommentTable
|
|
123
|
+
attachment?: DailyReportAttachmentTable
|
|
93
124
|
label: DailyReportLabelTable
|
|
94
125
|
hubLabel: DailyReportHubLabelTable
|
|
95
126
|
userStatus: DailyReportUserStatusTable
|
|
96
127
|
}
|
|
97
128
|
|
|
98
129
|
/**
|
|
99
|
-
* Generic factory: build the
|
|
100
|
-
* 任意のスキーマ名の下に日報
|
|
130
|
+
* Generic factory: build the seven daily-report tables under an arbitrary schema name.
|
|
131
|
+
* 任意のスキーマ名の下に日報 7 テーブルを生成する汎用ファクトリ。
|
|
101
132
|
*
|
|
102
133
|
* 制約・索引名は `${schemaName}_<Table>_...` 規約で生成する。
|
|
103
134
|
* ユーザーテーブルへの FK は注入された `userTable` を参照する。
|
|
@@ -262,5 +293,49 @@ export function defineDailyReportSchema<S extends string>(schemaName: S, opts: {
|
|
|
262
293
|
],
|
|
263
294
|
)
|
|
264
295
|
|
|
265
|
-
|
|
296
|
+
const attachment = s.table(
|
|
297
|
+
"DailyReportAttachment",
|
|
298
|
+
{
|
|
299
|
+
id: bigint("id", { mode: "number" }).identity().notNull(),
|
|
300
|
+
hubId: bigint("hub_id", { mode: "number" }).notNull(),
|
|
301
|
+
// 外部取込ソース由来の行には投稿者が存在しないため NULL 許容
|
|
302
|
+
uploadedBy: bigint("uploaded_by", { mode: "number" }),
|
|
303
|
+
// 取込の冪等 upsert の自然キー。`<source>:<external-id>` 形式を推奨 (値の規約は消費アプリが決める)
|
|
304
|
+
sourceKey: nvarchar("source_key", { length: 200 }).notNull(),
|
|
305
|
+
fileName: nvarchar("file_name", { length: 255 }).notNull(),
|
|
306
|
+
// ストレージ URI。受け入れるのは `gcs://` のみ (取込側で表明・読み取り側で再検証)
|
|
307
|
+
filePath: nvarchar("file_path", { length: "max" }).notNull(),
|
|
308
|
+
fileType: nvarchar("file_type", { length: 100 }),
|
|
309
|
+
fileSize: bigint("file_size", { mode: "number" }),
|
|
310
|
+
// 実体存在の三値をこの 2 列から導出する:
|
|
311
|
+
// objectCheckedAt が null -> unknown (未検証)
|
|
312
|
+
// objectMissingAt が非 null -> absent (消失を確認)
|
|
313
|
+
// 上記以外 -> present (実在を確認)
|
|
314
|
+
objectCheckedAt: datetime2("object_checked_at"),
|
|
315
|
+
objectMissingAt: datetime2("object_missing_at"),
|
|
316
|
+
createdAt: datetime2("created_at").notNull(),
|
|
317
|
+
createdBy: nvarchar("created_by", { length: 50 }).notNull(),
|
|
318
|
+
updatedAt: datetime2("updated_at").notNull(),
|
|
319
|
+
updatedBy: nvarchar("updated_by", { length: 50 }).notNull(),
|
|
320
|
+
},
|
|
321
|
+
(table) => [
|
|
322
|
+
primaryKey({ name: `${schemaName}_DailyReportAttachment_pk`, columns: [table.id] }),
|
|
323
|
+
foreignKey({
|
|
324
|
+
name: `${schemaName}_DailyReportAttachment_hub_id_fk`,
|
|
325
|
+
columns: [table.hubId],
|
|
326
|
+
foreignColumns: [hub.id],
|
|
327
|
+
}),
|
|
328
|
+
foreignKey({
|
|
329
|
+
name: `${schemaName}_DailyReportAttachment_uploaded_by_fk`,
|
|
330
|
+
columns: [table.uploadedBy],
|
|
331
|
+
foreignColumns: [users.id],
|
|
332
|
+
}),
|
|
333
|
+
// 取込の冪等性を DB 側で担保する
|
|
334
|
+
uniqueIndex(`${schemaName}_DailyReportAttachment_source_unique`).on(table.hubId, table.sourceKey),
|
|
335
|
+
// 一括取得 (hub_id IN (...) ORDER BY created_at) を被覆する
|
|
336
|
+
index(`${schemaName}_DailyReportAttachment_hub_id_index`).on(table.hubId, table.createdAt),
|
|
337
|
+
],
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return { hub, internal, comment, attachment, label, hubLabel, userStatus } satisfies DailyReportTables
|
|
266
341
|
}
|