Skip to content

feat(backend): 将后端全量迁移至 TypeScript(strict) - #2

Merged
knqiufan merged 12 commits into
mainfrom
feat/backend-typescript-migration
Jul 11, 2026
Merged

feat(backend): 将后端全量迁移至 TypeScript(strict)#2
knqiufan merged 12 commits into
mainfrom
feat/backend-typescript-migration

Conversation

@knqiufan

@knqiufan knqiufan commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • backend/src/ 从纯 JavaScript 全量迁移为 TypeScript(strict: true),源文件全部改为 .ts,生产构建产出 dist/
  • 分 10 阶段自底向上完成:基础设施 → utils/config → models → services → middleware → routes → 入口/prompts/Dockerfile → 测试 → strict 收紧;每阶段独立 commit。
  • Sequelize 模型统一为 class extends Model + declare + .init();认证请求用显式 AuthedRequest,不做 Express 全局模块增强。
  • 开发改为 tsx watch src/index.ts,生产改为 tsc 构建后 node dist/index.js;Dockerfile 同步调整。

Test plan

  • cd backend && npx tsc --noEmit → 0 错误
  • cd backend && pnpm test → 176 测试全绿
  • cd backend && pnpm build → 产出 dist/index.js
  • 确认 backend/src 下无残留 .js 源文件
  • (建议)本地联调:SeekDB + pnpm dev,跑通上传 → 分析 → 匹配 → 导入
  • (建议)Dockerfile / docker compose 构建验证生产镜像能启动

knqiufan and others added 12 commits July 11, 2026 12:24
Establish TypeScript toolchain for gradual JS→TS migration per
docs/后端TypeScript迁移实施计划.md Phase 0. No business code changed.

- Install typescript 7.0.2, tsx, @types/{node,express,jsonwebtoken,multer,cors}
- Add tsconfig.json (NodeNext, allowJs, checkJs:false, strict:false) +
  tsconfig.build.json (production, excludes tests)
- package.json: add build/typecheck scripts; dev → tsx watch; start → dist/
- Add src/types/mammoth.d.ts ambient declaration (mammoth has no @types)
- vitest.config.js → vitest.config.ts (include matches .test.{js,ts})

Deviations from plan (TS 7.0 + npm registry reality):
- baseUrl removed — TS 5102: option removed in TS 7.0 (paths was empty)
- @types/uuid, @types/jwt-decode skipped — deprecated stubs, packages
  ship own types
- @types/mammoth → custom ambient declaration (404 on npm registry)
- setupFiles uses concrete setup.js — vitest does not expand {js,ts}
  brace glob in setupFiles (the include glob does support it)

Verified: tsc --noEmit (0 errors) · pnpm test (176/176) · pnpm build
(dist/ emitted with index.js) · tsx boots server without load errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrate all 7 pure-function files in src/utils/ from .js to .ts with
type annotations. Logic unchanged; types only.

- array.ts: chunk<T>, runWithConcurrency<T> generics
- response.ts: ApiResponse<T> interface, typed success/error/paginated
- retry.ts: RetryOptions interface, withRetry<T> generic, AxiosLikeError cast
- crypto.ts: Buffer/string types, decrypt<T> generic preserves passthrough
- assignee.ts: ProjectMember interface
- syncCompare.ts: RemoteItem / SyncedRecord interfaces
- workItem.ts: WorkloadUnit / WorkItemIds types, Map<string,string> for lookups

