diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd1512c..41cf6150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - 本命盘和月亮盘的上升行现在有星宿、宿主和 pada。状态写「不适用」,不再是空横杠。分盘等表里居中的那几列,表头也居中。当前大运不再画左侧那条线,浅色底还在。已经打开过的星盘会重新计算。Skill 版本不变(BUG-1352、BUG-1353)。 +## 2026-10-15 — 旧版校正评分轮询不再中途报错(待合入 staging) + +- 旧版生时校正在查询评分进度时,会因为多读了一步数据库结果而直接失败。现在按数据库实际返回的那一行读取。其他查询行为不变。Skill 版本不变(BUG-1351)。 + ## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging) - 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码,网址末尾多一个斜杠也拒绝。迁移演练在主机上不再依赖本机 Node;演练库删不掉会明确失败。只多了文档时,回退不再被拒绝。Skill 版本不变(BUG-1348、BUG-1349)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 90582d0f..30a95ab1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36134,6 +36134,22 @@ - 复发自:无。 - 修复版本:分支 `codex/release-path-production-20261014`。编号接在 BUG-1349 之后。 +## BUG-1351 | 自托管下旧版校正评分轮询 rpc 结果多读一步,必抛 TypeError + +- 状态:resolved +- 首次发现:2026-10-13 +- 最近更新:2026-10-15 +- 影响面:旧版生时校正(legacy-guided-v1)的评分轮询,`POST /api/birth-time-journey` 且类型为 `poll_scoring` +- 用户现象:这类存量校正在查看评分进度时直接失败,页面拿不到「已领取 / 处理中 / 已完成」。 +- 触发条件:自托管数据库(当前 staging 与生产都是这条路径)上,对 `claim_birth_time_scoring_job` 的返回值再调用 `.single()`。该函数返回的是表格里的一行,本地适配层把整表收成数组,没有 `.single`。 +- 根因:数据访问层被标成完整的 Supabase 客户端,编译器看不出适配层没有 `.single`。原有测试用内存仓库,没有走过真实适配层。 +- 修复:去掉 `.single()`。返回值如果是数组就取第一行,如果已是单行对象就直接用,再做原来的字段校验。Supabase 回滚分支不带 `.single()` 时同样返回数组,行为一致。 +- 验证:修复前两条单元测试都抛出 `supabase.rpc(...).single is not a function`。修复后这两条通过。另有一条真实迁移测试:函数返回值是数组,认领结果为已领取,且不抛错。`tsc --noEmit` 0 错。 +- 防复发:适配层 `implements` 自己的 `DataClient`。新增调用如果用了适配层没有的方法,编译直接失败。评分认领有数据库级用例。 +- 相关记录:BUG-124、BUG-130、BUG-576、BUG-926 +- 复发自:无 +- 修复版本:分支 `codex/data-layer-types-20261013`。任务书预留 BUG-1343;开工时本文件最大号已是 BUG-1350,1343 没有条目。按「从当前最大号往后」编为 BUG-1351。 + ## BUG-1352 | 星盘页上升行的星宿三项被排盘接口丢掉 - 状态:resolved diff --git a/docs/tasks/PROGRESS-data-layer-types-20261013.md b/docs/tasks/PROGRESS-data-layer-types-20261013.md new file mode 100644 index 00000000..3fbf0f66 --- /dev/null +++ b/docs/tasks/PROGRESS-data-layer-types-20261013.md @@ -0,0 +1,92 @@ +# PROGRESS · 数据访问层 DataClient 类型(第一阶段) + +- 分支:`codex/data-layer-types-20261013` +- 基线:`origin/staging` `9274491681f6fcacbc87e3797441d2c5221de874` +- 前置:`origin/codex/node-runtime-resources-20261013` 已是 `origin/staging` 的祖先(`merge-base --is-ancestor` 退出码 0)。`local-postgres-client-core.ts` 只加了 `import type` 和两处 `implements`。 +- `frontend/src/lib/rectification-agentic/session.ts` 已不在,本单没有改它。 +- 编号:任务书预留 BUG-1343。开工时 `docs/BUG_HISTORY.md` 最大号是 BUG-1350,1343 没有条目。按台账规则从最大号往后,记为 **BUG-1351**。 + +## T1 · 编译器暴露的 2 条 + +`DataClient` 换上之后、缺陷尚未修时,`tsc --noEmit` 退出码 2,恰好 2 条: + +``` +src/app/api/chart-profiles/route.ts(59,40): error TS7006: Parameter 'row' implicitly has an 'any' type. +src/lib/birth-time-scoring-job-store.ts(103,10): error TS2339: Property 'single' does not exist on type 'Promise | null; count?: number | null | undefined; }>>'. +``` + +`grep`:`as unknown as SupabaseClient` 在 `frontend/src` 无结果。`@supabase/supabase-js` 只剩 `admin-client-core.ts` 的 `createClient` 回滚分支。 + +## T2 · 缺陷修复红转绿 + +修复前,`claimScoringJob` 对返回数组的假 `rpc` 调用 `.single()`: + +- `claimScoringJob reads the first row when rpc returns an array`:失败,`TypeError: supabase.rpc(...).single is not a function` +- `claimScoringJob also accepts the single-object rpc shape`:同样失败 + +去掉 `.single()` 并兼容数组与单对象之后,这两条通过。真实迁移用例 `claim_birth_time_scoring_job returns an array and claimScoringJob accepts it` 通过:`data` 是数组,第一行 `claim_state` 为 `claimed`,store 返回 `{ kind: "claimed", algorithmVersion }`。第一次启动测试库时迁移进程只留下笼统的 `database migration failed`(脚本会把非预期异常收成这一句),容器未到 Healthy。同一条用例在容器 Healthy 后重跑通过。 + +`chart-profiles/route.ts` 的 `row` 标为 `unknown`。 + +修完后 `tsc --noEmit` 退出码 0。`npm run lint` 退出码 0,0 error,123 warning(与改动前的存量警告数量一致,本单没有新增 warning)。 + +## T3 · 转译差分 + +类型层改动用 esbuild 0.28.1、`--format=esm`、`--tsconfig=frontend/tsconfig.json`,旧文件与新文件都按这份 tsconfig 转译。除 T2 的两个文件和新建的 `data-client.ts` 外,全部 `SAME`: + +- `src/lib/birth-time-journey-case-loader.ts` +- `src/lib/birth-time-journey-store.ts` +- `src/lib/birth-time-journey-turn-persistence.ts` +- `src/lib/db/local-postgres-client-core.ts` +- `src/lib/rectification-agentic/v9/case-service.ts` +- `src/lib/rectification-agentic/v9/product-domain.ts` +- `src/lib/rectification-agentic/v9/tool-service.ts`(只改了一句注释;转译后注释被去掉,产物相同) +- `src/lib/rectification-in-chat-server.ts` +- `src/lib/supabase/admin-client-core.ts` +- `src/lib/supabase/server.ts` +- `src/mastra/rectification-v9-tools.ts` + +任务书把旧文件写到系统临时目录再转译。esbuild 0.28.1 会沿文件所在目录寻找 `tsconfig.json`。临时目录找不到本仓库的 `tsconfig.json`(`target` 为 ES2017),类字段的转译结果会和仓库内文件不一致,即使源码只有类型或注释差别也会报 DIFF。两边都带上同一份 `--tsconfig` 后为 SAME。这不是运行时变化。 + +## T4 · 把 data 收成 unknown 的错误清单 + +临时把 `UntypedData` 改成 `unknown` 后 `tsc --noEmit` 退出码 2,共 93 条、12 个文件。随后已改回 `any`。 + +| 条数 | 文件 | +| ---: | --- | +| 24 | `src/app/api/account/route.ts` | +| 17 | `src/app/api/consult/route.ts`(其中 1 条是 `DataClient` 不能赋给 `GuardedTitleClient`,`consult/route.ts` 约 1055 行) | +| 16 | `src/app/api/consult/status/route.ts` | +| 12 | `src/app/api/payment/epay/status/route.ts` | +| 7 | `src/lib/epay/availability.ts` | +| 5 | `src/lib/rectification-agentic/v9/product-domain.ts` | +| 4 | `src/app/api/sessions/route.ts` | +| 3 | `src/lib/rectification-agentic/v9/agent-route-request.ts` | +| 2 | `src/app/api/chart-profiles/[id]/route.ts` | +| 1 | `src/app/api/account/avatar/route.ts` | +| 1 | `src/app/api/chart-profiles/route.ts` | +| 1 | `src/app/api/sessions/[id]/route.ts` | + +和任务书写作时的 122 条相比,`consult/route.ts` 少了 10 条,`chart-profiles/route.ts` 少了 1 条(本单已把 `row` 标成 `unknown`)。没有单独再出现一条 `session-title-guard.ts` 的错误,`GuardedTitleClient` 那条记在 `consult/route.ts` 里。这些留给第二阶段,本单没有改那些业务文件。 + +## test:db + +`npm run test:db`(`tsx --test --test-concurrency=1 tests/database-*.test.ts`)约 19 分钟,退出码 1。汇总:85 条,通过 72,失败 13,取消 0,跳过 0。 + +失败的 13 条与 `tools/agent-loop/windows-known-failures.txt` 逐条一致,没有多出来的失败: + +- staging backups are encrypted, atomic, private, and retain the newest three +- rejects destructive backup directory aliases and symlink components before mutation +- rejects unsafe writable backup parents before creating the target +- rejects a direct canonical sticky shared backup directory before chmod +- creates every absent backup path component privately despite a permissive caller umask +- same-second backups publish once without overwriting the completed archive +- find enumeration failures preserve existing backups and do not report completion +- refuses full disks and removes a failed-pipeline partial file +- database env validator accepts punctuated literal required secrets +- database env validator enforces an explicit staging owner uid without printing values +- database env validator accepts a private valid file without printing values +- personal report job migration is atomic, lease-bound, recoverable, and owner read-only +- personal report sections enforce owner-read RLS and service-owned durable transitions + +本单新增的 3 条(两条假客户端、一条真实迁移认领)都在通过的 72 条里。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index bd829596..6a5ab0fe 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -492,5 +492,5 @@ | `TASK-python-hygiene-20261013.md` | — | 免费层排队开关报反、静默吞异常、功能吉凶兜底合一、测试专用脚本归位(BUG-1340) | 待领取(排队:pl9-render-split) | `codex/python-hygiene-20261013` | | `TASK-dasha-unify-20261013.md` | — | /api/dasha 改走引擎同一套实现、删占位注册表、Ashtottari 出生余额(BUG-1341、1344) | 待领取(排队:python-hygiene) | `codex/dasha-unify-20261013` | | `TASK-api-server-service-extract-20261013.md` | — | API 单体第一阶段:拆出不带 HTTP 的计算类,生产伪造 handler 归零 | 待领取(排队:dasha-unify) | `codex/api-server-service-extract-20261013` | -| `TASK-data-layer-types-20261013.md` | — | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1343) | 待领取(排队:node-runtime-resources) | `codex/data-layer-types-20261013` | +| `TASK-data-layer-types-20261013.md` | `PROGRESS-data-layer-types-20261013.md` | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1351;任务书预留的 1343 已被更大编号越过) | 待验收 | `codex/data-layer-types-20261013` | | `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` | diff --git a/frontend/src/app/api/chart-profiles/route.ts b/frontend/src/app/api/chart-profiles/route.ts index 24eefec2..5df64576 100644 --- a/frontend/src/app/api/chart-profiles/route.ts +++ b/frontend/src/app/api/chart-profiles/route.ts @@ -56,7 +56,7 @@ export async function GET() { .order("updated_at", { ascending: false }); if (error) throw error; - const profiles = (data ?? []).map((row) => toClientProfile(row as Record)); + const profiles = (data ?? []).map((row: unknown) => toClientProfile(row as Record)); return NextResponse.json({ profiles, limit: MAX_SAVED_SUBJECTS, diff --git a/frontend/src/lib/birth-time-journey-case-loader.ts b/frontend/src/lib/birth-time-journey-case-loader.ts index b5b32463..9621ac50 100644 --- a/frontend/src/lib/birth-time-journey-case-loader.ts +++ b/frontend/src/lib/birth-time-journey-case-loader.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { z } from "zod"; import { parseRectificationQuestionnaire, @@ -34,7 +34,7 @@ export type JourneyLoadClient = { from(table: string): { select(columns: string): JourneyLoadQuery }; }; -export function createJourneyLoadClient(supabase: SupabaseClient): JourneyLoadClient { +export function createJourneyLoadClient(supabase: DataClient): JourneyLoadClient { return { from(table) { return { diff --git a/frontend/src/lib/birth-time-journey-store.ts b/frontend/src/lib/birth-time-journey-store.ts index 1f5571c8..44370ec1 100644 --- a/frontend/src/lib/birth-time-journey-store.ts +++ b/frontend/src/lib/birth-time-journey-store.ts @@ -1,6 +1,6 @@ import "server-only"; -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import type { BirthTimeJourneyStore, } from "./birth-time-journey-service.ts"; @@ -24,7 +24,7 @@ import { createDynamicScoringJobStore } from "./birth-time-dynamic-scoring-job-s export { BirthTimeJourneyStoreError } from "./birth-time-journey-turn-persistence.ts"; export function createSupabaseBirthTimeJourneyStore( - supabase: SupabaseClient, + supabase: DataClient, now: () => Date = () => new Date(), ): BirthTimeJourneyStore { const loadClient = createJourneyLoadClient(supabase); diff --git a/frontend/src/lib/birth-time-journey-turn-persistence.ts b/frontend/src/lib/birth-time-journey-turn-persistence.ts index 3ca50096..588a105a 100644 --- a/frontend/src/lib/birth-time-journey-turn-persistence.ts +++ b/frontend/src/lib/birth-time-journey-turn-persistence.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { z } from "zod"; import type { JourneySnapshot } from "./birth-time-journey.ts"; import type { LegacyStoredRectificationCase, StoredRectificationCase } from "./birth-time-journey-service.ts"; @@ -56,7 +56,7 @@ function canonicalAskedDomains(input: readonly EvidenceDomain[]): readonly Evide } export function createJourneyTurnPersistence( - supabase: SupabaseClient, + supabase: DataClient, loadCase: (userId: string, caseId: string) => Promise, ) { return { diff --git a/frontend/src/lib/birth-time-scoring-job-store.ts b/frontend/src/lib/birth-time-scoring-job-store.ts index 8fb36d79..3d13559c 100644 --- a/frontend/src/lib/birth-time-scoring-job-store.ts +++ b/frontend/src/lib/birth-time-scoring-job-store.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { z } from "zod"; import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts"; import type { @@ -58,7 +58,7 @@ async function requireReloaded( } export function createSupabaseScoringJobStore( - supabase: SupabaseClient, + supabase: DataClient, loadCase: (userId: string, caseId: string) => Promise, ): ScoringStoreMethods { return { @@ -91,18 +91,17 @@ export function createSupabaseScoringJobStore( }, async claimScoringJob(identity) { - const { data, error } = await supabase - .rpc("claim_birth_time_scoring_job", { - p_user_id: identity.userId, - p_case_id: identity.caseId, - p_job_id: identity.jobId, - p_evidence_fingerprint: identity.evidenceFingerprint, - p_algorithm_version: identity.algorithmVersion, - p_now: identity.now, - }) - .single(); + const { data, error } = await supabase.rpc("claim_birth_time_scoring_job", { + p_user_id: identity.userId, + p_case_id: identity.caseId, + p_job_id: identity.jobId, + p_evidence_fingerprint: identity.evidenceFingerprint, + p_algorithm_version: identity.algorithmVersion, + p_now: identity.now, + }); if (error) throw mapScoringRpcError(error.message, error.code); - const parsed = claimSchema.safeParse(data); + const row = Array.isArray(data) ? data[0] ?? null : data; + const parsed = claimSchema.safeParse(row); if (!parsed.success) throw new BirthTimeJourneyStoreError("load_case"); return { kind: parsed.data.claim_state, diff --git a/frontend/src/lib/db/data-client.ts b/frontend/src/lib/db/data-client.ts new file mode 100644 index 00000000..6b65cc63 --- /dev/null +++ b/frontend/src/lib/db/data-client.ts @@ -0,0 +1,46 @@ +export type DataQueryError = Readonly<{ message: string; code?: string }>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- phase 1: equals SupabaseClient; phase 2 narrows per table +type UntypedData = any; + +export type DataQueryResult = Readonly<{ + data: UntypedData; + error: DataQueryError | null; + count?: number | null; +}>; + +export interface DataQueryBuilder extends PromiseLike { + select(columns?: string): DataQueryBuilder; + insert(value: Record | readonly Record[]): DataQueryBuilder; + upsert( + value: Record | readonly Record[], + options?: { onConflict?: string }, + ): DataQueryBuilder; + update(values: Record): DataQueryBuilder; + delete(options?: { count?: string }): DataQueryBuilder; + eq(column: string, value: unknown): DataQueryBuilder; + neq(column: string, value: unknown): DataQueryBuilder; + gt(column: string, value: unknown): DataQueryBuilder; + lte(column: string, value: unknown): DataQueryBuilder; + like(column: string, value: unknown): DataQueryBuilder; + in(column: string, value: readonly unknown[]): DataQueryBuilder; + is(column: string, value: unknown): DataQueryBuilder; + not(column: string, operator: string, value: unknown): DataQueryBuilder; + or(expression: string): DataQueryBuilder; + order(column: string, options?: { ascending?: boolean }): DataQueryBuilder; + limit(value: number): DataQueryBuilder; + abortSignal(signal: AbortSignal): DataQueryBuilder; + single(): Promise; + maybeSingle(): Promise; +} + +export interface DataClient { + readonly auth: Readonly<{ + getUser: () => Promise<{ + data: { user: { id: string; email?: string | null } | null }; + error: unknown; + }>; + }>; + from(table: string): DataQueryBuilder; + rpc(fn: string, args?: Readonly>): Promise; +} diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index a0c2b5a9..4439cb81 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient } from "pg"; +import type { DataClient, DataQueryBuilder } from "./data-client"; type LocalIdentity = Readonly<{ id: string; email: string | null }> | null; export type LocalDatabaseRole = "authenticated" | "service_role"; @@ -457,7 +458,7 @@ export function queryValue(type: string | undefined, value: unknown): unknown { return value; } -class LocalPostgresQueryBuilder implements PromiseLike { +class LocalPostgresQueryBuilder implements DataQueryBuilder { private selectedColumns: string[] | null = null; private mutation: Mutation | null = null; private readonly filters: Filter[] = []; @@ -863,7 +864,7 @@ function castType(value: string): string { return value; } -export class LocalPostgresDataClient { +export class LocalPostgresDataClient implements DataClient { readonly auth: Readonly<{ getUser: () => Promise< Readonly<{ diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index db9daa26..37559fba 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -8,7 +8,7 @@ */ import { createHash } from "node:crypto"; import { requestCandidateIntervals, type DatedCandidateRange } from "../core/candidate-window.ts"; -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts"; import { declaredClockRange } from "../../declared-birth-window.ts"; import { @@ -43,7 +43,7 @@ import { type RectificationEntrySummary, } from "./open-request.ts"; -type AccountingClient = SupabaseClient; +type AccountingClient = DataClient; export type V9BaselineSnapshot = Readonly<{ birth_date: string; diff --git a/frontend/src/lib/rectification-agentic/v9/product-domain.ts b/frontend/src/lib/rectification-agentic/v9/product-domain.ts index 60e10495..d151cd4f 100644 --- a/frontend/src/lib/rectification-agentic/v9/product-domain.ts +++ b/frontend/src/lib/rectification-agentic/v9/product-domain.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { RectificationCaseServiceError } from "./case-service.ts"; import type { OpenRectificationCaseRequest } from "./open-request.ts"; @@ -17,7 +17,7 @@ const domainsBySessionTheme: Readonly> = { /** Only an owned persisted consultation is a product-domain source. */ export async function resolveRectificationProductDomain( - client: SupabaseClient, + client: DataClient, userId: string, request: OpenRectificationCaseRequest, ): Promise { diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 0e62ff60..92a4e5ff 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -44,7 +44,7 @@ import { decisionStateFingerprint } from "../core/decision-fingerprint"; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** - * Minimal structural RPC client. SupabaseClient satisfies this; tests can + * Minimal structural RPC client. DataClient satisfies this; tests can * pass a fake with a single rpc() method. */ export type RectificationRpcClient = { diff --git a/frontend/src/lib/rectification-in-chat-server.ts b/frontend/src/lib/rectification-in-chat-server.ts index 4ec8d784..2414a299 100644 --- a/frontend/src/lib/rectification-in-chat-server.ts +++ b/frontend/src/lib/rectification-in-chat-server.ts @@ -1,6 +1,6 @@ import "server-only"; -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { resolveFeaturePricing } from "@/lib/feature-pricing"; import { isProductEnabled } from "@/lib/product-access"; import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service"; @@ -27,7 +27,7 @@ import { /** C3: whether the offer card may be shown, decided by the server and never by the model. */ export async function decideRectificationOffer(input: { - accounting: SupabaseClient; + accounting: DataClient; userId: string; subjectRole: "self" | "other"; modelId: string; @@ -42,7 +42,7 @@ export async function decideRectificationOffer(input: { type SessionRow = { id: string; messages: unknown; session_type?: string | null }; -async function readConsultationSession(client: SupabaseClient, userId: string, sessionId: string): Promise { +async function readConsultationSession(client: DataClient, userId: string, sessionId: string): Promise { const { data, error } = await client .from("chat_sessions") .select("id,messages,session_type") @@ -54,7 +54,7 @@ async function readConsultationSession(client: SupabaseClient, userId: string, s return (row.session_type ?? "consultation") === "consultation" ? row : null; } -async function writeMessages(client: SupabaseClient, userId: string, sessionId: string, messages: unknown[]): Promise { +async function writeMessages(client: DataClient, userId: string, sessionId: string, messages: unknown[]): Promise { const { error } = await client .from("chat_sessions") .update({ messages }) @@ -71,7 +71,7 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId: * Does not touch consultation_requests. */ export async function persistOpenedDirectOffer(input: { - client: SupabaseClient; + client: DataClient; userId: string; sessionId: string; sentence: string; @@ -96,7 +96,7 @@ export async function persistOpenedDirectOffer(input: { /** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */ export async function bindRectificationSource(input: { - client: SupabaseClient; + client: DataClient; userId: string; sourceSessionId: string; caseId: string; @@ -118,7 +118,7 @@ const SOURCE_SEARCH_LIMIT = 20; * from history has none. */ export async function findRectificationSource( - client: SupabaseClient, + client: DataClient, userId: string, caseId: string, ): Promise { @@ -139,8 +139,8 @@ export async function findRectificationSource( * never from the browser. */ export async function returnToRectificationSource(input: { - client: SupabaseClient; - accounting: SupabaseClient; + client: DataClient; + accounting: DataClient; userId: string; caseId: string; now?: () => Date; diff --git a/frontend/src/lib/supabase/admin-client-core.ts b/frontend/src/lib/supabase/admin-client-core.ts index 2a462b90..d30291ba 100644 --- a/frontend/src/lib/supabase/admin-client-core.ts +++ b/frontend/src/lib/supabase/admin-client-core.ts @@ -1,11 +1,12 @@ -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { createClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client-core"; import { getSupabaseUrl, SupabaseConfigurationError, } from "./config"; -export function createAdminSupabaseClient() { +export function createAdminSupabaseClient(): DataClient { if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { const connectionString = process.env.SERVICE_DATABASE_URL?.trim(); if (!connectionString) { @@ -18,7 +19,7 @@ export function createAdminSupabaseClient() { connectionString, null, "service_role", - ) as unknown as SupabaseClient; + ) as DataClient; } const url = getSupabaseUrl(); const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; @@ -26,9 +27,10 @@ export function createAdminSupabaseClient() { throw new SupabaseConfigurationError(["SUPABASE_SERVICE_ROLE_KEY"]); } + // 回滚分支,运行时形状是超集 return createClient(url, serviceRoleKey, { auth: { autoRefreshToken: false, persistSession: false }, - }); + }) as unknown as DataClient; } export function isAdminEmail(email: string | null | undefined) { diff --git a/frontend/src/lib/supabase/server.ts b/frontend/src/lib/supabase/server.ts index 351603bd..c9c3fc38 100644 --- a/frontend/src/lib/supabase/server.ts +++ b/frontend/src/lib/supabase/server.ts @@ -1,8 +1,8 @@ import "server-only"; import { createServerClient } from "@supabase/ssr"; -import type { SupabaseClient } from "@supabase/supabase-js"; import { cookies, headers } from "next/headers"; +import type { DataClient } from "@/lib/db/data-client"; import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client"; import { readDatabaseUrl } from "@/lib/db/config"; import { getIdentityAuthServices } from "@/modules/identity/auth"; @@ -11,7 +11,7 @@ import { getSupabasePublicConfig } from "./config"; export async function createServerSupabaseClient(options?: { readonly disableCookieCache?: boolean; -}) { +}): Promise { if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { const requestHeaders = new Headers(await headers()); const session = await readIdentitySession( @@ -22,11 +22,12 @@ export async function createServerSupabaseClient(options?: { return createLocalPostgresDataClient( readDatabaseUrl(process.env, "APP_DATABASE_URL"), session ? { id: session.user.id, email: session.user.email } : null, - ) as unknown as SupabaseClient; + ) as DataClient; } const { url, anonKey } = getSupabasePublicConfig(); const cookieStore = await cookies(); + // 回滚分支,运行时形状是超集 return createServerClient(url, anonKey, { cookies: { getAll: () => cookieStore.getAll(), @@ -40,5 +41,5 @@ export async function createServerSupabaseClient(options?: { } }, }, - }); + }) as unknown as DataClient; } diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 41a66610..315c4aa9 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -12,7 +12,7 @@ import { windowIntervalsWidth } from "../lib/rectification-agentic/core/candidate-window.ts"; import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import type { PublicRectificationMethod } from "@/lib/rectification-agentic/v9/public-receipt"; import { canonicalToolInputFingerprint, @@ -196,7 +196,7 @@ export type RectificationV9Context = Readonly<{ turnId: string; attemptId?: string; userMessage?: string | null; - accounting: SupabaseClient; + accounting: DataClient; engineBase?: string; decision?: RectificationDecision; productDelivery?: import("../lib/rectification-agentic/v9/product-delivery.ts").ProductDelivery | null; diff --git a/frontend/tests/database-scoring-job-claim-shape.test.ts b/frontend/tests/database-scoring-job-claim-shape.test.ts new file mode 100644 index 00000000..367a7bf3 --- /dev/null +++ b/frontend/tests/database-scoring-job-claim-shape.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + closeLocalPostgresDataPools, + createLocalPostgresDataClient, +} from "../src/lib/db/local-postgres-client-core.ts"; +import type { DataClient } from "../src/lib/db/data-client.ts"; +import { birthTimeScoringAlgorithmVersion } from "../src/lib/birth-time-scoring-job.ts"; +import { createSupabaseScoringJobStore } from "../src/lib/birth-time-scoring-job-store.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); + +function dockerAvailable(): boolean { + return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { + encoding: "utf8", + stdio: "ignore", + }).status === 0; +} + +const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host"; + +const ids = { + user: "e1111111-1111-4111-8111-111111111111", + caseId: "e2222222-2222-4222-8222-222222222222", + job: "e3333333-3333-4333-8333-333333333333", + shapeJob: "e4444444-4444-4444-8444-444444444444", +}; + +const fingerprint = "claim-shape-fingerprint"; + +function sqlLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +const identity = { + userId: ids.user, + caseId: ids.caseId, + jobId: ids.job, + evidenceFingerprint: fingerprint, + algorithmVersion: birthTimeScoringAlgorithmVersion, + now: "2026-10-15T00:00:00.000Z", +}; + +test("claimScoringJob reads the first row when rpc returns an array", async () => { + const rpc = async () => ({ + data: [{ claim_state: "claimed", algorithm_version: birthTimeScoringAlgorithmVersion }], + error: null, + }); + const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null); + const claim = await store.claimScoringJob(identity); + assert.deepEqual(claim, { + kind: "claimed", + algorithmVersion: birthTimeScoringAlgorithmVersion, + }); +}); + +test("claimScoringJob also accepts the single-object rpc shape", async () => { + const rpc = async () => ({ + data: { claim_state: "processing", algorithm_version: birthTimeScoringAlgorithmVersion }, + error: null, + }); + const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null); + const claim = await store.claimScoringJob(identity); + assert.deepEqual(claim, { + kind: "processing", + algorithmVersion: birthTimeScoringAlgorithmVersion, + }); +}); + +test("claim_birth_time_scoring_job returns an array and claimScoringJob accepts it", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal( + migration.status, + 0, + JSON.stringify({ + status: migration.status, + stderr: migration.stderr, + stdoutTail: String(migration.stdout).slice(-1500), + }), + ); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `insert into identity.users (id, name, email, email_verified, email_verified_at) + values (${sqlLiteral(ids.user)}, 'Claim Shape', 'claim-shape@example.com', true, now());`, + ); + fixture.psql(` + insert into public.birth_time_rectification_cases ( + id, user_id, status, reported_date, source + ) values ( + ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, 'rectifying', '1990-01-01', 'family_exact' + ); + insert into public.birth_time_rectification_scoring_jobs ( + id, case_id, user_id, evidence_fingerprint, algorithm_version, status, expires_at + ) values ( + ${sqlLiteral(ids.job)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, + ${sqlLiteral(fingerprint)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)}, + 'pending', '2026-10-15T01:00:00Z' + ), ( + ${sqlLiteral(ids.shapeJob)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, + ${sqlLiteral(`${fingerprint}-shape`)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)}, + 'pending', '2026-10-15T01:00:00Z' + ); + update public.birth_time_rectification_cases + set journey_protocol = 'legacy-guided-v1', + turn_state = ${sqlLiteral(JSON.stringify({ + nextAction: { kind: "score_pending", jobId: ids.shapeJob }, + }))}::jsonb + where id = ${sqlLiteral(ids.caseId)}; + `); + + const client = createLocalPostgresDataClient( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + null, + "service_role", + ); + const direct = await client.rpc("claim_birth_time_scoring_job", { + p_user_id: ids.user, + p_case_id: ids.caseId, + p_job_id: ids.shapeJob, + p_evidence_fingerprint: `${fingerprint}-shape`, + p_algorithm_version: birthTimeScoringAlgorithmVersion, + p_now: identity.now, + }); + assert.equal(direct.error, null, direct.error?.message); + assert.equal(Array.isArray(direct.data), true); + assert.equal(direct.data[0]?.claim_state, "claimed"); + + fixture.psql(` + update public.birth_time_rectification_cases + set turn_state = ${sqlLiteral(JSON.stringify({ + nextAction: { kind: "score_pending", jobId: ids.job }, + }))}::jsonb + where id = ${sqlLiteral(ids.caseId)}; + `); + const store = createSupabaseScoringJobStore(client, async () => null); + const claim = await store.claimScoringJob(identity); + assert.deepEqual(claim, { + kind: "claimed", + algorithmVersion: birthTimeScoringAlgorithmVersion, + }); + } finally { + await closeLocalPostgresDataPools(); + fixture.stop(); + } +});