@aiquants/daily-report 0.6.2 → 0.8.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 +13 -6
- package/dist/client.d.mts +443 -23
- package/dist/client.d.ts +443 -23
- package/dist/client.js +5 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +77 -3
- package/dist/index.d.ts +77 -3
- 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 +436 -6
- package/dist/server.d.ts +436 -6
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -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-ids-stream-status.spec.tsx +93 -0
- package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
- package/src/client/components/daily-report-list.tsx +19 -11
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +56 -68
- package/src/client/components/daily-report-resolved-content.tsx +31 -7
- 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.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +151 -52
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -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/route-helpers.spec.ts +100 -0
- package/src/client/route-helpers.ts +20 -41
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
- package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
- package/src/client/utils/constants.spec.ts +96 -0
- package/src/client/utils/constants.ts +47 -2
- package/src/client.ts +6 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.attachment.spec.ts +470 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +671 -6
- 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 +348 -25
- package/src/server/test-helpers/handlers-config.ts +181 -0
- package/src/server.ts +16 -1
- package/src/shared/ids-stream.ts +92 -0
- package/src/shared/sse-schema.spec.ts +50 -0
- package/src/shared/sse-schema.ts +27 -0
- package/src/shared/types.ts +31 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture that assembles a minimal `DailyReportHandlersConfig`.
|
|
3
|
+
* 最小限の `DailyReportHandlersConfig` を組み立てるテスト用フィクスチャ。
|
|
4
|
+
*
|
|
5
|
+
* `handlers.ts` を対象にした spec がこれまで存在しなかったため、認証・サービス・
|
|
6
|
+
* 難読化・SSE リーダーのモック一式をここに集約する。各テストは必要な部分だけを上書きする。
|
|
7
|
+
*
|
|
8
|
+
* 本番コードからは import されない (テスト専用)。
|
|
9
|
+
*/
|
|
10
|
+
import type { DailyReportHandlersConfig } from "../handlers"
|
|
11
|
+
import type { DailyReportAttachmentBytes, DailyReportAttachmentError, DailyReportIdCodec } from "../ports"
|
|
12
|
+
import type { DailyReportService } from "../service"
|
|
13
|
+
import type { DailyReportSseReader } from "../sse-reader"
|
|
14
|
+
|
|
15
|
+
/** 既定の外部ユーザー ID (認証済みユーザーを表す)。 */
|
|
16
|
+
export const TEST_EXTERNAL_USER_ID = "external-user"
|
|
17
|
+
|
|
18
|
+
/** 既定の内部ユーザー ID。 */
|
|
19
|
+
export const TEST_INTERNAL_USER_ID = 42
|
|
20
|
+
|
|
21
|
+
/** テスト用の添付 ID コーデック。`A<id>` 形式で、ユーザー ID 用とは別空間であることを表す。 */
|
|
22
|
+
export const testAttachmentCodec: DailyReportIdCodec = {
|
|
23
|
+
encode: (id) => `A${id}`,
|
|
24
|
+
decode: (token) => {
|
|
25
|
+
if (!token.startsWith("A")) return null
|
|
26
|
+
const value = Number(token.slice(1))
|
|
27
|
+
return Number.isInteger(value) ? value : null
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `service.getAttachmentForUser` が返す行の形。 */
|
|
32
|
+
export type TestAttachmentRow = { fileName: string; filePath: string; fileType: string | null; fileSize: number | null }
|
|
33
|
+
|
|
34
|
+
/** フィクスチャの調整点。 */
|
|
35
|
+
export type HandlersFixtureOptions = {
|
|
36
|
+
/** 認証結果。null を渡すと未認証を表す。 */
|
|
37
|
+
user?: { id: string } | null
|
|
38
|
+
/** 認証ポートが返す `Set-Cookie`。 */
|
|
39
|
+
cookie?: string
|
|
40
|
+
/** 外部 ID → 内部 ID の解決結果。 */
|
|
41
|
+
internalUserId?: number | null
|
|
42
|
+
/** 認可判定の結果。null なら不可視 (404 相当)。 */
|
|
43
|
+
attachment?: TestAttachmentRow | null
|
|
44
|
+
/** 読み取りポートの応答。 */
|
|
45
|
+
readResult?: DailyReportAttachmentBytes | DailyReportAttachmentError
|
|
46
|
+
/** ポートを未注入にする (縮退動作の検証用)。 */
|
|
47
|
+
omitPorts?: boolean
|
|
48
|
+
/** 添付の上限バイト数。 */
|
|
49
|
+
attachmentMaxBytes?: number
|
|
50
|
+
/** レート上限 (1 分あたり)。 */
|
|
51
|
+
attachmentRateLimitPerMinute?: number
|
|
52
|
+
/** 同時実行上限。 */
|
|
53
|
+
attachmentConcurrency?: number
|
|
54
|
+
/** ids ストリームが返すアイテム一覧 (ストリーム順で渡す)。 */
|
|
55
|
+
idsStreamItems?: Array<{ reportHubId: number; businessDate: string | null; sourceType?: string }>
|
|
56
|
+
/** ids 取得 (全量・先頭ページの両方) を失敗させるエラー。 */
|
|
57
|
+
idsStreamError?: Error
|
|
58
|
+
/** 全量クエリの解決を保留にする Promise (高速先頭ページ経路の検証用)。 */
|
|
59
|
+
idsStreamFullGate?: Promise<void>
|
|
60
|
+
/** 先頭ページの結果を上書きする (既定は idsStreamItems の先頭スライス)。 */
|
|
61
|
+
idsStreamFirstPageItems?: Array<{ reportHubId: number; businessDate: string | null; sourceType?: string }>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 呼び出し記録。テストが副作用を検証するために使う。 */
|
|
65
|
+
export type HandlersFixtureCalls = {
|
|
66
|
+
/** `service.getAttachmentForUser` が受け取った `(attachmentId, viewerUserId)`。 */
|
|
67
|
+
getAttachmentForUser: Array<[number, number]>
|
|
68
|
+
readAttachment: Array<{ filePath: string; maxBytes: number; head: boolean; principal?: string }>
|
|
69
|
+
markAttachmentMissing: number[]
|
|
70
|
+
markAttachmentPresent: number[]
|
|
71
|
+
/** `service.getDailyReportIdsByExternalId` が受け取った `(externalId, forceRefresh)`。 */
|
|
72
|
+
getDailyReportIds: Array<[string, boolean]>
|
|
73
|
+
/** `service.getDailyReportIdsFirstPageByExternalId` が受け取った `(externalId, limit)`。 */
|
|
74
|
+
getDailyReportIdsFirstPage: Array<[string, number]>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds a handlers config plus the call log the assertions inspect.
|
|
79
|
+
* ハンドラー設定と、表明で参照する呼び出し記録を組み立てる処理。
|
|
80
|
+
*
|
|
81
|
+
* @param options Fixture adjustments. フィクスチャの調整点。
|
|
82
|
+
* @returns The config and its call log. 設定と呼び出し記録。
|
|
83
|
+
*/
|
|
84
|
+
export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { config: DailyReportHandlersConfig; calls: HandlersFixtureCalls } => {
|
|
85
|
+
const calls: HandlersFixtureCalls = { getAttachmentForUser: [], readAttachment: [], markAttachmentMissing: [], markAttachmentPresent: [], getDailyReportIds: [], getDailyReportIdsFirstPage: [] }
|
|
86
|
+
|
|
87
|
+
const user = options.user === undefined ? { id: TEST_EXTERNAL_USER_ID } : options.user
|
|
88
|
+
const internalUserId = options.internalUserId === undefined ? TEST_INTERNAL_USER_ID : options.internalUserId
|
|
89
|
+
const attachment = options.attachment === undefined ? null : options.attachment
|
|
90
|
+
const readResult: DailyReportAttachmentBytes | DailyReportAttachmentError = options.readResult ?? { ok: true, bytes: new Uint8Array(new ArrayBuffer(3)), contentType: null }
|
|
91
|
+
|
|
92
|
+
const service = {
|
|
93
|
+
getUserIdByExternalId: async () => internalUserId,
|
|
94
|
+
getDailyReportIdsByExternalId: async (externalId: string, opts?: { forceRefresh?: boolean }) => {
|
|
95
|
+
calls.getDailyReportIds.push([externalId, opts?.forceRefresh ?? false])
|
|
96
|
+
if (options.idsStreamError) throw options.idsStreamError
|
|
97
|
+
if (options.idsStreamFullGate) await options.idsStreamFullGate
|
|
98
|
+
return options.idsStreamItems ?? []
|
|
99
|
+
},
|
|
100
|
+
getDailyReportIdsFirstPageByExternalId: async (externalId: string, limit: number) => {
|
|
101
|
+
calls.getDailyReportIdsFirstPage.push([externalId, limit])
|
|
102
|
+
if (options.idsStreamError) throw options.idsStreamError
|
|
103
|
+
return options.idsStreamFirstPageItems ?? (options.idsStreamItems ?? []).slice(0, limit)
|
|
104
|
+
},
|
|
105
|
+
getAttachmentForUser: async (attachmentId: number, viewerUserId: number) => {
|
|
106
|
+
// 引数を記録する。記録しないと「閲覧者 ID を渡し忘れた」欠陥がテストに映らない
|
|
107
|
+
calls.getAttachmentForUser.push([attachmentId, viewerUserId])
|
|
108
|
+
return attachment
|
|
109
|
+
},
|
|
110
|
+
markAttachmentMissing: async (id: number) => {
|
|
111
|
+
calls.markAttachmentMissing.push(id)
|
|
112
|
+
},
|
|
113
|
+
markAttachmentPresent: async (id: number) => {
|
|
114
|
+
calls.markAttachmentPresent.push(id)
|
|
115
|
+
},
|
|
116
|
+
} as unknown as DailyReportService
|
|
117
|
+
|
|
118
|
+
const base: DailyReportHandlersConfig = {
|
|
119
|
+
authenticate: async () => ({ user: user ?? undefined, cookie: options.cookie }),
|
|
120
|
+
service,
|
|
121
|
+
encodeUserId: (id) => `ENC${id}`,
|
|
122
|
+
sseReader: { subscribe: () => () => undefined } as unknown as DailyReportSseReader,
|
|
123
|
+
streamKey: "test-stream",
|
|
124
|
+
attachmentMaxBytes: options.attachmentMaxBytes,
|
|
125
|
+
attachmentRateLimitPerMinute: options.attachmentRateLimitPerMinute,
|
|
126
|
+
attachmentConcurrency: options.attachmentConcurrency,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (options.omitPorts) {
|
|
130
|
+
return { config: base, calls }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
config: {
|
|
135
|
+
...base,
|
|
136
|
+
attachmentIdCodec: testAttachmentCodec,
|
|
137
|
+
readAttachment: async (filePath, opts) => {
|
|
138
|
+
calls.readAttachment.push({ filePath, ...opts })
|
|
139
|
+
// 本物の実装は head:true のとき本体を読まず長さ 0 のビューを返し、実サイズは `size` で申告する。
|
|
140
|
+
// フィクスチャが常に本体長を返すと、HEAD の Content-Length が 0 になる欠陥が観測できない
|
|
141
|
+
if (opts.head && readResult.ok) {
|
|
142
|
+
return { ...readResult, bytes: new Uint8Array(new ArrayBuffer(0)), size: readResult.size ?? readResult.bytes.byteLength }
|
|
143
|
+
}
|
|
144
|
+
return readResult
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
calls,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Builds loader args for the attachment endpoint.
|
|
153
|
+
* 添付エンドポイント用の loader 引数を組み立てる処理。
|
|
154
|
+
*
|
|
155
|
+
* @param token Attachment token in the URL. URL 中の添付トークン。
|
|
156
|
+
* @param init Optional method / query / headers. メソッド・クエリ・ヘッダーの任意指定。
|
|
157
|
+
* @returns Loader args. loader 引数。
|
|
158
|
+
*/
|
|
159
|
+
/**
|
|
160
|
+
* Builds loader args for the `:endpoint` API router.
|
|
161
|
+
* `:endpoint` API ルーター用の loader 引数を組み立てる処理。
|
|
162
|
+
*
|
|
163
|
+
* @param endpoint Endpoint segment (e.g. "ids-stream"). エンドポイントセグメント。
|
|
164
|
+
* @param init Optional method / query / signal. メソッド・クエリ・中断シグナルの任意指定。
|
|
165
|
+
* @returns Loader args. loader 引数。
|
|
166
|
+
*/
|
|
167
|
+
export const makeApiArgs = (endpoint: string, init: { method?: string; query?: string; signal?: AbortSignal } = {}) => ({
|
|
168
|
+
request: new Request(`http://localhost/daily_report/api/${endpoint}${init.query ?? ""}`, {
|
|
169
|
+
method: init.method ?? "GET",
|
|
170
|
+
signal: init.signal ?? undefined,
|
|
171
|
+
}),
|
|
172
|
+
params: { endpoint } as Record<string, string | undefined>,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
export const makeAttachmentArgs = (token: string, init: { method?: string; query?: string; headers?: Record<string, string> } = {}) => ({
|
|
176
|
+
request: new Request(`http://localhost/daily_report/api/attachment/${encodeURIComponent(token)}${init.query ?? ""}`, {
|
|
177
|
+
method: init.method ?? "GET",
|
|
178
|
+
headers: init.headers,
|
|
179
|
+
}),
|
|
180
|
+
params: { token } as Record<string, string | undefined>,
|
|
181
|
+
})
|
package/src/server.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createEpochStore, SqlResultCache } from "./server/cache"
|
|
6
6
|
import type { DailyReportExternalSource } from "./server/external-source"
|
|
7
7
|
import { createDailyReportHandlers } from "./server/handlers"
|
|
8
|
-
import type { DailyReportAuthenticate } from "./server/ports"
|
|
8
|
+
import type { DailyReportAuthenticate, DailyReportReadAttachment } from "./server/ports"
|
|
9
9
|
import { createDailyReportService, type DailyReportServiceConfig } from "./server/service"
|
|
10
10
|
import { DailyReportSseReader } from "./server/sse-reader"
|
|
11
11
|
|
|
@@ -24,6 +24,14 @@ export * from "./server/sse-reader"
|
|
|
24
24
|
export type DailyReportServerConfig = Omit<DailyReportServiceConfig, "cache" | "epochs"> & {
|
|
25
25
|
/** リクエスト認証ポート。 */
|
|
26
26
|
authenticate: DailyReportAuthenticate
|
|
27
|
+
/** 添付ファイル読み取りポート。未注入なら添付エンドポイントは常に 404。 */
|
|
28
|
+
readAttachment?: DailyReportReadAttachment
|
|
29
|
+
/** 添付ファイルの最大バイト数 (既定 32 MiB)。サービス側のワイヤ上限より低く保つこと。 */
|
|
30
|
+
attachmentMaxBytes?: number
|
|
31
|
+
/** 添付エンドポイントの 1 分あたり呼び出し上限 (ユーザー単位・既定 60)。 */
|
|
32
|
+
attachmentRateLimitPerMinute?: number
|
|
33
|
+
/** 添付読み取りの同時実行上限 (プロセス単位・既定 4)。 */
|
|
34
|
+
attachmentConcurrency?: number
|
|
27
35
|
/** 未ログイン時のリダイレクト先 (index.loader 用、既定 "/auth/login")。 */
|
|
28
36
|
loginRedirectPath?: string
|
|
29
37
|
/** SQL 結果キャッシュの既定 TTL (既定 60,000ms)。 */
|
|
@@ -57,10 +65,17 @@ export function createDailyReportServer(config: DailyReportServerConfig) {
|
|
|
57
65
|
logger: config.logger,
|
|
58
66
|
})
|
|
59
67
|
|
|
68
|
+
// ❗ この呼び出しはスプレッドではなく明示列挙である。新しいポートをここへ足し忘れると
|
|
69
|
+
// アプリが注入しても黙って捨てられ、添付エンドポイントは永久に 404 を返す。
|
|
60
70
|
const handlers = createDailyReportHandlers({
|
|
61
71
|
authenticate: config.authenticate,
|
|
62
72
|
service,
|
|
63
73
|
encodeUserId: config.encodeUserId,
|
|
74
|
+
attachmentIdCodec: config.attachmentIdCodec,
|
|
75
|
+
readAttachment: config.readAttachment,
|
|
76
|
+
attachmentMaxBytes: config.attachmentMaxBytes,
|
|
77
|
+
attachmentRateLimitPerMinute: config.attachmentRateLimitPerMinute,
|
|
78
|
+
attachmentConcurrency: config.attachmentConcurrency,
|
|
64
79
|
redis: config.redis,
|
|
65
80
|
sseReader,
|
|
66
81
|
streamKey: service.streamKey,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire contract and ordering helpers for the daily-report ids NDJSON stream.
|
|
3
|
+
* 日報 ID 一覧 NDJSON ストリームのワイヤ契約と並び順ヘルパー。
|
|
4
|
+
*
|
|
5
|
+
* サーバーは `GET {apiBasePath}/ids-stream` で 1 行 1 JSON の NDJSON を返す。
|
|
6
|
+
* 行は原則 {@link DailyReportIdsStreamChunkLine} で、送出中に予期しない失敗が
|
|
7
|
+
* 起きた場合のみ {@link DailyReportIdsStreamErrorLine} を 1 行流して正常クローズする
|
|
8
|
+
* (クライアントは「isLast 無しで終端」を再開シグナルとして扱う)。
|
|
9
|
+
*
|
|
10
|
+
* 並び順は SQL の `ORDER BY business_date DESC, id DESC` (MSSQL の DESC は NULL を
|
|
11
|
+
* 末尾に置く) と完全一致させる。カーソル再開はこの全順序に対する「厳密に後」の
|
|
12
|
+
* 位置からのスライスとして定義する。
|
|
13
|
+
*/
|
|
14
|
+
import type { DailyReportItem } from "./types"
|
|
15
|
+
|
|
16
|
+
/** ストリーム再開カーソル。直前チャンク末尾アイテムの並び順キー。 */
|
|
17
|
+
export type DailyReportIdsStreamCursor = {
|
|
18
|
+
/** 営業日 (YYYY-MM-DD)。null は「営業日なし領域」(並び順の末尾) を表す。 */
|
|
19
|
+
businessDate: string | null
|
|
20
|
+
/** 日報 Hub ID。 */
|
|
21
|
+
reportHubId: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** データを運ぶ NDJSON 行。 */
|
|
25
|
+
export type DailyReportIdsStreamChunkLine = {
|
|
26
|
+
/** このチャンクのアイテム群 (並び順どおり)。 */
|
|
27
|
+
items: DailyReportItem[]
|
|
28
|
+
/** 次チャンクの再開カーソル。最終チャンクでは null。 */
|
|
29
|
+
nextCursor: DailyReportIdsStreamCursor | null
|
|
30
|
+
/** これが最終チャンクかどうか。 */
|
|
31
|
+
isLast: boolean
|
|
32
|
+
/** 全体件数。接続ごとの先頭行にのみ載る。 */
|
|
33
|
+
total?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** サーバー側の予期しない失敗を伝える NDJSON 行 (内部情報は含めない)。 */
|
|
37
|
+
export type DailyReportIdsStreamErrorLine = {
|
|
38
|
+
error: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** NDJSON 1 行のユニオン。 */
|
|
42
|
+
export type DailyReportIdsStreamLine = DailyReportIdsStreamChunkLine | DailyReportIdsStreamErrorLine
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Type guard for chunk lines (anything else is treated as an error line).
|
|
46
|
+
* チャンク行の型ガード (それ以外はエラー行として扱う)。
|
|
47
|
+
*
|
|
48
|
+
* @param line Parsed NDJSON line. パース済みの NDJSON 行。
|
|
49
|
+
* @returns True when the line carries items. アイテムを運ぶ行なら true。
|
|
50
|
+
*/
|
|
51
|
+
export const isIdsStreamChunkLine = (line: unknown): line is DailyReportIdsStreamChunkLine => {
|
|
52
|
+
return typeof line === "object" && line !== null && Array.isArray((line as { items?: unknown }).items)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Total order of the ids stream: businessDate DESC (NULL last), then id DESC.
|
|
57
|
+
* ids ストリームの全順序。営業日降順 (NULL は末尾)、同日内は ID 降順。
|
|
58
|
+
*
|
|
59
|
+
* MSSQL の `ORDER BY business_date DESC, id DESC` と一致させることが唯一の存在理由。
|
|
60
|
+
* 営業日は正規化済みの `YYYY-MM-DD` 文字列なので辞書順比較 = 時系列比較になる。
|
|
61
|
+
*
|
|
62
|
+
* @param a Left key. 左辺キー。
|
|
63
|
+
* @param b Right key. 右辺キー。
|
|
64
|
+
* @returns Negative when `a` streams before `b`. `a` が先に流れるなら負値。
|
|
65
|
+
*/
|
|
66
|
+
export const compareIdsStreamOrder = (a: DailyReportIdsStreamCursor, b: DailyReportIdsStreamCursor): number => {
|
|
67
|
+
if (a.businessDate !== b.businessDate) {
|
|
68
|
+
// null 営業日は並び順の末尾 (MSSQL DESC の NULL 末尾挙動へ揃える)
|
|
69
|
+
if (a.businessDate === null) return 1
|
|
70
|
+
if (b.businessDate === null) return -1
|
|
71
|
+
return a.businessDate > b.businessDate ? -1 : 1
|
|
72
|
+
}
|
|
73
|
+
return b.reportHubId - a.reportHubId
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Finds the first index that streams strictly after the cursor.
|
|
78
|
+
* カーソルより厳密に後ろへ並ぶ最初のインデックスを求める処理。
|
|
79
|
+
*
|
|
80
|
+
* 配列は必ずストリーム順 (SQL の ORDER BY) で渡されるが、比較器とのズレが
|
|
81
|
+
* あっても壊れない線形走査で決定する (件数は高々数万・再開時のみの実行)。
|
|
82
|
+
* 該当なし (カーソルが末尾以降) は `items.length` を返し、空チャンク +
|
|
83
|
+
* `isLast: true` の正常終端に落ちる。
|
|
84
|
+
*
|
|
85
|
+
* @param items Stream-ordered items. ストリーム順のアイテム配列。
|
|
86
|
+
* @param cursor Resume cursor. 再開カーソル。
|
|
87
|
+
* @returns Index to resume emission from. 送出を再開するインデックス。
|
|
88
|
+
*/
|
|
89
|
+
export const findIdsStreamResumeIndex = (items: readonly DailyReportItem[], cursor: DailyReportIdsStreamCursor): number => {
|
|
90
|
+
const index = items.findIndex((item) => compareIdsStreamOrder({ businessDate: item.businessDate, reportHubId: item.reportHubId }, cursor) > 0)
|
|
91
|
+
return index === -1 ? items.length : index
|
|
92
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-compatibility tests for the SSE detail schema.
|
|
3
|
+
* SSE 詳細スキーマのワイヤ互換テスト。
|
|
4
|
+
*
|
|
5
|
+
* SSE の実体は永続 Redis Stream であり、配備をまたいで**旧形式のエントリが残る**。
|
|
6
|
+
* 共有リーダーはプロセス起動時にストリーム先頭から再生し、再接続クライアントの
|
|
7
|
+
* catch-up も同じエントリを読み直す。したがって受信側スキーマに既定値なしの
|
|
8
|
+
* 必須キーを足すと、そのキーを持たない配備前のエントリが fail-closed で全て捨てられ、
|
|
9
|
+
* 再接続タブへ更新が永久に届かなくなる。ここではその回帰を防ぐ。
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest"
|
|
12
|
+
import { dailyReportDetailSchema, dailyReportSseMessageSchema } from "./sse-schema"
|
|
13
|
+
|
|
14
|
+
/** 添付機能を入れる前に publish された `report-update` エントリ (attachments キーを持たない)。 */
|
|
15
|
+
const legacyDetail = {
|
|
16
|
+
reportHubId: 12,
|
|
17
|
+
interviewers: [],
|
|
18
|
+
comments: [],
|
|
19
|
+
isRead: false,
|
|
20
|
+
isStarred: false,
|
|
21
|
+
labels: [],
|
|
22
|
+
commentItems: [],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("dailyReportDetailSchema / 旧形式エントリの受理", () => {
|
|
26
|
+
it("attachments キーが無い旧ペイロードを空配列として受理する", () => {
|
|
27
|
+
const parsed = dailyReportDetailSchema.parse(legacyDetail)
|
|
28
|
+
expect(parsed.attachments).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("Stream に残る旧 report-update メッセージを捨てない", () => {
|
|
32
|
+
const result = dailyReportSseMessageSchema.safeParse({ type: "report-update", reportHubId: 12, report: legacyDetail, clientTempId: "c1" })
|
|
33
|
+
expect(result.success).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("新形式の attachments はそのまま通す", () => {
|
|
37
|
+
const parsed = dailyReportDetailSchema.parse({
|
|
38
|
+
...legacyDetail,
|
|
39
|
+
attachments: [{ id: "TOK", fileName: "a.png", fileType: "image/png", fileSize: 3, createdAt: null, state: "present" }],
|
|
40
|
+
})
|
|
41
|
+
expect(parsed.attachments).toHaveLength(1)
|
|
42
|
+
expect(parsed.attachments[0].state).toBe("present")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("attachments の要素が壊れている場合は拒否する (既定値で握り潰さない)", () => {
|
|
46
|
+
// 欠損キーの救済と「不正な値の受理」は別物。後者は fail-closed のままにする
|
|
47
|
+
const result = dailyReportDetailSchema.safeParse({ ...legacyDetail, attachments: [{ id: "TOK", state: "bogus" }] })
|
|
48
|
+
expect(result.success).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
package/src/shared/sse-schema.ts
CHANGED
|
@@ -33,6 +33,25 @@ export const dailyReportCommentItemSchema = z.object({
|
|
|
33
33
|
isMine: z.boolean(),
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 添付ファイル 1 件のスキーマ。
|
|
38
|
+
*
|
|
39
|
+
* 周囲のフィールドは `.nullish()` だが、ここは意図して `.nullable()` を使う。
|
|
40
|
+
* `.nullish()` は推論キーを任意 (`?`) にするため、`types.ts` 側の必須 `fileType: string | null`
|
|
41
|
+
* へ代入できず、ファイル末尾の `_AssertDetailCompat` が `never` に落ちてコンパイルエラーになる。
|
|
42
|
+
* 既存の `.nullish()` フィールドはいずれも `types.ts` 側が `?` 付きの任意キーとペアになっており、
|
|
43
|
+
* 「`.nullish()` ↔ 任意キー」「`.nullable()` ↔ 必須 `| null`」の対応で揃っている。
|
|
44
|
+
* `| undefined` を混ぜないことは、詳細型を消費する DuckDB ステージ行の要請でもある。
|
|
45
|
+
*/
|
|
46
|
+
export const dailyReportAttachmentItemSchema = z.object({
|
|
47
|
+
id: z.string(),
|
|
48
|
+
fileName: z.string(),
|
|
49
|
+
fileType: z.string().nullable(),
|
|
50
|
+
fileSize: z.number().nullable(),
|
|
51
|
+
createdAt: z.string().nullable(),
|
|
52
|
+
state: z.enum(["present", "absent", "unknown"]),
|
|
53
|
+
})
|
|
54
|
+
|
|
36
55
|
export const dailyReportDetailSchema = z.object({
|
|
37
56
|
reportHubId: z.number(),
|
|
38
57
|
date: z.string().nullish(),
|
|
@@ -56,6 +75,14 @@ export const dailyReportDetailSchema = z.object({
|
|
|
56
75
|
isStarred: z.boolean(),
|
|
57
76
|
labels: z.array(dailyReportLabelDefSchema),
|
|
58
77
|
commentItems: z.array(dailyReportCommentItemSchema),
|
|
78
|
+
// ❗ `.default([])` は必須。SSE の実体は永続 Redis Stream (MAXLEN 保持) であり、
|
|
79
|
+
// 配備をまたいで**旧形式のエントリが残る**。共有リーダーはプロセス起動時に先頭から
|
|
80
|
+
// 再生し、再接続クライアントの catch-up も同じエントリを読み直すため、
|
|
81
|
+
// このキーを必須にすると添付機能を入れる前に publish された全エントリが
|
|
82
|
+
// fail-closed で捨てられ、再接続タブへ更新が永久に届かなくなる。
|
|
83
|
+
// 送信側の詰め忘れ検知は types.ts の必須フィールドと `_AssertDetailCompat`
|
|
84
|
+
// (出力型は既定値適用後なので必須のまま) が担っており、ここを緩めても失われない。
|
|
85
|
+
attachments: z.array(dailyReportAttachmentItemSchema).default([]),
|
|
59
86
|
})
|
|
60
87
|
|
|
61
88
|
// --- SSE メッセージスキーマ(8種) ---
|
package/src/shared/types.ts
CHANGED
|
@@ -30,6 +30,32 @@ export type DailyReportLabelDef = {
|
|
|
30
30
|
color?: string | null
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Verification state of an attachment's backing object.
|
|
35
|
+
* 添付ファイルの実体の検証状態。
|
|
36
|
+
*
|
|
37
|
+
* 三値にする理由: 存在確認そのものが失敗した場合 (権限・通信障害) を偽の真偽で埋めないため。
|
|
38
|
+
* `unknown` は「まだ検証していない / 検証できなかった」であり、`absent` (消失を確認した) とは異なる。
|
|
39
|
+
*/
|
|
40
|
+
export type DailyReportAttachmentState = "present" | "absent" | "unknown"
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One attachment as exposed to the client.
|
|
44
|
+
* クライアントへ公開する添付ファイル 1 件。
|
|
45
|
+
*
|
|
46
|
+
* `id` は難読化トークンであり内部数値 ID ではない。ストレージパス (`filePath`) は
|
|
47
|
+
* サーバー内部にとどまり、この型には現れない。
|
|
48
|
+
* `fileSize` / `createdAt` が null になり得るのは、サイズや作成日時を報告しない取込元があるため。
|
|
49
|
+
*/
|
|
50
|
+
export type DailyReportAttachmentSummary = {
|
|
51
|
+
id: string
|
|
52
|
+
fileName: string
|
|
53
|
+
fileType: string | null
|
|
54
|
+
fileSize: number | null
|
|
55
|
+
createdAt: string | null
|
|
56
|
+
state: DailyReportAttachmentState
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
/** リレーショナルコメント 1 件。`userId` は難読化済み文字列。 */
|
|
34
60
|
export type DailyReportCommentItem = {
|
|
35
61
|
id: number
|
|
@@ -64,6 +90,11 @@ export type DailyReportDetail = {
|
|
|
64
90
|
isStarred: boolean
|
|
65
91
|
labels: DailyReportLabelDef[]
|
|
66
92
|
commentItems: DailyReportCommentItem[]
|
|
93
|
+
/**
|
|
94
|
+
* 添付ファイル一覧。必須にしているのは詰め忘れをコンパイル時に落とすため。
|
|
95
|
+
* 任意にすると `?? []` で黙って空配列になり、SSE 経由でクライアントの既存一覧を空で上書きする。
|
|
96
|
+
*/
|
|
97
|
+
attachments: DailyReportAttachmentSummary[]
|
|
67
98
|
}
|
|
68
99
|
|
|
69
100
|
/**
|