Verified: tsc --noEmit (0 errors) · pnpm test (176/176). Confirmed vitest
resolves .js import specifiers to .ts source (test files stay .js until
Phase 8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- index.ts: AppConfig interface for the exported config object,
  typed loadEnv/pingcodeHostAuthority
- validate.ts: validateRequiredConfig(): string[]

Verified: tsc --noEmit (0 errors) · pnpm test (176/176).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrate all 15 Sequelize model files (.js → .ts). Schema, hooks, and
associations are unchanged — verified by 176/176 tests.

Approach (deviation from plan, lower risk): keep sequelize.define() and
annotate each export as ModelDefined<Attrs, Attrs> instead of converting
to `class extends Model` + .init(). Rationale: the plan's class-based
pattern is a structural refactor that risks sync({alter:true})/hook/
association compatibility, and there is no live DB to validate against.
The define + ModelDone pattern adds full attribute typing (typed
findByPk/findAll/creation) while leaving model definitions byte-for-byte
equivalent.

- Each model exports a *Attributes interface + literal-union types for
  ENUM columns (ImportRecordStatus, ImportRecordItemStatus, ModelProvider)
- User.ts / ModelConfig.ts: encrypt/decrypt hooks preserved verbatim;
  hook callbacks typed (instance: any) to avoid Sequelize hook-signature
  friction while keeping logic untouched
- index.ts: pure rename, associations unchanged

Verified: tsc --noEmit (0 errors) · pnpm test (176/176).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Switch all 14 models from sequelize.define() + ModelDefined<Attrs,Attrs>
to `class extends Model<Attrs,Attrs>` + declare fields + .init(). Schema,
hooks, associations, table names unchanged (176/176 tests green).

Why: ModelDefined instances do NOT expose attributes as properties, so
service/route code accessing model fields directly (config.name,
user.id, record.title — pervasive) fails type-checking. The class +
declare pattern provides both typed queries AND direct attribute access,
matching Sequelize's official TS guidance. This is what the migration
plan originally specified; the earlier ModelDefined attempt (795d97f)
was a lower-risk deviation that turned out to be unworkable downstream.

Verified: tsc --noEmit (0 errors) · pnpm test (176/176). statsAnalyzer
(a service that reads ModelConfig fields directly) now type-checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrate all 10 service files (.js → .ts). Business logic unchanged.

- db.ts: Sequelize + SeekdbClient typed; dynamic model imports resolve
  to index.ts; initDB/connectRelational/initDefault* return Promise<void>
- agent.ts: LLMConfig interface (covers both ModelConfig instances and
  frontend test configs); NormalizedWorkItem output type; dynamic
  @langchain/anthropic import preserved
- pingcode.ts: axios API wrappers typed (string token/domain params);
  dynamic PingCode responses kept Promise<any>; BatchCreateResult /
  BatchProgressItem interfaces; jwtDecode cast to Record for sub/uid
- metadata.ts: MetadataCounts, PingCodeProject, Set<string> existing-id
  accumulators
- importHelper.ts: ImportItemInput / ImportContext; reuses typed
  ProjectMember from utils/assignee
- statsAnalyzer.ts, uploadCleanup.ts, clearSyncedData.ts, auditLog.ts,
  parser.ts: parameter/return types, exported option/result interfaces

Verified: tsc --noEmit (0 errors) · pnpm test (176/176).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrate all 6 middleware files (.js → .ts). Logic unchanged.

- auth.ts: JwtPayload cast, sliding-refresh logic typed; Sequelize
  include association (ur.Role) accessed via cast
- permission.ts: requireAdmin / requirePermission typed
- tokenRefresh.ts: grant-type helpers take User; ensureFreshToken typed
- errorHandler.ts: HttpError interface, createError(): HttpError
- logger.ts, rateLimit.ts: typed handlers / inferred limiters

req.user typing — deviation from plan: the plan prescribed a global
`declare module 'express-serve-static-core'` augmentation, but this
project's @types/express does NOT install express-serve-static-core as
a resolvable module, so a `declare module` creates an empty module that
clobbers express's native Request (every property vanishes). Also, a
module-form .d.ts (top-level import) is not auto-loaded by tsconfig
include. Fix: a plain exported AuthedRequest interface
(src/types/authRequest.ts) that extends express's Request. Under
strict:false, handler param bivariance lets it register as a
RequestHandler. Routes (Phase 6) will reuse AuthedRequest for req.user.

Verified: tsc --noEmit (0 errors) · pnpm test (176/176).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrate all 12 route files (.js → .ts). Behavior unchanged; 176/176 tests
green (including records/workItems whose tests mock express + workItem utils).

- All authed handlers take AuthedRequest (req.user/req.isAdmin); plain
  routes use Request. Express 5 types req.params.* as string|string[] and
  req.body-sourced values surface as unknown in some positions — applied
  targeted `as string`/`as any[]` casts where needed.
- Dynamic Sequelize where objects typed Record<string, any> / any to allow
  Op-prefixed (symbol) keys and literal-union value friction.
- Sequelize include associations (ur.Role, rp.Permission) accessed via cast.
- workItems.ts: ImportResults interface, ImportRecordStatus-typed
  finalStatus, Map<string,any[]> grouping; seekdb query results kept any.

Supporting tweaks (routes revealed these):
- utils/syncCompare: drop [key:string] index sig from RemoteItem/
  SyncedRecord so SyncedProject/SyncedWorkItem class instances are
  structurally assignable to SyncedRecord
- services/auditLog: AuditEntry/Record.detail loosened Record<…> → object
  (ClearSyncedResult has no index signature)
- services/pingcode: BatchCreateResult.errors/created members unknown → any
  (flow into Sequelize where clauses)

Verified: tsc --noEmit (0 errors) · pnpm test (176/176).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- app.ts: typed Express app; audit-logs/health inline routes (AuthedRequest
  / Request), dynamic checks object typed Record<string,string>; SPA
  fallback handler typed; CORS callback contextually typed
- index.ts: start()/shutdown(signal) typed
- prompts/*.js → *.ts via git mv (pure string exports, no content change)
- package.json: dev now `tsx watch src/index.ts`
- Dockerfile: install full deps (need typescript) + `pnpm run build` step;
  CMD → node backend/dist/index.js (src/index.js no longer exists)

All backend source (.js under src/) is now TypeScript; only test files
remain .js (Phase 8).

Verified: tsc --noEmit (0 errors) · pnpm test (176/176) · pnpm build
(dist/index.js emitted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
git mv all 22 *.test.js → *.test.ts and setup.js → setup.ts. Test bodies
unchanged (vitest globals + vi.mock factories are type-agnostic under
esbuild transform).

tsconfig continues to exclude **/__tests__/**, so tests are run by vitest
(not type-checked by tsc) — this keeps the rich vi.mock patterns (mock
express router, mock utils) working without friction. Enabling test
type-checking is a possible follow-up under Phase 9 strict mode.

vitest.config.ts setupFiles: setup.js → setup.ts.

Entire backend (src source + tests) is now .ts. Verified: tsc --noEmit
(0 errors) · pnpm test (176/176) · zero .js files under backend/src.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tsconfig: strict:true + strictNullChecks/noImplicitAny/noUnusedLocals/
noUnusedParameters true. noImplicitReturns kept false — Express route
handlers legitimately mix `return res.json()` early-exit with fall-through
paths, and forcing consistency there adds churn with no behavioral value.

Fixed the 28 surfaced errors (the other 40 were noImplicitReturns):
- null safety: assert non-null on user.access_token/domain/pingcode_user_id
  in routes behind ensureFreshToken (sync/stats/workItems destructures);
  targetProjectId! at fetchAndStore calls; domain ?? undefined for optional
  pingcode domain params
- unused: dropped unused Op import; _-prefixed unused req/next/params
- Sequelize CreationAttributes strictness: Model.create/findOrCreate args
  for Role/User cast as any (auto-generated id field friction under strict)

Verified: tsc --noEmit --strict (0 errors) · pnpm test (176/176) ·
pnpm build (dist/ emitted).

Backend TypeScript migration complete: all 77 source+test files migrated,
strict type-checking on, full test suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@knqiufan
knqiufan merged commit 357771a into main Jul 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant