From 3e52033d761b4a0bb470d38f98845b27b762eb46 Mon Sep 17 00:00:00 2001 From: sommio Date: Fri, 24 Apr 2026 21:38:47 +0800 Subject: [PATCH 01/11] docs(brainstorms): frame OpenAPI-first web/api read contract Document the contract-first direction for the current web/api read seam, including scope, non-goals, success criteria, and follow-up planning questions in both language trees. --- ...api-openapi-contract-first-requirements.md | 143 ++++++++++++++++++ ...api-openapi-contract-first-requirements.md | 124 +++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md create mode 100644 docs/zh-Hans/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md diff --git a/docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md b/docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md new file mode 100644 index 0000000..fbc0bee --- /dev/null +++ b/docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md @@ -0,0 +1,143 @@ +--- +date: 2026-04-24 +topic: web-api-openapi-contract-first +--- + +# Make the Web/API Read Boundary OpenAPI-First + +## Problem Frame + +`apps/web` still reads `apps/api` through hand-written fetch wrappers. +`apps/web/src/widgets/article-reader/api/articles-api.ts` defines its own return +types and then trusts `response.json() as T`. On the API side, `apps/api` keeps a +separate set of DTO classes and controller mappings. That leaves a gap between +"looks typed" and "is actually contract-safe." + +The goal of this refactor is not to switch to a different RPC style. It is to +collapse the Web/API read boundary onto a single OpenAPI contract that is +reviewable, generatable, and checkable. The current external read surface is +narrow, but it is the template for every future web->api read path, so it should +become the repo standard first. + +```mermaid +flowchart LR + SPEC[Repo-owned OpenAPI contract] --> GEN[Generated types / client] + SPEC --> API[apps/api REST implementation] + API --> DOC[OpenAPI document] + DOC --> GEN + GEN --> WEB[apps/web readers] + WEB --> UI[Article reader UI] + CI[CI drift / breaking-change gate] --> SPEC + CI --> API +``` + +## Requirements + +**Contract Source of Truth** + +- R1. The repository must provide a single OpenAPI contract source for every API + read surface consumed by `apps/web`. +- R2. The contract must cover all API response shapes currently consumed by + `apps/web`; this cannot only fix the article reader while leaving another hand- + written contract behind. +- R3. The contract must be a reviewable repo artifact in PRs, not something that + exists only at runtime. + +**Web Consumption** + +- R4. `apps/web` must read API data through generated types or a generated client + derived from the contract, not through hand-written DTO-like types. +- R5. `apps/web` must remove every unchecked `response.json() as T` or equivalent + cast for API responses. +- R6. The current reader experience must remain intact: default article + selection, URL persistence, 404 handling, summary fallback copy, and detail + rendering must not regress. + +**Backend Ownership and Validation** + +- R7. `apps/api` remains the owner of the REST implementation, runtime + validation, response mapping, and data sourcing. +- R8. Any response-shape change must land together with the contract change in + the same PR; the backend must not move first and the contract later. +- R9. The contract layer must make breaking changes visible before merge, not + only at runtime. + +**Workflow** + +- R10. The repository must provide a repeatable contract refresh flow so + developers can regenerate the OpenAPI contract and web-side generated + artifacts reliably. +- R11. CI or an equivalent repo-level check must detect drift between the + implementation and the published contract. +- R12. This migration must cover all current web->api read seams, not just the + article reader, so no parallel hand-written path remains behind. + +## Success Criteria + +- `apps/web` no longer relies on hand-written API return types as the only + contract source. +- The current reader page still works and user-visible behavior does not regress. +- Contract changes are explicit in PRs, so reviewers can see the impact surface + directly. +- If implementation and contract diverge, repo-level checks catch it before + merge. + +## Scope Boundaries + +- This will not migrate to tRPC or turn REST into an internal RPC style. +- This will not change ingestion, summary handling, Prisma schema, or database + persistence semantics. +- This does not require exposing more reader data, such as `contentMarkdown`. +- This is not a broader frontend data-layer redesign; it only solves the + Web/API read contract layer. + +## Key Decisions + +- OpenAPI contract-first is the chosen direction, not tRPC. +- The OpenAPI contract will be a repo asset that is reviewed and synchronized, + not something that exists only as an implicit runtime output. +- Migration scope is defined as "all current web->api read seams," not just the + article reader. +- `apps/api` keeps the Nest REST shape, and the contract and implementation stay + on the same mainline instead of splitting into two sources of truth. + +**NestJS Reference Docs** + +- OpenAPI introduction: +- OpenAPI CLI plugin: +- Validation: +- Monorepo / workspace: + +**Next.js / OpenAPI Frontend References** + +- Data fetching / Client Components: + +- Orval: + +- Orval React Query: + + +## Dependencies / Assumptions + +- Assume the current REST response shapes can be represented clearly in + OpenAPI without redesigning the API first. +- Assume `apps/web` can accept Orval-generated clients/hooks instead of + continuing to maintain handwritten fetch wrappers. +- Assume the existing CI flow can host contract-drift checks without a full + pipeline rebuild. + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R1][Technical] Should the canonical OpenAPI file be YAML or JSON? +- [Affects R10][Technical] Which script or workspace task should own contract + generation and web client generation? +- [Affects R10][Needs research] Which contract diff / breaking-change gate fits + this repo best? +- [Affects R4][Technical] Should web-generated artifacts read directly from the + repo contract or from an exported artifact? + +## Next Steps + +-> /ce:plan for structured implementation planning diff --git a/docs/zh-Hans/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md b/docs/zh-Hans/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md new file mode 100644 index 0000000..17f33e8 --- /dev/null +++ b/docs/zh-Hans/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md @@ -0,0 +1,124 @@ +--- +date: 2026-04-24 +topic: web-api-openapi-contract-first +--- + +# Web/API 读取边界改为 OpenAPI 合同优先 + +## Problem Frame + +当前 `apps/web` 里读 `apps/api` 的方式还是手写 fetch 包装: +`apps/web/src/widgets/article-reader/api/articles-api.ts` 里自己定义返回 +类型,再通过 `response.json() as T` 信任 payload。与此同时, +`apps/api` 侧又有一套独立的 DTO class 和控制器映射。这个结构让“看起来有类型” +和“真的有合同”之间仍然有缝。 + +这次重构的目标不是换成另一种 RPC 风格,而是把整个 Web/API 读取边界收敛到 +一个可审查、可生成、可校验的 OpenAPI 合同上。当前已经能确认的外部读取面很窄, +但这条 seam 是后续所有 web->api 读路径的模板,所以它需要先变成 repo 级标准。 + +```mermaid +flowchart LR + SPEC[Repo-owned OpenAPI contract] --> GEN[Generated types / client] + SPEC --> API[apps/api REST implementation] + API --> DOC[OpenAPI document] + DOC --> GEN + GEN --> WEB[apps/web readers] + WEB --> UI[Article reader UI] + CI[CI drift / breaking-change gate] --> SPEC + CI --> API +``` + +## Requirements + +**Contract Source of Truth** + +- R1. 仓库必须为所有 `apps/web` 消费的 API read surface 提供一个单一的 + OpenAPI 合同来源。 +- R2. 这个合同必须能覆盖当前 `apps/web` 已消费的所有 API 响应形状,不得只修 + 文章 reader 这一条 seam 而保留另一套手写合同。 +- R3. 合同必须成为 PR 中可审查的仓库资产,避免只在运行时生成而无法被人类直接 + 评审。 + +**Web Consumption** + +- R4. `apps/web` 必须通过由合同生成的类型或 client 读取 API 数据,不再保留手写 + DTO-like 类型作为契约来源。 +- R5. `apps/web` 必须移除所有 unchecked 的 `response.json() as T` 或等价类型断言。 +- R6. 当前 reader 体验必须保持:默认文章选择、URL 保持、404 处理、摘要 fallback + 文案、文章详情渲染都不能因为迁移而退化。 + +**Backend Ownership and Validation** + +- R7. `apps/api` 继续作为 REST 实现的所有者,负责运行时校验、响应映射和数据来源。 +- R8. 任何响应形状变化都必须和合同变化一起提交,不能让 backend 实现先变、合同 + 后补。 +- R9. 合同层必须能让 breaking change 在合并前被看见,而不是等到 web 运行时才暴露。 + +**Workflow** + +- R10. 仓库必须提供一个可重复的 contract 更新流程,让开发者可以稳定生成或刷新 + OpenAPI 合同及 web 侧生成产物。 +- R11. CI 或等价的仓库级检查必须能发现实现与已发布合同之间的漂移。 +- R12. 这次迁移必须覆盖当前所有 web->api 读取 seam,不能只迁 article reader 后把 + 另一条手写路径留在仓库里。 + +## Success Criteria + +- `apps/web` 不再依赖手写的 API 返回类型作为唯一契约来源。 +- 当前 reader 页面仍能正常工作,且用户可见行为没有退化。 +- 合同变更会在 PR 里变得显式,review 时能直接看到影响范围。 +- 如果实现和合同不一致,仓库级检查能在合并前把问题拦住。 + +## Scope Boundaries + +- 本次不迁移到 tRPC,也不把 REST 改成内部 RPC 风格。 +- 本次不改 ingestion、summary、Prisma schema 或数据库持久化语义。 +- 本次不要求公开更多 reader 数据,例如 `contentMarkdown`。 +- 本次不重构成新的前端数据层架构,只解决 Web/API 读取合同这一层。 + +## Key Decisions + +- 选择 OpenAPI contract-first,而不是 tRPC。 +- OpenAPI 合同会作为 repo 资产被审查和同步,而不是只在运行时隐式生成。 +- 迁移范围按“所有当前 web->api 读取 seam”定义,不只限于 article reader。 +- backend 仍保留 Nest REST 形态,合同与实现保持同一条主线,而不是拆成两套真相。 + +**NestJS 参考文档** + +- OpenAPI 介绍: +- OpenAPI CLI plugin: +- 输入校验: +- Monorepo / workspace: + +**Next.js / OpenAPI 前端参考** + +- 数据获取 / Client Components: + +- Orval: + +- Orval React Query: + + +## Dependencies / Assumptions + +- 假设当前 REST response 形状可以被 OpenAPI 清晰表达,不需要先重设计接口。 +- 假设 web 侧可以接受 Orval 生成的 client / hooks,而不是继续维护手写 fetch + 包装。 +- 假设现有 CI 流程可以承接 contract drift 检查,而不需要重新搭一套独立流水线。 + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R1][Technical] OpenAPI 合同的 canonical 文件格式应选 YAML 还是 JSON。 +- [Affects R10][Technical] 合同生成和 web client 生成应分别放在哪个脚本或 workspace + 任务里。 +- [Affects R10][Needs research] 适合当前 repo 的 contract diff / breaking-change gate + 工具是哪一个。 +- [Affects R4][Technical] web 侧生成产物应直接从 repo 里的合同读取,还是从某个导出 + artifact 读取。 + +## Next Steps + +-> /ce:plan for structured implementation planning From 42d74b8c77fd3dcf6cd967730c69a5eb7ba2a694 Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 14:56:56 +0800 Subject: [PATCH 02/11] docs(plans): add openapi-first web/api contract plan --- ...eat-web-api-openapi-contract-first-plan.md | 387 ++++++++++++++++++ ...eat-web-api-openapi-contract-first-plan.md | 382 +++++++++++++++++ 2 files changed, 769 insertions(+) create mode 100644 docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md create mode 100644 docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md diff --git a/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md b/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md new file mode 100644 index 0000000..0b081d1 --- /dev/null +++ b/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md @@ -0,0 +1,387 @@ +--- +title: refactor: Make the Web/API Read Boundary OpenAPI-First +type: refactor +status: active +date: 2026-04-24 +origin: + - docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md + - docs/zh-Hans/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md +--- + +# refactor: Make the Web/API Read Boundary OpenAPI-First + +## Likely Additional Packages + +- Any new dependency must use the newest stable version available at the time + of implementation whenever possible; only step down when compatibility, + lockfile policy, or repo constraints require it. +- `@nestjs/swagger` - generate the OpenAPI document from the Nest article + controllers/DTOs. +- `orval` - generate the web-side client/types from the checked-in OpenAPI + contract. + +## Overview + +Turn the current `apps/api` public HTTP surface into a repo-owned OpenAPI +contract that is checked in, reviewable, and used to generate the web-side +client/types. The API still owns the runtime REST implementation and response +mapping, but the contract becomes the shared source that both sides must +follow. This keeps the change bounded to the current public endpoints instead +of opening a broader API redesign. + +The current `apps/api` public interface set is four routes: `GET /health/live`, +`GET /health/ready`, `GET /articles`, and `GET /articles/:id`. Web only +consumes the article read seam, but the health probes also need to live in the +OpenAPI contract and drift check. + +## Problem Frame + +`apps/web` still consumes `apps/api` through a handwritten wrapper in +`apps/web/src/widgets/article-reader/api/articles-api.ts`. That file defines its +own return types and trusts `response.json() as T`, so the web layer can drift +from the API without an obvious review signal. + +`apps/api` already has two public HTTP entry points: article read routes in +`apps/api/src/articles/*` and liveness/readiness probes in +`apps/api/src/health/health.controller.ts`. Those runtime shapes are not +published as a single repo-owned OpenAPI artifact yet, so there is still a gap +between "typed enough" and "contract-safe enough". + +This plan makes the OpenAPI contract the reviewed repo artifact, generates the +web client from that artifact, and adds a drift check so the API implementation +cannot move without the contract moving with it. + +## Requirements Trace + +- R1. Provide one OpenAPI source of truth for every public HTTP surface in + `apps/api`. +- R2. Cover every current public response shape, not just the article reader + seam; health probes must be included too. +- R3. Keep the contract reviewable in PRs as a checked-in repo asset. +- R4. Move `apps/web` to generated types or a generated client instead of + handwritten DTO-like types. +- R5. Remove unchecked `response.json() as T` casts from the article-reader + seam. +- R6. Keep the current reader behavior intact: default selection, URL + persistence, 404 handling, summary fallback copy, and detail rendering. +- R7. Keep `apps/api` as the owner of runtime validation, response mapping, and + persistence. +- R8. Land response-shape changes and contract changes together. +- R9. Make breaking changes visible before merge. +- R10. Provide a repeatable contract refresh flow. +- R11. Add a repo-level drift check that catches implementation/contract + divergence. +- R12. Ensure the migration covers all current public HTTP interfaces, not just + a single hand-written path. + +## Scope Boundaries + +- No tRPC migration. +- No ingestion, summary-generation, Prisma-schema, or persistence-semantic + changes. +- No expansion of the public reader payload, including `contentMarkdown`. +- No broader frontend data-layer rewrite. +- No broader API redesign beyond folding the existing public endpoints into the + contract. +- No public Swagger UI route is required; the contract artifact itself is the + reviewable output. + +## Context & Research + +### Relevant Code and Patterns + +- `apps/web/src/widgets/article-reader/api/articles-api.ts` is the current + manual seam with local DTO-like types and `response.json() as T`. +- `apps/web/src/widgets/article-reader/ui/article-reader-page.tsx`, + `apps/web/src/widgets/article-reader/ui/article-list.tsx`, and + `apps/web/src/widgets/article-reader/ui/article-detail.tsx` are the reader UI + consumers that must keep the same visible behavior. +- `apps/api/src/articles/articles.controller.ts`, + `apps/api/src/articles/articles.service.ts`, and + `apps/api/src/articles/article.repository.ts` define the current response + mapping boundary. +- `apps/api/src/articles/dto/article-list-item.dto.ts` and + `apps/api/src/articles/dto/article-detail-item.dto.ts` are the current DTO + shapes that the OpenAPI document should describe. +- `apps/api/src/health/health.controller.ts` and + `apps/api/e2e/health.e2e-spec.ts` define the liveness/readiness public + contract and need to be in the same OpenAPI view. +- `apps/api/e2e/articles.e2e-spec.ts` already asserts the article payload + shape, and `apps/web/app/page.spec.tsx` plus `apps/web/e2e/home.spec.ts` lock + the reader behavior. +- `apps/api/README.md` and `apps/web/README.md` still describe the current seam + in prose, including stale field naming in places, so they need to be updated + with the new contract path. +- `packages/ui/package.json` is the closest existing pattern for a shared + workspace package that is built and imported across app boundaries. + +## Key Technical Decisions + +- Keep the canonical contract as YAML in `packages/api-contract/openapi/openapi.yaml`. + YAML is the reviewable artifact, and the refresh flow can read and rewrite it + directly without a second exported-spec layer. +- Add a shared `packages/api-contract` workspace package that exports the + generated web client/types. This keeps the contract artifact and the consumer + boundary in the normal Turborepo package graph instead of a one-off root + script path. +- Keep API runtime ownership in `apps/api/src/articles` and + `apps/api/src/health`, using explicit OpenAPI decorators and a shared + document helper instead of a separate public docs server. +- Keep the web seam thin: it still owns `API_BASE_URL`, request caching policy, + URL encoding, and 404-to-null translation, but it no longer owns the contract + shapes themselves; health does not need a web adapter. +- Use a contract drift check that compares the emitted OpenAPI document against + the checked-in YAML and fails on drift. +- Keep the reader route flow server-side; generated client code should replace + the manual payload types, not introduce a new client-state model. + +## Open Questions + +### Resolved During Planning + +- YAML vs JSON: YAML wins because this contract is meant to be human-reviewed + and committed. +- Where the canonical artifact lives: `packages/api-contract/openapi/openapi.yaml`. +- How web reads the contract: `apps/web` imports the generated + `@repo/api-contract` package, while the refresh flow reads the YAML directly. + Web only imports the article read surface; health stays in the contract and + drift check. +- Whether to expose a public Swagger route: no; the checked-in artifact is the + contract. + +## High-Level Technical Design + +> This illustrates the intended approach and is directional guidance for review, +> not implementation specification. The implementing agent should treat it as +> context, not code to reproduce. + +```mermaid +flowchart LR + API_IMPL[apps/api/src/articles/* + apps/api/src/health/*] --> DOC_HELPER[OpenAPI document helper] + DOC_HELPER --> SPEC[packages/api-contract/openapi/openapi.yaml] + SPEC --> CLIENT_GEN[packages/api-contract generated client/types] + CLIENT_GEN --> WEB_ADAPTER[apps/web/src/widgets/article-reader/api/articles-api.ts] + WEB_ADAPTER --> UI[Article reader UI] + DOC_HELPER --> DRIFT_TEST[apps/api/e2e/openapi-contract.e2e-spec.ts] + DRIFT_TEST --> SPEC +``` + +## Implementation Units + +- [ ] **Unit 1: Publish the shared contract package** + +**Goal:** Create the repo-owned OpenAPI package and the checked-in canonical +API contract that the web app can consume. + +**Requirements:** R1, R2, R3, R4, R10 + +**Dependencies:** Current response shapes in `apps/api/src/articles/*` and +`apps/api/src/health/*`, plus the existing reader seam in `apps/web`. + +**Files:** + +- Create: `packages/api-contract/package.json` +- Create: `packages/api-contract/tsconfig.json` +- Create: `packages/api-contract/orval.config.ts` +- Create: `packages/api-contract/openapi/openapi.yaml` +- Create: `packages/api-contract/src/generated/api-client.ts` +- Create: `packages/api-contract/src/index.ts` +- Modify: `package.json` +- Modify: `apps/api/package.json` +- Modify: `apps/web/package.json` +- Modify: `apps/api/README.md` +- Modify: `apps/web/README.md` + +**Approach:** + +- Keep the checked-in YAML as the canonical contract artifact and generate the + web-facing client/types from that file; the generated surface can include the + health operations even though the web reader only imports the article + helpers. +- Expose the generated surface from `packages/api-contract/src/index.ts` so the + web app imports a normal workspace package instead of reading the spec file + directly at runtime. +- Add explicit refresh entrypoints in the API and contract package, plus a + repo-level alias, so developers have one obvious way to regenerate the + contract and the generated client together. + +**Execution note:** Start from the contract artifact and keep the package +boundary clean; do not recreate a parallel handwritten web contract. + +**Patterns to follow:** + +- `packages/ui/package.json` +- `packages/ui/src/index.ts` +- `apps/web/package.json` +- `apps/api/package.json` + +**Test scenarios:** + +- Happy path: shared package can build from the checked-in YAML and export a + usable client surface for the article reader. +- Edge case: the contract file preserves the current article list/detail field + set, including `summaryError` on detail and no `contentMarkdown`; health live + and ready schemas still match the current implementation. +- Integration: `apps/web` can depend on `@repo/api-contract` without falling + back to hand-written DTO-like types. + +**Verification:** + +- A single checked-in contract artifact exists, the generated client builds from + it, and the repo-level refresh flow is discoverable. + +- [ ] **Unit 2: Add API emission and drift validation** + +**Goal:** Make `apps/api` emit the full public OpenAPI document from the real +Nest implementation and fail when the checked-in contract diverges. + +**Requirements:** R7, R8, R9, R11, R12 + +**Dependencies:** Unit 1 and the current article/health controller and DTO +mapping. + +**Files:** + +- Create: `apps/api/src/openapi/openapi-document.ts` +- Create: `apps/api/src/openapi/openapi-refresh.ts` +- Modify: `apps/api/src/articles/articles.controller.ts` +- Modify: `apps/api/src/articles/dto/article-list-item.dto.ts` +- Modify: `apps/api/src/articles/dto/article-detail-item.dto.ts` +- Modify: `apps/api/src/health/health.controller.ts` +- Modify: `apps/api/src/articles/articles.controller.spec.ts` +- Modify: `apps/api/src/health/health.controller.spec.ts` +- Create: `apps/api/e2e/openapi-contract.e2e-spec.ts` +- Modify: `apps/api/e2e/articles.e2e-spec.ts` +- Modify: `apps/api/e2e/health.e2e-spec.ts` + +**Approach:** + +- Annotate the current article and health DTOs/controllers with explicit + OpenAPI metadata so the generated document mirrors the full public surface + instead of inferring it indirectly. +- Factor document creation into a reusable helper so the refresh path and the + drift test use the same source of truth. +- Compare the emitted document against `packages/api-contract/openapi/openapi.yaml` + and fail on drift or breaking changes before merge. + +**Execution note:** Start with the drift test and the checked-in contract, then +make the controller metadata satisfy that contract. + +**Patterns to follow:** + +- `apps/api/src/articles/articles.controller.ts` +- `apps/api/src/articles/articles.controller.spec.ts` +- `apps/api/src/health/health.controller.ts` +- `apps/api/src/health/health.controller.spec.ts` +- `apps/api/e2e/articles.e2e-spec.ts` +- `apps/api/e2e/health.e2e-spec.ts` +- `apps/api/README.md` + +**Test scenarios:** + +- Happy path: the generated OpenAPI document includes `GET /health/live`, + `GET /health/ready`, `GET /articles`, and `GET /articles/{id}` with the + current response fields. +- Edge case: the readiness 503 response and the article detail schema still + expose `summaryError` as nullable while keeping internal persistence fields + out of the public contract. +- Error path: any implementation change that adds, removes, or renames public + fields without updating the contract fails the drift check. +- Integration: the OpenAPI document and the existing HTTP e2e assertions + describe the same health/article payloads, 404 behavior, and 503 readiness + behavior. + +**Verification:** + +- The API test suite can regenerate the contract document and detect mismatches + against the checked-in YAML before the change merges. + +- [ ] **Unit 3: Swap the web seam to the generated contract** + +**Goal:** Remove the handwritten article-client contract from `apps/web` and +keep the reader behavior intact through the generated package. + +**Requirements:** R4, R5, R6, R12 + +**Dependencies:** Units 1 and 2. + +**Files:** + +- Modify: `apps/web/src/widgets/article-reader/api/articles-api.ts` +- Modify: `apps/web/src/widgets/article-reader/ui/article-reader-page.tsx` +- Modify: `apps/web/src/widgets/article-reader/ui/article-list.tsx` +- Modify: `apps/web/src/widgets/article-reader/ui/article-detail.tsx` +- Modify: `apps/web/package.json` +- Modify: `apps/web/next.config.ts` +- Modify: `apps/web/app/next-config.spec.ts` +- Modify: `apps/web/app/page.spec.tsx` +- Modify: `apps/web/e2e/home.spec.ts` + +**Approach:** + +- Keep a thin web adapter for `API_BASE_URL`, `cache: "no-store"`, URL + encoding, and 404-to-null translation, but source its types and request + surface from `@repo/api-contract`. The contract package may also contain + health operations, but this seam only consumes the article read helpers. +- Remove the local DTO-like type declarations from the seam file so the web app + stops owning the contract shape by hand. +- Update the web package wiring so direct app runs still build the shared + contract package before the reader code imports it. + +## System-Wide Impact + +- **Interaction graph:** `apps/api/src/articles/*` + `apps/api/src/health/*` + -> OpenAPI document helper -> `packages/api-contract/openapi/openapi.yaml` + -> generated client/types -> `apps/web/src/widgets/article-reader/api/articles-api.ts` + -> reader UI. +- **Error propagation:** 404 remains a 404 in the contract and API layer, but + the web adapter still translates it to `null` so the unavailable state stays + unchanged. +- **State lifecycle risks:** the checked-in contract or generated client can + drift from the Nest implementation; the drift test must catch that before the + mismatch reaches the browser. +- **API surface parity:** `/health/live`, `/health/ready`, `/articles`, and + `/articles/:id` all live in the same public contract, and `summaryError` + remains the public failure detail instead of any internal persistence field. +- **Integration coverage:** the OpenAPI drift test, HTTP health/article e2e + suite, and browser reader spec together prove the same contract from + different layers. +- **Unchanged invariants:** ingestion, summary generation, Prisma schema, + article IDs, URL-driven selection, and current summary fallback copy all stay + as they are. + +## Risks & Dependencies + +| Risk | Mitigation | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| Generated client shape feels heavier than the current thin wrapper | Keep `apps/web/src/widgets/article-reader/api/articles-api.ts` as a small adapter that only owns env, encoding, and 404 translation. | +| Contract artifact drifts from the Nest implementation | Add a dedicated API drift test that compares the emitted document to `packages/api-contract/openapi/openapi.yaml`. | +| New shared package creates workspace friction | Keep `packages/api-contract` private, exported, and aligned with the existing `packages/ui` pattern. | +| App README prose lags behind the contract | Update both app READMEs in the same pass and replace stale field names at the same time. | + +## Documentation / Operational Notes + +- Update `apps/api/README.md` and `apps/web/README.md` to point at the new + contract package and refresh flow. +- Keep the YAML contract as the reviewable repo artifact; do not require a + separate public docs server for this feature. +- When the contract changes later, refresh the checked-in YAML and regenerate + the client from that file instead of editing web copies by hand. + +## Sources & References + +- **Origin documents:** `docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md` + + `docs/zh-Hans/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md` +- Related code: `apps/api/src/articles/articles.controller.ts`, + `apps/api/src/articles/articles.service.ts`, + `apps/api/src/articles/article.repository.ts`, + `apps/api/src/health/health.controller.ts`, + `apps/web/src/widgets/article-reader/api/articles-api.ts`, + `apps/web/src/widgets/article-reader/ui/article-reader-page.tsx`, + `apps/api/e2e/articles.e2e-spec.ts`, + `apps/api/e2e/health.e2e-spec.ts`, + `apps/web/app/page.spec.tsx`, + `apps/web/e2e/home.spec.ts` +- Related docs: `apps/api/README.md`, `apps/web/README.md`, + `packages/ui/package.json` +- External docs: NestJS OpenAPI introduction and Orval diff --git a/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md b/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md new file mode 100644 index 0000000..e98c00b --- /dev/null +++ b/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md @@ -0,0 +1,382 @@ +--- +title: refactor: 让 Web/API 读取边界改为 OpenAPI 优先 +type: refactor +status: active +date: 2026-04-24 +origin: + - docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md + - docs/zh-Hans/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md +--- + +# refactor: 让 Web/API 读取边界改为 OpenAPI 优先 + +## 可能引入的额外包 + +- 新安装的依赖必须尽可能使用当前可用的最新稳定版本;只有在兼容 + 性、锁定策略或仓库约束明确不允许时,才向下退让。 +- `@nestjs/swagger` - 从 Nest 的 article controller/DTO 生成 OpenAPI + 文档。 +- `orval` - 从 checked-in 的 OpenAPI 合同生成 web 侧 client/types。 + +## 概览 + +把当前 `apps/api` 的对外 HTTP surface 收敛成一个仓库自有的 OpenAPI +合同:合同文件要检查入库、可审查、可生成,并作为 web 侧 client/types +的生成来源。API 仍然负责运行时 REST 实现和响应映射,但 wire contract +要变成双方都必须遵守的共享来源。这样可以把变化限制在当前公开接口, +而不是扩大成更大的 API 重构。 + +这次范围里 `apps/api` 的公开接口一共有 4 个:`GET /health/live`、 +`GET /health/ready`、`GET /articles`、`GET /articles/:id`。其中 web 只 +消费 articles 读取 seam,但 health 也必须进入 OpenAPI 和 drift check。 + +## 问题背景 + +`apps/web` 现在还是通过 `apps/web/src/widgets/article-reader/api/articles-api.ts` +里的手写包装去读 `apps/api`。这个文件自己定义返回类型,再通过 +`response.json() as T` 直接信任 payload,所以 web 层可以在不明显的 +情况下和 API 漂移。 + +`apps/api` 已经有两类对外 HTTP 入口:`apps/api/src/articles/*` 里的 +article 读取接口,以及 `apps/api/src/health/health.controller.ts` 里的 +liveness/readiness probes。当前它们各自有 runtime 实现,但还没有被统一 +发布成一个仓库级的 OpenAPI 资产。结果就是“看起来有类型”和“真的有 +合同”之间仍然有缝。 + +这份 plan 要做的是:把 OpenAPI 合同变成可审查的仓库资产,从它生成 +web 侧 client/types,并加一层 drift check,确保 API 实现不能先动、 +合同后补。 + +## 需求追踪 + +- R1. 为 `apps/api` 所有对外 HTTP surface 提供唯一 OpenAPI 来源。 +- R2. 覆盖当前所有公开响应形状,不只修 reader 这一条 seam;health + probes 也要进入合同。 +- R3. 把合同保留为 PR 里可审查的仓库资产。 +- R4. `apps/web` 必须改用生成的 types/client,而不是手写 DTO-like 类型。 +- R5. 清掉 reader seam 里的 `response.json() as T` 之类 unchecked 断言。 +- R6. reader 行为保持不退化:默认选择、URL 保持、404 处理、摘要 + fallback 文案、详情渲染都不变。 +- R7. `apps/api` 继续负责运行时校验、响应映射和持久化。 +- R8. 响应形状变化必须和合同变化一起提交。 +- R9. breaking change 要在合并前可见。 +- R10. 提供可重复的 contract refresh 流程。 +- R11. 加一个 repo 级 drift check,能抓住实现和发布合同的偏差。 +- R12. 迁移必须覆盖当前所有公开 HTTP 接口,不能只留一条手写路径。 + +## 范围边界 + +- 不迁移到 tRPC。 +- 不改 ingestion、summary 生成、Prisma schema 或持久化语义。 +- 不扩展公开 reader payload,包括 `contentMarkdown`。 +- 不重做整套前端数据层。 +- 不把这次 refactor 扩大成更大的 API 重构,只把现有公开接口收进合同。 +- 不需要 public Swagger UI route;合同文件本身就是可审查产物。 + +## 背景与调研 + +### 相关代码与模式 + +- `apps/web/src/widgets/article-reader/api/articles-api.ts` 是当前手写 seam, + 里面有 DTO-like 类型和 `response.json() as T`。 +- `apps/web/src/widgets/article-reader/ui/article-reader-page.tsx`、 + `apps/web/src/widgets/article-reader/ui/article-list.tsx` 和 + `apps/web/src/widgets/article-reader/ui/article-detail.tsx` 是 reader UI + 消费者,必须继续保持同样的可见行为。 +- `apps/api/src/articles/articles.controller.ts`、 + `apps/api/src/articles/articles.service.ts` 和 + `apps/api/src/articles/article.repository.ts` 定义了当前响应映射边界。 +- `apps/api/src/articles/dto/article-list-item.dto.ts` 与 + `apps/api/src/articles/dto/article-detail-item.dto.ts` 是当前 DTO 形状, + OpenAPI 文档应该描述的就是它们。 +- `apps/api/src/health/health.controller.ts` 与 + `apps/api/e2e/health.e2e-spec.ts` 定义了 liveness/readiness 对外合同, + 也必须进入同一份 OpenAPI 视野。 +- `apps/api/e2e/articles.e2e-spec.ts` 已经在断言文章 payload 形状, + `apps/web/app/page.spec.tsx` 和 `apps/web/e2e/home.spec.ts` 则锁住了 + reader 行为。 +- `apps/api/README.md` 和 `apps/web/README.md` 现在仍在用 prose 描述这条 + seam,部分字段命名还偏旧,所以需要一起更新到新的合同路径。 +- `packages/ui/package.json` 是最接近的现成模式:一个跨 app 的共享 + workspace package,被 build 后再被别的 app import。 + +### 仓库记忆里的相关经验 + +- 这条 seam 还没有专门的 OpenAPI 合同方案文档;最接近的仓库约束是: + contract drift 要在合并前可见,workspace 边界要保持 package-local, + 不要漏到 repo root。 +- 现有记忆里对 `articles-api.ts` 的合同安全风险有过同类提醒: + 手写 client 类型和像 `summaryErrorReason` 这种旧命名,正是这类 plan + 要防的漂移点。 + +### 外部参考 + +- NestJS OpenAPI introduction: +- NestJS OpenAPI CLI plugin: +- Orval: +- Orval React Query guide: + +## 关键技术决策 + +- canonical 合同放在 `packages/api-contract/openapi/openapi.yaml`,并且用 + YAML 做可审查产物。refresh 流程直接读写这份文件,不再多包一层导出 + 的 spec artifact。 +- 新增一个共享的 `packages/api-contract` workspace package,对外导出 + 生成后的 web client/types。这样合同资产和消费边界都落在正常的 + Turborepo package graph 里,而不是用一个临时 root 脚本硬接。 +- API 的 runtime 归属继续留在 `apps/api/src/articles` 和 + `apps/api/src/health`,用显式 OpenAPI decorator 和共享的 document + helper 来描述,不额外搭一个 public docs server。 +- web seam 保持很薄:它仍然负责 `API_BASE_URL`、请求缓存策略、URL + 编码和 404 -> null 转换,但不再自己定义合同 shape;health 不需要 + web adapter。 +- 用一个 contract drift check,把 emitted OpenAPI 文档和 checked-in YAML + 的偏差挡在 merge 前。 +- reader 仍然走 server-side flow;生成 client 取代手写 payload 类型, + 但不要引入新的 client-state 模型。 + +## Open Questions + +### 已在规划阶段解决 + +- YAML 还是 JSON:选 YAML,因为这份合同就是要给人 review 的,并且要 + 一起进入仓库。 +- canonical artifact 放哪:`packages/api-contract/openapi/openapi.yaml`。 +- web 怎么读合同:`apps/web` import 生成后的 `@repo/api-contract` + package;refresh 流程则直接读 YAML。web 只取 articles 读取 seam, + health 只进入 spec 和 drift check。 +- 是否需要 public Swagger route:不需要;checked-in artifact 就是合同。 + +### 延后到实现 + +- `packages/api-contract` 里具体的 generated 文件名和 export 名称。 +- API document helper 是只给 refresh script 用,还是也给 e2e drift test + 复用,或者两者都复用。 +- web 侧薄 adapter 的具体形状,只要还能保住 `API_BASE_URL` 和 404 + 处理归属即可。 + +## 高层技术设计 + +> 这张图只表达 intended approach,给 review 用,不是实现规格。落地时 +> 只把它当上下文,不要把它照抄成代码。 + +```mermaid +flowchart LR + API_IMPL[apps/api/src/articles/* + apps/api/src/health/*] --> DOC_HELPER[OpenAPI document helper] + DOC_HELPER --> SPEC[packages/api-contract/openapi/openapi.yaml] + SPEC --> CLIENT_GEN[packages/api-contract generated client/types] + CLIENT_GEN --> WEB_ADAPTER[apps/web/src/widgets/article-reader/api/articles-api.ts] + WEB_ADAPTER --> UI[Article reader UI] + DOC_HELPER --> DRIFT_TEST[apps/api/e2e/openapi-contract.e2e-spec.ts] + DRIFT_TEST --> SPEC +``` + +## Implementation Units + +- [ ] **Unit 1: 发布共享合同 package** + +**Goal:** 创建仓库自有的 OpenAPI package,以及 web 可以消费的、检查入库 +的 canonical API contract。 + +**Requirements:** R1, R2, R3, R4, R10 + +**Dependencies:** `apps/api/src/articles/*`、`apps/api/src/health/*` 里的 +当前响应形状,以及 `apps/web` 里的现有 reader seam。 + +**Files:** + +- Create: `packages/api-contract/package.json` +- Create: `packages/api-contract/tsconfig.json` +- Create: `packages/api-contract/orval.config.ts` +- Create: `packages/api-contract/openapi/openapi.yaml` +- Create: `packages/api-contract/src/generated/api-client.ts` +- Create: `packages/api-contract/src/index.ts` +- Modify: `package.json` +- Modify: `apps/api/package.json` +- Modify: `apps/web/package.json` +- Modify: `apps/api/README.md` +- Modify: `apps/web/README.md` + +**Approach:** + +- 把 checked-in YAML 作为 canonical contract artifact,用它生成 web-facing + client/types;生成产物里可以包含 articles 和 health 的操作,但 web + 只会 import articles 读取 seam。 +- 通过 `packages/api-contract/src/index.ts` 暴露生成后的 surface,让 web + import 一个正常的 workspace package,而不是运行时去直接读 spec 文件。 +- 在 API package、contract package 和 repo root 上都给出明确的 refresh + 入口,保证开发者有一条很清楚的合同刷新路径。 + +**Execution note:** 从合同文件本身开始,保持 package 边界干净;不要再 +搞一套平行的手写 web contract。 + +**Patterns to follow:** + +- `packages/ui/package.json` +- `packages/ui/src/index.ts` +- `apps/web/package.json` +- `apps/api/package.json` + +**Test scenarios:** + +- Happy path: shared package 能基于 checked-in YAML 构建,并导出 + article reader 可用的 client surface。 +- Edge case: 合同文件保留当前 article list/detail 字段集,包括 detail + 上的 `summaryError`,并且不出现 `contentMarkdown`;health 的 live/ready + schema 也保持和当前实现一致。 +- Integration: `apps/web` 可以依赖 `@repo/api-contract`,而不会回退到 + 手写 DTO-like 类型。 + +**Verification:** + +- 仓库里只有一份可审查的合同文件,生成后的 client 可以从它构建, + 并且 refresh 流程对开发者是可发现的。 + +- [ ] **Unit 2: 增加 API emit 和 drift validation** + +**Goal:** 让 `apps/api` 从真实 Nest 实现里 emit 全部公开 HTTP OpenAPI +文档,并在 checked-in contract 偏离时失败。 + +**Requirements:** R7, R8, R9, R11, R12 + +**Dependencies:** Unit 1,以及当前 article / health controller 与 DTO +mapping。 + +**Files:** + +- Create: `apps/api/src/openapi/openapi-document.ts` +- Create: `apps/api/src/openapi/openapi-refresh.ts` +- Modify: `apps/api/src/articles/articles.controller.ts` +- Modify: `apps/api/src/articles/dto/article-list-item.dto.ts` +- Modify: `apps/api/src/articles/dto/article-detail-item.dto.ts` +- Modify: `apps/api/src/health/health.controller.ts` +- Modify: `apps/api/src/articles/articles.controller.spec.ts` +- Modify: `apps/api/src/health/health.controller.spec.ts` +- Create: `apps/api/e2e/openapi-contract.e2e-spec.ts` +- Modify: `apps/api/e2e/articles.e2e-spec.ts` +- Modify: `apps/api/e2e/health.e2e-spec.ts` + +**Approach:** + +- 给当前 article 和 health controller / DTO 加显式的 OpenAPI metadata, + 让生成出来的文档描述全部 public surface,而不是靠隐式推断。 +- 把 document creation 抽到一个可复用 helper 里,这样 refresh path 和 + drift test 用的是同一份 source of truth。 +- 把 emitted document 和 `packages/api-contract/openapi/openapi.yaml` + 做比较;只要有 drift 或 breaking change,就在 merge 前失败。 + +**Execution note:** 先把 drift test 和 checked-in contract 锁住,再去 +改 controller metadata 让它们对齐。 + +**Patterns to follow:** + +- `apps/api/src/articles/articles.controller.ts` +- `apps/api/src/articles/articles.controller.spec.ts` +- `apps/api/src/health/health.controller.ts` +- `apps/api/src/health/health.controller.spec.ts` +- `apps/api/e2e/articles.e2e-spec.ts` +- `apps/api/e2e/health.e2e-spec.ts` +- `apps/api/README.md` + +**Test scenarios:** + +- Happy path: 生成的 OpenAPI 文档包含 `GET /health/live`、 + `GET /health/ready`、`GET /articles` 和 `GET /articles/{id}`,并且字段和 + 当前 public surface 一致。 +- Edge case: health readiness 的 503 响应和 article detail schema 继续把 + `summaryError` 作为 nullable 暴露,并且不把内部持久化字段带进公开合同。 +- Error path: 任何没有同步更新合同的字段新增、删除或重命名,都会让 + drift check 失败。 +- Integration: OpenAPI 文档和现有 HTTP e2e 断言描述的是同一组 + health/article payload、404 行为和 503 readiness 行为。 + +**Verification:** + +- API 测试套件可以重新生成合同文档,并在 merge 前抓住它和 checked-in + YAML 的不一致。 + +- [ ] **Unit 3: 把 web seam 切到生成后的合同** + +**Goal:** 移除 `apps/web` 里手写的 article-client contract,并通过生成 +package 保持 reader 行为不变。 + +**Requirements:** R4, R5, R6, R12 + +**Dependencies:** Units 1 和 2。 + +**Files:** + +- Modify: `apps/web/src/widgets/article-reader/api/articles-api.ts` +- Modify: `apps/web/src/widgets/article-reader/ui/article-reader-page.tsx` +- Modify: `apps/web/src/widgets/article-reader/ui/article-list.tsx` +- Modify: `apps/web/src/widgets/article-reader/ui/article-detail.tsx` +- Modify: `apps/web/package.json` +- Modify: `apps/web/next.config.ts` +- Modify: `apps/web/app/next-config.spec.ts` +- Modify: `apps/web/app/page.spec.tsx` +- Modify: `apps/web/e2e/home.spec.ts` + +**Approach:** + +- 让 web seam 继续只负责 `API_BASE_URL`、`cache: "no-store"`、URL 编码 + 和 404 -> null 转换,但 types 和 request surface 从 `@repo/api-contract` + 来;contract package 里即使包含 health operations,reader seam 也只会 + 读取 articles 相关导出。 +- 删掉 seam 文件里本地手写的 DTO-like 类型,让 web app 不再手工拥有 + contract shape。 +- 更新 web package wiring,保证直接跑 app 时也会先 build shared + contract package,再让 reader 代码 import 它。 + +## 全局影响 + +- **Interaction graph:** `apps/api/src/articles/*` + `apps/api/src/health/*` + -> OpenAPI document helper -> `packages/api-contract/openapi/openapi.yaml` + -> generated client/types -> `apps/web/src/widgets/article-reader/api/articles-api.ts` + -> reader UI. +- **Error propagation:** 404 在合同和 API 层仍然是 404,但 web adapter + 继续把它转成 `null`,让 unavailable state 保持原样。 +- **State lifecycle risks:** checked-in contract 或 generated client 可能会和 + Nest 实现漂移;drift test 必须在它们到 browser 之前把问题拦住。 +- **API surface parity:** `/health/live`、`/health/ready`、`/articles` 和 + `/articles/:id` 都纳入同一份公开合同,`summaryError` 仍然是公开 failure + detail,而不是任何内部持久化字段。 +- **Integration coverage:** OpenAPI drift test、HTTP health/article e2e 和 + browser reader spec 三层一起,才能证明同一份合同。 +- **Unchanged invariants:** ingestion、summary 生成、Prisma schema、 + article id、URL-driven selection,以及现有 summary fallback copy 都不变。 + +## 风险与依赖 + +| Risk | Mitigation | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| generated client 比现在的薄 wrapper 更重 | 保持 `apps/web/src/widgets/article-reader/api/articles-api.ts` 只是很薄的 adapter,只负责 env、编码和 404 转换。 | +| 合同文件和 Nest 实现漂移 | 增加专门的 API drift test,把 emitted document 和 `packages/api-contract/openapi/openapi.yaml` 做比较。 | +| 新 shared package 增加 workspace 摩擦 | 让 `packages/api-contract` 保持 private、可导出,并对齐现有 `packages/ui` 模式。 | +| app README 文案落后于合同 | 在同一轮里同步更新 `apps/api/README.md` 和 `apps/web/README.md`,并一起修掉旧字段名。 | + +## 文档 / 运维说明 + +- 更新 `apps/api/README.md` 和 `apps/web/README.md`,把新的合同 package + 和 refresh 流程写进去。 +- 保持 YAML 合同作为可审查的仓库产物;这个 feature 不要求再加一个 + public docs server。 +- 以后只要合同变了,就先 refresh checked-in YAML,再从这份文件重新 + generate client,不要手改 web 副本。 + +## 来源与参考 + +- **Origin documents:** `docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md` + + `docs/zh-Hans/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md` +- 相关代码:`apps/api/src/articles/articles.controller.ts`、 + `apps/api/src/articles/articles.service.ts`、 + `apps/api/src/articles/article.repository.ts`、 + `apps/api/src/health/health.controller.ts`、 + `apps/web/src/widgets/article-reader/api/articles-api.ts`、 + `apps/web/src/widgets/article-reader/ui/article-reader-page.tsx`、 + `apps/api/e2e/articles.e2e-spec.ts`、 + `apps/api/e2e/health.e2e-spec.ts`、 + `apps/web/app/page.spec.tsx`、 + `apps/web/e2e/home.spec.ts` +- 相关文档:`apps/api/README.md`、`apps/web/README.md`、 + `packages/ui/package.json` +- 外部文档:NestJS OpenAPI introduction 和 Orval From 9b4afa0192dbab7c4e01df67a3dfd277f53d19ef Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 15:40:15 +0800 Subject: [PATCH 03/11] feat(api-contract): keep generated contract files out of hooks Add a shared OpenAPI package, expose refreshed API documents from the API, replace the web client seam with generated contract types, and make hook-time routing skip generated contract output instead of formatting or linting it. --- AGENTS.md | 10 + apps/api/README.md | 12 +- apps/api/e2e/openapi-contract.e2e-spec.ts | 61 + apps/api/package.json | 4 + apps/api/src/articles/articles.controller.ts | 14 + .../articles/dto/article-detail-item.dto.ts | 24 + .../src/articles/dto/article-list-item.dto.ts | 13 + .../api/src/health/dto/health-response.dto.ts | 36 + apps/api/src/health/health.controller.ts | 16 + apps/api/src/openapi/openapi-document.ts | 36 + apps/api/src/openapi/openapi-refresh.ts | 44 + apps/web/README.md | 20 +- apps/web/app/next-config.spec.ts | 10 +- apps/web/jest.config.ts | 3 + apps/web/next.config.ts | 2 +- apps/web/package.json | 10 +- .../article-reader/api/articles-api.ts | 58 +- ...eat-web-api-openapi-contract-first-plan.md | 2 +- ...eat-web-api-openapi-contract-first-plan.md | 2 +- lint-staged.config.mjs | 13 +- package.json | 1 + packages/api-contract/openapi/openapi.yaml | 194 +++ packages/api-contract/orval.config.ts | 15 + packages/api-contract/package.json | 29 + .../api-contract/src/generated/api-client.ts | 211 ++++ packages/api-contract/src/index.ts | 123 ++ packages/api-contract/tsconfig.build.json | 19 + packages/api-contract/tsconfig.json | 12 + packages/eslint-config/base.js | 3 + pnpm-lock.yaml | 1121 ++++++++++++++++- turbo.json | 3 + 31 files changed, 2027 insertions(+), 94 deletions(-) create mode 100644 apps/api/e2e/openapi-contract.e2e-spec.ts create mode 100644 apps/api/src/health/dto/health-response.dto.ts create mode 100644 apps/api/src/openapi/openapi-document.ts create mode 100644 apps/api/src/openapi/openapi-refresh.ts create mode 100644 packages/api-contract/openapi/openapi.yaml create mode 100644 packages/api-contract/orval.config.ts create mode 100644 packages/api-contract/package.json create mode 100644 packages/api-contract/src/generated/api-client.ts create mode 100644 packages/api-contract/src/index.ts create mode 100644 packages/api-contract/tsconfig.build.json create mode 100644 packages/api-contract/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 671f988..62172bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,3 +60,13 @@ Do not add new category unless both language trees intentionally expand. - If FSD placement conflicts with required Next.js convention, keep Next.js convention and bend FSD around it. `src/pages` vs root `app/` = explicit example. - For `apps/api` NestJS implementation work, invoke `nestjs-best-practices`. - For `apps/api` backend architecture, follow `.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md` and organize by feature modules. Prefer self-contained feature folders grouping controllers, services, DTOs, entities, repositories, module defs. Avoid repo-wide tech-layer folders unless deeper scoped rule overrides. + +## Git Hook Discipline + +- Never bypass Git hooks or hook-time checks with flags that suppress warnings, + ignore files, or otherwise hide a failure. +- If a hook or staged check fails, fix the underlying config or code first. + Do not use `--no-warn-ignored`, `--quiet`, or similar skip-style workarounds + to make the hook pass. +- Keep hook behavior honest: a passing commit or push should mean the check + actually ran and succeeded, not that it was silenced. diff --git a/apps/api/README.md b/apps/api/README.md index 3397a94..f2b686d 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -3,7 +3,7 @@ This app owns the PostgreSQL-backed feed ingestion backbone for the repository. It reads `apps/api/feeds.opml`, ingests feeds on boot with best-effort isolation, attempts article body extraction during ingestion, and serves the -existing read-only `/articles` contract from persisted data. +existing read-only `/articles` and health contracts from persisted data. ## Local Run @@ -152,6 +152,7 @@ pnpm --filter api db:reset pnpm --filter api db:seed pnpm --filter api test pnpm --filter api test:e2e +pnpm --filter api contract:refresh ``` Use `pnpm --filter api db:deploy` to align the local development database with @@ -189,3 +190,12 @@ behind. - `GET /health/live` reports process liveness for orchestration probes. - `GET /health/ready` reports readiness only when bootstrap has completed and the database ping succeeds. + +## OpenAPI Contract + +- `apps/api/src/openapi/openapi-refresh.ts` refreshes the checked-in contract at + `packages/api-contract/openapi/openapi.yaml`. +- `packages/api-contract` generates the client/types that `apps/web` imports. +- Use `pnpm --filter api contract:refresh` first, then + `pnpm --filter @repo/api-contract contract:refresh` when the API surface + changes. diff --git a/apps/api/e2e/openapi-contract.e2e-spec.ts b/apps/api/e2e/openapi-contract.e2e-spec.ts new file mode 100644 index 0000000..ff423ec --- /dev/null +++ b/apps/api/e2e/openapi-contract.e2e-spec.ts @@ -0,0 +1,61 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from "@jest/globals"; +import { type INestApplication } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import YAML from "yaml"; + +import { ArticlesController } from "../src/articles/articles.controller"; +import { ArticlesService } from "../src/articles/articles.service"; +import { HealthController } from "../src/health/health.controller"; +import { PrismaService } from "../src/prisma/prisma.service"; +import { createOpenApiDocument } from "../src/openapi/openapi-document"; + +describe("OpenAPI contract", () => { + let app: INestApplication | undefined; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [ArticlesController, HealthController], + providers: [ + { + provide: ArticlesService, + useValue: { + getArticleById: jest.fn(), + getArticles: jest.fn(), + }, + }, + { + provide: PrismaService, + useValue: { + $queryRawUnsafe: jest.fn(), + }, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + it("matches the checked-in contract", () => { + const emitted = createOpenApiDocument(app as INestApplication); + const checkedIn: unknown = YAML.parse( + readFileSync( + resolve( + process.cwd(), + "../../packages/api-contract/openapi/openapi.yaml", + ), + "utf8", + ), + ); + + expect(emitted).toEqual(checkedIn); + }); +}); diff --git a/apps/api/package.json b/apps/api/package.json index f67a7a8..edfeff0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,6 +9,7 @@ "db:seed": "prisma db execute --config ./prisma.config.ts --file ./prisma/seed/seed.sql", "db:studio": "prisma studio --config ./prisma.config.ts", "dev": "nest start --watch", + "contract:refresh": "tsx src/openapi/openapi-refresh.ts", "prebuild": "pnpm db:generate", "build": "nest build", "start": "nest start", @@ -29,6 +30,7 @@ "@nestjs/config": "4.0.4", "@nestjs/core": "^11.1.19", "@nestjs/platform-express": "^11.1.19", + "@nestjs/swagger": "11.4.1", "@prisma/adapter-pg": "7.8.0", "@prisma/client": "7.8.0", "escape-html": "^1.0.3", @@ -63,6 +65,8 @@ "ts-jest": "^29.4.9", "ts-loader": "^9.5.7", "tsconfig-paths": "^4.2.0", + "tsx": "4.21.0", + "yaml": "2.8.3", "typescript": "6.0.3" } } diff --git a/apps/api/src/articles/articles.controller.ts b/apps/api/src/articles/articles.controller.ts index f2e4b5d..0e056a0 100644 --- a/apps/api/src/articles/articles.controller.ts +++ b/apps/api/src/articles/articles.controller.ts @@ -1,19 +1,33 @@ import { Controller, Get, NotFoundException, Param } from "@nestjs/common"; +import { + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from "@nestjs/swagger"; import { ArticleDetailItemDto } from "./dto/article-detail-item.dto"; import { ArticleListItemDto } from "./dto/article-list-item.dto"; import { ArticlesService } from "./articles.service"; +@ApiTags("articles") @Controller("articles") export class ArticlesController { constructor(private readonly articlesService: ArticlesService) {} @Get() + @ApiOperation({ operationId: "articles" }) + @ApiOkResponse({ type: ArticleListItemDto, isArray: true }) async getArticles(): Promise { return this.articlesService.getArticles(); } @Get(":id") + @ApiOperation({ operationId: "articleById" }) + @ApiParam({ name: "id", required: true, type: String }) + @ApiOkResponse({ type: ArticleDetailItemDto }) + @ApiNotFoundResponse({ description: "Article not found" }) async getArticleById(@Param("id") id: string): Promise { const article = await this.articlesService.getArticleById(id); diff --git a/apps/api/src/articles/dto/article-detail-item.dto.ts b/apps/api/src/articles/dto/article-detail-item.dto.ts index 03185e5..ad8d4b3 100644 --- a/apps/api/src/articles/dto/article-detail-item.dto.ts +++ b/apps/api/src/articles/dto/article-detail-item.dto.ts @@ -1,17 +1,41 @@ +import { ApiProperty } from "@nestjs/swagger"; + export class ArticleSummaryErrorDto { + @ApiProperty({ type: String }) action!: string; + + @ApiProperty({ type: String }) code!: string; + + @ApiProperty({ type: String }) copyText!: string; + + @ApiProperty({ type: String }) message!: string; + + @ApiProperty({ type: String }) title!: string; } export class ArticleDetailItemDto { + @ApiProperty({ type: String }) title!: string; + + @ApiProperty({ type: String }) translatedTitle!: string; + + @ApiProperty({ type: String }) sourceTitle!: string; + + @ApiProperty({ type: String, format: "date-time" }) publishedAt!: string; + + @ApiProperty({ type: String }) summary!: string; + + @ApiProperty({ type: () => ArticleSummaryErrorDto, nullable: true }) summaryError!: ArticleSummaryErrorDto | null; + + @ApiProperty({ type: String }) originalUrl!: string; } diff --git a/apps/api/src/articles/dto/article-list-item.dto.ts b/apps/api/src/articles/dto/article-list-item.dto.ts index 48df7ea..5d2e30c 100644 --- a/apps/api/src/articles/dto/article-list-item.dto.ts +++ b/apps/api/src/articles/dto/article-list-item.dto.ts @@ -1,8 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; + export class ArticleListItemDto { + @ApiProperty({ type: String }) id!: string; + + @ApiProperty({ type: String }) title!: string; + + @ApiProperty({ type: String }) translatedTitle!: string; + + @ApiProperty({ type: String }) sourceTitle!: string; + + @ApiProperty({ type: String, format: "date-time" }) publishedAt!: string; + + @ApiProperty({ type: String }) originalUrl!: string; } diff --git a/apps/api/src/health/dto/health-response.dto.ts b/apps/api/src/health/dto/health-response.dto.ts new file mode 100644 index 0000000..11d9ba7 --- /dev/null +++ b/apps/api/src/health/dto/health-response.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class HealthLiveChecksDto { + @ApiProperty({ type: String }) + application!: string; +} + +export class HealthReadyChecksDto { + @ApiProperty({ type: String }) + application!: string; + + @ApiProperty({ type: String }) + database!: string; +} + +export class HealthLiveResponseDto { + @ApiProperty({ type: () => HealthLiveChecksDto }) + checks!: HealthLiveChecksDto; + + @ApiProperty({ type: String }) + service!: string; + + @ApiProperty({ type: String }) + status!: string; +} + +export class HealthReadyResponseDto { + @ApiProperty({ type: () => HealthReadyChecksDto }) + checks!: HealthReadyChecksDto; + + @ApiProperty({ type: String }) + service!: string; + + @ApiProperty({ type: String }) + status!: string; +} diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index be914a7..a3415a6 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -1,8 +1,19 @@ import { Controller, Get, OnApplicationBootstrap, Res } from "@nestjs/common"; +import { + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from "@nestjs/swagger"; import type { Response } from "express"; import { PrismaService } from "../prisma/prisma.service"; +import { + HealthLiveResponseDto, + HealthReadyResponseDto, +} from "./dto/health-response.dto"; +@ApiTags("health") @Controller("health") export class HealthController implements OnApplicationBootstrap { private appReady = false; @@ -14,6 +25,8 @@ export class HealthController implements OnApplicationBootstrap { } @Get("live") + @ApiOperation({ operationId: "healthLive" }) + @ApiOkResponse({ type: HealthLiveResponseDto }) live() { return { checks: { @@ -25,6 +38,9 @@ export class HealthController implements OnApplicationBootstrap { } @Get("ready") + @ApiOperation({ operationId: "healthReady" }) + @ApiOkResponse({ type: HealthReadyResponseDto }) + @ApiServiceUnavailableResponse({ type: HealthReadyResponseDto }) async ready(@Res({ passthrough: true }) response: Response) { if (!this.appReady) { response.status(503); diff --git a/apps/api/src/openapi/openapi-document.ts b/apps/api/src/openapi/openapi-document.ts new file mode 100644 index 0000000..230f8c0 --- /dev/null +++ b/apps/api/src/openapi/openapi-document.ts @@ -0,0 +1,36 @@ +import { type INestApplication } from "@nestjs/common"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; + +import { + ArticleDetailItemDto, + ArticleSummaryErrorDto, +} from "../articles/dto/article-detail-item.dto"; +import { ArticleListItemDto } from "../articles/dto/article-list-item.dto"; +import { + HealthLiveChecksDto, + HealthLiveResponseDto, + HealthReadyChecksDto, + HealthReadyResponseDto, +} from "../health/dto/health-response.dto"; + +export function createOpenApiDocument(app: INestApplication) { + const config = new DocumentBuilder() + .setTitle("RSSift API") + .setDescription("OpenAPI contract for the RSSift API surface") + .setVersion("0.1.0") + .build(); + + return SwaggerModule.createDocument(app, config, { + deepScanRoutes: true, + extraModels: [ + ArticleDetailItemDto, + ArticleListItemDto, + ArticleSummaryErrorDto, + HealthLiveChecksDto, + HealthLiveResponseDto, + HealthReadyChecksDto, + HealthReadyResponseDto, + ], + operationIdFactory: (_controllerKey, methodKey) => methodKey, + }); +} diff --git a/apps/api/src/openapi/openapi-refresh.ts b/apps/api/src/openapi/openapi-refresh.ts new file mode 100644 index 0000000..2f8ccb7 --- /dev/null +++ b/apps/api/src/openapi/openapi-refresh.ts @@ -0,0 +1,44 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import YAML from "yaml"; + +import { Test } from "@nestjs/testing"; +import { AppModule } from "../app.module"; +import { FeedBootstrapService } from "../feeds/feed-bootstrap.service"; +import { createOpenApiDocument } from "./openapi-document"; + +const contractFilePath = resolve( + __dirname, + "../../../../packages/api-contract/openapi/openapi.yaml", +); + +export async function refreshOpenApiContract() { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(FeedBootstrapService) + .useValue({ + onApplicationBootstrap: () => undefined, + }) + .compile(); + + const app = moduleRef.createNestApplication(); + + try { + await app.init(); + + const document = createOpenApiDocument(app); + const yaml = YAML.stringify(document, { + sortMapEntries: true, + }); + + await mkdir(dirname(contractFilePath), { recursive: true }); + await writeFile(contractFilePath, `${yaml.trimEnd()}\n`, "utf8"); + } finally { + await app.close(); + } +} + +if (require.main === module) { + void refreshOpenApiContract(); +} diff --git a/apps/web/README.md b/apps/web/README.md index fdf0d2a..561fcf6 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -2,7 +2,9 @@ This app renders the first runnable reader slice for the repository. It consumes the persisted `apps/api` article contract over real HTTP and presents a -two-pane reading shell on port `3001`. +two-pane reading shell on port `3001`. It imports generated contract types and +helpers from `@repo/api-contract`, so the web side no longer owns handwritten +DTO-like contract shapes. The current reader contract is summary-first: @@ -13,6 +15,10 @@ The current reader contract is summary-first: `summaryErrorReason` or the pending-state copy `Summary pending` while keeping the rest of the reader chrome visible +The checked-in OpenAPI document is the source of truth for that contract. The +web seam stays thin and only handles `API_BASE_URL`, cache policy, URL +encoding, and the `404 -> null` reader fallback. + ## Local Development 1. Start the API in a separate terminal: @@ -34,6 +40,9 @@ pnpm --dir apps/web dev ``` The web package now bootstraps `@repo/ui` before `dev`, and bootstraps both `@repo/ui` plus `@repo/jest-config` before `build`, `typecheck`, and `test`, so a clean checkout only needs `pnpm install` first. You do not need pre-existing `packages/ui/dist` or `packages/jest-config/dist` directories. +It also bootstraps `@repo/api-contract` before `dev`, `build`, `lint`, +`typecheck`, and `test`, so the generated contract package stays in sync with +the web app entrypoints. The local default contract is: @@ -74,6 +83,8 @@ pnpm --filter web test ``` Direct Jest invocations also resolve `@repo/ui` from `packages/ui/src`, which keeps ad-hoc test runs safe even when `packages/ui/dist` has not been built yet. +The contract package stays in the normal workspace graph, so generated types are +available without manual copying. Run the browser flow against both apps: @@ -91,6 +102,13 @@ The Playwright suite starts both the API and the web app, then verifies: API-to-web seam - `/api/health` stays available as a stable, dependency-light probe surface +## Contract Refresh + +- Refresh the API OpenAPI file with `pnpm --filter api contract:refresh`. +- Regenerate the client/types with `pnpm --filter @repo/api-contract contract:refresh`. +- Do not hand-edit the reader contract shape in + `apps/web/src/widgets/article-reader/api/articles-api.ts`. + ## Shared UI Boundary Reusable shadcn/Tailwind primitives live in `packages/ui`. App-specific reader data loading and composition stay in `apps/web`. diff --git a/apps/web/app/next-config.spec.ts b/apps/web/app/next-config.spec.ts index dae53dd..39a93b9 100644 --- a/apps/web/app/next-config.spec.ts +++ b/apps/web/app/next-config.spec.ts @@ -15,13 +15,19 @@ describe("web next config", () => { expect(config.allowedDevOrigins).toEqual(["127.0.0.1"]); expect(config.output).toBe("standalone"); expect(config.outputFileTracingRoot).toBeUndefined(); - expect(config.transpilePackages).toEqual(["@repo/ui"]); + expect(config.transpilePackages).toEqual([ + "@repo/api-contract", + "@repo/ui", + ]); }); it("keeps monorepo tracing enabled outside the dev server", () => { const config = nextConfig(PHASE_PRODUCTION_BUILD); expect(config.outputFileTracingRoot).toBe(resolve(process.cwd(), "../..")); - expect(config.transpilePackages).toEqual(["@repo/ui"]); + expect(config.transpilePackages).toEqual([ + "@repo/api-contract", + "@repo/ui", + ]); }); }); diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index c714e04..3c889b7 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -5,6 +5,9 @@ export default createNextJestConfig("./", { modulePathIgnorePatterns: ["/.next/standalone"], moduleNameMapper: { // Keep direct Jest runs independent from `packages/ui/dist` bootstrap state. + "^@repo/api-contract$": + "/../../packages/api-contract/src/index.ts", + "^@repo/api-contract/(.*)$": "/../../packages/api-contract/src/$1", "^@repo/ui$": "/../../packages/ui/src/index.ts", "^@repo/ui/(.*)$": "/../../packages/ui/src/$1", "^react-markdown$": "/test-support/react-markdown.mock.tsx", diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index a43538e..bbb068b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -10,7 +10,7 @@ const workspaceRoot = resolve( const baseConfig = { allowedDevOrigins: ["127.0.0.1"], output: "standalone", - transpilePackages: ["@repo/ui"], + transpilePackages: ["@repo/api-contract", "@repo/ui"], } satisfies NextConfig; export default function nextConfig(phase: string): NextConfig { diff --git a/apps/web/package.json b/apps/web/package.json index 9346c73..eb2821a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,19 +3,21 @@ "type": "module", "private": true, "scripts": { - "predev": "pnpm --filter @repo/ui build", + "predev": "pnpm --filter @repo/ui build && pnpm --filter @repo/api-contract build", "dev": "NEXT_TELEMETRY_DISABLED=1 next dev --turbopack --port 3001", - "prebuild": "pnpm --filter @repo/ui build && pnpm --filter @repo/jest-config build", + "prebuild": "pnpm --filter @repo/ui build && pnpm --filter @repo/api-contract build && pnpm --filter @repo/jest-config build", "build": "NEXT_TELEMETRY_DISABLED=1 next build", "start": "NEXT_TELEMETRY_DISABLED=1 next start --port 3001", + "prelint": "pnpm --filter @repo/ui build && pnpm --filter @repo/api-contract build", "lint": "eslint . --max-warnings 0", - "pretypecheck": "pnpm --filter @repo/ui build && pnpm --filter @repo/jest-config build", + "pretypecheck": "pnpm --filter @repo/ui build && pnpm --filter @repo/api-contract build && pnpm --filter @repo/jest-config build", "typecheck": "tsc --noEmit", - "pretest": "pnpm --filter @repo/ui build && pnpm --filter @repo/jest-config build", + "pretest": "pnpm --filter @repo/ui build && pnpm --filter @repo/api-contract build && pnpm --filter @repo/jest-config build", "test": "jest --config ./jest.config.ts", "test:e2e": "playwright test" }, "dependencies": { + "@repo/api-contract": "workspace:*", "@repo/ui": "workspace:*", "next": "16.2.4", "react": "^19.2.5", diff --git a/apps/web/src/widgets/article-reader/api/articles-api.ts b/apps/web/src/widgets/article-reader/api/articles-api.ts index 9ca290b..d38b2f0 100644 --- a/apps/web/src/widgets/article-reader/api/articles-api.ts +++ b/apps/web/src/widgets/article-reader/api/articles-api.ts @@ -1,29 +1,14 @@ -export type ArticleListItem = { - id: string; - originalUrl: string; - publishedAt: string; - sourceTitle: string; - title: string; - translatedTitle: string; -}; +import { + getArticleById, + getArticles, + type ArticleDetailItemDto, + type ArticleListItemDto, + type ArticleSummaryErrorDto, +} from "@repo/api-contract"; -export type ArticleSummaryError = { - action: string; - code: string; - copyText: string; - message: string; - title: string; -}; - -export type ArticleDetail = { - originalUrl: string; - publishedAt: string; - sourceTitle: string; - summary: string; - summaryError: ArticleSummaryError | null; - title: string; - translatedTitle: string; -}; +export type ArticleListItem = ArticleListItemDto; +export type ArticleSummaryError = ArticleSummaryErrorDto; +export type ArticleDetail = ArticleDetailItemDto; export class MissingApiBaseUrlError extends Error { constructor() { @@ -44,27 +29,24 @@ function getApiBaseUrl() { return value.replace(/\/$/, ""); } -async function fetchJson(path: string): Promise { - const response = await fetch(`${getApiBaseUrl()}${path}`, { +export async function fetchArticles() { + const response = await getArticles(getApiBaseUrl(), { cache: "no-store", }); - if (!response.ok) { + if (response.status < 200 || response.status >= 300) { throw new Error( - `API request failed for ${path}: ${String(response.status)}`, + `API request failed for /articles: ${String(response.status)}`, ); } - return (await response.json()) as T; -} - -export async function fetchArticles() { - return fetchJson("/articles"); + return response.data; } export async function fetchArticleDetail(articleId: string) { - const response = await fetch( - `${getApiBaseUrl()}/articles/${encodeURIComponent(articleId)}`, + const response = await getArticleById( + getApiBaseUrl(), + encodeURIComponent(articleId), { cache: "no-store", }, @@ -74,11 +56,11 @@ export async function fetchArticleDetail(articleId: string) { return null; } - if (!response.ok) { + if (response.status < 200 || response.status >= 300) { throw new Error( `API request failed for /articles/${articleId}: ${String(response.status)}`, ); } - return (await response.json()) as ArticleDetail; + return response.data; } diff --git a/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md b/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md index 0b081d1..e25cb0c 100644 --- a/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md +++ b/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md @@ -1,7 +1,7 @@ --- title: refactor: Make the Web/API Read Boundary OpenAPI-First type: refactor -status: active +status: completed date: 2026-04-24 origin: - docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md diff --git a/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md b/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md index e98c00b..00ba635 100644 --- a/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md +++ b/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md @@ -1,7 +1,7 @@ --- title: refactor: 让 Web/API 读取边界改为 OpenAPI 优先 type: refactor -status: active +status: completed date: 2026-04-24 origin: - docs/en/brainstorms/2026-04-24-web-api-openapi-contract-first-requirements.md diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index 364daae..dbfc5b5 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -8,6 +8,7 @@ const jsTsPattern = "**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"; const prettierOnlyPattern = "**/*.{css,html,json,md,mdx}"; const eslintPackageRoots = ["apps", "packages"]; const rootScopedIgnoredEslintPrefixes = [".github/scripts/fixtures/"]; +const rootScopedIgnoredLintPrefixes = ["packages/api-contract/src/generated/"]; /** * Quote a file path for safe shell usage. @@ -30,6 +31,14 @@ const toWorkspaceRelativePath = (file) => { return file; }; +/** + * Keep generated contract files out of staged format/lint routing. + * @param {string} file + * @returns {boolean} + */ +const isIgnoredByLintRouting = (file) => + rootScopedIgnoredLintPrefixes.some((prefix) => file.startsWith(prefix)); + /** * Discover package directories that own their own ESLint config. * @returns {string[]} @@ -134,7 +143,9 @@ export default { * @returns {string[]} */ [jsTsPattern]: (files) => { - const normalizedFiles = files.map(toWorkspaceRelativePath); + const normalizedFiles = files + .map(toWorkspaceRelativePath) + .filter((file) => !isIgnoredByLintRouting(file)); const rootFiles = []; const packageFiles = new Map(); diff --git a/package.json b/package.json index 3cf1747..7a32bae 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "turbo run dev", "build": "turbo run build", + "contract:refresh": "turbo run contract:refresh --filter=api && turbo run contract:refresh --filter=@repo/api-contract", "prepare": "husky .husky", "typecheck": "turbo run typecheck", "test": "pnpm test:root && turbo run test --ui=stream", diff --git a/packages/api-contract/openapi/openapi.yaml b/packages/api-contract/openapi/openapi.yaml new file mode 100644 index 0000000..502d918 --- /dev/null +++ b/packages/api-contract/openapi/openapi.yaml @@ -0,0 +1,194 @@ +components: + schemas: + ArticleDetailItemDto: + properties: + originalUrl: + type: string + publishedAt: + format: date-time + type: string + sourceTitle: + type: string + summary: + type: string + summaryError: + allOf: + - $ref: "#/components/schemas/ArticleSummaryErrorDto" + nullable: true + type: object + title: + type: string + translatedTitle: + type: string + required: + - title + - translatedTitle + - sourceTitle + - publishedAt + - summary + - summaryError + - originalUrl + type: object + ArticleListItemDto: + properties: + id: + type: string + originalUrl: + type: string + publishedAt: + format: date-time + type: string + sourceTitle: + type: string + title: + type: string + translatedTitle: + type: string + required: + - id + - title + - translatedTitle + - sourceTitle + - publishedAt + - originalUrl + type: object + ArticleSummaryErrorDto: + properties: + action: + type: string + code: + type: string + copyText: + type: string + message: + type: string + title: + type: string + required: + - action + - code + - copyText + - message + - title + type: object + HealthLiveChecksDto: + properties: + application: + type: string + required: + - application + type: object + HealthLiveResponseDto: + properties: + checks: + $ref: "#/components/schemas/HealthLiveChecksDto" + service: + type: string + status: + type: string + required: + - checks + - service + - status + type: object + HealthReadyChecksDto: + properties: + application: + type: string + database: + type: string + required: + - application + - database + type: object + HealthReadyResponseDto: + properties: + checks: + $ref: "#/components/schemas/HealthReadyChecksDto" + service: + type: string + status: + type: string + required: + - checks + - service + - status + type: object +info: + contact: {} + description: OpenAPI contract for the RSSift API surface + title: RSSift API + version: 0.1.0 +openapi: 3.0.0 +paths: + /articles: + get: + operationId: articles + parameters: [] + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/ArticleListItemDto" + type: array + description: "" + summary: "" + tags: &a1 + - articles + /articles/{id}: + get: + operationId: articleById + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ArticleDetailItemDto" + description: "" + "404": + description: Article not found + summary: "" + tags: *a1 + /health/live: + get: + operationId: healthLive + parameters: [] + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/HealthLiveResponseDto" + description: "" + summary: "" + tags: &a2 + - health + /health/ready: + get: + operationId: healthReady + parameters: [] + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/HealthReadyResponseDto" + description: "" + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/HealthReadyResponseDto" + description: "" + summary: "" + tags: *a2 +servers: [] +tags: [] diff --git a/packages/api-contract/orval.config.ts b/packages/api-contract/orval.config.ts new file mode 100644 index 0000000..922804c --- /dev/null +++ b/packages/api-contract/orval.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "orval"; + +export default defineConfig({ + api: { + input: { + target: "./openapi/openapi.yaml", + }, + output: { + baseUrl: "/", + client: "fetch", + mode: "single", + target: "./src/generated/api-client.ts", + }, + }, +}); diff --git a/packages/api-contract/package.json b/packages/api-contract/package.json new file mode 100644 index 0000000..bb4e390 --- /dev/null +++ b/packages/api-contract/package.json @@ -0,0 +1,29 @@ +{ + "name": "@repo/api-contract", + "type": "module", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "generate": "orval --config ./orval.config.ts", + "contract:refresh": "pnpm generate", + "prebuild": "pnpm generate", + "build": "tsc -p tsconfig.build.json", + "lint": "eslint . --max-warnings 0", + "pretypecheck": "pnpm generate", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "eslint": "catalog:", + "orval": "8.8.1", + "typescript": "6.0.3" + } +} diff --git a/packages/api-contract/src/generated/api-client.ts b/packages/api-contract/src/generated/api-client.ts new file mode 100644 index 0000000..07013c8 --- /dev/null +++ b/packages/api-contract/src/generated/api-client.ts @@ -0,0 +1,211 @@ +/** + * Generated by orval v8.8.1 🍺 + * Do not edit manually. + * RSSift API + * OpenAPI contract for the RSSift API surface + * OpenAPI spec version: 0.1.0 + */ +export interface ArticleSummaryErrorDto { + action: string; + code: string; + copyText: string; + message: string; + title: string; +} + +export interface ArticleDetailItemDto { + originalUrl: string; + publishedAt: string; + sourceTitle: string; + summary: string; + /** @nullable */ + summaryError: ArticleSummaryErrorDto | null; + title: string; + translatedTitle: string; +} + +export interface ArticleListItemDto { + id: string; + originalUrl: string; + publishedAt: string; + sourceTitle: string; + title: string; + translatedTitle: string; +} + +export interface HealthLiveChecksDto { + application: string; +} + +export interface HealthLiveResponseDto { + checks: HealthLiveChecksDto; + service: string; + status: string; +} + +export interface HealthReadyChecksDto { + application: string; + database: string; +} + +export interface HealthReadyResponseDto { + checks: HealthReadyChecksDto; + service: string; + status: string; +} + +export type articlesResponse200 = { + data: ArticleListItemDto[]; + status: 200; +}; + +export type articlesResponseSuccess = articlesResponse200 & { + headers: Headers; +}; +export type articlesResponse = articlesResponseSuccess; + +const parseResponseBody = (body: string | null, fallback: T): T => + body ? (JSON.parse(body) as unknown as T) : fallback; + +export const getArticlesUrl = () => { + return `/articles`; +}; + +export const articles = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getArticlesUrl(), { + ...options, + method: "GET", + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data = parseResponseBody(body, []); + return { data, status: res.status, headers: res.headers } as articlesResponse; +}; + +export type articleByIdResponse200 = { + data: ArticleDetailItemDto; + status: 200; +}; + +export type articleByIdResponse404 = { + data: undefined; + status: 404; +}; + +export type articleByIdResponseSuccess = articleByIdResponse200 & { + headers: Headers; +}; +export type articleByIdResponseError = articleByIdResponse404 & { + headers: Headers; +}; + +export type articleByIdResponse = + | articleByIdResponseSuccess + | articleByIdResponseError; + +export const getArticleByIdUrl = (id: string) => { + return `/articles/${id}`; +}; + +export const articleById = async ( + id: string, + options?: RequestInit, +): Promise => { + const res = await fetch(getArticleByIdUrl(id), { + ...options, + method: "GET", + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data = parseResponseBody(body, {} as ArticleDetailItemDto); + return { + data, + status: res.status, + headers: res.headers, + } as articleByIdResponse; +}; + +export type healthLiveResponse200 = { + data: HealthLiveResponseDto; + status: 200; +}; + +export type healthLiveResponseSuccess = healthLiveResponse200 & { + headers: Headers; +}; +export type healthLiveResponse = healthLiveResponseSuccess; + +export const getHealthLiveUrl = () => { + return `/health/live`; +}; + +export const healthLive = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getHealthLiveUrl(), { + ...options, + method: "GET", + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data = parseResponseBody( + body, + {} as HealthLiveResponseDto, + ); + return { + data, + status: res.status, + headers: res.headers, + } as healthLiveResponse; +}; + +export type healthReadyResponse200 = { + data: HealthReadyResponseDto; + status: 200; +}; + +export type healthReadyResponse503 = { + data: HealthReadyResponseDto; + status: 503; +}; + +export type healthReadyResponseSuccess = healthReadyResponse200 & { + headers: Headers; +}; +export type healthReadyResponseError = healthReadyResponse503 & { + headers: Headers; +}; + +export type healthReadyResponse = + | healthReadyResponseSuccess + | healthReadyResponseError; + +export const getHealthReadyUrl = () => { + return `/health/ready`; +}; + +export const healthReady = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getHealthReadyUrl(), { + ...options, + method: "GET", + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data = parseResponseBody( + body, + {} as HealthReadyResponseDto, + ); + return { + data, + status: res.status, + headers: res.headers, + } as healthReadyResponse; +}; diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts new file mode 100644 index 0000000..f16c20e --- /dev/null +++ b/packages/api-contract/src/index.ts @@ -0,0 +1,123 @@ +import { + articleById, + articles, + getArticleByIdUrl, + getArticlesUrl, + getHealthLiveUrl, + getHealthReadyUrl, + healthLive, + healthReady, + type ArticleDetailItemDto, + type ArticleListItemDto, + type ArticleSummaryErrorDto, + type HealthLiveResponseDto, + type HealthReadyResponseDto, +} from "./generated/api-client"; + +export type { + ArticleDetailItemDto, + ArticleListItemDto, + ArticleSummaryErrorDto, + HealthLiveResponseDto, + HealthReadyResponseDto, +}; + +export type ApiResponse = { + data: T; + status: number; +}; + +export { + articleById, + articles, + getArticleByIdUrl, + getArticlesUrl, + getHealthLiveUrl, + getHealthReadyUrl, + healthLive, + healthReady, +}; + +async function fetchJson( + url: string, + init?: RequestInit, +): Promise> { + const response = await fetch(url, init); + const data = response.body + ? ((await response.json()) as T) + : (undefined as unknown as T); + + return { + data, + status: response.status, + }; +} + +export async function getArticles( + apiBaseUrl: string, + init?: RequestInit, +): Promise> { + return fetchJson( + new URL(getArticlesUrl(), apiBaseUrl).toString(), + { + ...init, + method: "GET", + }, + ); +} + +export async function getArticleById( + apiBaseUrl: string, + encodedArticleId: string, + init?: RequestInit, +): Promise> { + const response = await fetch( + new URL(`/articles/${encodedArticleId}`, apiBaseUrl).toString(), + { + ...init, + method: "GET", + }, + ); + + if (response.status === 404) { + return { + data: null, + status: response.status, + }; + } + + const data = response.body + ? ((await response.json()) as ArticleDetailItemDto) + : (undefined as unknown as ArticleDetailItemDto); + + return { + data, + status: response.status, + }; +} + +export async function getHealthLive( + apiBaseUrl: string, + init?: RequestInit, +): Promise> { + return fetchJson( + new URL(getHealthLiveUrl(), apiBaseUrl).toString(), + { + ...init, + method: "GET", + }, + ); +} + +export async function getHealthReady( + apiBaseUrl: string, + init?: RequestInit, +): Promise> { + return fetchJson( + new URL(getHealthReadyUrl(), apiBaseUrl).toString(), + { + ...init, + method: "GET", + }, + ); +} diff --git a/packages/api-contract/tsconfig.build.json b/packages/api-contract/tsconfig.build.json new file mode 100644 index 0000000..2736dc9 --- /dev/null +++ b/packages/api-contract/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "ignoreDeprecations": "6.0", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/api-contract/tsconfig.json b/packages/api-contract/tsconfig.json new file mode 100644 index 0000000..c256054 --- /dev/null +++ b/packages/api-contract/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/typescript-config/nextjs.json", + "compilerOptions": { + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 1a7a2e3..3912665 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -11,6 +11,7 @@ export const baseConfig = defineConfig( "**/node_modules/**", "**/.next/**", "**/dist/**", + "**/src/generated/**", "**/coverage/**", "**/.turbo/**", "**/playwright-report/**", @@ -35,6 +36,8 @@ export const baseConfig = defineConfig( "*.mjs", ".github/scripts/*.mjs", ".github/scripts/*.test.mjs", + "orval.config.ts", + "packages/api-contract/orval.config.ts", ], }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7e922b..b119554 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@nestjs/platform-express': specifier: ^11.1.19 version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/swagger': + specifier: 11.4.1 + version: 11.4.1(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2) '@prisma/adapter-pg': specifier: 7.8.0 version: 7.8.0 @@ -162,18 +165,27 @@ importers: tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 + tsx: + specifier: 4.21.0 + version: 4.21.0 typescript: specifier: 6.0.3 version: 6.0.3 + yaml: + specifier: 2.8.3 + version: 2.8.3 apps/web: dependencies: + '@repo/api-contract': + specifier: workspace:* + version: link:../../packages/api-contract '@repo/ui': specifier: workspace:* version: link:../../packages/ui next: specifier: 16.2.4 - version: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: specifier: ^19.2.5 version: 19.2.5 @@ -239,6 +251,24 @@ importers: specifier: 6.0.3 version: 6.0.3 + packages/api-contract: + devDependencies: + '@repo/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@repo/typescript-config': + specifier: workspace:* + version: link:../typescript-config + eslint: + specifier: 'catalog:' + version: 9.39.4(jiti@2.6.1) + orval: + specifier: 8.8.1 + version: 8.8.1(prettier@3.8.3)(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + packages/eslint-config: devDependencies: '@eslint/js': @@ -291,7 +321,7 @@ importers: version: 30.3.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@6.0.3)) next: specifier: 16.2.4 - version: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) typescript: specifier: 6.0.3 version: 6.0.3 @@ -546,6 +576,11 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@commander-js/extra-typings@14.0.0': + resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} + peerDependencies: + commander: ~14.0.0 + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -601,6 +636,162 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -643,6 +834,9 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + '@hono/node-server@1.19.11': resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} engines: {node: '>=18.14.1'} @@ -1098,6 +1292,9 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -1158,6 +1355,19 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/platform-express@11.1.19': resolution: {integrity: sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==} peerDependencies: @@ -1173,6 +1383,23 @@ packages: prettier: optional: true + '@nestjs/swagger@11.4.1': + resolution: {integrity: sha512-GuGzs8F1Cb3n+eEarmOqB4nt2ai+x4XGOYUXNYplOtDeB59DaFY5E16bsHsBWXiWgD1ywbyKQ5OVv02bQtB1Dw==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/testing@11.1.19': resolution: {integrity: sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ==} peerDependencies: @@ -1268,6 +1495,44 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@orval/angular@8.8.1': + resolution: {integrity: sha512-/6YxMY27dGNEeHR+f5+WgNDUYaXpcUmZ1JXJzW/dGmVl8fHTPAMgmJAlKQcTsD2/bdfGl6zkgHsI5XOT5oALow==} + + '@orval/axios@8.8.1': + resolution: {integrity: sha512-IVVtH3krs9c7t6bgzZ955KpNGAvn/FM2Y+30SRpgLGVn6y/0puo94JjuaziVZMY3FBZpXlFB5/9vnbTi87MTLA==} + + '@orval/core@8.8.1': + resolution: {integrity: sha512-x0u4O9FdoHaB/kDjCSGPkaRw/xe3qws9ERqQrcnGhvoBPjcrmdOR87siqUnQFDHsRbv3iocQcdg4hr62WFORBQ==} + peerDependencies: + '@faker-js/faker': '>=10' + peerDependenciesMeta: + '@faker-js/faker': + optional: true + + '@orval/fetch@8.8.1': + resolution: {integrity: sha512-nd8qS181wF8tVfCNl7PjdH3edNCMnKDDew0tJ3eakwlZqQ7e4x3JAPm2deNYP7sll94M9MVPdz8beFfbhA7APg==} + + '@orval/hono@8.8.1': + resolution: {integrity: sha512-GX6e2C4cO+Qi9xdtBl69+2bg5Fb79CjYrqiHLmuNf/n5avmgVLXB94+MGE6NRAZoVFhYiaovlhChYZ06wyaUfw==} + + '@orval/mcp@8.8.1': + resolution: {integrity: sha512-rdliCwbo1akUoV3oX39sTNAlYbHSD4Qj9MBE5CA9DzIoZXS+1KqhDpH+rGnfZL9st6dxHXxsWX1yV16G10LngA==} + + '@orval/mock@8.8.1': + resolution: {integrity: sha512-NWpMl0M3tvRGCtuvydoHna1sVCxmHH3gI6gMAlO3VOXDnAJxANpuyVEXSsGlYTzGtdMG4vvjYpwcpBM7OKQnAA==} + + '@orval/query@8.8.1': + resolution: {integrity: sha512-t177W0bXOVV0K0brnc3rDOQwaNX+mejenhfA8yLaNDBAX5g1nTIedhAbrbjJAmkHY/f3kxjiY05DztG+pwkTgg==} + + '@orval/solid-start@8.8.1': + resolution: {integrity: sha512-54ygWCI5aGLALhsTHmqsIPyMzTDVEg+VeGEfDSE+MJ6/8GicE+f96qBdaCAa/cSu0a5EPRDwwNaGUKMZrhh4Hw==} + + '@orval/swr@8.8.1': + resolution: {integrity: sha512-sWdzZSdp1MFwQa0ngh7HfSwT5A6qmNWNGB6aRnTgBlKxRuRrA40YnwAfb5VlbxK2UOzII7VlY4vinz/e/0UNag==} + + '@orval/zod@8.8.1': + resolution: {integrity: sha512-TkYH4deLNqnxhXNZpHZ3/SkqTOUszHFq7UEYdTEkkuF257rFSM+bRTo30kajX6PfmG/jJrcRhXvkycp/6z7Kkw==} + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -1512,9 +1777,58 @@ packages: '@types/react': optional: true + '@scalar/helpers@0.5.2': + resolution: {integrity: sha512-Pi1GAl8jO6ungmGj2sjDfCfqiBNrKW6HXDZmminV94ybGU/KtRLOqHwd0n9FIhY3j0RYGpGC0VCuniCICfQPHg==} + engines: {node: '>=22'} + + '@scalar/json-magic@0.12.8': + resolution: {integrity: sha512-a559iO8tmFeA90JJAAM3U5x1Asf3mr0Z8uDC1PmyLTDjdSOfajP7EY9VzNoXE2cM48ilf9qrjmkbw/d4VCFjQw==} + engines: {node: '>=22'} + + '@scalar/openapi-parser@0.25.12': + resolution: {integrity: sha512-1hajBAbc7cbEcsSZEQxaPXZyCjMf6h6hObV+SO32jkC6rrxinPXQIucDu9HTu/jm/FaaMnNhc8/XDWz5/E49cQ==} + engines: {node: '>=22'} + + '@scalar/openapi-types@0.6.1': + resolution: {integrity: sha512-P1RvyTFN0vRSL136OqWjlZfSFjY9JoJfuD6LM1mIjoocfwmqX3WuzsFEFX6hAeeDlTh6gjbiy+OdhSee8GFfSA==} + engines: {node: '>=22'} + + '@scalar/openapi-types@0.8.0': + resolution: {integrity: sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g==} + engines: {node: '>=22'} + + '@scalar/openapi-upgrader@0.2.6': + resolution: {integrity: sha512-pvEmfSCDNYR4+lygidUqfo+shzyp4OSh9+UgK110rzA8Oot6WbJBM03Fuq3M255G7G6R9iXyfsebB7MBUocPkw==} + engines: {node: '>=22'} + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sinclair/typebox@0.34.49': resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -2051,6 +2365,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -2442,6 +2764,9 @@ packages: resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} engines: {node: '>= 6'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -2674,6 +2999,14 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -2728,6 +3061,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2861,6 +3199,10 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + exit-x@0.2.2: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} @@ -2890,6 +3232,10 @@ packages: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2927,6 +3273,10 @@ packages: feedsmith@2.9.3: resolution: {integrity: sha512-H2Dj/gax2p61HszgUdhORg4Wtpfz9wu6w6fhloEWovcx2xF9+QzzNJvHisn4Vr2yoozjQpH75pq2OLf9/JY8Gg==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2951,6 +3301,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-up@8.0.0: + resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==} + engines: {node: '>=20'} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -2993,6 +3347,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} @@ -3057,10 +3415,17 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + giget@3.2.0: resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} hasBin: true @@ -3101,6 +3466,10 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globby@16.1.0: + resolution: {integrity: sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==} + engines: {node: '>=20'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3191,6 +3560,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -3341,6 +3714,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -3370,6 +3747,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -3386,6 +3767,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -3631,6 +4016,10 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3642,6 +4031,10 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + leven@4.1.0: + resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3723,6 +4116,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + lint-staged@16.4.0: resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} engines: {node: '>=20.17'} @@ -3748,6 +4144,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@8.0.0: + resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==} + engines: {node: '>=20'} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -3794,6 +4194,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -3810,6 +4213,10 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3865,6 +4272,9 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -4111,6 +4521,10 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} @@ -4180,6 +4594,16 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + orval@8.8.1: + resolution: {integrity: sha512-iOW+AoFd5SzGNqBx9gjedH+eS26UwmJ5897KOCLsZ8W9kCz70I8qUlk1rOEVq+EXrcQNyMEELSLEdXKxjUe2tQ==} + engines: {node: '>=22.18.0'} + hasBin: true + peerDependencies: + prettier: '>=3.0.0' + peerDependenciesMeta: + prettier: + optional: true + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -4192,6 +4616,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -4200,6 +4628,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -4218,6 +4650,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -4241,6 +4677,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -4439,6 +4879,10 @@ packages: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + prisma@7.8.0: resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -4465,6 +4909,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4552,6 +5000,9 @@ packages: remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + remeda@2.33.7: + resolution: {integrity: sha512-cXlyjevWx5AcslOUEETG4o8XYi9UkoCXcJmj7XhPFVbla+ITuOBxv6ijBrmbeg+ZhzmDThkNdO+iXKUfrJep1w==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4572,6 +5023,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@2.0.0-next.6: resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} engines: {node: '>= 0.4'} @@ -4719,6 +5173,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -4851,6 +5309,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4901,6 +5363,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swagger-ui-dist@5.32.4: + resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==} + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -5054,6 +5519,16 @@ packages: '@swc/wasm': optional: true + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsconfig-paths-webpack-plugin@4.2.0: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} @@ -5065,6 +5540,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo@2.9.6: resolution: {integrity: sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg==} hasBin: true @@ -5119,6 +5599,25 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typedoc-plugin-coverage@4.0.3: + resolution: {integrity: sha512-baim3wyMkqpX7rBzL/6iZ7wzKJuSr9ffP16RHOsdTUNoHUZeXLIZHSUBtUhXmNHaUNRgfqdmKLBwyggbJjGdeQ==} + engines: {node: '>= 18'} + peerDependencies: + typedoc: 0.28.x + + typedoc-plugin-markdown@4.11.0: + resolution: {integrity: sha512-2iunh2ALyfyh204OF7h2u0kuQ84xB3jFZtFyUr01nThJkLvR8oGGSSDlyt2gyO4kXhvUxDcVbO0y43+qX+wFbw==} + engines: {node: '>= 18'} + peerDependencies: + typedoc: 0.28.x + + typedoc@0.28.19: + resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + typescript-eslint@8.59.0: resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5136,6 +5635,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -5159,6 +5661,14 @@ packages: undici-types@7.25.0: resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -5384,10 +5894,18 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.3: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} @@ -5637,59 +6155,141 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@borewit/text-codec@0.2.2': {} + '@borewit/text-codec@0.2.2': {} + + '@colors/colors@1.5.0': + optional: true + + '@commander-js/extra-typings@14.0.0(commander@14.0.3)': + dependencies: + commander: 14.0.3 + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true - '@colors/colors@1.5.0': + '@esbuild/linux-ppc64@0.27.7': optional: true - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/linux-riscv64@0.27.7': + optional: true - '@csstools/color-helpers@5.1.0': {} + '@esbuild/linux-s390x@0.27.7': + optional: true - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 + '@esbuild/linux-x64@0.27.7': + optional: true - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 + '@esbuild/netbsd-arm64@0.27.7': + optional: true - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-tokenizer': 3.0.4 + '@esbuild/netbsd-x64@0.27.7': + optional: true - '@csstools/css-tokenizer@3.0.4': {} + '@esbuild/openbsd-arm64@0.27.7': + optional: true - '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': - dependencies: - '@electric-sql/pglite': 0.4.1 + '@esbuild/openbsd-x64@0.27.7': + optional: true - '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': - dependencies: - '@electric-sql/pglite': 0.4.1 + '@esbuild/openharmony-arm64@0.27.7': + optional: true - '@electric-sql/pglite@0.4.1': {} + '@esbuild/sunos-x64@0.27.7': + optional: true - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 + '@esbuild/win32-arm64@0.27.7': optional: true - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 + '@esbuild/win32-ia32@0.27.7': optional: true - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 + '@esbuild/win32-x64@0.27.7': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': @@ -5743,6 +6343,14 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@hono/node-server@1.19.11(hono@4.12.15)': dependencies: hono: 4.12.15 @@ -6240,6 +6848,8 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@microsoft/tsdoc@0.16.0': {} + '@mixmark-io/domino@2.2.0': {} '@mozilla/readability@0.6.0': {} @@ -6312,6 +6922,11 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + '@nestjs/platform-express@11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)': dependencies: '@nestjs/common': 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -6350,6 +6965,18 @@ snapshots: transitivePeerDependencies: - chokidar + '@nestjs/swagger@11.4.1(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.4 + '@nestjs/testing@11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19)': dependencies: '@nestjs/common': 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -6408,6 +7035,115 @@ snapshots: dependencies: consola: 3.4.2 + '@orval/angular@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + + '@orval/axios@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + + '@orval/core@8.8.1(typescript@6.0.3)': + dependencies: + '@scalar/openapi-types': 0.6.1 + acorn: 8.16.0 + compare-versions: 6.1.1 + debug: 4.4.3 + esbuild: 0.27.7 + esutils: 2.0.3 + fs-extra: 11.3.4 + globby: 16.1.0 + jiti: 2.6.1 + remeda: 2.33.7 + typedoc: 0.28.19(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@orval/fetch@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + '@scalar/openapi-types': 0.6.1 + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + + '@orval/hono@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + '@orval/zod': 8.8.1(typescript@6.0.3) + fs-extra: 11.3.4 + remeda: 2.33.7 + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + + '@orval/mcp@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + '@orval/fetch': 8.8.1(typescript@6.0.3) + '@orval/zod': 8.8.1(typescript@6.0.3) + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + + '@orval/mock@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + remeda: 2.33.7 + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + + '@orval/query@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + '@orval/fetch': 8.8.1(typescript@6.0.3) + remeda: 2.33.7 + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + + '@orval/solid-start@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + '@scalar/openapi-types': 0.6.1 + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + + '@orval/swr@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + '@orval/fetch': 8.8.1(typescript@6.0.3) + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + + '@orval/zod@8.8.1(typescript@6.0.3)': + dependencies: + '@orval/core': 8.8.1(typescript@6.0.3) + remeda: 2.33.7 + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -6648,8 +7384,65 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@scalar/helpers@0.5.2': {} + + '@scalar/json-magic@0.12.8': + dependencies: + '@scalar/helpers': 0.5.2 + pathe: 2.0.3 + yaml: 2.8.3 + + '@scalar/openapi-parser@0.25.12': + dependencies: + '@scalar/helpers': 0.5.2 + '@scalar/json-magic': 0.12.8 + '@scalar/openapi-types': 0.8.0 + '@scalar/openapi-upgrader': 0.2.6 + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + ajv-formats: 3.0.1(ajv@8.18.0) + jsonpointer: 5.0.1 + leven: 4.1.0 + yaml: 2.8.3 + + '@scalar/openapi-types@0.6.1': + dependencies: + zod: 4.3.6 + + '@scalar/openapi-types@0.8.0': {} + + '@scalar/openapi-upgrader@0.2.6': + dependencies: + '@scalar/openapi-types': 0.8.0 + + '@scarf/scarf@1.4.0': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sinclair/typebox@0.34.49': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -7198,6 +7991,10 @@ snapshots: agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -7614,6 +8411,8 @@ snapshots: array-timsort: 1.0.3 esprima: 4.0.1 + compare-versions@6.1.1: {} + component-emitter@1.3.1: {} concat-map@0.0.1: {} @@ -7804,6 +8603,13 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@4.5.0: {} + entities@6.0.1: {} entities@7.0.1: {} @@ -7919,6 +8725,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -8088,6 +8923,21 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + exit-x@0.2.2: {} expect@30.3.0: @@ -8150,6 +9000,14 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -8186,6 +9044,10 @@ snapshots: entities: 7.0.1 fast-xml-parser: 5.7.1 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -8224,6 +9086,11 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@8.0.0: + dependencies: + locate-path: 8.0.0 + unicorn-magic: 0.3.0 + flat-cache@4.0.1: dependencies: flatted: 3.4.2 @@ -8281,6 +9148,12 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-monkey@1.1.0: {} fs.realpath@1.0.0: {} @@ -8340,12 +9213,21 @@ snapshots: get-stream@6.0.1: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 get-intrinsic: 1.3.0 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + giget@3.2.0: {} glob-parent@5.1.2: @@ -8391,6 +9273,15 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globby@16.1.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -8496,6 +9387,8 @@ snapshots: human-signals@2.1.0: {} + human-signals@8.0.1: {} + husky@9.1.7: {} iconv-lite@0.6.3: @@ -8633,6 +9526,8 @@ snapshots: is-number@7.0.0: {} + is-path-inside@4.0.0: {} + is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} @@ -8656,6 +9551,8 @@ snapshots: is-stream@2.0.1: {} + is-stream@4.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -8673,6 +9570,8 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unicode-supported@2.1.0: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -9125,6 +10024,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonpointer@5.0.1: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -9138,6 +10039,8 @@ snapshots: leven@3.1.0: {} + leven@4.1.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -9194,6 +10097,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + lint-staged@16.4.0: dependencies: commander: 14.0.3 @@ -9224,6 +10131,10 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@8.0.0: + dependencies: + p-locate: 6.0.0 + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} @@ -9265,6 +10176,8 @@ snapshots: dependencies: react: 19.2.5 + lunr@2.3.9: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -9283,6 +10196,15 @@ snapshots: dependencies: tmpl: 1.0.5 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -9442,6 +10364,8 @@ snapshots: mdn-data@2.27.1: {} + mdurl@2.0.0: {} + media-typer@0.3.0: {} media-typer@1.1.0: {} @@ -9725,7 +10649,7 @@ snapshots: neo-async@2.6.2: {} - next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.4(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@next/env': 16.2.4 '@swc/helpers': 0.5.15 @@ -9734,7 +10658,7 @@ snapshots: postcss: 8.4.31 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) + styled-jsx: 5.1.6(react@19.2.5) optionalDependencies: '@next/swc-darwin-arm64': 16.2.4 '@next/swc-darwin-x64': 16.2.4 @@ -9773,6 +10697,11 @@ snapshots: dependencies: path-key: 3.1.1 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + nwsapi@2.2.23: {} object-assign@4.1.1: {} @@ -9855,6 +10784,44 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + orval@8.8.1(prettier@3.8.3)(typescript@6.0.3): + dependencies: + '@commander-js/extra-typings': 14.0.0(commander@14.0.3) + '@orval/angular': 8.8.1(typescript@6.0.3) + '@orval/axios': 8.8.1(typescript@6.0.3) + '@orval/core': 8.8.1(typescript@6.0.3) + '@orval/fetch': 8.8.1(typescript@6.0.3) + '@orval/hono': 8.8.1(typescript@6.0.3) + '@orval/mcp': 8.8.1(typescript@6.0.3) + '@orval/mock': 8.8.1(typescript@6.0.3) + '@orval/query': 8.8.1(typescript@6.0.3) + '@orval/solid-start': 8.8.1(typescript@6.0.3) + '@orval/swr': 8.8.1(typescript@6.0.3) + '@orval/zod': 8.8.1(typescript@6.0.3) + '@scalar/json-magic': 0.12.8 + '@scalar/openapi-parser': 0.25.12 + '@scalar/openapi-types': 0.6.1 + chokidar: 5.0.0 + commander: 14.0.3 + enquirer: 2.4.1 + execa: 9.6.1 + find-up: 8.0.0 + fs-extra: 11.3.4 + jiti: 2.6.1 + js-yaml: 4.1.1 + remeda: 2.33.7 + string-argv: 0.3.2 + tsconfck: 3.1.6(typescript@6.0.3) + typedoc: 0.28.19(typescript@6.0.3) + typedoc-plugin-coverage: 4.0.3(typedoc@0.28.19(typescript@6.0.3)) + typedoc-plugin-markdown: 4.11.0(typedoc@0.28.19(typescript@6.0.3)) + optionalDependencies: + prettier: 3.8.3 + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + - typescript + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -9869,6 +10836,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -9877,6 +10848,10 @@ snapshots: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -9902,6 +10877,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-ms@4.0.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -9916,6 +10893,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -10041,6 +11020,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3): dependencies: '@prisma/config': 7.8.0 @@ -10077,6 +11060,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -10200,6 +11185,8 @@ snapshots: remeda@2.33.4: {} + remeda@2.33.7: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -10212,6 +11199,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@2.0.0-next.6: dependencies: es-errors: 1.3.0 @@ -10430,6 +11419,8 @@ snapshots: slash@3.0.0: {} + slash@5.1.0: {} + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 @@ -10578,6 +11569,8 @@ snapshots: strip-final-newline@2.0.0: {} + strip-final-newline@4.0.0: {} + strip-json-comments@3.1.1: {} strnum@2.2.3: {} @@ -10594,12 +11587,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5): + styled-jsx@5.1.6(react@19.2.5): dependencies: client-only: 0.0.1 react: 19.2.5 - optionalDependencies: - '@babel/core': 7.29.0 superagent@10.3.0: dependencies: @@ -10633,6 +11624,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swagger-ui-dist@5.32.4: + dependencies: + '@scarf/scarf': 1.4.0 + symbol-observable@4.0.0: {} symbol-tree@3.2.4: {} @@ -10761,6 +11756,10 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + tsconfck@3.1.6(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -10776,6 +11775,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + turbo@2.9.6: optionalDependencies: '@turbo/darwin-64': 2.9.6 @@ -10847,6 +11853,23 @@ snapshots: typedarray@0.0.6: {} + typedoc-plugin-coverage@4.0.3(typedoc@0.28.19(typescript@6.0.3)): + dependencies: + typedoc: 0.28.19(typescript@6.0.3) + + typedoc-plugin-markdown@4.11.0(typedoc@0.28.19(typescript@6.0.3)): + dependencies: + typedoc: 0.28.19(typescript@6.0.3) + + typedoc@0.28.19(typescript@6.0.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.1.1 + minimatch: 10.2.5 + typescript: 6.0.3 + yaml: 2.8.3 + typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) @@ -10862,6 +11885,8 @@ snapshots: typescript@6.0.3: {} + uc.micro@2.1.0: {} + uglify-js@3.19.3: optional: true @@ -10882,6 +11907,10 @@ snapshots: undici-types@7.25.0: {} + unicorn-magic@0.3.0: {} + + unicorn-magic@0.4.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -11159,8 +12188,12 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yoctocolors-cjs@2.1.3: {} + yoctocolors@2.1.2: {} + zeptomatch@2.1.0: dependencies: grammex: 3.1.12 diff --git a/turbo.json b/turbo.json index b2cc252..cb4352a 100644 --- a/turbo.json +++ b/turbo.json @@ -34,6 +34,9 @@ "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, + "contract:refresh": { + "cache": false + }, "lint": { "dependsOn": ["^build", "^lint"], "outputs": [] From 0c9b54c23f4de659fffa63b6324bdd60fe35a1ac Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 15:56:26 +0800 Subject: [PATCH 04/11] fix(api-contract): keep generated clients out of hooks Route generated API contract files away from formatting hooks so lint-staged can skip them cleanly. Refresh the generated client to match the new routing behavior. --- lint-staged.config.mjs | 28 ++- .../api-contract/src/generated/api-client.ts | 212 ++++++++++-------- 2 files changed, 140 insertions(+), 100 deletions(-) diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index dbfc5b5..37f3675 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -39,6 +39,13 @@ const toWorkspaceRelativePath = (file) => { const isIgnoredByLintRouting = (file) => rootScopedIgnoredLintPrefixes.some((prefix) => file.startsWith(prefix)); +/** + * Keep generated files out of formatting routing as well. + * @param {string} file + * @returns {boolean} + */ +const isIgnoredByFormattingRouting = (file) => isIgnoredByLintRouting(file); + /** * Discover package directories that own their own ESLint config. * @returns {string[]} @@ -137,6 +144,23 @@ const runPackageEslint = (packageDir, files) => { ]; }; +/** + * Build a prettier command only when there are files left to format. + * @param {string[]} files + * @returns {string[]} + */ +const runPrettier = (files) => { + const formattedFiles = files + .map(toWorkspaceRelativePath) + .filter((file) => !isIgnoredByFormattingRouting(file)); + + if (formattedFiles.length === 0) { + return []; + } + + return [`prettier --write ${formattedFiles.map(quote).join(" ")}`]; +}; + export default { /** * @param {string[]} files @@ -163,7 +187,7 @@ export default { } return [ - `prettier --write ${normalizedFiles.map(quote).join(" ")}`, + ...runPrettier(normalizedFiles), ...runRootEslint(rootFiles), ...Array.from(packageFiles.entries()).flatMap( ([packageDir, packageDirFiles]) => @@ -171,5 +195,5 @@ export default { ), ]; }, - [prettierOnlyPattern]: ["prettier --write"], + [prettierOnlyPattern]: (files) => runPrettier(files), }; diff --git a/packages/api-contract/src/generated/api-client.ts b/packages/api-contract/src/generated/api-client.ts index 07013c8..3521062 100644 --- a/packages/api-contract/src/generated/api-client.ts +++ b/packages/api-contract/src/generated/api-client.ts @@ -55,157 +55,173 @@ export interface HealthReadyResponseDto { } export type articlesResponse200 = { - data: ArticleListItemDto[]; - status: 200; -}; + data: ArticleListItemDto[] + status: 200 +} -export type articlesResponseSuccess = articlesResponse200 & { +export type articlesResponseSuccess = (articlesResponse200) & { headers: Headers; }; -export type articlesResponse = articlesResponseSuccess; +; -const parseResponseBody = (body: string | null, fallback: T): T => - body ? (JSON.parse(body) as unknown as T) : fallback; +export type articlesResponse = (articlesResponseSuccess) export const getArticlesUrl = () => { - return `/articles`; -}; -export const articles = async ( - options?: RequestInit, -): Promise => { - const res = await fetch(getArticlesUrl(), { + + + + return `/articles` +} + +export const articles = async ( options?: RequestInit): Promise => { + + const res = await fetch(getArticlesUrl(), + { ...options, - method: "GET", - }); + method: 'GET' + + + } +) + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - const data = parseResponseBody(body, []); - return { data, status: res.status, headers: res.headers } as articlesResponse; -}; + const data: articlesResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as articlesResponse +} + + export type articleByIdResponse200 = { - data: ArticleDetailItemDto; - status: 200; -}; + data: ArticleDetailItemDto + status: 200 +} export type articleByIdResponse404 = { - data: undefined; - status: 404; -}; + data: void + status: 404 +} -export type articleByIdResponseSuccess = articleByIdResponse200 & { +export type articleByIdResponseSuccess = (articleByIdResponse200) & { headers: Headers; }; -export type articleByIdResponseError = articleByIdResponse404 & { +export type articleByIdResponseError = (articleByIdResponse404) & { headers: Headers; }; -export type articleByIdResponse = - | articleByIdResponseSuccess - | articleByIdResponseError; +export type articleByIdResponse = (articleByIdResponseSuccess | articleByIdResponseError) -export const getArticleByIdUrl = (id: string) => { - return `/articles/${id}`; -}; +export const getArticleByIdUrl = (id: string,) => { + + + + + return `/articles/${id}` +} -export const articleById = async ( - id: string, - options?: RequestInit, -): Promise => { - const res = await fetch(getArticleByIdUrl(id), { +export const articleById = async (id: string, options?: RequestInit): Promise => { + + const res = await fetch(getArticleByIdUrl(id), + { ...options, - method: "GET", - }); + method: 'GET' + + + } +) + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - const data = parseResponseBody(body, {} as ArticleDetailItemDto); - return { - data, - status: res.status, - headers: res.headers, - } as articleByIdResponse; -}; + const data: articleByIdResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as articleByIdResponse +} + + export type healthLiveResponse200 = { - data: HealthLiveResponseDto; - status: 200; -}; + data: HealthLiveResponseDto + status: 200 +} -export type healthLiveResponseSuccess = healthLiveResponse200 & { +export type healthLiveResponseSuccess = (healthLiveResponse200) & { headers: Headers; }; -export type healthLiveResponse = healthLiveResponseSuccess; +; + +export type healthLiveResponse = (healthLiveResponseSuccess) export const getHealthLiveUrl = () => { - return `/health/live`; -}; -export const healthLive = async ( - options?: RequestInit, -): Promise => { - const res = await fetch(getHealthLiveUrl(), { + + + + return `/health/live` +} + +export const healthLive = async ( options?: RequestInit): Promise => { + + const res = await fetch(getHealthLiveUrl(), + { ...options, - method: "GET", - }); + method: 'GET' + + + } +) + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - const data = parseResponseBody( - body, - {} as HealthLiveResponseDto, - ); - return { - data, - status: res.status, - headers: res.headers, - } as healthLiveResponse; -}; + const data: healthLiveResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as healthLiveResponse +} + + export type healthReadyResponse200 = { - data: HealthReadyResponseDto; - status: 200; -}; + data: HealthReadyResponseDto + status: 200 +} export type healthReadyResponse503 = { - data: HealthReadyResponseDto; - status: 503; -}; + data: HealthReadyResponseDto + status: 503 +} -export type healthReadyResponseSuccess = healthReadyResponse200 & { +export type healthReadyResponseSuccess = (healthReadyResponse200) & { headers: Headers; }; -export type healthReadyResponseError = healthReadyResponse503 & { +export type healthReadyResponseError = (healthReadyResponse503) & { headers: Headers; }; -export type healthReadyResponse = - | healthReadyResponseSuccess - | healthReadyResponseError; +export type healthReadyResponse = (healthReadyResponseSuccess | healthReadyResponseError) export const getHealthReadyUrl = () => { - return `/health/ready`; -}; -export const healthReady = async ( - options?: RequestInit, -): Promise => { - const res = await fetch(getHealthReadyUrl(), { + + + + return `/health/ready` +} + +export const healthReady = async ( options?: RequestInit): Promise => { + + const res = await fetch(getHealthReadyUrl(), + { ...options, - method: "GET", - }); + method: 'GET' + + + } +) + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - const data = parseResponseBody( - body, - {} as HealthReadyResponseDto, - ); - return { - data, - status: res.status, - headers: res.headers, - } as healthReadyResponse; -}; + const data: healthReadyResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as healthReadyResponse +} From a212a03c5d60d1c44198f7e4a93e5640ba92ba4b Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 16:11:47 +0800 Subject: [PATCH 05/11] docs(api-contract): align README terminology with summaryError Use the current `summaryError` field name in both app READMEs so the published contract matches the OpenAPI source of truth. --- apps/api/README.md | 6 +++--- apps/web/README.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/README.md b/apps/api/README.md index f2b686d..f48e51b 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -102,7 +102,7 @@ inside the container instead of the app-local default `./feeds.opml`. - Prepared summaries use the official `openai` SDK against the configured OpenAI-compatible gateway, validate the structured result with `zod`, and persist canonical Markdown, `translatedTitle`, and any terminal failure reason - in `summaryErrorReason`. + in `summaryError`. - Same-process sleep/freeze recovery is allowed to trigger a background feed auto-refresh in the future, but `INGEST_ON_BOOT` remains startup-only; the wake interval is an elapsed-hours check, not a cron schedule. @@ -132,14 +132,14 @@ inside the container instead of the app-local default `./feeds.opml`. - `sourceTitle` - `publishedAt` - `summary` - - `summaryErrorReason` + - `summaryError` - `originalUrl` - Returns `404` for unknown article IDs. Article body markdown stays internal in this slice. The public `GET /articles` and `GET /articles/:id` payloads expose the original `title`, the prepared `translatedTitle`, the canonical Markdown `summary`, and any persisted -`summaryErrorReason`, but they still never expose `contentMarkdown` or +`summaryError`, but they still never expose `contentMarkdown` or `contentExtractedAt`. ## Validation diff --git a/apps/web/README.md b/apps/web/README.md index 561fcf6..a34b243 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -12,7 +12,7 @@ The current reader contract is summary-first: `title` when the prepared translation is still missing - the detail pane renders canonical Markdown summary content directly - an empty prepared `summary` renders either the persisted - `summaryErrorReason` or the pending-state copy `Summary pending` while + `summaryError` or the pending-state copy `Summary pending` while keeping the rest of the reader chrome visible The checked-in OpenAPI document is the source of truth for that contract. The From 78571d3c966f5afa8d688da7b856f919f5ee372a Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 16:25:50 +0800 Subject: [PATCH 06/11] chore(agents): add plan completion GitHub Actions check --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 62172bc..6d2a39d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,11 @@ Do not add new category unless both language trees intentionally expand. - For `apps/api` NestJS implementation work, invoke `nestjs-best-practices`. - For `apps/api` backend architecture, follow `.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md` and organize by feature modules. Prefer self-contained feature folders grouping controllers, services, DTOs, entities, repositories, module defs. Avoid repo-wide tech-layer folders unless deeper scoped rule overrides. +## Plan Completion Checks + +- After finishing a plan, check whether GitHub Actions needs updates; if it + does, update the relevant workflow files in the same work. + ## Git Hook Discipline - Never bypass Git hooks or hook-time checks with flags that suppress warnings, From bccc9b2652b3cd7000bff8b919ee1fa85b8b7e5b Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 17:00:17 +0800 Subject: [PATCH 07/11] chore(prettier): ignore generated OpenAPI outputs --- .prettierignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierignore b/.prettierignore index 1033c36..1a0af73 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,5 +4,6 @@ .codex/ .omx/ .turbo/ +packages/api-contract/src/generated/ pnpm-lock.yaml tmp/ From b7bdaaf10a2eda3f684cb209ce5acd37d16ec103 Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 18:00:05 +0800 Subject: [PATCH 08/11] chore: stop tracking generated API client output Generated client artifacts should stay local and be ignored by git. This keeps the repo clean while preserving the source generator workflow. --- .gitignore | 1 + .../api-contract/src/generated/api-client.ts | 227 ------------------ 2 files changed, 1 insertion(+), 227 deletions(-) delete mode 100644 packages/api-contract/src/generated/api-client.ts diff --git a/.gitignore b/.gitignore index 035dd4a..6350b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ dist .swc/ *.tsbuildinfo apps/api/src/generated/ +packages/api-contract/src/generated/ # Tool caches .eslintcache diff --git a/packages/api-contract/src/generated/api-client.ts b/packages/api-contract/src/generated/api-client.ts deleted file mode 100644 index 3521062..0000000 --- a/packages/api-contract/src/generated/api-client.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Generated by orval v8.8.1 🍺 - * Do not edit manually. - * RSSift API - * OpenAPI contract for the RSSift API surface - * OpenAPI spec version: 0.1.0 - */ -export interface ArticleSummaryErrorDto { - action: string; - code: string; - copyText: string; - message: string; - title: string; -} - -export interface ArticleDetailItemDto { - originalUrl: string; - publishedAt: string; - sourceTitle: string; - summary: string; - /** @nullable */ - summaryError: ArticleSummaryErrorDto | null; - title: string; - translatedTitle: string; -} - -export interface ArticleListItemDto { - id: string; - originalUrl: string; - publishedAt: string; - sourceTitle: string; - title: string; - translatedTitle: string; -} - -export interface HealthLiveChecksDto { - application: string; -} - -export interface HealthLiveResponseDto { - checks: HealthLiveChecksDto; - service: string; - status: string; -} - -export interface HealthReadyChecksDto { - application: string; - database: string; -} - -export interface HealthReadyResponseDto { - checks: HealthReadyChecksDto; - service: string; - status: string; -} - -export type articlesResponse200 = { - data: ArticleListItemDto[] - status: 200 -} - -export type articlesResponseSuccess = (articlesResponse200) & { - headers: Headers; -}; -; - -export type articlesResponse = (articlesResponseSuccess) - -export const getArticlesUrl = () => { - - - - - return `/articles` -} - -export const articles = async ( options?: RequestInit): Promise => { - - const res = await fetch(getArticlesUrl(), - { - ...options, - method: 'GET' - - - } -) - - - const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - - const data: articlesResponse['data'] = body ? JSON.parse(body) : {} - return { data, status: res.status, headers: res.headers } as articlesResponse -} - - - -export type articleByIdResponse200 = { - data: ArticleDetailItemDto - status: 200 -} - -export type articleByIdResponse404 = { - data: void - status: 404 -} - -export type articleByIdResponseSuccess = (articleByIdResponse200) & { - headers: Headers; -}; -export type articleByIdResponseError = (articleByIdResponse404) & { - headers: Headers; -}; - -export type articleByIdResponse = (articleByIdResponseSuccess | articleByIdResponseError) - -export const getArticleByIdUrl = (id: string,) => { - - - - - return `/articles/${id}` -} - -export const articleById = async (id: string, options?: RequestInit): Promise => { - - const res = await fetch(getArticleByIdUrl(id), - { - ...options, - method: 'GET' - - - } -) - - - const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - - const data: articleByIdResponse['data'] = body ? JSON.parse(body) : {} - return { data, status: res.status, headers: res.headers } as articleByIdResponse -} - - - -export type healthLiveResponse200 = { - data: HealthLiveResponseDto - status: 200 -} - -export type healthLiveResponseSuccess = (healthLiveResponse200) & { - headers: Headers; -}; -; - -export type healthLiveResponse = (healthLiveResponseSuccess) - -export const getHealthLiveUrl = () => { - - - - - return `/health/live` -} - -export const healthLive = async ( options?: RequestInit): Promise => { - - const res = await fetch(getHealthLiveUrl(), - { - ...options, - method: 'GET' - - - } -) - - - const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - - const data: healthLiveResponse['data'] = body ? JSON.parse(body) : {} - return { data, status: res.status, headers: res.headers } as healthLiveResponse -} - - - -export type healthReadyResponse200 = { - data: HealthReadyResponseDto - status: 200 -} - -export type healthReadyResponse503 = { - data: HealthReadyResponseDto - status: 503 -} - -export type healthReadyResponseSuccess = (healthReadyResponse200) & { - headers: Headers; -}; -export type healthReadyResponseError = (healthReadyResponse503) & { - headers: Headers; -}; - -export type healthReadyResponse = (healthReadyResponseSuccess | healthReadyResponseError) - -export const getHealthReadyUrl = () => { - - - - - return `/health/ready` -} - -export const healthReady = async ( options?: RequestInit): Promise => { - - const res = await fetch(getHealthReadyUrl(), - { - ...options, - method: 'GET' - - - } -) - - - const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - - const data: healthReadyResponse['data'] = body ? JSON.parse(body) : {} - return { data, status: res.status, headers: res.headers } as healthReadyResponse -} From ff53e0455bfced75a2a4145c95cbbb4b0c54d61c Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 19:05:42 +0800 Subject: [PATCH 09/11] fix(api-contract): keep contract refresh portable Refresh the checked-in OpenAPI artifact from a real app module, keep the web adapter and generated client aligned on article IDs, and add fetch cleanup plus a default timeout for contract helpers. Normalize staged paths for lint-staged so the contract refresh flow stays reliable on Windows too. --- apps/api/e2e/openapi-contract.e2e-spec.ts | 46 ++++---- .../api/src/openapi/openapi-refresh.module.ts | 13 +++ apps/api/src/openapi/openapi-refresh.ts | 9 +- .../article-reader/api/articles-api.ts | 10 +- lint-staged.config.mjs | 12 +- packages/api-contract/src/index.ts | 109 +++++++++++++----- 6 files changed, 138 insertions(+), 61 deletions(-) create mode 100644 apps/api/src/openapi/openapi-refresh.module.ts diff --git a/apps/api/e2e/openapi-contract.e2e-spec.ts b/apps/api/e2e/openapi-contract.e2e-spec.ts index ff423ec..baf107e 100644 --- a/apps/api/e2e/openapi-contract.e2e-spec.ts +++ b/apps/api/e2e/openapi-contract.e2e-spec.ts @@ -5,34 +5,38 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import YAML from "yaml"; -import { ArticlesController } from "../src/articles/articles.controller"; +import { AppModule } from "../src/app.module"; import { ArticlesService } from "../src/articles/articles.service"; -import { HealthController } from "../src/health/health.controller"; import { PrismaService } from "../src/prisma/prisma.service"; import { createOpenApiDocument } from "../src/openapi/openapi-document"; +import { FeedBootstrapService } from "../src/feeds/feed-bootstrap.service"; describe("OpenAPI contract", () => { let app: INestApplication | undefined; beforeAll(async () => { + process.env["TEST_DATABASE_URL"] ??= + "postgresql://rssift:rssift@127.0.0.1:5432/rssift_test"; + process.env["DATABASE_URL"] = process.env["TEST_DATABASE_URL"]; + process.env["INGEST_ON_BOOT"] = "false"; + const moduleRef = await Test.createTestingModule({ - controllers: [ArticlesController, HealthController], - providers: [ - { - provide: ArticlesService, - useValue: { - getArticleById: jest.fn(), - getArticles: jest.fn(), - }, - }, - { - provide: PrismaService, - useValue: { - $queryRawUnsafe: jest.fn(), - }, - }, - ], - }).compile(); + imports: [AppModule], + }) + .overrideProvider(ArticlesService) + .useValue({ + getArticleById: jest.fn(), + getArticles: jest.fn(), + }) + .overrideProvider(PrismaService) + .useValue({ + $queryRawUnsafe: jest.fn(), + }) + .overrideProvider(FeedBootstrapService) + .useValue({ + onApplicationBootstrap: () => undefined, + }) + .compile(); app = moduleRef.createNestApplication(); await app.init(); @@ -49,8 +53,8 @@ describe("OpenAPI contract", () => { const checkedIn: unknown = YAML.parse( readFileSync( resolve( - process.cwd(), - "../../packages/api-contract/openapi/openapi.yaml", + __dirname, + "../../../packages/api-contract/openapi/openapi.yaml", ), "utf8", ), diff --git a/apps/api/src/openapi/openapi-refresh.module.ts b/apps/api/src/openapi/openapi-refresh.module.ts new file mode 100644 index 0000000..75afdcd --- /dev/null +++ b/apps/api/src/openapi/openapi-refresh.module.ts @@ -0,0 +1,13 @@ + +import { Module } from "@nestjs/common"; + +import { ArticlesModule } from "../articles/articles.module"; +import { FeedBootstrapService } from "../feeds/feed-bootstrap.service"; +import { HealthModule } from "../health/health.module"; +import { PrismaModule } from "../prisma/prisma.module"; + +@Module({ + imports: [ArticlesModule, HealthModule, PrismaModule], + providers: [FeedBootstrapService], +}) +export class OpenApiRefreshModule {} diff --git a/apps/api/src/openapi/openapi-refresh.ts b/apps/api/src/openapi/openapi-refresh.ts index 2f8ccb7..6471e56 100644 --- a/apps/api/src/openapi/openapi-refresh.ts +++ b/apps/api/src/openapi/openapi-refresh.ts @@ -3,9 +3,9 @@ import { dirname, resolve } from "node:path"; import YAML from "yaml"; import { Test } from "@nestjs/testing"; -import { AppModule } from "../app.module"; import { FeedBootstrapService } from "../feeds/feed-bootstrap.service"; import { createOpenApiDocument } from "./openapi-document"; +import { OpenApiRefreshModule } from "./openapi-refresh.module"; const contractFilePath = resolve( __dirname, @@ -14,7 +14,7 @@ const contractFilePath = resolve( export async function refreshOpenApiContract() { const moduleRef = await Test.createTestingModule({ - imports: [AppModule], + imports: [OpenApiRefreshModule], }) .overrideProvider(FeedBootstrapService) .useValue({ @@ -40,5 +40,8 @@ export async function refreshOpenApiContract() { } if (require.main === module) { - void refreshOpenApiContract(); + void refreshOpenApiContract().catch((error: unknown) => { + console.error(error); + process.exit(1); + }); } diff --git a/apps/web/src/widgets/article-reader/api/articles-api.ts b/apps/web/src/widgets/article-reader/api/articles-api.ts index d38b2f0..f6636a8 100644 --- a/apps/web/src/widgets/article-reader/api/articles-api.ts +++ b/apps/web/src/widgets/article-reader/api/articles-api.ts @@ -44,13 +44,9 @@ export async function fetchArticles() { } export async function fetchArticleDetail(articleId: string) { - const response = await getArticleById( - getApiBaseUrl(), - encodeURIComponent(articleId), - { - cache: "no-store", - }, - ); + const response = await getArticleById(getApiBaseUrl(), articleId, { + cache: "no-store", + }); if (response.status === 404) { return null; diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index 37f3675..bcb7d54 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -23,12 +23,14 @@ const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`; * @param {string} file * @returns {string} */ +const normalizePathSeparators = (file) => file.replace(/\\/g, "/"); + const toWorkspaceRelativePath = (file) => { if (path.isAbsolute(file)) { - return path.relative(workspaceRoot, file); + return normalizePathSeparators(path.relative(workspaceRoot, file)); } - return file; + return normalizePathSeparators(file); }; /** @@ -136,7 +138,11 @@ const runPackageEslint = (packageDir, files) => { const packageAbsoluteDir = path.join(workspaceRoot, packageDir); const packageRelativeFiles = lintableFiles.map((file) => - quote(path.relative(packageAbsoluteDir, path.join(workspaceRoot, file))), + quote( + normalizePathSeparators( + path.relative(packageAbsoluteDir, path.join(workspaceRoot, file)), + ), + ), ); return [ diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index f16c20e..24d9c2e 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -27,6 +27,8 @@ export type ApiResponse = { status: number; }; +const DEFAULT_FETCH_TIMEOUT_MS = 10_000; + export { articleById, articles, @@ -38,19 +40,63 @@ export { healthReady, }; +type ManagedFetch = { + cleanup: () => void; + signal: AbortSignal; +}; + +function createManagedFetchSignal( + signal?: AbortSignal | null, + timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, +): ManagedFetch { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + const onAbort = () => { + controller.abort(); + }; + + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + return { + cleanup: () => { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + }, + signal: controller.signal, + }; +} + async function fetchJson( url: string, init?: RequestInit, ): Promise> { - const response = await fetch(url, init); - const data = response.body - ? ((await response.json()) as T) - : (undefined as unknown as T); + const { cleanup, signal } = createManagedFetchSignal(init?.signal); - return { - data, - status: response.status, - }; + try { + const response = await fetch(url, { + ...init, + signal, + }); + const data = response.body + ? ((await response.json()) as T) + : (undefined as unknown as T); + + return { + data, + status: response.status, + }; + } finally { + cleanup(); + } } export async function getArticles( @@ -68,32 +114,41 @@ export async function getArticles( export async function getArticleById( apiBaseUrl: string, - encodedArticleId: string, + articleId: string, init?: RequestInit, ): Promise> { - const response = await fetch( - new URL(`/articles/${encodedArticleId}`, apiBaseUrl).toString(), - { - ...init, - method: "GET", - }, - ); + const { cleanup, signal } = createManagedFetchSignal(init?.signal); + try { + const response = await fetch( + new URL( + getArticleByIdUrl(encodeURIComponent(articleId)), + apiBaseUrl, + ).toString(), + { + ...init, + signal, + method: "GET", + }, + ); + + if (response.status === 404) { + return { + data: null, + status: response.status, + }; + } + + const data = response.body + ? ((await response.json()) as ArticleDetailItemDto) + : (undefined as unknown as ArticleDetailItemDto); - if (response.status === 404) { return { - data: null, + data, status: response.status, }; + } finally { + cleanup(); } - - const data = response.body - ? ((await response.json()) as ArticleDetailItemDto) - : (undefined as unknown as ArticleDetailItemDto); - - return { - data, - status: response.status, - }; } export async function getHealthLive( From 1266a9f5efcc40bf76c081c87dcc36fde7ecdb3c Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 20:27:22 +0800 Subject: [PATCH 10/11] docs(plans): mark openapi contract units complete --- ...01-feat-web-api-openapi-contract-first-plan.md | 15 ++++++++++++--- ...01-feat-web-api-openapi-contract-first-plan.md | 6 +++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md b/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md index e25cb0c..5fdfd70 100644 --- a/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md +++ b/docs/en/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md @@ -149,6 +149,15 @@ cannot move without the contract moving with it. - Whether to expose a public Swagger route: no; the checked-in artifact is the contract. +### Deferred to Implementation + +- The exact generated file names and export names inside + `packages/api-contract`. +- Whether the API document helper is only for the refresh script, reused by the + e2e drift test, or shared by both. +- The exact shape of the thin web adapter, as long as it still owns + `API_BASE_URL` and 404 handling. + ## High-Level Technical Design > This illustrates the intended approach and is directional guidance for review, @@ -168,7 +177,7 @@ flowchart LR ## Implementation Units -- [ ] **Unit 1: Publish the shared contract package** +- [x] **Unit 1: Publish the shared contract package** **Goal:** Create the repo-owned OpenAPI package and the checked-in canonical API contract that the web app can consume. @@ -230,7 +239,7 @@ boundary clean; do not recreate a parallel handwritten web contract. - A single checked-in contract artifact exists, the generated client builds from it, and the repo-level refresh flow is discoverable. -- [ ] **Unit 2: Add API emission and drift validation** +- [x] **Unit 2: Add API emission and drift validation** **Goal:** Make `apps/api` emit the full public OpenAPI document from the real Nest implementation and fail when the checked-in contract diverges. @@ -296,7 +305,7 @@ make the controller metadata satisfy that contract. - The API test suite can regenerate the contract document and detect mismatches against the checked-in YAML before the change merges. -- [ ] **Unit 3: Swap the web seam to the generated contract** +- [x] **Unit 3: Swap the web seam to the generated contract** **Goal:** Remove the handwritten article-client contract from `apps/web` and keep the reader behavior intact through the generated package. diff --git a/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md b/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md index 00ba635..1064022 100644 --- a/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md +++ b/docs/zh-Hans/plans/2026-04-24-001-feat-web-api-openapi-contract-first-plan.md @@ -173,7 +173,7 @@ flowchart LR ## Implementation Units -- [ ] **Unit 1: 发布共享合同 package** +- [x] **Unit 1: 发布共享合同 package** **Goal:** 创建仓库自有的 OpenAPI package,以及 web 可以消费的、检查入库 的 canonical API contract。 @@ -232,7 +232,7 @@ flowchart LR - 仓库里只有一份可审查的合同文件,生成后的 client 可以从它构建, 并且 refresh 流程对开发者是可发现的。 -- [ ] **Unit 2: 增加 API emit 和 drift validation** +- [x] **Unit 2: 增加 API emit 和 drift validation** **Goal:** 让 `apps/api` 从真实 Nest 实现里 emit 全部公开 HTTP OpenAPI 文档,并在 checked-in contract 偏离时失败。 @@ -295,7 +295,7 @@ mapping。 - API 测试套件可以重新生成合同文档,并在 merge 前抓住它和 checked-in YAML 的不一致。 -- [ ] **Unit 3: 把 web seam 切到生成后的合同** +- [x] **Unit 3: 把 web seam 切到生成后的合同** **Goal:** 移除 `apps/web` 里手写的 article-client contract,并通过生成 package 保持 reader 行为不变。 From ba178a301dbfe9ed8960d471ae9868b09dd70919 Mon Sep 17 00:00:00 2001 From: sommio Date: Sat, 25 Apr 2026 21:04:11 +0800 Subject: [PATCH 11/11] chore(openapi): tighten contract lint and guidance Narrow the generated contract lint scope, keep the OpenAPI refresh\nmodule clean, and align repo guidance with the current workflow.\n\nAlso relax linting for the generated API client that is intentionally\nchecked in under packages/api-contract. --- AGENTS.md | 41 +++++++++---------- .../api/src/openapi/openapi-refresh.module.ts | 1 - packages/eslint-config/base.js | 14 ++++++- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6d2a39d..cca0325 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,28 +3,29 @@ ## Repository Structure Repo = Turborepo monorepo. -Preserve monorepo shape. Do not break workspace layout, package boundaries, shared config, task graph, repo conventions. +Keep monorepo shape. +No break workspace layout, package boundaries, shared config, task graph, repo conventions. Follow canonical Turborepo layout in `.agents/skills/turborepo/references/best-practices/RULE.md`. Keep deployable apps in `apps/`. Keep shared libs + shared config in `packages/`. No nested packages. -Do not move package responsibilities into repo root. +No move package responsibility to repo root. ## Documentation Language Policy All durable docs stay synced in Chinese + English. -- Rule covers all docs under `docs/`: plans, brainstorms, solutions, other long-lived docs. -- Chinese docs live in `docs/zh-Hans/`. -- English docs live in `docs/en/`. +- Rule covers all docs under `docs/`: plans, brainstorms, solutions, other long-life docs. +- Chinese docs in `docs/zh-Hans/`. +- English docs in `docs/en/`. - If one language doc exists, matching other-language doc must exist. -- Both versions stay semantically synced. Update both in same work. No drift. +- Both versions stay same meaning. Update both in same work. No drift. ## Repository Docs Convention Use language-scoped layout: `docs/en/` = English, `docs/zh-Hans/` = Simplified Chinese. -Place each doc in matching category dir for both languages. +Put each doc in matching category dir for both languages. - Brainstorms in `docs/{lang}/brainstorms/` - requirements, ideas, options, early framing. - Plans in `docs/{lang}/plans/` - implementation plans, milestones, delivery order, progress tracking. @@ -40,8 +41,8 @@ Use closest category below. - `integration-issues/` - project integrations, generated outputs, external platform behavior mismatch; cross-platform issues; third-party API/service mismatch. - `workflow-issues/` - agent workflow patterns, skill design, orchestration improvements, repo process decisions, repeatable execution guidance. -If none fits perfectly, use closest existing category. -Do not add new category unless both language trees intentionally expand. +If none fits perfect, use closest existing category. +No add new category unless both language trees expand on purpose. ## Skill-Level Conventions @@ -52,9 +53,9 @@ Do not add new category unless both language trees intentionally expand. - For `apps/web` frontend architecture work, also reference `feature-sliced-design`. - When `next-best-practices` and `feature-sliced-design` overlap, use rules below: - Next.js owns framework entry semantics + special files. Keep root `app/` as App Router entry. Put required files like `layout.tsx`, `page.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`, `template.tsx`, `default.tsx`, `route.ts` there when Next.js requires. - - FSD owns business structure inside `src/`, but do **not** create `src/pages` in `apps/web`. Next.js treats `src/pages` as Pages Router root; this conflicts with root App Router `app/`. Adapt FSD around framework rule. - - In `apps/web`, prefer `src/app`, `src/widgets`, `src/features`, `src/entities`, `src/shared` for business structure. If page-scoped business slice needed, keep under `src/app` or `src/widgets` with route-aligned name. Do not add `src/pages`. - - Keep root `app/` thin: route entrypoints, top-level providers, metadata wiring, route handlers, minimal bridge code only. Do not make root `app/` main home for reusable business slices. + - FSD owns business structure inside `src/`, but do **not** create `src/pages` in `apps/web`. Next.js treats `src/pages` as Pages Router root; this conflicts with root App Router `app/`. Bend FSD around framework rule. + - In `apps/web`, prefer `src/app`, `src/widgets`, `src/features`, `src/entities`, `src/shared` for business structure. If page-scoped business slice needed, keep under `src/app` or `src/widgets` with route-aligned name. No add `src/pages`. + - Keep root `app/` thin: route entrypoints, top-level providers, metadata wiring, route handlers, minimal bridge code only. No make root `app/` main home for reusable business slices. - Follow `next-best-practices` first for RSC boundaries, Server vs Client Components, Server Actions, route handlers, metadata, async Next.js APIs, runtime constraints. - Follow `feature-sliced-design` first for slice boundaries, public API usage, import direction, Pages First decomposition inside business layer. - If FSD placement conflicts with required Next.js convention, keep Next.js convention and bend FSD around it. `src/pages` vs root `app/` = explicit example. @@ -63,15 +64,13 @@ Do not add new category unless both language trees intentionally expand. ## Plan Completion Checks -- After finishing a plan, check whether GitHub Actions needs updates; if it - does, update the relevant workflow files in the same work. +- After finishing a plan, check if GitHub Actions need updates; if yes, update relevant workflow files in same work. +- Before handoff, run full repo validation from root: `pnpm lint`, `pnpm format:check`, `pnpm typecheck`, `pnpm test`, and `pnpm test:e2e`. +- If any command fails, fix root issue first and rerun full set before closing turn. ## Git Hook Discipline -- Never bypass Git hooks or hook-time checks with flags that suppress warnings, - ignore files, or otherwise hide a failure. -- If a hook or staged check fails, fix the underlying config or code first. - Do not use `--no-warn-ignored`, `--quiet`, or similar skip-style workarounds - to make the hook pass. -- Keep hook behavior honest: a passing commit or push should mean the check - actually ran and succeeded, not that it was silenced. +- Never bypass Git hooks or hook-time checks with flags that suppress warnings, ignore files, or hide failure. +- If a hook or staged check fails, fix underlying config or code first. + Do not use `--no-warn-ignored`, `--quiet`, or similar skip-style workarounds to make hook pass. +- Keep hook behavior honest: passing commit or push means check really ran and passed, not got silenced. diff --git a/apps/api/src/openapi/openapi-refresh.module.ts b/apps/api/src/openapi/openapi-refresh.module.ts index 75afdcd..7460f05 100644 --- a/apps/api/src/openapi/openapi-refresh.module.ts +++ b/apps/api/src/openapi/openapi-refresh.module.ts @@ -1,4 +1,3 @@ - import { Module } from "@nestjs/common"; import { ArticlesModule } from "../articles/articles.module"; diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 3912665..f87a722 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -11,7 +11,7 @@ export const baseConfig = defineConfig( "**/node_modules/**", "**/.next/**", "**/dist/**", - "**/src/generated/**", + "apps/api/src/generated/**", "**/coverage/**", "**/.turbo/**", "**/playwright-report/**", @@ -71,6 +71,18 @@ export const baseConfig = defineConfig( "@typescript-eslint/restrict-template-expressions": "off", }, }, + { + files: ["packages/api-contract/src/generated/api-client.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-invalid-void-type": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + }, + }, eslintConfigPrettier, );