@aiquants/daily-report 0.18.2 → 0.20.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 +8 -0
- package/dist/server.d.mts +17 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +10 -10
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +10 -10
- package/dist/server.mjs.map +1 -1
- package/package.json +10 -9
- package/src/client/components/daily-report-resize-panels.spec.tsx +3 -1
- package/src/server/service.ts +23 -2
- package/src/server/transaction-retry.spec.ts +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiquants/daily-report",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Reusable daily-report feature package: shared types/schemas, React (Router v7 / v8) 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": [
|
|
6
6
|
"**/*.css"
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"author": "AI Quants",
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"@aiquants/mssql": "^0.1.1",
|
|
47
48
|
"@heroicons/react": "^2.2.0",
|
|
48
49
|
"@radix-ui/react-label": "^2.1.8",
|
|
49
50
|
"@radix-ui/react-slot": "^1.2.4",
|
|
@@ -55,6 +56,9 @@
|
|
|
55
56
|
"tailwind-merge": "^3.5.0"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
59
|
+
"@aiquants/resize-panels": "^2.0.0",
|
|
60
|
+
"@aiquants/swipe-overlay": "^1.2.4",
|
|
61
|
+
"@aiquants/virtualscroll": "^3.5.0",
|
|
58
62
|
"@tailwindcss/cli": "^4.1.18",
|
|
59
63
|
"@testing-library/jest-dom": "^6.9.1",
|
|
60
64
|
"@testing-library/react": "^16.3.2",
|
|
@@ -72,20 +76,17 @@
|
|
|
72
76
|
"tsup": "^8.5.1",
|
|
73
77
|
"typescript": "^5.9.3",
|
|
74
78
|
"vitest": "^4.1.10",
|
|
75
|
-
"zod": "^3.25.76"
|
|
76
|
-
"@aiquants/resize-panels": "^1.9.0",
|
|
77
|
-
"@aiquants/swipe-overlay": "^1.2.4",
|
|
78
|
-
"@aiquants/virtualscroll": "^3.4.0"
|
|
79
|
+
"zod": "^3.25.76"
|
|
79
80
|
},
|
|
80
81
|
"peerDependencies": {
|
|
82
|
+
"@aiquants/resize-panels": "^2.0.0",
|
|
83
|
+
"@aiquants/swipe-overlay": "^1.2.4",
|
|
84
|
+
"@aiquants/virtualscroll": "^3.5.0",
|
|
81
85
|
"drizzle-orm": ">=1.0.0-beta.4 <2.0.0",
|
|
82
86
|
"react": ">=18",
|
|
83
87
|
"react-dom": ">=18",
|
|
84
88
|
"react-router": "^7.0.0 || ^8.0.0",
|
|
85
|
-
"zod": ">=3.25.0 <5.0.0"
|
|
86
|
-
"@aiquants/resize-panels": "^1.9.0",
|
|
87
|
-
"@aiquants/swipe-overlay": "^1.2.4",
|
|
88
|
-
"@aiquants/virtualscroll": "^3.4.0"
|
|
89
|
+
"zod": ">=3.25.0 <5.0.0"
|
|
89
90
|
},
|
|
90
91
|
"peerDependenciesMeta": {
|
|
91
92
|
"drizzle-orm": {
|
|
@@ -151,7 +151,9 @@ describe("DailyReportList / リサイズパネル統合", () => {
|
|
|
151
151
|
const handle = container.querySelector('[data-resize-handle-id="daily-report-list-handle"]')
|
|
152
152
|
expect(handle).toBeInTheDocument()
|
|
153
153
|
expect(handle).toHaveAttribute("aria-label")
|
|
154
|
-
|
|
154
|
+
// 支援技術が読み上げるのは、このパッケージが与えた名前でなければならない
|
|
155
|
+
// (@aiquants/resize-panels 1.x は利用者の aria-label を黙って捨て、自前の英語名を出していた)
|
|
156
|
+
expect(handle?.getAttribute("aria-label")).toBe("日報一覧と詳細ペインの境界リサイズ")
|
|
155
157
|
|
|
156
158
|
// 視覚インジケーターおよび内部バーの存在検証
|
|
157
159
|
const indicator = handle?.querySelector("[data-resize-handle-indicator]")
|
package/src/server/service.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* キャッシュキー・無効化順序・SSE publish 順序を含むデータアクセスロジックの中核。
|
|
6
6
|
*/
|
|
7
|
+
|
|
8
|
+
import { runWithTransactionRetry, type TransactionRetryOptions } from "@aiquants/mssql"
|
|
7
9
|
import { aliasedTable, and, asc, desc, eq, getColumns, inArray, isNull, or, sql } from "drizzle-orm"
|
|
8
10
|
import { normalizeBusinessDateKey } from "../shared/business-date"
|
|
9
11
|
import { compareIdsStreamOrder, isIdsStreamOrdered } from "../shared/ids-stream"
|
|
@@ -131,6 +133,15 @@ export type DailyReportServiceConfig = {
|
|
|
131
133
|
cache: SqlResultCache
|
|
132
134
|
/** クロスワーカー epoch ストア (facade が生成して注入)。 */
|
|
133
135
|
epochs: EpochStore
|
|
136
|
+
/**
|
|
137
|
+
* Retry policy for transactions SQL Server chose as the deadlock victim. See `@aiquants/mssql`.
|
|
138
|
+
* サーバがロールバックしたトランザクションの再試行方針。
|
|
139
|
+
*
|
|
140
|
+
* 鍵の範囲が重なる並行操作はデッドロックしうる。犠牲に選ばれた側はサーバによって
|
|
141
|
+
* ロールバックされる — 何もコミットされていないので、書き直す再実行が正しい。既定で 3 回試す。
|
|
142
|
+
* `{ attempts: 1 }` で再試行しない。
|
|
143
|
+
*/
|
|
144
|
+
transactionRetry?: TransactionRetryOptions
|
|
134
145
|
/**
|
|
135
146
|
* 下書きラベル名 (単一または配列で指定可能)。
|
|
136
147
|
* 消費アプリ側で自 DB のラベル名や作成区分の候補名 ("下書き", "DRAFT" など) を注入できる。
|
|
@@ -219,6 +230,16 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
219
230
|
const draftLabelNames = config.draftLabelNames && config.draftLabelNames.length > 0 ? config.draftLabelNames : [config.draftLabelName ?? "下書き"]
|
|
220
231
|
const draftLabelName = draftLabelNames[0] ?? "下書き"
|
|
221
232
|
const logger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportService]")
|
|
233
|
+
/**
|
|
234
|
+
* 全トランザクションの唯一の入口。サーバがロールバックした場合だけやり直す。
|
|
235
|
+
*
|
|
236
|
+
* ⚠️ **閉包を DB の外へ波及させないこと。** 再実行は毎回はじめから走るため、ロールバックで元に
|
|
237
|
+
* 戻らない副作用 (キャッシュ無効化・epoch 更新・SSE 配信) を閉包へ入れてはならない。現在それらは
|
|
238
|
+
* すべてトランザクションの**後**に置かれている。
|
|
239
|
+
*/
|
|
240
|
+
// 閉包を capture で包む。包まないと ORM が 1205 を EABORT へ置き換えた後しか見えず、
|
|
241
|
+
// デッドロックだと判定できなくなる
|
|
242
|
+
const inTransaction = <T>(fn: (tx: DailyReportDb) => Promise<T>): Promise<T> => runWithTransactionRetry((capture) => db.transaction((tx) => capture(() => fn(tx))), config.transactionRetry)
|
|
222
243
|
|
|
223
244
|
const DAILY_REPORT_IDS_CACHE_KEY = "daily-report:ids"
|
|
224
245
|
const DAILY_REPORT_IDS_TTL_MS = config.idsTtlMs ?? 180_000
|
|
@@ -1592,7 +1613,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1592
1613
|
const userName = user?.displayName ?? encodeUserId(userId)
|
|
1593
1614
|
|
|
1594
1615
|
try {
|
|
1595
|
-
const result = await
|
|
1616
|
+
const result = await inTransaction(async (tx) => {
|
|
1596
1617
|
logger.info("Starting transaction")
|
|
1597
1618
|
// 1. Hub作成
|
|
1598
1619
|
const sourceId = `internal-temp-${Date.now()}-${Math.random()}` // 一時的なID
|
|
@@ -1817,7 +1838,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1817
1838
|
throw new Error("Unauthorized")
|
|
1818
1839
|
}
|
|
1819
1840
|
|
|
1820
|
-
await
|
|
1841
|
+
await inTransaction(async (tx) => {
|
|
1821
1842
|
if (data.title !== undefined) {
|
|
1822
1843
|
await updateDailyReportHub(tx, reportHubId, {
|
|
1823
1844
|
title: data.title,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module transaction-retry.spec
|
|
3
|
+
* Tests that every transaction in this service goes through the retry seam.
|
|
4
|
+
* 本サービスの全トランザクションが再試行の接ぎ口を通っていることを検証するテストモジュール。
|
|
5
|
+
*
|
|
6
|
+
* The distinction between "rolled back by the server" and "might have committed" lives in
|
|
7
|
+
* `@aiquants/mssql` and is tested there. What can only be tested here is whether the
|
|
8
|
+
* service actually routes through it — a seam nobody calls is the same as no seam, and a per-call-site
|
|
9
|
+
* test cannot cover a site that does not exist yet.
|
|
10
|
+
* 「サーバがロールバックした」と「コミットされたかもしれない」の区別は `@aiquants/mssql`
|
|
11
|
+
* が持ち、そこで検証されている。ここでしか検証できないのは、サービスが実際にそれを通っているかで
|
|
12
|
+
* ある — 誰も呼ばない接ぎ口は接ぎ口が無いことと同じであり、呼び出し箇所ごとの検査では**まだ存在
|
|
13
|
+
* しない箇所**を覆えない。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from "vitest"
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* `import.meta.glob` は vite が解決する。型は `vite/client` が持つが、それを本パッケージの
|
|
20
|
+
* `tsconfig` へ足すと DOM 由来の型も一緒に入る。必要な 1 つだけを名指しする。
|
|
21
|
+
*/
|
|
22
|
+
const globSources = () =>
|
|
23
|
+
(import.meta as unknown as { glob: (pattern: string, options: { query: string; import: string; eager: boolean }) => Record<string, string> }).glob("./**/*.ts", {
|
|
24
|
+
query: "?raw",
|
|
25
|
+
import: "default",
|
|
26
|
+
eager: true,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("トランザクションの入口は 1 つだけである", () => {
|
|
30
|
+
const sources = globSources()
|
|
31
|
+
|
|
32
|
+
it("`db.transaction(` を直に呼ぶのは接ぎ口の定義だけ", () => {
|
|
33
|
+
/*
|
|
34
|
+
* 呼び出し箇所で直に呼ぶ形に戻すと、方針を変えるたびに全箇所を触ることになり、いずれ
|
|
35
|
+
* 1 箇所だけが取り残される。取り残された箇所は、再試行されないことを誰にも告げない。
|
|
36
|
+
*/
|
|
37
|
+
const offenders = Object.entries(sources)
|
|
38
|
+
.filter(([path]) => !path.endsWith(".spec.ts"))
|
|
39
|
+
.filter(([, source]) => {
|
|
40
|
+
// 接ぎ口自身の 1 行 (`runWithTransactionRetry((capture) => db.transaction(...))`) は除く
|
|
41
|
+
const direct = source.match(/\bdb\.transaction\(/g) ?? []
|
|
42
|
+
const inSeam = source.match(/runWithTransactionRetry\(\(capture\) => db\.transaction\(/g) ?? []
|
|
43
|
+
return direct.length > inSeam.length
|
|
44
|
+
})
|
|
45
|
+
.map(([path]) => path)
|
|
46
|
+
|
|
47
|
+
expect(offenders).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("接ぎ口が再試行へ繋がり、原因を押さえている", () => {
|
|
51
|
+
/*
|
|
52
|
+
* `capture` を落とすと ORM が 1205 を `EABORT` へ置き換えた後しか見えず、デッドロックが
|
|
53
|
+
* 一度も再試行されなくなる — 例外は出ないので、静かに効かなくなる。
|
|
54
|
+
*/
|
|
55
|
+
expect(sources["./service.ts"]).toContain("runWithTransactionRetry((capture) => db.transaction((tx) => capture(() => fn(tx))), config.transactionRetry)")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("走査が実際に源を読めている (空振りで緑にならない)", () => {
|
|
59
|
+
// 走査対象が 0 件でも上の検査は通る。件数と、必ず存在する印を先に確かめる。
|
|
60
|
+
expect(Object.keys(sources).length).toBeGreaterThan(5)
|
|
61
|
+
expect(sources["./service.ts"]).toContain("createDailyReportService")
|
|
62
|
+
})
|
|
63
|
+
})
|