merge origin/staging into chart ascendant row (issue #53)
Docs-only catch-up; agent-loop rule A. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
This commit is contained in:
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Readonly<{ data: any; error: Readonly<{ message: string; code?: string | undefined; }> | 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 条里。
|
||||
@@ -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` |
|
||||
|
||||
@@ -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<string, unknown>));
|
||||
const profiles = (data ?? []).map((row: unknown) => toClientProfile(row as Record<string, unknown>));
|
||||
return NextResponse.json({
|
||||
profiles,
|
||||
limit: MAX_SAVED_SUBJECTS,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<StoredRectificationCase | null>,
|
||||
) {
|
||||
return {
|
||||
|
||||
@@ -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<StoredRectificationCase | null>,
|
||||
): 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,
|
||||
|
||||
@@ -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<any>; 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<DataQueryResult> {
|
||||
select(columns?: string): DataQueryBuilder;
|
||||
insert(value: Record<string, unknown> | readonly Record<string, unknown>[]): DataQueryBuilder;
|
||||
upsert(
|
||||
value: Record<string, unknown> | readonly Record<string, unknown>[],
|
||||
options?: { onConflict?: string },
|
||||
): DataQueryBuilder;
|
||||
update(values: Record<string, unknown>): 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<DataQueryResult>;
|
||||
maybeSingle(): Promise<DataQueryResult>;
|
||||
}
|
||||
|
||||
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<Record<string, unknown>>): Promise<DataQueryResult>;
|
||||
}
|
||||
@@ -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<QueryResult> {
|
||||
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<{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
|
||||
/** Only an owned persisted consultation is a product-domain source. */
|
||||
export async function resolveRectificationProductDomain(
|
||||
client: SupabaseClient,
|
||||
client: DataClient,
|
||||
userId: string,
|
||||
request: OpenRectificationCaseRequest,
|
||||
): Promise<string> {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<SessionRow | null> {
|
||||
async function readConsultationSession(client: DataClient, userId: string, sessionId: string): Promise<SessionRow | null> {
|
||||
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<boolean> {
|
||||
async function writeMessages(client: DataClient, userId: string, sessionId: string, messages: unknown[]): Promise<boolean> {
|
||||
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<RectificationSource | null> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<DataClient> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user