fix(api): keep session and birth-row types through next build
Transcript limits were collapsing the create schema, and joined profile selects typed as GenericStringError, so Docker next build failed after tests passed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+17
-1
@@ -4617,7 +4617,7 @@
|
||||
- 防复发:生时纠正用户面不得再渲染 CompletedActivityReceipt;分盘句来自公开 executed methods;宫位表来自 decision receipt,缺表不得由模型编造。
|
||||
- 相关记录:BUG-179、BUG-181、BUG-304
|
||||
- 复发自:BUG-181(完成凭证成为用户主结果后,分盘和宫位没有独立正文位置)
|
||||
- 修复版本:待提交
|
||||
- 修复版本:`c8af18e9`
|
||||
|
||||
## BUG-308 | 咨询重跑改了草稿回填条件后,源码合同仍切旧 if,staging 质量门 1807/1808
|
||||
|
||||
@@ -4633,4 +4633,20 @@
|
||||
- 防复发:切 `send()` 失败回填不得再用无 `restoreOnFailure` 的旧 if。源码合同的起止标记必须随被切代码一起改,否则质量门会把无关提交打红。
|
||||
- 相关记录:BUG-249、BUG-306
|
||||
- 复发自:BUG-306(加了 `restoreOnFailure` 而未更新草稿隔离切片)
|
||||
- 修复版本:`d434aa48`
|
||||
|
||||
## BUG-309 | Docker `npm run build` 因会话 schema 与动态 select 类型检查失败
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-19
|
||||
- 最近更新:2026-08-19
|
||||
- 影响面:`deploy/railway-web.Dockerfile` 的 `RUN npm run build`、`POST /api/sessions`、`POST /api/daily-starlanguage`、`POST /api/synastry`
|
||||
- 用户现象:staging publish 在 web 镜像构建失败。BuildKit 日志末尾是 `skill-package-registry.ts` 的 Import traces(动态文件访问追踪警告),真正失败是 `Failed to type check`。
|
||||
- 触发条件:向 `staging` 推送后走 publish 镜像构建。push 上的 validate 跳过 `npm run build`,因此质量门绿、镜像红。
|
||||
- 根因:两处互不相关的 TypeScript 收口。`limitTranscriptSize()` 的泛型下界是 `{ messages: { text: string }[] }`,`.superRefine()` 之后推断输出被收成这个下界,`parsed.data` 不再有 `id`,也丢失消息上的 receipt 字段。每日星语与合盘把出生列 `.join(",")` 成普通 `string` 再交给 supabase-js,`select` 结果变成 `GenericStringError`,无法传入 `AccountBirthRow`。
|
||||
- 修复:`limitTranscriptSize` 按输入 schema 的 `Output` 原样返回 `z.ZodType<Output>`。出生列改为共享字面量 `ACCOUNT_BIRTH_SELECT`,`globalBirthProfileFromAccountRow` 接受 `unknown` 再收成账户行。
|
||||
- 验证:`npx tsc --noEmit` 无错误(修复前 3 个 route + 2 个测试文件)。`tsx --test tests/chat-session-write.test.ts tests/server-owned-birth-profile.test.ts tests/daily-starlanguage.test.ts tests/chart-library-other-profile.test.ts` 36/36。
|
||||
- 防复发:创建会话 schema 必须保留 `id`;出生资料 `select` 不得用 `.join(",")` 得到非字面量字符串。staging push 的 validate 不跑 `next build`,类型回归只在 publish Docker 暴露。
|
||||
- 相关记录:BUG-261、BUG-308
|
||||
- 复发自:无
|
||||
- 修复版本:待提交
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { dailyProfilePayload } from "@/lib/global-birth-payloads";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile";
|
||||
import { ACCOUNT_BIRTH_SELECT, globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getDailyStarlanguageAgent } from "@/mastra";
|
||||
|
||||
@@ -22,22 +22,6 @@ export const maxDuration = 20;
|
||||
|
||||
type Profile = ReturnType<typeof globalBirthProfileFromAccountRow>;
|
||||
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
|
||||
const accountBirthColumns = [
|
||||
"name",
|
||||
"birth_date",
|
||||
"reported_birth_time",
|
||||
"active_birth_time",
|
||||
"birth_time",
|
||||
"birth_time_status",
|
||||
"country_code",
|
||||
"province_code",
|
||||
"city_code",
|
||||
"district_code",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"timezone_offset",
|
||||
"timezone_id",
|
||||
].join(",");
|
||||
type CardSource = "engine_evidence" | "agent";
|
||||
type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard; readonly source: CardSource };
|
||||
type Generated =
|
||||
@@ -192,7 +176,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const { data: row, error } = await supabase
|
||||
.from("profiles")
|
||||
.select(accountBirthColumns)
|
||||
.select(ACCOUNT_BIRTH_SELECT)
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
if (error || !row) return unavailable("birth_profile_incomplete");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { isProductEnabled } from "@/lib/product-access";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import {
|
||||
ACCOUNT_BIRTH_SELECT,
|
||||
globalBirthProfileFromAccountRow,
|
||||
globalBirthProfileFromStoredChart,
|
||||
} from "@/lib/server-owned-birth-profile";
|
||||
@@ -10,22 +11,6 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { synastryBirthPayload } from "@/lib/global-birth-payloads";
|
||||
|
||||
const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
const accountBirthColumns = [
|
||||
"name",
|
||||
"birth_date",
|
||||
"reported_birth_time",
|
||||
"active_birth_time",
|
||||
"birth_time",
|
||||
"birth_time_status",
|
||||
"country_code",
|
||||
"province_code",
|
||||
"city_code",
|
||||
"district_code",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"timezone_offset",
|
||||
"timezone_id",
|
||||
].join(",");
|
||||
const synastryRequestSchema = z.object({
|
||||
partnerChartProfileId: z.string().uuid(),
|
||||
relationshipType: z.enum(["romance", "business", "family", "general"]).optional(),
|
||||
@@ -168,7 +153,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const { data: selfRow, error: selfError } = await supabase
|
||||
.from("profiles")
|
||||
.select(accountBirthColumns)
|
||||
.select(ACCOUNT_BIRTH_SELECT)
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
if (selfError || !selfRow) {
|
||||
|
||||
@@ -34,7 +34,9 @@ const chatSessionWriteObjectSchema = z.object({
|
||||
updated_at: z.string().datetime(),
|
||||
}).strict();
|
||||
|
||||
function limitTranscriptSize<Schema extends z.ZodType<{ messages: Array<{ text: string }> }>>(schema: Schema) {
|
||||
function limitTranscriptSize<Output extends { messages: Array<{ text: string }> }>(
|
||||
schema: z.ZodType<Output>,
|
||||
): z.ZodType<Output> {
|
||||
return schema.superRefine((value, context) => {
|
||||
const totalChars = value.messages.reduce((sum, message) => sum + message.text.length, 0);
|
||||
if (totalChars > CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS) {
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { GlobalBirthProfile } from "./global-birth-payloads.ts";
|
||||
|
||||
const usableActiveStatuses = new Set(["accepted", "confirmed"]);
|
||||
|
||||
export const ACCOUNT_BIRTH_SELECT =
|
||||
"name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id" as const;
|
||||
|
||||
export type AccountBirthRow = Readonly<{
|
||||
name?: unknown;
|
||||
birth_date?: unknown;
|
||||
@@ -19,6 +22,11 @@ export type AccountBirthRow = Readonly<{
|
||||
timezone_id?: unknown;
|
||||
}>;
|
||||
|
||||
function asAccountBirthRow(row: unknown): AccountBirthRow {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) return {};
|
||||
return row as AccountBirthRow;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
@@ -60,22 +68,23 @@ function selectedClock(row: AccountBirthRow): string | undefined {
|
||||
return reported ?? active;
|
||||
}
|
||||
|
||||
export function globalBirthProfileFromAccountRow(row: AccountBirthRow): GlobalBirthProfile & {
|
||||
export function globalBirthProfileFromAccountRow(row: unknown): GlobalBirthProfile & {
|
||||
birthTimeStatus?: string;
|
||||
} {
|
||||
const accountRow = asAccountBirthRow(row);
|
||||
return {
|
||||
name: text(row.name),
|
||||
date: calendarDate(row.birth_date),
|
||||
time: selectedClock(row),
|
||||
countryCode: text(row.country_code),
|
||||
provinceCode: text(row.province_code),
|
||||
cityCode: text(row.city_code),
|
||||
districtCode: text(row.district_code),
|
||||
latitude: finiteNumber(row.latitude) ?? null,
|
||||
longitude: finiteNumber(row.longitude) ?? null,
|
||||
timezoneOffset: finiteNumber(row.timezone_offset) ?? null,
|
||||
timezoneId: text(row.timezone_id),
|
||||
birthTimeStatus: text(row.birth_time_status),
|
||||
name: text(accountRow.name),
|
||||
date: calendarDate(accountRow.birth_date),
|
||||
time: selectedClock(accountRow),
|
||||
countryCode: text(accountRow.country_code),
|
||||
provinceCode: text(accountRow.province_code),
|
||||
cityCode: text(accountRow.city_code),
|
||||
districtCode: text(accountRow.district_code),
|
||||
latitude: finiteNumber(accountRow.latitude) ?? null,
|
||||
longitude: finiteNumber(accountRow.longitude) ?? null,
|
||||
timezoneOffset: finiteNumber(accountRow.timezone_offset) ?? null,
|
||||
timezoneId: text(accountRow.timezone_id),
|
||||
birthTimeStatus: text(accountRow.birth_time_status),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
|
||||
import { chatSessionCreateSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
|
||||
|
||||
const sessionId = "11111111-1111-4111-8111-111111111111";
|
||||
const values = {
|
||||
@@ -15,6 +15,17 @@ const values = {
|
||||
} satisfies ChatSessionWrite;
|
||||
|
||||
|
||||
test("create schema keeps the client-generated session id after transcript limits", () => {
|
||||
const parsed = chatSessionCreateSchema.parse({
|
||||
id: sessionId,
|
||||
...values,
|
||||
});
|
||||
const id: string = parsed.id;
|
||||
assert.equal(id, sessionId);
|
||||
assert.equal(parsed.title, values.title);
|
||||
assert.equal(parsed.messages[0]?.text, "你好");
|
||||
});
|
||||
|
||||
test("chat session schema preserves the safe agent execution receipt", () => {
|
||||
const receipt = {
|
||||
runId: "run-1",
|
||||
@@ -99,6 +110,7 @@ test("session API owns create and update while answer UI keeps sync failures out
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
||||
const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(page, /writeChatSession\(session\.id, values, mode\)/);
|
||||
assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/);
|
||||
@@ -109,6 +121,9 @@ test("session API owns create and update while answer UI keeps sync failures out
|
||||
assert.match(itemRoute, /readChatSessionJson/);
|
||||
assert.match(collectionRoute, /ChatSessionBodyTooLargeError/);
|
||||
assert.match(itemRoute, /ChatSessionBodyTooLargeError/);
|
||||
assert.match(collectionRoute, /const \{ id, \.\.\.values \} = parsed\.data/);
|
||||
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string \}> \}>/);
|
||||
assert.match(contract, /\): z\.ZodType<Output> \{/);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
ACCOUNT_BIRTH_SELECT,
|
||||
globalBirthProfileFromAccountRow,
|
||||
globalBirthProfileFromStoredChart,
|
||||
} from "../src/lib/server-owned-birth-profile.ts";
|
||||
@@ -74,3 +76,18 @@ test("a stored chart without a clock cannot be used for synastry", () => {
|
||||
assert.equal(globalBirthProfileFromStoredChart({ date: "1990-01-01", name: "对方" }), null);
|
||||
assert.equal(globalBirthProfileFromStoredChart(null), null);
|
||||
});
|
||||
|
||||
test("daily and synastry routes share a literal account-birth select string", () => {
|
||||
const daily = readFileSync(new URL("../src/app/api/daily-starlanguage/route.ts", import.meta.url), "utf8");
|
||||
const synastry = readFileSync(new URL("../src/app/api/synastry/route.ts", import.meta.url), "utf8");
|
||||
assert.equal(
|
||||
ACCOUNT_BIRTH_SELECT,
|
||||
"name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id",
|
||||
);
|
||||
assert.match(daily, /select\(ACCOUNT_BIRTH_SELECT\)/);
|
||||
assert.match(synastry, /select\(ACCOUNT_BIRTH_SELECT\)/);
|
||||
assert.doesNotMatch(daily, /accountBirthColumns/);
|
||||
assert.doesNotMatch(synastry, /accountBirthColumns/);
|
||||
assert.doesNotMatch(daily, /\.join\(",\"\)/);
|
||||
assert.doesNotMatch(synastry, /\.join\(",\"\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user