# 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` 推导类型,**不需要逐个改**。 ## 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` 等价) | **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`),不是缺陷。 另核实:`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; phase 2 narrows per table`(这是全仓唯一允许的一处)。 - `DataQueryBuilder extends PromiseLike`:逐个声明 §2.1 列出的方法,签名照抄 `LocalPostgresQueryBuilder`(`single()`/`maybeSingle()` 返回 `Promise`)。 - `DataClient`:`auth.getUser(): Promise<{ data: { user: { id: string; email?: string | null } | null }; error: unknown }>`、`from(table: string): DataQueryBuilder`、`rpc(fn: string, args?: Readonly>): Promise`。 - `server.ts`:返回类型 `Promise`;本地分支 `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 转译新旧两版并比对: ```bash 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. 开工前置命令 ```bash 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,不在本单。