Batch 1 (dispatch now): rect-scoring-perf, node-runtime-resources, home-sidebar-rerender, api-disk-cache-retention, report-reference-perf, consult-prep-parallel, frontend-dead-code. Batch 2 (queued on predecessors): api-chart-subprocess-gate, answer-effort-ab, consult-route-split, pl9-render-split, python-hygiene, dasha-unify, api-server-service-extract, data-layer-types, contracts-helpers-dedupe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
16 KiB
TASK · 数据访问层改用自己的 DataClient 类型(第一阶段)(issue #待定)
- 日期:2026-10-13
- 来源:「2026-10-13 全仓屎山与性能排查」· 数据层冒充 SupabaseClient 条目
- 执行方:Grok(Windows + Docker),分支
codex/data-layer-types-20261013,只推这个分支 - 验收:Claude(Linux)
- BUG 编号:本单发现 1 个真实缺陷(§2.2,
rpc(...).single()在自托管运行时必抛 TypeError),编号 BUG-1343(写作时最大号 BUG-1317,1323~1342 已被同批任务书占用;开工时核对docs/BUG_HISTORY.md最大号)。其余为纯类型重构,记 PROGRESS。(另:TASK-upstream-sync7-20261014占 BUG-1318~1322,本批号段整体从 BUG-1323 起) - 开工前置:
node-runtime-resources已合入origin/staging(两单都改frontend/src/lib/db/local-postgres-client-core.ts)。第二批。
1. 基线
- 基线 commit:
origin/staging77057a819(写作时);开工时以合入node-runtime-resources之后的origin/staging为准。 - 涉及文件:
frontend/src/lib/supabase/server.ts::createServerSupabaseClient(第 22 行as unknown as SupabaseClient)frontend/src/lib/supabase/admin-client-core.ts::createAdminSupabaseClient(第 21 行同样的 cast)frontend/src/lib/db/local-postgres-client-core.ts(771 行):LocalPostgresQueryBuilder、LocalPostgresDataClient- 显式
import type { SupabaseClient } from "@supabase/supabase-js"的 9 个文件:rectification-in-chat-server.ts、birth-time-journey-case-loader.ts、birth-time-journey-turn-persistence.ts、birth-time-scoring-job-store.ts、birth-time-journey-store.ts、rectification-agentic/v9/case-service.ts、rectification-agentic/v9/product-domain.ts、mastra/rectification-v9-tools.ts、rectification-agentic/session.ts(后者由frontend-dead-code删除,本单开工时应已不存在) - 其余 55 个调用
create(Server|Admin)SupabaseClient的文件靠ReturnType<typeof ...>推导类型,不需要逐个改。
2. 事故实证
2.1 cast 掩盖适配层缺口(历史上已反复出事)
运行时(AUTH_PROVIDER=self-hosted,staging 与生产均如此)拿到的是 LocalPostgresDataClient,它只实现了 Supabase 查询构造器的一小部分:
- 客户端:
auth.getUser、from、rpc(rpc是async,返回 Promise,不是构造器) - 构造器:
select insert upsert update delete eq neq gt lte like in is not(eq|is|cs) or order limit abortSignal single maybeSingle then
但类型被 as unknown as SupabaseClient 冒充成完整的 supabase-js 客户端,调用方用到未实现的能力时编译器不报,运行时才炸。历史同类事故:
| BUG | 缺口 | 发现方式 |
|---|---|---|
| BUG-124 | 关联 select、分页、计数、Admin Auth | staging 后台降级 |
| BUG-130 | nested select、缺 lte/like |
self-hosted 支付查询失败 |
| BUG-576 | upsert 缺 onConflict 直接抛错 |
staging 报告三次重试 failed |
| BUG-926 | order() 只保留最后一次 |
会话列表排序错 |
2.2 实测:把 cast 目标换成真实接口后编译器立刻暴露的问题
写作时在 scratchpad 拷贝的 frontend/(基线 77057a819,借用同版本 node_modules)做了试验:新建 DataClient 接口(只声明上面列出的方法),两个工厂返回 DataClient,9 个文件的类型 import 换成 DataClient,LocalPostgresDataClient implements DataClient、构造器 implements DataQueryBuilder。基线 tsc --noEmit 0 错。
| 方案 | data 字段类型 |
tsc 错误数 | 说明 |
|---|---|---|---|
| U | unknown |
122(14 个文件) | 110 条是「对 {}/unknown 取属性」——调用方当前依赖 supabase-js any 推导,属于类型收紧,不是运行缺陷 |
| A | any(与现状 SupabaseClient<any> 等价) |
2 | 见下 |
方案 A 的 2 条:
- 真实缺陷:
src/lib/birth-time-scoring-job-store.ts::createSupabaseScoringJobStore.claimScoringJob(约 94–103 行)写supabase.rpc("claim_birth_time_scoring_job", {...}).single()。本地适配层rpc返回 Promise,没有.single→ 自托管下必抛TypeError: ... .single is not a function。该函数是returns table (claim_state text, algorithm_version text)(frontend/supabase/migrations/20260718030000_birth_time_scoring_job_lifecycle.sql),本地适配层对返回集合的函数给data = rows[]。可达路径:POST /api/birth-time-journeytype: "poll_scoring"→birth-time-scoring-service.ts::pollScoringJob(仅legacy-guided-v1存量 Case 的评分轮询)。现有测试用内存 store(tests/birth-time-scoring-memory-store.ts),从未经过真实适配层,所以没被发现。 src/app/api/chart-profiles/route.ts:58(data ?? []).map((row) => ...)隐式 any——标注row: unknown即可(函数体本来就row as Record<string, unknown>),不是缺陷。
另核实:src/ 中 .select("...") 字面量的列全部是简单标识符(0 个关联/重命名列);.not() 运算符只用到 cs;未发现 gte/lt/range/ilike/contains 调用。auth.getUser() 的调用方只读 id/email。
试验复现(在 Grok 自己的分支上照做即可,不需要 scratchpad):做完 T1 后跑 tsc --noEmit,应恰好出现上面 2 条。
3. 根因
07~08 月从 Supabase 迁到自托管 PostgreSQL 时,为了让 58 个调用方零改动,写了一个模拟 Supabase 查询构造器的适配层,再用 as unknown as SupabaseClient 让类型「看起来一样」。之后每次调用方用到适配层没实现的写法,都要到 staging 运行时才发现(§2.1 四条 BUG)。不是回归,是迁移时的权宜之计没有收口。
4. 决策记录
- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。
- 第一阶段只做类型:定义
DataClient(只声明适配层真实实现的方法)、把 cast 目标换成它、让适配层implements它,从而让编译器替我们找缺口。函数改名(createServerSupabaseClient→createServerDataClient等)、data字段收紧、依赖移除全部留到后续阶段。 data字段第一阶段用方案 A(与现状等价的宽类型),不得为了收紧类型去改 14 个文件的业务代码(CLAUDE.md:不为迎合编译器改业务代码);方案 U 的 122 条错误清单写进 PROGRESS,作为第二阶段输入。- Supabase 分支(
AUTH_PROVIDER非self-hosted时createServerClient/createClient)是迁移的回滚资产(AGENTS.md §1:保留到最终对账),本单不删,只在该分支返回处同样 cast 为DataClient。删除需产品拍板,见后续阶段。 @supabase/ssr、@supabase/supabase-js、supabaseCLI 依赖:本单不动(浏览器端email-otp-login.tsx仍用createBrowserSupabaseClient().auth.signInWithOtp/verifyOtp,并非零引用)。- §2.2 第 1 条缺陷在本单修(最小修复),占一个 BUG 号。
- 本单不属于 P1/P2 性能项,不做 A/B;运行时行为除缺陷修复外零变化,用「转译产物差分」证明(T3)。
5. 硬红线
- 除
birth-time-scoring-job-store.ts缺陷修复与chart-profiles/route.ts一个参数标注外,不改任何运行时代码;所有改动必须是类型层(import type、类型注解、implements、as DataClient)。 DataClient/DataQueryBuilder只能声明适配层真实实现的方法与参数形状;不得为了让编译通过而在接口里加适配层没有的方法,也不得给适配层补新能力(补能力 = 后续阶段,另立单)。- 不删 Supabase 回滚分支;不改
AUTH_PROVIDER判断;不删依赖、不升级依赖。 - 不改数据库结构与迁移;
frontend/src/app/(app)/page.tsx不增长;scripts/jyotish_api_server.py不碰;不改.gitea/workflows/**。 - 与
node-runtime-resources在local-postgres-client-core.ts上的改动不得冲突回退:开工先确认其已合入,只在类声明行加implements与顶部加import type。 - 改既有断言写「原值 / 新值 / 原因」三栏;fixture 不得手造响应形状。
- 动了数据访问层:Grok 必须跑
npm run test:db。
6. 任务分解
T1 · 定义 DataClient 并替换 cast(Grok 自跑 + Claude 复验)
- 新建
frontend/src/lib/db/data-client.ts(无server-only,纯类型):DataQueryError = Readonly<{ message: string; code?: string }>DataQueryResult = Readonly<{ data: UntypedData; error: DataQueryError | null; count?: number | null }>;UntypedData定义为any,上一行写// eslint-disable-next-line @typescript-eslint/no-explicit-any -- phase 1: equals SupabaseClient<any>; phase 2 narrows per table(这是全仓唯一允许的一处)。DataQueryBuilder extends PromiseLike<DataQueryResult>:逐个声明 §2.1 列出的方法,签名照抄LocalPostgresQueryBuilder(single()/maybeSingle()返回Promise<DataQueryResult>)。DataClient:auth.getUser(): Promise<{ data: { user: { id: string; email?: string | null } | null }; error: unknown }>、from(table: string): DataQueryBuilder、rpc(fn: string, args?: Readonly<Record<string, unknown>>): Promise<DataQueryResult>。
server.ts:返回类型Promise<DataClient>;本地分支as DataClient(不再需要unknown);Supabase 分支as unknown as DataClient,注释写明「回滚分支,运行时形状是超集」。admin-client-core.ts同理,返回DataClient。local-postgres-client-core.ts:class LocalPostgresQueryBuilder implements DataQueryBuilder、class LocalPostgresDataClient implements DataClient。- 8 个文件(session.ts 已删)
import type { SupabaseClient } from "@supabase/supabase-js"改为import type { DataClient } from "@/lib/db/data-client",并把文件内类型名SupabaseClient换成DataClient(含type AccountingClient = ...)。 src/lib/rectification-agentic/v9/tool-service.ts第 47 行注释「SupabaseClient satisfies this」改为 DataClient(只改注释)。- 验收:
tsc --noEmit恰好报 §2.2 的 2 条(把输出贴进 PROGRESS),然后进入 T2。grep -rn "as unknown as SupabaseClient" frontend/src无结果;grep -rln "@supabase/supabase-js" frontend/src只剩admin-client-core.ts(createClient回滚分支)。
T2 · 修缺陷与标注(本单 BUG)
birth-time-scoring-job-store.ts::claimScoringJob:去掉.single(),取const row = Array.isArray(data) ? data[0] ?? null : data;再交给claimSchema.safeParse(row)。数组与单对象都兼容,回滚分支(supabase-js 不带.single()时返回数组)行为一致。- 回归测试(新增,放
frontend/tests/database-*.test.ts让它进test:db):用startPostgresFixture+createLocalPostgresDataClient(service_role)跑真实迁移,建一个虚构 legacy Case 与 scoring job,调用createSupabaseScoringJobStore(client).claimScoringJob(...),断言返回{ kind, algorithmVersion }且不抛错;去掉修复时该用例必须红(PROGRESS 记录红→绿)。若建 Case 的前置数据过重,退而写一个直接client.rpc("claim_birth_time_scoring_job", …)断言Array.isArray(data)的用例,并在claimScoringJob上用最小 fake(rpc返回数组)做单元测试——两者都写,DB 用例以 Grok 结果为准。 chart-profiles/route.ts:58:(row: unknown) =>。- 验收:
tsc --noEmit0 错;npm run lint0 error;新测试通过;npm run test:db失败清单与tools/agent-loop/windows-known-failures.txt逐条一致。
T3 · 运行时零变化证明(差分)
- 对 T1 改过的每个文件(除 T2 两个文件),用 esbuild 转译新旧两版并比对:
cd frontend
for f in $(git diff --name-only origin/staging -- src | grep -v -e birth-time-scoring-job-store -e chart-profiles/route -e data-client.ts); do
git show origin/staging:frontend/$f > /tmp/old.ts 2>/dev/null || continue
./node_modules/.bin/esbuild /tmp/old.ts --loader=ts --format=esm > /tmp/old.js
./node_modules/.bin/esbuild $f --loader=ts --format=esm > /tmp/new.js
cmp -s /tmp/old.js /tmp/new.js && echo "SAME $f" || echo "DIFF $f"
done
(Windows 下用 Git Bash,临时文件放系统临时目录,不入库。)
- 验收:全部
SAME;任何DIFF必须在 PROGRESS 逐条解释(例:仅注释差异)。
T4 · 第二阶段输入清单(只写 PROGRESS,不改代码)
- 临时把
UntypedData换成unknown跑一次tsc --noEmit,把错误按文件计数贴进 PROGRESS(写作时:consult/route.ts27、account/route.ts24、consult/status/route.ts16、payment/epay/status/route.ts12、epay/availability.ts7、v9/product-domain.ts5、sessions/route.ts4、v9/agent-route-request.ts3、chart-profiles/route.ts2、chart-profiles/[id]/route.ts2、sessions/[id]/route.ts1、account/avatar/route.ts1,另含session-title-guard.ts::GuardedTitleClient与 DataClient 不兼容 1 条),然后改回any。
T5 · 记录
docs/BUG_HISTORY.md新号:「自托管下旧版校正评分轮询rpc(...).single()必抛 TypeError」,影响面、触发条件(legacy-guided-v1 Case 的poll_scoring)、根因(cast 冒充 + 内存 store 测试绕开真实适配层)、修复、验证、防复发(DataClient由适配层implements,新增调用写法必须先过编译器;评分 store 有 DB 级用例),关联 BUG-124/130/576/926。docs/tasks/PROGRESS-data-layer-types-20261013.md:T1 tsc 输出、T3 差分结果、T4 清单、test:db比对。CHANGELOG.md:一行(旧版校正评分轮询报错修复);不涉及 UI,DESIGN.md 不改。
7. 让步顺序
T4 → T3 可先只做 server.ts / admin-client-core.ts / local-postgres-client-core.ts 三个文件的差分。T1、T2 不得砍(T2 是缺陷修复)。
8. 验收命令分工
| 谁 | 命令 |
|---|---|
| Grok 自跑 | tsc --noEmit、npm run lint、新增/改到的测试单跑、npm run test:db(以 Grok 为准,按 windows-known-failures.txt 比对,多一条即未通过;Docker 未开写「未跑」)、T3 差分脚本 |
| Claude 验收 | 上述(test:db 除外,记环境缺口或用本机 PG17 替身复跑)+ 前端全量 npm test 与基线逐条比对 + next build(/ 仍 Static,首屏 gzip ±2%,预期不变)+ 复跑 T3 |
9. 开工前置命令
git fetch origin --prune
# 前置单已合入的核对(应能看到 node-runtime-resources 的提交)
git log origin/staging --oneline -30 | grep -i "node-runtime-resources" || echo "前置未合入,停止开工"
git worktree add -b codex/data-layer-types-20261013 .worktrees/data-layer-types-20261013 origin/staging
cd .worktrees/data-layer-types-20261013
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3
grep -rn "as unknown as SupabaseClient\|from \"@supabase/supabase-js\"" frontend/src
test -e frontend/src/lib/rectification-agentic/session.ts && echo "frontend-dead-code 尚未合入:session.ts 仍在,照常一并改类型"
10. 交付物
分支 codex/data-layer-types-20261013;frontend/src/lib/db/data-client.ts;PROGRESS;BUG_HISTORY 新号;CHANGELOG 一行;无 DESIGN.md 改动。
11. 后续阶段(本单不做)
- 第二阶段:
data按表收紧为unknown+ 每表行类型/zod 解析(T4 清单为输入);修GuardedTitleClient兼容。 - 函数改名:
createServerSupabaseClient→createServerDataClient、createAdminSupabaseClient→createAdminDataClient,lib/supabase/→lib/db/(纯搬移,58 个文件 import 变化)。 - 删 Supabase 回滚分支与
@supabase/supabase-js、supabaseCLI 依赖——需产品拍板(回滚资产保留期限见docs/operations/production-server-migration-2026-08.md);浏览器 OTP 登录仍用@supabase/ssr,需先确认 self-hosted 下该组件是否还可达。 - 适配层能力补齐(如需要
gte/lt/range)一律另立单,不在类型单里顺手加。 - 「数据库适配层往返」性能项(P1)在
node-runtime-resources或另立单中做 A/B,不在本单。