diff --git a/eslint.config.mjs b/eslint.config.mjs index 2048d9b..b6a7919 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,7 @@ // linting via projectService. Same three strictTypeChecked relaxations as the consumers so // re-exported / moved source stays lint-consistent across the fleet. import baseConfig from '@hono/eslint-config'; +import rdlabo from '@rdlabo/eslint-plugin-rules'; import tseslint from 'typescript-eslint'; export default tseslint.config( @@ -20,10 +21,22 @@ export default tseslint.config( }, }, { + plugins: { + '@rdlabo/rules': rdlabo, + }, rules: { '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/require-await': 'off', + '@rdlabo/rules/restrict-try-block': [ + 'error', + { + allowPromise: false, + allowRxjs: false, + allowInSignal: false, + maxLines: 3, + }, + ], // 相対 import には明示拡張子(.js)を必須化する。source は bundler 前提 // (module:ESNext / moduleResolution:Bundler)で書くが、tsc は specifier を // そのまま emit するため、拡張子が無いと dist が Node ESM で読めなくなり、 diff --git a/package-lock.json b/package-lock.json index b799857..af1f2d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,15 +12,22 @@ "stripe": "^20.4.1" }, "bin": { + "workers-hono-kit-check-realtime-bundle": "scripts/check-realtime-bundle.mjs", "workers-hono-kit-check-subrequest-fanout": "scripts/check-subrequest-fanout.mjs", "workers-hono-kit-db-baseline": "scripts/db-baseline.mjs", + "workers-hono-kit-query-realtime-do-metrics": "scripts/query-realtime-do-metrics.mjs", "workers-hono-kit-sync-dev-aws": "scripts/sync-dev-aws.mjs" }, "devDependencies": { "@ai-sdk/anthropic": "^3.0.84", "@ai-sdk/openai": "^3.0.71", + "@angular-eslint/template-parser": "^21.4.0", + "@angular/core": "^21.2.20", + "@angular/forms": "^21.2.20", + "@angular/router": "^21.2.20", "@hono/eslint-config": "^2.1.0", "@hono/zod-validator": "^0.8.0", + "@rdlabo/eslint-plugin-rules": "^21.2.6", "@types/node": "^22.19.21", "ai": "^6.0.204", "ai-gateway-provider": "^3.1.3", @@ -432,6 +439,154 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.4.0.tgz", + "integrity": "sha512-/3H4BPbQ1BHJkkrUsfusZtmHc+qiFWBBZ9UDPWah4xZMjflexOK9U4GYeH7nMjcuyqFnIlMMeJJNwNLGt/hmdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/template-parser": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.4.0.tgz", + "integrity": "sha512-BaUSLSyS+43fzDoJkTMkGqNdCXq3fGnUZsfXTmrlZPJf5AYFbgAlAPGZXDJyoNWw43fux+DafdlrlKcYUSgSIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "eslint-scope": "9.1.2" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/template-parser/node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@angular/common": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.20.tgz", + "integrity": "sha512-TzUVfa4Asq/np3eStFX3Q6W+dPG7JDNtkj4XwTvRnSL0CN8pPe+wPHSo76awHFzYWcucxpZTmk2GUemeqxa4Yw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.20", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/core": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.20.tgz", + "integrity": "sha512-4yp6EKd1VJHUOiKmd3+s9UUXtBGsdO/IKewABuPXZ+UdrXPQSRMhgnMt49yqgz29XS7jSHyq6nZgt0CXOVEDZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.20", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/forms": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.20.tgz", + "integrity": "sha512-OP1/UtMGBfJEVGs85ns1jSYVyyezQRQYPG1ReUDzGPGRt62+JQkLdtHYu6RKwkXzW+LF2mt8cpIb4YfhxnnYrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.20", + "@angular/core": "21.2.20", + "@angular/platform-browser": "21.2.20", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.20.tgz", + "integrity": "sha512-KtIrkSol2Q5qx6Poj53fLN6x3FYTPXCJ3Qi4KzlPbK0EFoiltaYCh3C2hmBze9QNdA8TM719THGvIDpCznl8PQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/animations": "21.2.20", + "@angular/common": "21.2.20", + "@angular/core": "21.2.20" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/router": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.20.tgz", + "integrity": "sha512-4QINfZGcIBwupu1ULCXFdXsDZeZbpRX5mm61DAqc7CnuCGV1udwbIVStOf4AmPEdfGzV7yLl77Z++EFoQQXgRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.20", + "@angular/core": "21.2.20", + "@angular/platform-browser": "21.2.20", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1225,6 +1380,41 @@ "node": ">=18" } }, + "node_modules/@ionic/angular": { + "version": "8.8.18", + "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-8.8.18.tgz", + "integrity": "sha512-sY3CJiJ75xivCXd0SA/OTzys1EhichoX+DfAMyeliyWNuE8NsWhaTTAH1ghU6EI52+O0PbafhwmaA1/vUl9yTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/core": "8.8.18", + "ionicons": "^8.0.13", + "jsonc-parser": "^3.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@angular/forms": ">=16.0.0", + "@angular/router": ">=16.0.0", + "rxjs": ">=7.5.0", + "zone.js": ">=0.13.0" + } + }, + "node_modules/@ionic/core": { + "version": "8.8.18", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.18.tgz", + "integrity": "sha512-QRFqi6gMSTk89FMjtoDx3p6e2dD7mYxqYzS0+Hl+yjxnoWoNFluWKM9rnGEEkdvpY7bF1pt6CI5IwMt+vjYQWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stencil/core": "4.43.5", + "ionicons": "^8.0.13", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1366,6 +1556,37 @@ "node": ">=12" } }, + "node_modules/@rdlabo/eslint-plugin-rules": { + "version": "21.2.6", + "resolved": "https://registry.npmjs.org/@rdlabo/eslint-plugin-rules/-/eslint-plugin-rules-21.2.6.tgz", + "integrity": "sha512-KO05NpZx1dXNcwfYn0m177zNbEIppnnLe/9a/36XFiCPObqlJeN5az/3xdDyj3gOjbc6UgpFHsKsbZUFLv3ZUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/angular": "> 8.0.0", + "ts-api-utils": "2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": ">=8.33.0 <9.0.0", + "eslint": ">=9.0.0" + } + }, + "node_modules/@rdlabo/eslint-plugin-rules/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -1846,6 +2067,154 @@ "dev": true, "license": "MIT" }, + "node_modules/@stencil/core": { + "version": "4.43.5", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.5.tgz", + "integrity": "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q==", + "dev": true, + "license": "MIT", + "bin": { + "stencil": "bin/stencil" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.10.0" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0" + } + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stencil/core/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -1883,6 +2252,13 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -5830,6 +6206,16 @@ "dev": true, "license": "0BSD" }, + "node_modules/ionicons": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-8.1.0.tgz", + "integrity": "sha512-XSM2gYWTXxSYTwjmKWAoy4yR7AbAFDjpc5ZIivAiGXskM0fe5CdMPfs6InQRUcrYtVb2WZ/0HQ1/jeh3JW78SA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stencil/core": "^4.43.5" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6299,6 +6685,13 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -10228,6 +10621,14 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zone.js": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.2.tgz", + "integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==", + "dev": true, + "license": "MIT", + "peer": true } } } diff --git a/package.json b/package.json index 74297b5..2e343c4 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,13 @@ "engines": { "node": ">=20.0.0" }, + "devEngines": { + "runtime": { + "name": "node", + "version": "^20.19.0 || ^22.13.0 || >=24.0.0", + "onFail": "error" + } + }, "files": [ "dist", "scripts", @@ -120,8 +127,13 @@ "devDependencies": { "@ai-sdk/anthropic": "^3.0.84", "@ai-sdk/openai": "^3.0.71", + "@angular-eslint/template-parser": "^21.4.0", + "@angular/core": "^21.2.20", + "@angular/forms": "^21.2.20", + "@angular/router": "^21.2.20", "@hono/eslint-config": "^2.1.0", "@hono/zod-validator": "^0.8.0", + "@rdlabo/eslint-plugin-rules": "^21.2.6", "@types/node": "^22.19.21", "ai": "^6.0.204", "ai-gateway-provider": "^3.1.3", diff --git a/src/ai/gateway.spec.ts b/src/ai/gateway.spec.ts index ac81a11..9d3b76c 100644 --- a/src/ai/gateway.spec.ts +++ b/src/ai/gateway.spec.ts @@ -1,6 +1,10 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createAiGatewayProvider } from './gateway.js'; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('createAiGatewayProvider', () => { const valid = { accountId: 'acc', gateway: 'gw', token: 'tok' }; @@ -44,19 +48,15 @@ describe('createAiGatewayProvider', () => { }), ); const fetchMock = vi.spyOn(globalThis, 'fetch'); - try { - const { generateText } = await import('ai'); - const { aigateway } = createAiGatewayProvider({ binding: { run } }); - await generateText({ - model: aigateway(createAnthropic({ apiKey: 'x' })('claude-haiku-4-5-20251001')), - prompt: 'hi', - }).catch(() => undefined); - expect(run).toHaveBeenCalledTimes(1); - // binding 経由は同一アカウント内で事前認証されるため global fetch は使わない。 - expect(fetchMock).not.toHaveBeenCalled(); - } finally { - fetchMock.mockRestore(); - } + const { generateText } = await import('ai'); + const { aigateway } = createAiGatewayProvider({ binding: { run } }); + await generateText({ + model: aigateway(createAnthropic({ apiKey: 'x' })('claude-haiku-4-5-20251001')), + prompt: 'hi', + }).catch(() => undefined); + expect(run).toHaveBeenCalledTimes(1); + // binding 経由は同一アカウント内で事前認証されるため global fetch は使わない。 + expect(fetchMock).not.toHaveBeenCalled(); }); it('doGenerate は AI Gateway の Universal Endpoint へ POST する(cf-aig-authorization 付き)', async () => { @@ -67,19 +67,15 @@ describe('createAiGatewayProvider', () => { headers: { 'content-type': 'application/json', 'cf-aig-step': '0' }, }), ); - try { - const { generateText } = await import('ai'); - const { aigateway } = createAiGatewayProvider(valid); - await generateText({ - model: aigateway(createAnthropic({ apiKey: 'x' })('claude-haiku-4-5-20251001')), - prompt: 'hi', - }).catch(() => undefined); - const [url, init] = fetchMock.mock.calls.at(-1) ?? []; - expect(url).toBe('https://gateway.ai.cloudflare.com/v1/acc/gw'); - const headers = new Headers(init?.headers); - expect(headers.get('cf-aig-authorization')).toBe('Bearer tok'); - } finally { - fetchMock.mockRestore(); - } + const { generateText } = await import('ai'); + const { aigateway } = createAiGatewayProvider(valid); + await generateText({ + model: aigateway(createAnthropic({ apiKey: 'x' })('claude-haiku-4-5-20251001')), + prompt: 'hi', + }).catch(() => undefined); + const [url, init] = fetchMock.mock.calls.at(-1) ?? []; + expect(url).toBe('https://gateway.ai.cloudflare.com/v1/acc/gw'); + const headers = new Headers(init?.headers); + expect(headers.get('cf-aig-authorization')).toBe('Bearer tok'); }); }); diff --git a/src/aws/secrets-manager.spec.ts b/src/aws/secrets-manager.spec.ts index 8388f5e..3e72b7f 100644 --- a/src/aws/secrets-manager.spec.ts +++ b/src/aws/secrets-manager.spec.ts @@ -78,13 +78,12 @@ describe('secrets-manager (aws4fetch GetSecretValue)', () => { const opts = { accessKeyId: 'AKIA-retry', secretAccessKey: 's', region: 'ap-northeast-1' }; // 注: aws4fetch は 5xx レスポンスを内部 retry するため、ここでは network error(reject)で失敗させる。 let call = 0; - const fetchMock = vi.fn(() => { + const fetchMock = vi.fn(async () => { call += 1; - return call === 1 - ? Promise.reject(new Error('network down')) - : Promise.resolve( - new Response(JSON.stringify({ SecretString: JSON.stringify({ ok: true }) }), { status: 200 }), - ); + if (call === 1) { + throw new Error('network down'); + } + return new Response(JSON.stringify({ SecretString: JSON.stringify({ ok: true }) }), { status: 200 }); }); vi.stubGlobal('fetch', fetchMock); diff --git a/src/cache/kv-cache.spec.ts b/src/cache/kv-cache.spec.ts index 7bbf4e6..89c6f81 100644 --- a/src/cache/kv-cache.spec.ts +++ b/src/cache/kv-cache.spec.ts @@ -46,6 +46,36 @@ class FailingKV extends FakeKV { } } +class SynchronouslyFailingKV extends FakeKV { + constructor( + private readonly operation: 'read' | 'write' | 'delete', + private readonly error: Error, + ) { + super(); + } + + override get(key: string): Promise { + if (this.operation === 'read') { + throw this.error; + } + return super.get(key); + } + + override put(key: string, value: string, options?: { expirationTtl?: number }): Promise { + if (this.operation === 'write') { + throw this.error; + } + return super.put(key, value, options); + } + + override delete(key: string): Promise { + if (this.operation === 'delete') { + throw this.error; + } + return super.delete(key); + } +} + async function sha256Hex(input: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); @@ -159,6 +189,23 @@ describe('KVCache', () => { expect(context).not.toHaveProperty('value'); }); + it.each(['read', 'write', 'delete'] as const)('reports and absorbs a synchronous %s failure', async (operation) => { + const error = new Error(`synchronous ${operation} failure`); + const kv = new SynchronouslyFailingKV(operation, error); + const onError = vi.fn<(error: unknown, context: KVCacheErrorContext) => void>(); + const cache = new KVCache(kv, { appName: 'test', onError }); + + if (operation === 'read') { + await expect(cache.get('users', 'byId', 1)).resolves.toBeUndefined(); + } else if (operation === 'write') { + await expect(cache.set('users', 'byId', 1, { value: true })).resolves.toBeUndefined(); + } else { + await expect(cache.delete('users', 'byId', 1)).resolves.toBeUndefined(); + } + + expect(onError).toHaveBeenCalledWith(error, { operation, table: 'users' }); + }); + it('reports parse and serialization failures separately', async () => { const kv = new FakeKV(); kv.store.set('testv8_users_byId_1', '{broken'); diff --git a/src/cache/kv-cache.ts b/src/cache/kv-cache.ts index 9542eb6..bd99c25 100644 --- a/src/cache/kv-cache.ts +++ b/src/cache/kv-cache.ts @@ -225,13 +225,11 @@ export class KVCache { if (!key) { return undefined; } - let data: string | null; - try { - data = await this.#kv.get(key); - } catch (error) { + const read = async () => this.#kv.get(key); + const data = await read().catch((error: unknown) => { this.#reportError(error, { operation: 'read', table }); - return undefined; - } + return null; + }); if (!data) { return undefined; } @@ -287,7 +285,8 @@ export class KVCache { return; } const ttl = Math.max(this.#minTtl, lifetime ?? this.#defaultLifetime); - await this.#kv.put(key, payload, { expirationTtl: ttl }).catch((error: unknown) => { + const write = async () => this.#kv.put(key, payload, { expirationTtl: ttl }); + await write().catch((error: unknown) => { this.#reportError(error, { operation: 'write', table }); }); } @@ -354,7 +353,8 @@ export class KVCache { if (!key) { return; } - await this.#kv.delete(key).catch((error: unknown) => { + const remove = async () => this.#kv.delete(key); + await remove().catch((error: unknown) => { this.#reportError(error, { operation: 'delete', table }); }); } diff --git a/src/db/connection.spec.ts b/src/db/connection.spec.ts index 8899c78..6868f64 100644 --- a/src/db/connection.spec.ts +++ b/src/db/connection.spec.ts @@ -7,11 +7,11 @@ let holdConnections = false; let pendingConnections: (() => void)[] = []; vi.mock('mysql2/promise', () => ({ - createConnection: vi.fn((opts: Record) => { + createConnection: vi.fn(async (opts: Record) => { opened.push(opts); const connection = { end: vi.fn() }; if (!holdConnections) { - return Promise.resolve(connection); + return connection; } return new Promise((resolve) => pendingConnections.push(() => { diff --git a/src/db/database.ts b/src/db/database.ts index ba18887..7a91516 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -225,8 +225,8 @@ export function createHyperdriveDatabase( return retryWhenDeadlock(() => dz.transaction(fn)); }, /** @deprecated Workers cleans up invocation-scoped connections automatically. */ - dispose(): Promise { - return Promise.resolve(); + async dispose(): Promise { + return; }, }; } diff --git a/src/db/datetime-wire.spec.ts b/src/db/datetime-wire.spec.ts index 9891632..93e6154 100644 --- a/src/db/datetime-wire.spec.ts +++ b/src/db/datetime-wire.spec.ts @@ -22,13 +22,14 @@ const pool = createPool({ timezone: MYSQL_TIMEZONE, }); -let mysqlUp = false; -try { - await pool.query('SELECT 1'); - mysqlUp = true; -} catch { - await pool.end(); -} +const ping = async () => pool.query('SELECT 1'); +const mysqlUp = await ping().then( + () => true, + async () => { + await pool.end(); + return false; + }, +); describe.skipIf(!mysqlUp)('JST timestamp wire contract', () => { beforeAll(async () => { diff --git a/src/db/retry.spec.ts b/src/db/retry.spec.ts index 9c57c9f..baa460f 100644 --- a/src/db/retry.spec.ts +++ b/src/db/retry.spec.ts @@ -1,8 +1,12 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { retryWhenDeadlock } from './retry.js'; const deadlock = () => Object.assign(new Error('deadlock'), { code: 'ER_LOCK_DEADLOCK' }); +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('retryWhenDeadlock', () => { it('成功すればそのまま返す(retry しない)', async () => { const fn = vi.fn(async () => 'ok'); @@ -23,6 +27,21 @@ describe('retryWhenDeadlock', () => { expect(fn).toHaveBeenCalledTimes(3); }); + it('Promise を返す前の同期 ER_LOCK_DEADLOCK も retry する', async () => { + let calls = 0; + const success = async () => 'recovered'; + const fn = vi.fn((): Promise => { + calls += 1; + if (calls === 1) { + throw deadlock(); + } + return success(); + }); + + await expect(retryWhenDeadlock(fn, 2, 1)).resolves.toBe('recovered'); + expect(fn).toHaveBeenCalledTimes(2); + }); + it('retry を使い切ってもデッドロックなら最後のエラーを投げる', async () => { const fn = vi.fn(async () => { throw deadlock(); @@ -41,7 +60,7 @@ describe('retryWhenDeadlock', () => { it('指数バックオフで待つ(delay * (attempt+1))', async () => { const delays: number[] = []; - const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation((( + vi.spyOn(globalThis, 'setTimeout').mockImplementation((( handler: (...args: unknown[]) => void, timeout?: number, ) => { @@ -49,19 +68,15 @@ describe('retryWhenDeadlock', () => { handler(); // 同期実行してテストを高速化 return 0 as unknown as ReturnType; }) as typeof globalThis.setTimeout); - try { - let calls = 0; - const fn = async () => { - calls += 1; - if (calls < 3) { - throw deadlock(); - } - return 'ok'; - }; - await expect(retryWhenDeadlock(fn, 3, 100)).resolves.toBe('ok'); - expect(delays).toEqual([100, 200]); // 1回目=100, 2回目=200 - } finally { - spy.mockRestore(); - } + let calls = 0; + const fn = async () => { + calls += 1; + if (calls < 3) { + throw deadlock(); + } + return 'ok'; + }; + await expect(retryWhenDeadlock(fn, 3, 100)).resolves.toBe('ok'); + expect(delays).toEqual([100, 200]); // 1回目=100, 2回目=200 }); }); diff --git a/src/db/retry.ts b/src/db/retry.ts index fe5c07f..79b0426 100644 --- a/src/db/retry.ts +++ b/src/db/retry.ts @@ -27,16 +27,20 @@ */ export async function retryWhenDeadlock(fn: () => Promise, retries = 3, delay = 100): Promise { for (let attempt = 0; attempt < retries; attempt++) { - try { - return await fn(); - } catch (error) { - const code = (error as { code?: string }).code; - if (code === 'ER_LOCK_DEADLOCK' && attempt < retries - 1) { - await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1))); - continue; - } - throw error; + const invoke = async () => fn(); + const outcome = await invoke().then( + (value) => ({ ok: true, value }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + if (outcome.ok) { + return outcome.value; } + const code = (outcome.error as { code?: string }).code; + if (code === 'ER_LOCK_DEADLOCK' && attempt < retries - 1) { + await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1))); + continue; + } + throw outcome.error; } // Unreachable: the loop returns on success and throws on the final failed attempt. throw new Error('retryWhenDeadlock: exhausted retries'); diff --git a/src/firebase/identity-toolkit.spec.ts b/src/firebase/identity-toolkit.spec.ts index 3e2fc15..2d6592b 100644 --- a/src/firebase/identity-toolkit.spec.ts +++ b/src/firebase/identity-toolkit.spec.ts @@ -16,7 +16,7 @@ async function makeServiceAccount(): Promise { } function stubFetch(handler: (url: string) => Response) { - const fetchMock = vi.fn((input: string, _init?: RequestInit) => Promise.resolve(handler(input))); + const fetchMock = vi.fn(async (input: string, _init?: RequestInit) => handler(input)); vi.stubGlobal('fetch', fetchMock); return fetchMock; } @@ -72,17 +72,15 @@ describe('IdentityToolkit', () => { }); it('lookupMany は複数 uid を 1 回の accounts:lookup で localId 配列として送る', async () => { - const fetchMock = vi.fn((url: string, _init?: RequestInit) => { + const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { if (url === TOKEN_URL) { - return Promise.resolve(tokenResponse()); + return tokenResponse(); } - return Promise.resolve( - new Response( - JSON.stringify({ - users: [{ localId: 'uid1', email: 'a@b.c' }, { localId: 'uid2', email: 'd@e.f' }, { localId: 'uid3' }], - }), - { status: 200 }, - ), + return new Response( + JSON.stringify({ + users: [{ localId: 'uid1', email: 'a@b.c' }, { localId: 'uid2', email: 'd@e.f' }, { localId: 'uid3' }], + }), + { status: 200 }, ); }); vi.stubGlobal('fetch', fetchMock); @@ -102,13 +100,13 @@ describe('IdentityToolkit', () => { it('lookupMany は 101 件を 100 件 + 1 件の 2 回にチャンクして結果を結合する', async () => { const uids = Array.from({ length: 101 }, (_, i) => `uid${i}`); - const fetchMock = vi.fn((url: string, init?: RequestInit) => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { if (url === TOKEN_URL) { - return Promise.resolve(tokenResponse()); + return tokenResponse(); } const body = JSON.parse(init?.body as string) as { localId: string[] }; const users = body.localId.map((localId) => ({ localId })); - return Promise.resolve(new Response(JSON.stringify({ users }), { status: 200 })); + return new Response(JSON.stringify({ users }), { status: 200 }); }); vi.stubGlobal('fetch', fetchMock); const client = new IdentityToolkit(await makeServiceAccount()); diff --git a/src/http/defer.spec.ts b/src/http/defer.spec.ts index 807a3ce..af3f0b9 100644 --- a/src/http/defer.spec.ts +++ b/src/http/defer.spec.ts @@ -7,9 +7,15 @@ describe('defaultDefer', () => { it('does not report resolved promises', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); expect(() => { - defaultDefer(Promise.resolve('ok')); + defaultDefer( + new Promise((resolve) => { + resolve('ok'); + }), + ); }).not.toThrow(); - await Promise.resolve(); + await new Promise((resolve) => { + queueMicrotask(resolve); + }); expect(consoleError).not.toHaveBeenCalled(); }); @@ -19,7 +25,9 @@ describe('defaultDefer', () => { expect(() => { defaultDefer(Promise.reject(error)); }).not.toThrow(); - await Promise.resolve(); + await new Promise((resolve) => { + queueMicrotask(resolve); + }); expect(consoleError).toHaveBeenCalledWith('[defer] background task failed', error); }); }); diff --git a/src/iap/apple.spec.ts b/src/iap/apple.spec.ts index fa5a8b0..cdbf6ad 100644 --- a/src/iap/apple.spec.ts +++ b/src/iap/apple.spec.ts @@ -87,9 +87,7 @@ describe('classifyAppleRenewal', () => { describe('verifyAppleReceipt', () => { it('production が status=0 ならそれを返す(sandbox は叩かない)', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue({ json: () => Promise.resolve({ status: 0, environment: 'Production' }) }); + const fetchImpl = vi.fn().mockResolvedValue({ json: async () => ({ status: 0, environment: 'Production' }) }); const res = await verifyAppleReceipt('r', { password: 'p', fetchImpl: fetchImpl }); expect(res?.environment).toBe('Production'); expect(fetchImpl).toHaveBeenCalledTimes(1); @@ -99,15 +97,15 @@ describe('verifyAppleReceipt', () => { it('production が非0なら sandbox にフォールバックする', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce({ json: () => Promise.resolve({ status: 21007 }) }) - .mockResolvedValueOnce({ json: () => Promise.resolve({ status: 0, environment: 'Sandbox' }) }); + .mockResolvedValueOnce({ json: async () => ({ status: 21007 }) }) + .mockResolvedValueOnce({ json: async () => ({ status: 0, environment: 'Sandbox' }) }); const res = await verifyAppleReceipt('r', { password: 'p', fetchImpl: fetchImpl }); expect(res?.environment).toBe('Sandbox'); expect(fetchImpl.mock.calls[1][0]).toContain('sandbox.itunes.apple.com'); }); it('両方非0なら null(無効レシート)', async () => { - const fetchImpl = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ status: 21002 }) }); + const fetchImpl = vi.fn().mockResolvedValue({ json: async () => ({ status: 21002 }) }); expect(await verifyAppleReceipt('r', { password: 'p', fetchImpl: fetchImpl })).toBeNull(); }); }); diff --git a/src/iap/google.spec.ts b/src/iap/google.spec.ts index f189f63..e87c1b0 100644 --- a/src/iap/google.spec.ts +++ b/src/iap/google.spec.ts @@ -41,21 +41,21 @@ describe('classifyGoogleSubscription', () => { describe('googleAccessToken / getGoogleSubscription', () => { it('refresh_token を access_token に交換する', async () => { - const fetchImpl = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ access_token: 'tok' }) }); + const fetchImpl = vi.fn().mockResolvedValue({ json: async () => ({ access_token: 'tok' }) }); const tok = await googleAccessToken({ client_id: 'c', client_secret: 's', refresh_token: 'r' }, fetchImpl); expect(tok).toBe('tok'); expect(fetchImpl.mock.calls[0][0]).toContain('accounts.google.com/o/oauth2/token'); }); it('access_token が無ければ throw(refresh token 失効を可視化)', async () => { - const fetchImpl = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ error: 'invalid_grant' }) }); + const fetchImpl = vi.fn().mockResolvedValue({ json: async () => ({ error: 'invalid_grant' }) }); await expect( googleAccessToken({ client_id: 'c', client_secret: 's', refresh_token: 'r' }, fetchImpl), ).rejects.toThrow(); }); it('androidpublisher の URL を組み立て、token は Authorization ヘッダで送る(クエリに載せない)', async () => { - const fetchImpl = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ expiryTimeMillis: FUTURE }) }); + const fetchImpl = vi.fn().mockResolvedValue({ json: async () => ({ expiryTimeMillis: FUTURE }) }); const res = await getGoogleSubscription({ packageName: 'jp.rdlabo.app', subscriptionId: 'sub.standard', diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index b442e78..763743d 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -153,7 +153,7 @@ export function createAuthMiddleware { let stage: AuthMiddlewareFailureStage = 'token'; let tokenPresent = false; - try { + const authenticate = async () => { const token = c.req.header(tokenHeader) ?? ''; tokenPresent = token.trim().length > 0; if (!tokenPresent && rejectMissingToken) { @@ -167,21 +167,25 @@ export function createAuthMiddleware ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + if (!outcome.ok) { const details = { stage, tokenPresent } satisfies AuthMiddlewareFailureDetails; if (reportFailure) { - try { - await reportFailure(e, c, details); - } catch (reportingError) { + const report = async () => reportFailure(outcome.error, c, details); + await report().catch((reportingError: unknown) => { // Observability must never alter the authentication response. console.error(reportingError); - } + }); } else { // Preserve the historical default for consumers that have not adopted classified reporting. - console.error(e); + console.error(outcome.error); } if (onFailure) { - return onFailure(e, c, details); + return onFailure(outcome.error, c, details); } throw new HTTPException(failureStatus, { message: failureMessage }); } diff --git a/src/middleware/perf-log.ts b/src/middleware/perf-log.ts index 4d24a01..0c86af3 100644 --- a/src/middleware/perf-log.ts +++ b/src/middleware/perf-log.ts @@ -150,12 +150,13 @@ export function perfLog(options: PerfLogOptions = {}): MiddlewareHandler { // In-code sampling thins Analytics Engine writes only; Workers Logs volume is controlled separately // by the observability `head_sampling_rate`. Low-traffic Workers should leave `sampleRate` at 1. if (sink && (rate >= 1 || Math.random() < rate)) { + const point = { + doubles: [tApp, cold ? 1 : 0, status], + blobs: [path, colo, method], + indexes: [analyticsIndex(path)], + }; try { - sink.writeDataPoint({ - doubles: [tApp, cold ? 1 : 0, status], - blobs: [path, colo, method], - indexes: [analyticsIndex(path)], - }); + sink.writeDataPoint(point); } catch (error) { // Telemetry must never replace an otherwise successful application response with a 500. console.warn('[perfLog] Analytics Engine write failed', error); diff --git a/src/middleware/validation.spec.ts b/src/middleware/validation.spec.ts index d2d4ddd..ee4bbbe 100644 --- a/src/middleware/validation.spec.ts +++ b/src/middleware/validation.spec.ts @@ -1,9 +1,13 @@ import { Hono } from 'hono'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { validate } from './validation.js'; import type { ValidateOptions } from './validation.js'; +afterEach(() => { + vi.restoreAllMocks(); +}); + function buildApp(options?: ValidateOptions) { const app = new Hono(); const schema = z.object({ name: z.string(), age: z.number() }); @@ -42,36 +46,28 @@ describe('validate', () => { it('検証失敗を console.warn でログに出す(method/path/target と失敗フィールド)', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - const res = await buildApp().request('/', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name: 123 }), - }); - expect(res.status).toBe(400); - expect(warn).toHaveBeenCalledTimes(1); - const line = warn.mock.calls[0][0] as string; - expect(line).toContain('[validation]'); - expect(line).toContain('POST'); - expect(line).toContain('(json)'); - expect(line).toContain('age:'); - } finally { - warn.mockRestore(); - } + const res = await buildApp().request('/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 123 }), + }); + expect(res.status).toBe(400); + expect(warn).toHaveBeenCalledTimes(1); + const line = warn.mock.calls[0][0] as string; + expect(line).toContain('[validation]'); + expect(line).toContain('POST'); + expect(line).toContain('(json)'); + expect(line).toContain('age:'); }); it('検証成功時は console.warn を呼ばない', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - await buildApp().request('/', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name: 'a', age: 20 }), - }); - expect(warn).not.toHaveBeenCalled(); - } finally { - warn.mockRestore(); - } + await buildApp().request('/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'a', age: 20 }), + }); + expect(warn).not.toHaveBeenCalled(); }); it('query ターゲットでも検証できる', async () => { diff --git a/src/queue/consumer.spec.ts b/src/queue/consumer.spec.ts index 31130c0..0ea6a8f 100644 --- a/src/queue/consumer.spec.ts +++ b/src/queue/consumer.spec.ts @@ -59,6 +59,42 @@ describe('processBatch', () => { expect(onError.mock.calls[0][1]).toBe(messages[1]); }); + it('Promise を返す前の同期 throw も当該のみ retry し、後続処理を継続する', async () => { + const messages = [createMessage('a', 1), createMessage('b', 2)]; + const error = new Error('synchronous failure'); + const onError = vi.fn(); + const success = async () => undefined; + const handler = vi.fn((body: number): Promise => { + if (body === 1) { + throw error; + } + return success(); + }); + + const result = await processBatch(createBatch(messages), handler, { onError }); + + expect(result).toEqual({ processed: 1, discarded: 0, failed: 1 }); + expect(onError).toHaveBeenCalledWith(error, messages[0]); + expect(messages[0].retry).toHaveBeenCalledOnce(); + expect(messages[1].ack).toHaveBeenCalledOnce(); + }); + + it('ack の同期 throw も当該のみ retry し、後続処理を継続する', async () => { + const messages = [createMessage('a', 1), createMessage('b', 2)]; + const error = new Error('ack failure'); + messages[0].ack = vi.fn(() => { + throw error; + }); + const onError = vi.fn(); + + const result = await processBatch(createBatch(messages), async () => undefined, { onError }); + + expect(result).toEqual({ processed: 1, discarded: 0, failed: 1 }); + expect(onError).toHaveBeenCalledWith(error, messages[0]); + expect(messages[0].retry).toHaveBeenCalledOnce(); + expect(messages[1].ack).toHaveBeenCalledOnce(); + }); + it('retryDelaySeconds を retry に渡す', async () => { const message = createMessage('a', 1); const handler = vi.fn(async () => { diff --git a/src/queue/consumer.ts b/src/queue/consumer.ts index 0d7b95a..b177164 100644 --- a/src/queue/consumer.ts +++ b/src/queue/consumer.ts @@ -155,31 +155,37 @@ export async function processBatch( let discarded = 0; let failed = 0; for (const message of batch.messages) { - try { + const processMessage = async () => { await handler(message.body, message); message.ack(); + }; + const outcome = await processMessage().then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + if (outcome.ok) { processed++; - } catch (error) { - try { - onError(error, message); - } catch (reportingError) { - // Reporting is best-effort. Preserve the domain error's disposition, but never let a broken - // custom reporter make a permanent failure disappear without any local trace. - console.error( - `[queue:${batch.queue}] onError failed for message ${message.id}`, - reportingError, - 'original error:', - error, - ); - } - if (isNonRetryableQueueError(error)) { - message.ack(); - discarded++; - continue; - } - message.retry(retryOptions); - failed++; + continue; + } + try { + onError(outcome.error, message); + } catch (reportingError) { + // Reporting is best-effort. Preserve the domain error's disposition, but never let a broken + // custom reporter make a permanent failure disappear without any local trace. + console.error( + `[queue:${batch.queue}] onError failed for message ${message.id}`, + reportingError, + 'original error:', + outcome.error, + ); + } + if (isNonRetryableQueueError(outcome.error)) { + message.ack(); + discarded++; + continue; } + message.retry(retryOptions); + failed++; } return { processed, discarded, failed }; } diff --git a/src/realtime/invoke.spec.ts b/src/realtime/invoke.spec.ts index 9335fd7..514a93d 100644 --- a/src/realtime/invoke.spec.ts +++ b/src/realtime/invoke.spec.ts @@ -16,7 +16,7 @@ describe('invokeDurableObjectFetch', () => { getStub, createRequest, retry: true, - retryOptions: { random: () => 0, wait: () => Promise.resolve() }, + retryOptions: { random: () => 0, wait: async () => undefined }, }), ).resolves.toMatchObject({ status: 204 }); expect(getStub).toHaveBeenCalledTimes(2); diff --git a/src/realtime/retry.spec.ts b/src/realtime/retry.spec.ts index 3553783..ba3c2c3 100644 --- a/src/realtime/retry.spec.ts +++ b/src/realtime/retry.spec.ts @@ -7,7 +7,7 @@ describe('retryDurableObjectOperation', () => { .fn<(attempt: number) => Promise>() .mockRejectedValueOnce(Object.assign(new Error('reset'), { retryable: true })) .mockResolvedValue('ok'); - const wait = vi.fn(() => Promise.resolve()); + const wait = vi.fn(async () => undefined); await expect(retryDurableObjectOperation(operation, { random: () => 0.5, baseDelayMs: 100, wait })).resolves.toBe( 'ok', @@ -17,6 +17,21 @@ describe('retryDurableObjectOperation', () => { expect(wait).toHaveBeenCalledWith(50); }); + it('retries a retryable error thrown before a Promise is returned', async () => { + let calls = 0; + const success = async () => 'ok'; + const operation = vi.fn((): Promise => { + calls += 1; + if (calls === 1) { + throw Object.assign(new Error('reset'), { retryable: true }); + } + return success(); + }); + + await expect(retryDurableObjectOperation(operation, { wait: async () => undefined })).resolves.toBe('ok'); + expect(operation).toHaveBeenCalledTimes(2); + }); + it('does not retry overloaded or non-retryable failures', async () => { const overloaded = Object.assign(new Error('busy'), { retryable: true, overloaded: true }); const operation = vi.fn().mockRejectedValue(overloaded); @@ -28,9 +43,9 @@ describe('retryDurableObjectOperation', () => { it('stops after the configured attempt count', async () => { const error = Object.assign(new Error('reset'), { retryable: true }); const operation = vi.fn().mockRejectedValue(error); - await expect( - retryDurableObjectOperation(operation, { maxAttempts: 3, wait: () => Promise.resolve() }), - ).rejects.toBe(error); + await expect(retryDurableObjectOperation(operation, { maxAttempts: 3, wait: async () => undefined })).rejects.toBe( + error, + ); expect(operation).toHaveBeenCalledTimes(3); }); }); diff --git a/src/realtime/retry.ts b/src/realtime/retry.ts index 7ebed59..bafcbf1 100644 --- a/src/realtime/retry.ts +++ b/src/realtime/retry.ts @@ -49,15 +49,19 @@ export async function retryDurableObjectOperation( const wait = options.wait ?? defaultWait; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - try { - return await operation(attempt); - } catch (error: unknown) { - if (!isRetryableDurableObjectError(error) || attempt + 1 >= maxAttempts) { - throw error; - } - const delayMs = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt * random()); - await wait(delayMs); + const invoke = async () => operation(attempt); + const outcome = await invoke().then( + (value) => ({ ok: true, value }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + if (outcome.ok) { + return outcome.value; } + if (!isRetryableDurableObjectError(outcome.error) || attempt + 1 >= maxAttempts) { + throw outcome.error; + } + const delayMs = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt * random()); + await wait(delayMs); } throw new Error('Durable Object retry exhausted'); diff --git a/src/stripe/failure.ts b/src/stripe/failure.ts index 984395f..02802d9 100644 --- a/src/stripe/failure.ts +++ b/src/stripe/failure.ts @@ -247,29 +247,27 @@ export function parsePaymentFailure(receipt: string | null | undefined): Payment if (!receipt) { return null; } + let parsed: unknown; try { - const parsed = JSON.parse(receipt) as unknown; - const r = asRecord(parsed); - if (!r) { - return null; - } - if (asRecord(r.reason)) { - if ( - (r.source !== undefined && typeof r.source !== 'string') || - (r.occurredAt !== undefined && typeof r.occurredAt !== 'string') - ) { - return null; - } - return parsed as PaymentFailureRecord; - } - // IAP rows store the reason itself. `code` is required so arbitrary JSON is not accepted as a reason. - if (typeof r.code === 'string') { - return { reason: parsed as IapFailureReason }; - } - return null; + parsed = JSON.parse(receipt) as unknown; } catch { return null; } + const r = asRecord(parsed); + if (!r) { + return null; + } + if (asRecord(r.reason)) { + if ( + (r.source !== undefined && typeof r.source !== 'string') || + (r.occurredAt !== undefined && typeof r.occurredAt !== 'string') + ) { + return null; + } + return parsed as PaymentFailureRecord; + } + // IAP rows store the reason itself. `code` is required so arbitrary JSON is not accepted as a reason. + return typeof r.code === 'string' ? { reason: parsed as IapFailureReason } : null; } /** Response body carried by {@link PaymentDeclinedError}. */ diff --git a/src/testing/auth.spec.ts b/src/testing/auth.spec.ts index f645603..b6f33e3 100644 --- a/src/testing/auth.spec.ts +++ b/src/testing/auth.spec.ts @@ -52,8 +52,9 @@ describe('configurableFake', () => { it('then/catch/finally は undefined を返す(誤って await しても thenable 罠にならない)', async () => { const gw = configurableFake({ a: () => 'ok' }, 'FakeGw'); expect((gw as unknown as { then?: unknown }).then).toBeUndefined(); - // Promise.resolve(thenable) が then() を呼んで throw しないこと(解決値として素通り)。 - await expect(Promise.resolve(gw)).resolves.toBe(gw); + // async の解決処理が thenable 判定しても throw せず、解決値として素通りすること。 + const resolveFake = async () => gw; + await expect(resolveFake()).resolves.toBe(gw); }); }); diff --git a/src/testing/db.ts b/src/testing/db.ts index bd0a6ba..ba6c809 100644 --- a/src/testing/db.ts +++ b/src/testing/db.ts @@ -182,13 +182,16 @@ export function createTestDb(options: CreateTestDbOptions): TestDb { }, async mysqlReachable(): Promise { - try { - const c = await createConnection({ ...connection }); - await c.end(); - return true; - } catch { + const connect = async () => createConnection({ ...connection }); + const c = await connect().catch(() => undefined); + if (!c) { return false; } + const close = async () => c.end(); + return close().then( + () => true, + () => false, + ); }, }; } diff --git a/src/testing/provision-user.spec.ts b/src/testing/provision-user.spec.ts index ad2596d..3b31da5 100644 --- a/src/testing/provision-user.spec.ts +++ b/src/testing/provision-user.spec.ts @@ -12,7 +12,7 @@ let pool: Pool | undefined; let ready = false; beforeAll(async () => { - try { + const setup = async () => { const root = await mysql.createConnection({ host: '127.0.0.1', port: 3306, user: 'root', password: 'root' }); await root.query(`DROP DATABASE IF EXISTS \`${DB}\``); await root.query(`CREATE DATABASE \`${DB}\``); @@ -21,10 +21,11 @@ beforeAll(async () => { await pool.query( 'CREATE TABLE users (id BIGINT AUTO_INCREMENT PRIMARY KEY, firebase_uid VARCHAR(255) UNIQUE, agree INT)', ); - ready = true; - } catch { - ready = false; - } + }; + ready = await setup().then( + () => true, + () => false, + ); }); afterAll(async () => { diff --git a/src/testing/workers-bindings.ts b/src/testing/workers-bindings.ts index 5e6c588..8aac48e 100644 --- a/src/testing/workers-bindings.ts +++ b/src/testing/workers-bindings.ts @@ -48,16 +48,14 @@ export function fakeQueue(): FakeQueue { get batchCount() { return batchCount; }, - send(body: Body): Promise { + async send(body: Body): Promise { sent.push(body); - return Promise.resolve(); }, - sendBatch(messages: Iterable>): Promise { + async sendBatch(messages: Iterable>): Promise { batchCount++; for (const m of messages) { sent.push(m.body); } - return Promise.resolve(); }, }; } @@ -75,16 +73,14 @@ export function fakeQueue(): FakeQueue { export function fakeKv(): KVNamespace { const store = new Map(); return { - get: (key: string) => Promise.resolve(store.get(key) ?? null), - put: (key: string, value: string) => { + get: async (key: string) => store.get(key) ?? null, + put: async (key: string, value: string) => { store.set(key, value); - return Promise.resolve(); }, - delete: (key: string) => { + delete: async (key: string) => { store.delete(key); - return Promise.resolve(); }, - list: () => Promise.resolve({ keys: [], list_complete: true, cacheStatus: null }), - getWithMetadata: () => Promise.resolve({ value: null, metadata: null, cacheStatus: null }), + list: async () => ({ keys: [], list_complete: true, cacheStatus: null }), + getWithMetadata: async () => ({ value: null, metadata: null, cacheStatus: null }), } as KVNamespace; }