Files
Jyotisha/docs/tasks/TASK-data-layer-types-20261013.md
T
Jesse_ChenandClaude Opus 5.5 0be4cb299c docs(tasks): code-health + performance audit briefs, 16 tasks (BUG-1323 onward)
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
2026-10-14 00:34:35 +08:00

16 KiB
Raw Blame History

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/staging 77057a819(写作时);开工时以合入 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 条:

  1. 真实缺陷: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-journey type: "poll_scoring" → birth-time-scoring-service.ts::pollScoringJob(仅 legacy-guided-v1 存量 Case 的评分轮询)。现有测试用内存 store(tests/birth-time-scoring-memory-store.ts),从未经过真实适配层,所以没被发现。
  2. 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、supabase CLI 依赖:本单不动(浏览器端 email-otp-login.tsx 仍用 createBrowserSupabaseClient().auth.signInWithOtp/verifyOtp,并非零引用)。
  • §2.2 第 1 条缺陷在本单修(最小修复),占一个 BUG 号。
  • 本单不属于 P1/P2 性能项,不做 A/B;运行时行为除缺陷修复外零变化,用「转译产物差分」证明(T3)。

5. 硬红线

  1. 除 birth-time-scoring-job-store.ts 缺陷修复与 chart-profiles/route.ts 一个参数标注外,不改任何运行时代码;所有改动必须是类型层(import type、类型注解、implements、as DataClient)。
  2. DataClient / DataQueryBuilder 只能声明适配层真实实现的方法与参数形状;不得为了让编译通过而在接口里加适配层没有的方法,也不得给适配层补新能力(补能力 = 后续阶段,另立单)。
  3. 不删 Supabase 回滚分支;不改 AUTH_PROVIDER 判断;不删依赖、不升级依赖。
  4. 不改数据库结构与迁移;frontend/src/app/(app)/page.tsx 不增长;scripts/jyotish_api_server.py 不碰;不改 .gitea/workflows/**。
  5. 与 node-runtime-resources 在 local-postgres-client-core.ts 上的改动不得冲突回退:开工先确认其已合入,只在类声明行加 implements 与顶部加 import type。
  6. 改既有断言写「原值 / 新值 / 原因」三栏;fixture 不得手造响应形状。
  7. 动了数据访问层: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 --noEmit 0 错;npm run lint 0 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.ts 27、account/route.ts 24、consult/status/route.ts 16、payment/epay/status/route.ts 12、epay/availability.ts 7、v9/product-domain.ts 5、sessions/route.ts 4、v9/agent-route-request.ts 3、chart-profiles/route.ts 2、chart-profiles/[id]/route.ts 2、sessions/[id]/route.ts 1、account/avatar/route.ts 1,另含 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. 后续阶段(本单不做)

  1. 第二阶段:data 按表收紧为 unknown + 每表行类型/zod 解析(T4 清单为输入);修 GuardedTitleClient 兼容。
  2. 函数改名:createServerSupabaseClient → createServerDataClient、createAdminSupabaseClient → createAdminDataClient,lib/supabase/ → lib/db/(纯搬移,58 个文件 import 变化)。
  3. 删 Supabase 回滚分支与 @supabase/supabase-js、supabase CLI 依赖——需产品拍板(回滚资产保留期限见 docs/operations/production-server-migration-2026-08.md);浏览器 OTP 登录仍用 @supabase/ssr,需先确认 self-hosted 下该组件是否还可达。
  4. 适配层能力补齐(如需要 gte/lt/range)一律另立单,不在类型单里顺手加。
  5. 「数据库适配层往返」性能项(P1)在 node-runtime-resources 或另立单中做 A/B,不在本单。