fix(chart): keep account and chart-view birth truth consistent
This commit is contained in:
@@ -344,3 +344,11 @@
|
||||
- 影响:`scripts/run_quality_gate.py --profile quick` 因这一条退出码为 1(`1 failed, 791 passed, 1 skipped`)。任何 Python 轮次在本机都会看到快速门红。
|
||||
- 本单范围:只记录。AGENTS §7.7 不得顺手修不在任务书里的问题;改这条断言属于产品/前端归属,要单独立单确认「插入行里 user 归属现在由谁保证」再改,不得直接把断言删了变绿。
|
||||
- 相关:本单进度记录 `docs/tasks/PROGRESS-consultation-residual-hotspots-20260916.md` 测试段。
|
||||
|
||||
## BUG-996 · 现场账户的单一数据库状态未取证(2026-09-22)
|
||||
|
||||
- 状态:blocked(只限现场取证;代码分类与缓存失效已在本分支用合成 fixture 锁住)
|
||||
- 缺什么:会议里「更新出生时间后星盘没有可显示内容」的那一户,没有脱敏后的 `profiles` 行。不能证明当时是查询失败、adopted offset 缺失、时区解析失败,还是旧快照。
|
||||
- 替代证据:`frontend/tests/chart-profile-update-consistency.test.ts` 用合成 reported / accepted / confirmed / 跨午夜 fixture。`guard_adopted_birth_date` 把 date、offset、provenance 一起清空,没有留下可排盘的半截 adopted 元组,因此没有新迁移,也没有跑 `npm run test:db`。
|
||||
- 未跑:全量 `npm test`、`next build`。交给父会话串行验收。
|
||||
- 解除:拿到脱敏行或 staging 上同一操作的分类日志后,把命中的 reason 回填到 BUG-996,再划掉本条。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-22 — 更新出生资料后,星盘跟的是同一份时间
|
||||
|
||||
保存出生资料后,星盘不再沿用刚才那张盘。读不到资料、已采用的时间缺时区、时区对不上、盘算不出来,页面上分开说明,不再只剩一句没有内容。Skill 版本不变。
|
||||
|
||||
## 2026-09-22 — 普通聊天按会话绑定的人物排盘
|
||||
|
||||
普通聊天不再在切换人物后仍用登录用户本人的资料。会话锁定的是本人或当前用户名下的另一份资料;对方资料缺失、被删或不完整时会拦住发送,不会退回本人。已有消息的会话不能改绑。报告、每日星语和生时校正仍只用本人资料。Skill 版本不变。
|
||||
|
||||
@@ -13200,6 +13200,22 @@
|
||||
- 复发自:无
|
||||
- 修复版本:`2503c019`
|
||||
|
||||
## BUG-996 | 更新出生资料后星盘仍可能显示旧盘或无法区分失败原因
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-22
|
||||
- 最近更新:2026-09-22
|
||||
- 影响面:`PATCH /api/account`、`GET /api/account`、`GET /api/chart-view`、`frontend/src/lib/secondary-page-data.ts` 的星盘快照缓存
|
||||
- 用户现象:已经更新出生时间,打开星盘仍像没有可显示内容,或点开后看不到刚保存的日期和时间。现场这一户的数据库行没有取到,不能把某一种残缺资料写成已经证实的唯一原因。
|
||||
- 触发条件:账户资料 PATCH 成功后星盘模块缓存仍留着上一张快照;或 `profiles` 读取失败、已采用日期缺 offset、时区解析失败、引擎 429 / 超时 / 坏结果被收成同一种「资料不完整」或同一句失败。
|
||||
- 根因:三处代码断点同时存在,不是一条已经证实的现场数据。第一,chart-view 忽略 profile 查询错误,空行会落到 `birth_profile_incomplete`;已采用日期缺 offset 或时区解析抛错时没有稳定分类。第二,`chartCache` 不按账户和资料指纹换键,PATCH 成功后也不丢弃旧快照。第三,账户响应和星盘排盘各自读日期、时间、offset,确认后的普通编辑虽不覆盖 active time,但调用方看不到同一份服务端真值。`guard_adopted_birth_date` 会把 adopted date、offset、provenance 一起清空,不会留下「有日期、没 offset」的半截元组,因此本轮不加迁移。
|
||||
- 修复:账户 PATCH / GET 与 chart-view 共用 `resolveServerOwnedChartBirth`。chart-view 把查询失败、资料不完整、adopted 计算不完整、时区失败、引擎忙、超时、坏结果和响应格式失败分开,查询失败不再写成资料不完整。PATCH 成功后按账户和资料指纹钉住星盘缓存,进行中的旧请求不能把旧盘写回去。确认状态的普通编辑仍不覆盖 active time。不把 `chart_profiles` 当作 `/chart` 真值,不放宽 accepted / confirmed。
|
||||
- 验证:`frontend/tests/chart-profile-update-consistency.test.ts` 覆盖 reported、accepted、confirmed、跨午夜、缺 active offset、查询失败、时区失败、引擎分档、schema 失败、旧快照失效和 D1。旧行为下查询错误没有独立 reason、缓存也没有账户指纹键,这些断言不会过。现场单户数据库状态仍未取证。
|
||||
- 防复发:数据库查询错误不得映射成 `birth_profile_incomplete`。已采用日期存在而 offset 缺失时不得回退到声明 offset 或清空 `active_birth_time`。星盘快照键必须包含账户和资料指纹;引擎缓存键仍包含日期、时间、offset、ayanamsa、node mode。失败文案不得写「过一会儿再打开」或没有上下文的「没有可显示内容」。
|
||||
- 相关记录:BUG-073、BUG-076、BUG-715、BUG-716、BUG-717、TASK-chart-profile-update-consistency-20260922
|
||||
- 复发自:无。BUG-073 仍要求服务端不信任客户端咨询模式;BUG-076 仍要求 `profiles` 而不是星盘库 JSON 为真值;BUG-715 至 717 的星盘打开、分档日志和开页只打 `/api/chart` 都还在,本条补的是资料真值和快照失效。
|
||||
- 修复版本:本分支,尚未推送、尚未部署
|
||||
|
||||
## BUG-997 | 切换人物后普通聊天仍用登录用户本人的出生资料
|
||||
|
||||
- 状态:resolved
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 进度 · 更新出生资料后星盘真值一致性(2026-09-22)
|
||||
|
||||
基线:`10baeb2fa865743c428806e115ac92ca9d92fb71`(`origin/staging`)。分支 `codex/chart-profile-update-consistency-20260922`。未推送,未部署。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- `PATCH /api/account`、`GET /api/account`、`GET /api/chart-view` 共用 `resolveServerOwnedChartBirth`。reported 用声明日期、声明钟点和声明 offset;accepted / confirmed 的完整 adopted 元组用 active date、active time、active offset 和 provenance;跨午夜同此。
|
||||
- confirmed 的普通编辑仍返回空的 application patch,不覆盖 `active_birth_time`。声明字段变化时,与 `guard_adopted_birth_date` 相同:date、offset、provenance 一起清空,排盘落到既有的无 adopted 日期路径(声明日期 + 受保护的 active 分钟 + 声明 offset)。不是半截元组。
|
||||
- accepted 的普通声明编辑仍把状态收成 reported 并清空 active time;adopted 元组一并清空,不会停在「还能排、但 offset 缺了」。
|
||||
- adopted 日期在而 offset 缺失:`adopted_calculation_incomplete`,不回退声明 offset,也不去调时区解析。
|
||||
- chart-view 分开记录:`profile_query_error`、`profile_incomplete`、`adopted_calculation_incomplete`、`timezone_resolver_failure`、`engine_busy`、`engine_timeout`、`engine_bad_payload`、`engine_http_error`、`response_schema_failure`。查询失败的 HTTP 仍是 200 结构化状态,但 status 不是 `birth_profile_incomplete`。日志只有 reason、route、耗时、HTTP status。
|
||||
- 星盘模块缓存按账户 id + 资料指纹钉住。PATCH 成功立刻换键并丢掉不匹配的旧快照;与这次保存重叠的 `refreshAccount` 不会把旧指纹钉回去。进行中的旧 `/api/chart-view` 不能写回旧盘。引擎缓存键仍含日期、时间、offset、ayanamsa、node mode。
|
||||
- `/chart` 仍读账户 `profiles`,不读 `chart_profiles`。
|
||||
|
||||
## 没有新迁移
|
||||
|
||||
`20260920020000_adopted_birth_date.sql` 的 `guard_adopted_birth_date` 在同一分支把 `active_birth_date`、`active_birth_timezone_offset`、`active_birth_provenance` 置空,不会只清 offset。因此没有「可排盘且 adopted 元组不完整」的 trigger 产物,不加迁移,不跑 `npm run test:db`。
|
||||
|
||||
## 断言变更
|
||||
|
||||
无。既有 `chart-view-route`、`chart-view-engine`、`server-owned-birth-profile`、`adopted-birth-date`、`account-api`、`secondary-page-entry` 的测试名和断言原值都没改。
|
||||
|
||||
## 测试
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `.\node_modules\.bin\tsc --noEmit` | 通过 |
|
||||
| `npm run lint` | exit 0,0 error,119 条既有 warning,本次改动的文件不在其中 |
|
||||
| `npx tsx --test tests/chart-profile-update-consistency.test.ts` | 11 pass / 0 fail |
|
||||
| 同命令加上 `chart-view-route`、`chart-view-engine`、`server-owned-birth-profile`、`adopted-birth-date`、`account-api`、`secondary-page-entry` | 57 pass / 0 fail |
|
||||
|
||||
未跑全量 `npm test`,未跑 `next build`。
|
||||
|
||||
## 偏差
|
||||
|
||||
- 现场那一户的数据库行仍未知,BUG-996 不把某一种残缺写成唯一根因。见 `BLOCKED.md`。
|
||||
- 账户 GET 的 `chartBirth` 是库存真值。chart-view 只在声明 offset 缺失、且不是 adopted 半截元组时,才在内存里补时区;指纹仍用库存行,所以和 GET 的键一致。带齐 offset 的合成 fixture 上,日期、时间、offset、provenance 完全相同。
|
||||
- 没有改 `frontend/src/app/(app)/page.tsx`、没有改 `scripts/jyotish_api_server.py`、没有改迁移。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
无受控登录态,不能复核会议里的那一户。无新迁移,故不用 Docker 跑 `test:db`。
|
||||
+6
-2
@@ -875,7 +875,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
|
||||
|
||||
### 等待态
|
||||
|
||||
星盘页是独立文档。`SecondaryPageShell` 揭幕(标题、保留的 note 槽、撑满剩余视口的内容区)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。同一会话再次进入直接用模块缓存,不重放等待句。
|
||||
星盘页是独立文档。`SecondaryPageShell` 揭幕(标题、保留的 note 槽、撑满剩余视口的内容区)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。同一会话再次进入直接用模块缓存,不重放等待句。出生资料保存成功后,缓存按账户和资料指纹换键,旧盘不得留在页上冒充新的日期或时间。
|
||||
|
||||
| 时刻 | 文案 |
|
||||
| --- | --- |
|
||||
@@ -883,7 +883,11 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
|
||||
| 已点大运 / 西洋 / 七政,该路还没返回 | 这一栏还没拿到。 |
|
||||
| 已点非 D1 分盘,分盘包还没返回 | 这一分盘还没拿到。 |
|
||||
| 引擎 429 | 算盘的服务正忙,稍等几秒再打开就好。 |
|
||||
| 其它主盘失败 | 这张盘算不出来,我们已经记录下来了。 |
|
||||
| 其它主盘计算失败 | 这张盘算不出来,我们已经记录下来了。 |
|
||||
| 资料读不出来 | 星盘资料这会儿读不出来,我们已经记录下来了。 |
|
||||
| 已采用的时间缺时区 | 已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。 |
|
||||
| 时区没有对上 | 出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。 |
|
||||
| 结果格式对不上 | 星盘结果的格式对不上,我们已经记录下来了。 |
|
||||
|
||||
## 16. 星历页
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 吻合率低时模型追问要不要把误差放到 ±2 小时 | 按你说的经历,出生时间可能比家人记的偏得更多。放宽后再比一次? | 放宽卡由服务端出,选项写「前后半小时 / 一小时 / 两小时」,不用偏移、误差、置信度、概率。 |
|
||||
| 只知道傍晚时直接给分钟或采用卡 | 先切成三段(写起止钟点),比完再按分钟收。分不开就说「时段分不开,直接按分钟比」。 | 超过两小时的窗口先走子段,不在 4~6 小时上铺分钟卡。 |
|
||||
| 记下精确到日的事后不问日子从哪来 | 刚才那个日子是查过记录,还是凭记忆? | 只问日级、只问一次;不答就跳过。 |
|
||||
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
|
||||
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它计算失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
|
||||
| 没有可显示内容 / 资料还在,过一会儿再打开 | 读不到资料:「星盘资料这会儿读不出来,我们已经记录下来了。」已采用的时间缺时区:「已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。」时区对不上:「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」结果格式不对:「星盘结果的格式对不上,我们已经记录下来了。」 | 失败要说出是哪一种。不写「过一会儿再打开」,也不用一句没头没尾的「没有可显示内容」。 |
|
||||
| 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 |
|
||||
| Agent 未完成必要的方法与计算步骤,本次不会扣点。(模型其实写过一段) | 把模型写过的那段留下,文末由服务端加一句:「这次没跑完星盘计算,上面是模型直接写的,先看着。」 | 合同没绿但写过字时不得整轮空白。这句不是模型写的。没写过字仍报未完成、不扣点。 |
|
||||
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolveAccountBirthTimeApplicationPatch,
|
||||
resolveAppliedAccountBirthTime,
|
||||
} from "@/lib/account-profile-patch";
|
||||
import { chartBirthAfterAccountWrite, resolveServerOwnedChartBirth } from "@/lib/chart-birth-truth";
|
||||
import { optionalBeamAvatarFromProfile } from "@/lib/beam-avatar";
|
||||
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
|
||||
import {
|
||||
@@ -160,6 +161,7 @@ export async function GET() {
|
||||
latitude: profile.latitude ?? null,
|
||||
longitude: profile.longitude ?? null,
|
||||
},
|
||||
chartBirth: resolveServerOwnedChartBirth(profile),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
@@ -292,14 +294,14 @@ export async function PATCH(request: Request) {
|
||||
return admin
|
||||
.from("profiles")
|
||||
.upsert(values, { onConflict: "id" })
|
||||
.select("id,birth_time_status,active_birth_time,active_birth_date,active_birth_timezone_offset")
|
||||
.select("id,birth_time_status,active_birth_time,active_birth_date,active_birth_timezone_offset,active_birth_provenance,birth_date,reported_birth_time,birth_time,timezone_offset,timezone_id,latitude,longitude,ayanamsa")
|
||||
.maybeSingle();
|
||||
}
|
||||
let query = admin.from("profiles").update(values).eq("id", userId);
|
||||
if (invalidatesUnconfirmedApplication) {
|
||||
query = applyAccountProfileConcurrencyGuards(query, currentProfile);
|
||||
}
|
||||
return query.select("id,birth_time_status,active_birth_time,active_birth_date,active_birth_timezone_offset").maybeSingle();
|
||||
return query.select("id,birth_time_status,active_birth_time,active_birth_date,active_birth_timezone_offset,active_birth_provenance,birth_date,reported_birth_time,birth_time,timezone_offset,timezone_id,latitude,longitude,ayanamsa").maybeSingle();
|
||||
}
|
||||
|
||||
let { data, error } = await writeProfile(withCoordinates);
|
||||
@@ -352,6 +354,11 @@ export async function PATCH(request: Request) {
|
||||
activeDate: data.active_birth_date ?? null,
|
||||
activeTimezoneOffset: data.active_birth_timezone_offset ?? null,
|
||||
},
|
||||
chartBirth: chartBirthAfterAccountWrite({
|
||||
current: currentProfile,
|
||||
written: withCoordinates,
|
||||
returned: data,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
|
||||
@@ -76,9 +76,13 @@ export function useChartPage() {
|
||||
void fetchChartView({ layers: [layer] })
|
||||
.then((result) => {
|
||||
if (result.body?.status === "ok") {
|
||||
const stored = writeChartPage({ kind: "view", view: result.body });
|
||||
if (stored.kind !== "view" || stored.view.status !== "ok") {
|
||||
setView(stored.kind === "view" ? stored.view : result.body);
|
||||
return;
|
||||
}
|
||||
loadedLayers.current.add(layer);
|
||||
writeChartPage({ kind: "view", view: result.body });
|
||||
setView(result.body);
|
||||
setView(stored.view);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type BirthTimeConsultationConsentState,
|
||||
type LatestAccountRequestGuard,
|
||||
} from "@/lib/birth-time-consultation-consent";
|
||||
import { readChartBirthFingerprint } from "@/lib/chart-birth-truth";
|
||||
import { composerDraftSnapshot } from "@/lib/composer-draft";
|
||||
import {
|
||||
activeChartStorageKey,
|
||||
@@ -25,6 +26,11 @@ import {
|
||||
LoginRedirectError,
|
||||
saveCloudChartProfile,
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import {
|
||||
accountChartPinStillCurrent,
|
||||
beginChartCacheAccountRead,
|
||||
pinChartSnapshotIdentity,
|
||||
} from "@/lib/secondary-page-data";
|
||||
import {
|
||||
birthProfileDeclarationChanged,
|
||||
buildSelfChartRecord,
|
||||
@@ -124,9 +130,14 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
|
||||
|
||||
async function refreshAccount() {
|
||||
const requestIdentity = accountRefreshGuard.current.begin();
|
||||
const chartRead = beginChartCacheAccountRead();
|
||||
try {
|
||||
const latest = await fetchAccount();
|
||||
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
|
||||
const fingerprint = readChartBirthFingerprint(latest);
|
||||
if (fingerprint && accountChartPinStillCurrent(chartRead)) {
|
||||
pinChartSnapshotIdentity({ accountId: latest.user.id, fingerprint });
|
||||
}
|
||||
const nextProfile = readProfile(latest.profile);
|
||||
const activeOther = activeChartId !== "self"
|
||||
&& chartLibrary.find((record) => record.id === activeChartId && record.role === "other");
|
||||
@@ -231,10 +242,13 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
|
||||
const payload = await response.json().catch(() => null) as {
|
||||
error?: string;
|
||||
birthTime?: unknown;
|
||||
chartBirth?: { fingerprint?: unknown };
|
||||
} | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || "账户资料暂时无法保存。");
|
||||
}
|
||||
const fingerprint = readChartBirthFingerprint(payload);
|
||||
if (fingerprint) pinChartSnapshotIdentity({ accountId: account.user.id, fingerprint });
|
||||
const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime);
|
||||
setAccount((current) => current ? { ...current, profile: savedProfile } : current);
|
||||
setActiveChartId("self");
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { resolveAyanamsa } from "./ayanamsa.ts";
|
||||
import { birthCalendarDate } from "./effective-birth-date.ts";
|
||||
import { globalBirthProfileFromAccountRow } from "./server-owned-birth-profile.ts";
|
||||
|
||||
export const CHART_BIRTH_FAILURE_REASONS = [
|
||||
"profile_query_error",
|
||||
"profile_incomplete",
|
||||
"adopted_calculation_incomplete",
|
||||
"timezone_resolver_failure",
|
||||
"engine_busy",
|
||||
"engine_timeout",
|
||||
"engine_bad_payload",
|
||||
"engine_http_error",
|
||||
"response_schema_failure",
|
||||
] as const;
|
||||
|
||||
export type ChartBirthFailureReason = (typeof CHART_BIRTH_FAILURE_REASONS)[number];
|
||||
|
||||
export type ChartBirthAdoption = "adopted" | "legacy" | "declared" | "incomplete";
|
||||
|
||||
export type ServerOwnedChartBirth = {
|
||||
status: string | null;
|
||||
date: string | null;
|
||||
time: string | null;
|
||||
timezoneOffset: number | null;
|
||||
timezoneId: string | null;
|
||||
provenance: unknown;
|
||||
reportedTime: string | null;
|
||||
activeTime: string | null;
|
||||
activeDate: string | null;
|
||||
declaredDate: string | null;
|
||||
fingerprint: string;
|
||||
chartable: boolean;
|
||||
failure: ChartBirthFailureReason | null;
|
||||
adoption: ChartBirthAdoption;
|
||||
};
|
||||
|
||||
const ADOPTION_CLEAR_KEYS = [
|
||||
"birth_date",
|
||||
"reported_birth_time",
|
||||
"birth_time_source",
|
||||
"birth_time_period",
|
||||
"declared_window_start",
|
||||
"declared_window_end",
|
||||
"uncertainty_before_minutes",
|
||||
"uncertainty_after_minutes",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"timezone_id",
|
||||
"timezone_offset",
|
||||
] as const;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function clock(value: unknown): string | null {
|
||||
const raw = text(value);
|
||||
if (!raw) return null;
|
||||
return raw.slice(0, 5);
|
||||
}
|
||||
|
||||
function finite(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return Object.is(value, -0) ? 0 : value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canonical(value: unknown, depth = 0): string {
|
||||
if (depth > 8 || value == null) return "";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(Object.is(value, -0) ? 0 : value) : "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "boolean") return value ? "1" : "0";
|
||||
if (Array.isArray(value)) return `[${value.map((item) => canonical(item, depth + 1)).join(",")}]`;
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.keys(value as Record<string, unknown>).sort();
|
||||
return `{${entries.map((key) => `${key}:${canonical((value as Record<string, unknown>)[key], depth + 1)}`).join(",")}}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function fingerprintOf(parts: Record<string, unknown>): string {
|
||||
const textValue = canonical(parts);
|
||||
let out = "";
|
||||
for (const seed of [0x811c9dc5, 0x01000193, 0x9e3779b9, 0x85ebca6b]) {
|
||||
let hash = seed >>> 0;
|
||||
for (let index = 0; index < textValue.length; index += 1) {
|
||||
hash ^= textValue.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
out += (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function comparable(key: string, value: unknown): unknown {
|
||||
if (key === "birth_date" || key === "active_birth_date") return birthCalendarDate(value) ?? null;
|
||||
if (key.endsWith("_time") || key.endsWith("_start") || key.endsWith("_end")) return clock(value);
|
||||
if (key === "latitude" || key === "longitude" || key === "timezone_offset" || key.endsWith("_minutes")) {
|
||||
return finite(value);
|
||||
}
|
||||
if (key === "active_birth_provenance") return canonical(value);
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
function isDistinct(key: string, left: unknown, right: unknown): boolean {
|
||||
return comparable(key, left) !== comparable(key, right);
|
||||
}
|
||||
|
||||
function emptyBirth(failure: ChartBirthFailureReason): ServerOwnedChartBirth {
|
||||
return {
|
||||
status: null,
|
||||
date: null,
|
||||
time: null,
|
||||
timezoneOffset: null,
|
||||
timezoneId: null,
|
||||
provenance: null,
|
||||
reportedTime: null,
|
||||
activeTime: null,
|
||||
activeDate: null,
|
||||
declaredDate: null,
|
||||
fingerprint: fingerprintOf({ failure }),
|
||||
chartable: false,
|
||||
failure,
|
||||
adoption: "incomplete",
|
||||
};
|
||||
}
|
||||
|
||||
export function unresolvedChartBirth(failure: ChartBirthFailureReason): ServerOwnedChartBirth {
|
||||
return emptyBirth(failure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `public.guard_adopted_birth_date`. Date, offset, and provenance
|
||||
* clear together. This never clears `active_birth_time`.
|
||||
*/
|
||||
export function applyAdoptedBirthDateGuard<T extends Record<string, unknown>>(
|
||||
previous: T | null,
|
||||
next: T,
|
||||
): T {
|
||||
const status = next.birth_time_status;
|
||||
const statusUnusable = status == null || (status !== "accepted" && status !== "confirmed");
|
||||
const activeTimeMissing = next.active_birth_time == null || next.active_birth_time === "";
|
||||
const declarationChanged = previous != null && ADOPTION_CLEAR_KEYS.some((key) => (
|
||||
isDistinct(key, next[key], previous[key])
|
||||
));
|
||||
const activeTimeChangedWithoutProvenance = previous != null
|
||||
&& isDistinct("active_birth_time", next.active_birth_time, previous.active_birth_time)
|
||||
&& !isDistinct("active_birth_provenance", next.active_birth_provenance, previous.active_birth_provenance);
|
||||
if (statusUnusable || activeTimeMissing || declarationChanged || activeTimeChangedWithoutProvenance) {
|
||||
return {
|
||||
...next,
|
||||
active_birth_date: null,
|
||||
active_birth_timezone_offset: null,
|
||||
active_birth_provenance: null,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resolveServerOwnedChartBirth(row: unknown): ServerOwnedChartBirth {
|
||||
const source = record(row);
|
||||
const status = text(source.birth_time_status);
|
||||
const declaredDate = birthCalendarDate(source.birth_date) ?? null;
|
||||
const activeDate = birthCalendarDate(source.active_birth_date) ?? null;
|
||||
const reportedTime = clock(source.reported_birth_time);
|
||||
const activeTime = clock(source.active_birth_time) ?? clock(source.birth_time);
|
||||
const timezoneId = text(source.timezone_id);
|
||||
const provenance = source.active_birth_provenance ?? null;
|
||||
const name = text(source.name);
|
||||
const latitude = finite(source.latitude);
|
||||
const longitude = finite(source.longitude);
|
||||
const ayanamsa = resolveAyanamsa(source);
|
||||
let profile: ReturnType<typeof globalBirthProfileFromAccountRow> | null = null;
|
||||
let failure: ChartBirthFailureReason | null = null;
|
||||
try {
|
||||
profile = globalBirthProfileFromAccountRow(row);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "adopted_birth_calculation_incomplete") {
|
||||
failure = "adopted_calculation_incomplete";
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const usable = status === "accepted" || status === "confirmed";
|
||||
const date = failure ? null : profile?.date ?? null;
|
||||
const time = failure ? null : profile?.time ?? null;
|
||||
// An incomplete adopted tuple must not fall back to the declared offset.
|
||||
const timezoneOffset = failure
|
||||
? null
|
||||
: typeof profile?.timezoneOffset === "number" && Number.isFinite(profile.timezoneOffset)
|
||||
? profile.timezoneOffset
|
||||
: null;
|
||||
let adoption: ChartBirthAdoption = "declared";
|
||||
if (failure === "adopted_calculation_incomplete") adoption = "incomplete";
|
||||
else if (usable && activeDate) adoption = "adopted";
|
||||
else if (usable) adoption = "legacy";
|
||||
if (!failure && (date == null || time == null || timezoneOffset == null || latitude == null || longitude == null)) {
|
||||
failure = "profile_incomplete";
|
||||
}
|
||||
return {
|
||||
status,
|
||||
date,
|
||||
time,
|
||||
timezoneOffset,
|
||||
timezoneId,
|
||||
provenance,
|
||||
reportedTime,
|
||||
activeTime,
|
||||
activeDate,
|
||||
declaredDate,
|
||||
fingerprint: fingerprintOf({
|
||||
name,
|
||||
status,
|
||||
date,
|
||||
time,
|
||||
timezoneOffset,
|
||||
timezoneId,
|
||||
provenance,
|
||||
reportedTime,
|
||||
activeTime,
|
||||
activeDate,
|
||||
declaredDate,
|
||||
latitude,
|
||||
longitude,
|
||||
ayanamsa,
|
||||
failure,
|
||||
}),
|
||||
chartable: failure == null,
|
||||
failure,
|
||||
adoption,
|
||||
};
|
||||
}
|
||||
|
||||
export function chartBirthAfterAccountWrite(input: {
|
||||
current: unknown;
|
||||
written: unknown;
|
||||
returned: unknown;
|
||||
}): ServerOwnedChartBirth {
|
||||
const current = input.current == null ? null : record(input.current);
|
||||
const written = record(input.written);
|
||||
const returned = input.returned == null ? null : record(input.returned);
|
||||
const merged = {
|
||||
...(current ?? {}),
|
||||
...written,
|
||||
...(returned ?? {}),
|
||||
};
|
||||
const databaseAlreadyJudgedAdoption = returned != null && (
|
||||
"active_birth_date" in returned
|
||||
|| "active_birth_timezone_offset" in returned
|
||||
|| "active_birth_provenance" in returned
|
||||
);
|
||||
const row = databaseAlreadyJudgedAdoption
|
||||
? merged
|
||||
: applyAdoptedBirthDateGuard(current, merged);
|
||||
return resolveServerOwnedChartBirth(row);
|
||||
}
|
||||
|
||||
export function readChartBirthFingerprint(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object" || !("chartBirth" in value)) return null;
|
||||
const chartBirth = (value as { chartBirth?: { fingerprint?: unknown } }).chartBirth;
|
||||
return chartBirth && typeof chartBirth.fingerprint === "string" && chartBirth.fingerprint
|
||||
? chartBirth.fingerprint
|
||||
: null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { chartViewResponseSchema, type ChartViewResponse } from "./chart-view-contract.ts";
|
||||
import type { ChartViewLayer } from "./chart-view-engine.ts";
|
||||
import { chartViewFailureResponse } from "./chart-view-failure.ts";
|
||||
|
||||
export async function fetchChartView(input: {
|
||||
layers?: readonly ChartViewLayer[];
|
||||
@@ -18,8 +19,12 @@ export async function fetchChartView(input: {
|
||||
} catch (error) {
|
||||
const errorName = error instanceof Error ? error.name : "Error";
|
||||
console.warn("chart_view_client_bad_payload", { errorName, httpStatus: response.status });
|
||||
return { httpStatus: response.status, body: null };
|
||||
return { httpStatus: response.status, body: chartViewFailureResponse("response_schema_failure") };
|
||||
}
|
||||
const parsed = chartViewResponseSchema.safeParse(json);
|
||||
return { httpStatus: response.status, body: parsed.success ? parsed.data : null };
|
||||
if (!parsed.success) {
|
||||
console.warn("chart_view_client_schema_failed", { httpStatus: response.status });
|
||||
return { httpStatus: response.status, body: chartViewFailureResponse("response_schema_failure") };
|
||||
}
|
||||
return { httpStatus: response.status, body: parsed.data };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CHART_BIRTH_FAILURE_REASONS } from "./chart-birth-truth.ts";
|
||||
import { PLANET_GLYPH_KEYS } from "./planet-glyphs.ts";
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
@@ -169,6 +170,7 @@ export const chartViewProfileSchema = z.object({
|
||||
placeLabel: z.string().min(1),
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
timezoneOffset: z.number().finite().optional(),
|
||||
timezoneLabel: z.string().min(1),
|
||||
ayanamsa: z.string().min(1),
|
||||
ayanamsaDisplay: z.string().min(1),
|
||||
@@ -202,12 +204,21 @@ export const chartViewOkSchema = z.object({
|
||||
}),
|
||||
western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]),
|
||||
qizheng: z.union([chartViewQizhengOkSchema, chartViewUnavailableLayerSchema]),
|
||||
accountId: z.string().min(1).optional(),
|
||||
profileFingerprint: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const chartViewIdentitySchema = {
|
||||
accountId: z.string().min(1).optional(),
|
||||
profileFingerprint: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
export const chartViewMessageSchema = z.object({
|
||||
status: z.enum(["unauthenticated", "birth_profile_incomplete", "chart_unavailable", "rate_limited"]),
|
||||
billed: z.literal(false),
|
||||
message: z.string().min(1),
|
||||
reason: z.enum(CHART_BIRTH_FAILURE_REASONS).optional(),
|
||||
...chartViewIdentitySchema,
|
||||
});
|
||||
|
||||
export const chartViewResponseSchema = z.union([chartViewOkSchema, chartViewMessageSchema]);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ChartBirthFailureReason } from "./chart-birth-truth.ts";
|
||||
import type { ChartViewMessage } from "./chart-view-contract.ts";
|
||||
import { CHART_VIEW_COPY } from "./chart-view-labels.ts";
|
||||
|
||||
const FAILURE_COPY: Record<ChartBirthFailureReason, string> = {
|
||||
profile_query_error: CHART_VIEW_COPY.profileQueryError,
|
||||
profile_incomplete: CHART_VIEW_COPY.incomplete,
|
||||
adopted_calculation_incomplete: CHART_VIEW_COPY.adoptedIncomplete,
|
||||
timezone_resolver_failure: CHART_VIEW_COPY.timezoneFailure,
|
||||
engine_busy: CHART_VIEW_COPY.busy,
|
||||
engine_timeout: CHART_VIEW_COPY.unavailable,
|
||||
engine_bad_payload: CHART_VIEW_COPY.unavailable,
|
||||
engine_http_error: CHART_VIEW_COPY.unavailable,
|
||||
response_schema_failure: CHART_VIEW_COPY.schemaFailure,
|
||||
};
|
||||
|
||||
const FAILURE_STATUS: Record<ChartBirthFailureReason, ChartViewMessage["status"]> = {
|
||||
profile_query_error: "chart_unavailable",
|
||||
profile_incomplete: "birth_profile_incomplete",
|
||||
adopted_calculation_incomplete: "chart_unavailable",
|
||||
timezone_resolver_failure: "chart_unavailable",
|
||||
engine_busy: "chart_unavailable",
|
||||
engine_timeout: "chart_unavailable",
|
||||
engine_bad_payload: "chart_unavailable",
|
||||
engine_http_error: "chart_unavailable",
|
||||
response_schema_failure: "chart_unavailable",
|
||||
};
|
||||
|
||||
export function chartViewFailureResponse(reason: ChartBirthFailureReason): ChartViewMessage {
|
||||
return {
|
||||
status: FAILURE_STATUS[reason],
|
||||
billed: false,
|
||||
message: FAILURE_COPY[reason],
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function chartViewFailureStatus(reason: ChartBirthFailureReason): ChartViewMessage["status"] {
|
||||
return FAILURE_STATUS[reason];
|
||||
}
|
||||
|
||||
export function logChartViewFailure(input: {
|
||||
reason: ChartBirthFailureReason;
|
||||
elapsedMs?: number;
|
||||
httpStatus?: number | null;
|
||||
}): void {
|
||||
console.warn("chart_view_failure", {
|
||||
reason: input.reason,
|
||||
route: "/api/chart-view",
|
||||
elapsedMs: input.elapsedMs ?? null,
|
||||
httpStatus: input.httpStatus ?? null,
|
||||
});
|
||||
}
|
||||
@@ -158,6 +158,10 @@ export const CHART_VIEW_COPY = {
|
||||
waitingVarga: "这一分盘还没拿到。",
|
||||
unauthenticated: "请先登录后再看星盘。",
|
||||
incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。",
|
||||
profileQueryError: "星盘资料这会儿读不出来,我们已经记录下来了。",
|
||||
adoptedIncomplete: "已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。",
|
||||
timezoneFailure: "出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。",
|
||||
schemaFailure: "星盘结果的格式对不上,我们已经记录下来了。",
|
||||
rateLimited: "刚才打开得太勤,稍等再试。",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { resolveAyanamsa } from "./ayanamsa.ts";
|
||||
import type { ChartBirthFailureReason } from "./chart-birth-truth.ts";
|
||||
import {
|
||||
chartViewMessageSchema,
|
||||
type ChartViewResponse,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
type ChartViewLayer,
|
||||
type EngineCallResult,
|
||||
} from "./chart-view-engine.ts";
|
||||
import { chartViewFailureResponse } from "./chart-view-failure.ts";
|
||||
import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "./chart-view-labels.ts";
|
||||
import {
|
||||
buildChartView,
|
||||
@@ -28,13 +32,31 @@ export type ChartViewLoadInput = {
|
||||
asOf: string;
|
||||
rateLimited?: boolean;
|
||||
layers?: readonly ChartViewLayer[];
|
||||
accountId?: string | null;
|
||||
profileFingerprint?: string | null;
|
||||
};
|
||||
|
||||
function message(
|
||||
status: "unauthenticated" | "birth_profile_incomplete" | "chart_unavailable" | "rate_limited",
|
||||
copy: string,
|
||||
reason?: ChartBirthFailureReason,
|
||||
): ChartViewResponse {
|
||||
return chartViewMessageSchema.parse({ status, billed: false, message: copy });
|
||||
return chartViewMessageSchema.parse({ status, billed: false, message: copy, reason });
|
||||
}
|
||||
|
||||
function stamp(body: ChartViewResponse, input: ChartViewLoadInput): ChartViewResponse {
|
||||
return {
|
||||
...body,
|
||||
...(input.accountId ? { accountId: input.accountId } : {}),
|
||||
...(input.profileFingerprint ? { profileFingerprint: input.profileFingerprint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function engineFailureReason(status: Exclude<EngineCallResult["status"], "ok">): ChartBirthFailureReason {
|
||||
if (status === "busy") return "engine_busy";
|
||||
if (status === "timeout") return "engine_timeout";
|
||||
if (status === "bad_payload") return "engine_bad_payload";
|
||||
return "engine_http_error";
|
||||
}
|
||||
|
||||
function birthPayload(profile: ChartViewProfileInput): Record<string, unknown> {
|
||||
@@ -87,14 +109,14 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
if (input.rateLimited) {
|
||||
return {
|
||||
httpStatus: 429,
|
||||
body: message("rate_limited", CHART_VIEW_COPY.rateLimited),
|
||||
body: stamp(message("rate_limited", CHART_VIEW_COPY.rateLimited), input),
|
||||
};
|
||||
}
|
||||
const profile = input.profile;
|
||||
if (!profile) {
|
||||
return {
|
||||
httpStatus: 200,
|
||||
body: message("birth_profile_incomplete", CHART_VIEW_COPY.incomplete),
|
||||
body: stamp(chartViewFailureResponse("profile_incomplete"), input),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,10 +125,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
if (chartResult.status !== "ok") {
|
||||
return {
|
||||
httpStatus: 200,
|
||||
body: message(
|
||||
"chart_unavailable",
|
||||
chartResult.status === "busy" ? CHART_VIEW_COPY.busy : CHART_VIEW_COPY.unavailable,
|
||||
),
|
||||
body: stamp(chartViewFailureResponse(engineFailureReason(chartResult.status)), input),
|
||||
};
|
||||
}
|
||||
const chart = chartResult.payload;
|
||||
@@ -119,7 +138,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
});
|
||||
return {
|
||||
httpStatus: 200,
|
||||
body: message("chart_unavailable", CHART_VIEW_COPY.unavailable),
|
||||
body: stamp(chartViewFailureResponse("engine_bad_payload"), input),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,14 +184,15 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
profile,
|
||||
asOf: input.asOf,
|
||||
});
|
||||
return { httpStatus: 200, body };
|
||||
return { httpStatus: 200, body: stamp(body, input) };
|
||||
} catch (error) {
|
||||
const errorName = error instanceof Error ? error.name : "Error";
|
||||
const errorMessage = error instanceof Error ? error.message : "unknown";
|
||||
console.warn("chart_view_assemble_failed", { errorName, message: errorMessage });
|
||||
const reason = error instanceof ZodError ? "response_schema_failure" : "engine_bad_payload";
|
||||
return {
|
||||
httpStatus: 200,
|
||||
body: message("chart_unavailable", CHART_VIEW_COPY.unavailable),
|
||||
body: stamp(chartViewFailureResponse(reason), input),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +659,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
placeLabel,
|
||||
latitude: profile.latitude,
|
||||
longitude: profile.longitude,
|
||||
timezoneOffset: profile.timezoneOffset,
|
||||
timezoneLabel: profile.timezoneId || `UTC${profile.timezoneOffset >= 0 ? "+" : ""}${profile.timezoneOffset}`,
|
||||
ayanamsa,
|
||||
ayanamsaDisplay,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { BirthProfileTimezoneError, resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
|
||||
import {
|
||||
resolveServerOwnedChartBirth,
|
||||
unresolvedChartBirth,
|
||||
type ChartBirthFailureReason,
|
||||
type ServerOwnedChartBirth,
|
||||
} from "./chart-birth-truth.ts";
|
||||
import type { ChartViewProfileInput } from "./chart-view-mapper.ts";
|
||||
|
||||
export type ChartViewProfileRead =
|
||||
| { ok: true; profile: ChartViewProfileInput; birth: ServerOwnedChartBirth }
|
||||
| { ok: false; failure: ChartBirthFailureReason; birth: ServerOwnedChartBirth };
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isTimezoneFailure(error: unknown): boolean {
|
||||
return error instanceof BirthProfileTimezoneError
|
||||
|| (error instanceof Error && error.name === "BirthProfileTimezoneError");
|
||||
}
|
||||
|
||||
function chartInput(row: unknown, birth: ServerOwnedChartBirth): ChartViewProfileInput | null {
|
||||
if (!birth.chartable || birth.date == null || birth.time == null || birth.timezoneOffset == null) return null;
|
||||
const source = row !== null && typeof row === "object" && !Array.isArray(row)
|
||||
? row as Record<string, unknown>
|
||||
: {};
|
||||
const latitude = typeof source.latitude === "number" ? source.latitude : Number(source.latitude);
|
||||
const longitude = typeof source.longitude === "number" ? source.longitude : Number(source.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
return {
|
||||
name: text(source.name),
|
||||
date: birth.date,
|
||||
time: birth.time,
|
||||
placeLabel: text(source.birth_place_label),
|
||||
latitude,
|
||||
longitude,
|
||||
timezoneOffset: birth.timezoneOffset,
|
||||
timezoneId: birth.timezoneId ?? undefined,
|
||||
ayanamsa: typeof source.ayanamsa === "string" ? source.ayanamsa : undefined,
|
||||
birthTimeStatus: birth.status ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareChartViewProfile(input: {
|
||||
row: unknown;
|
||||
queryError?: unknown;
|
||||
resolveTimezone?: typeof resolveMissingBirthTimezoneOffset;
|
||||
}): Promise<ChartViewProfileRead> {
|
||||
if (input.queryError) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: "profile_query_error",
|
||||
birth: unresolvedChartBirth("profile_query_error"),
|
||||
};
|
||||
}
|
||||
const stored = resolveServerOwnedChartBirth(input.row);
|
||||
if (stored.failure === "adopted_calculation_incomplete") {
|
||||
return { ok: false, failure: stored.failure, birth: stored };
|
||||
}
|
||||
const resolveTimezone = input.resolveTimezone ?? resolveMissingBirthTimezoneOffset;
|
||||
let resolved: unknown;
|
||||
try {
|
||||
resolved = await resolveTimezone(input.row, { useActiveDate: true });
|
||||
} catch (error) {
|
||||
if (!isTimezoneFailure(error)) throw error;
|
||||
return {
|
||||
ok: false,
|
||||
failure: "timezone_resolver_failure",
|
||||
birth: {
|
||||
...stored,
|
||||
failure: "timezone_resolver_failure",
|
||||
chartable: false,
|
||||
timezoneOffset: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
const filled = resolveServerOwnedChartBirth(resolved);
|
||||
// Keep the stored-row fingerprint so account GET and chart-view name the
|
||||
// same profile even when a missing declared offset was filled in memory.
|
||||
const birth = { ...filled, fingerprint: stored.fingerprint };
|
||||
if (birth.failure === "adopted_calculation_incomplete") {
|
||||
return { ok: false, failure: birth.failure, birth };
|
||||
}
|
||||
const profile = chartInput(resolved, birth);
|
||||
if (!profile || birth.failure) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: birth.failure ?? "profile_incomplete",
|
||||
birth: { ...birth, failure: birth.failure ?? "profile_incomplete", chartable: false },
|
||||
};
|
||||
}
|
||||
return { ok: true, profile, birth };
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import "server-only";
|
||||
import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
|
||||
import { BirthProfileTimezoneError } from "./birth-profile-timezone.ts";
|
||||
|
||||
import { consumeRequestRateLimit } from "./request-rate-limit.ts";
|
||||
import {
|
||||
ACCOUNT_BIRTH_SELECT,
|
||||
globalBirthProfileFromAccountRow,
|
||||
} from "./server-owned-birth-profile.ts";
|
||||
import { ACCOUNT_BIRTH_SELECT } from "./server-owned-birth-profile.ts";
|
||||
import { createServerSupabaseClient } from "./supabase/server.ts";
|
||||
import { resolveAyanamsa } from "./ayanamsa.ts";
|
||||
import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts";
|
||||
import type { ChartViewProfileInput } from "./chart-view-mapper.ts";
|
||||
import type { ChartViewResponse } from "./chart-view-contract.ts";
|
||||
import { prepareChartViewProfile } from "./chart-view-profile-read.ts";
|
||||
import { chartViewFailureResponse, logChartViewFailure } from "./chart-view-failure.ts";
|
||||
import type { ChartBirthFailureReason } from "./chart-birth-truth.ts";
|
||||
import {
|
||||
CHART_VIEW_ENGINE_TIMEOUT_MS,
|
||||
chartViewEngineCacheKey,
|
||||
@@ -24,21 +24,6 @@ import {
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function finite(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function postEngine(path: string, body: Record<string, unknown>): Promise<EngineCallResult> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
@@ -104,30 +89,24 @@ function cachedPostEngine(userId: string, profile: ChartViewProfileInput): Chart
|
||||
};
|
||||
}
|
||||
|
||||
function profileFromRow(row: unknown): ChartViewProfileInput | null {
|
||||
const profile = globalBirthProfileFromAccountRow(row);
|
||||
const record = row && typeof row === "object" && !Array.isArray(row)
|
||||
? row as Record<string, unknown>
|
||||
: {};
|
||||
if (!profile.date || !profile.time) return null;
|
||||
const latitude = finite(profile.latitude);
|
||||
const longitude = finite(profile.longitude);
|
||||
const timezoneOffset = finite(profile.timezoneOffset);
|
||||
if (latitude === undefined || longitude === undefined || timezoneOffset === undefined) return null;
|
||||
function failureBody(reason: ChartBirthFailureReason, accountId: string, fingerprint?: string | null): ChartViewResponse {
|
||||
return {
|
||||
name: profile.name,
|
||||
date: profile.date,
|
||||
time: profile.time,
|
||||
placeLabel: text(record.birth_place_label),
|
||||
latitude,
|
||||
longitude,
|
||||
timezoneOffset,
|
||||
timezoneId: profile.timezoneId,
|
||||
ayanamsa: typeof profile.ayanamsa === "string" ? profile.ayanamsa : undefined,
|
||||
birthTimeStatus: profile.birthTimeStatus,
|
||||
...chartViewFailureResponse(reason),
|
||||
accountId,
|
||||
...(fingerprint ? { profileFingerprint: fingerprint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function classifyThrown(error: unknown): ChartBirthFailureReason {
|
||||
if (error instanceof BirthProfileTimezoneError || (error instanceof Error && error.name === "BirthProfileTimezoneError")) {
|
||||
return "timezone_resolver_failure";
|
||||
}
|
||||
if (error instanceof Error && error.message === "adopted_birth_calculation_incomplete") {
|
||||
return "adopted_calculation_incomplete";
|
||||
}
|
||||
return "response_schema_failure";
|
||||
}
|
||||
|
||||
export async function loadChartView(input: {
|
||||
now?: Date;
|
||||
layers?: readonly ChartViewLayer[];
|
||||
@@ -148,25 +127,67 @@ export async function loadChartView(input: {
|
||||
});
|
||||
}
|
||||
|
||||
const limited = consumeRequestRateLimit({
|
||||
key: `chartView:${user.id}`,
|
||||
limit: 20,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
const started = Date.now();
|
||||
try {
|
||||
const limited = consumeRequestRateLimit({
|
||||
key: `chartView:${user.id}`,
|
||||
limit: 20,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
if (!limited.ok) {
|
||||
return assembleChartView({
|
||||
userId: user.id,
|
||||
accountId: user.id,
|
||||
profile: null,
|
||||
postEngine,
|
||||
asOf: now.toISOString().slice(0, 10),
|
||||
rateLimited: true,
|
||||
layers: input.layers,
|
||||
});
|
||||
}
|
||||
|
||||
const { data: row } = await supabase
|
||||
.from("profiles")
|
||||
.select(ACCOUNT_BIRTH_SELECT)
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
const { data: row, error } = await supabase
|
||||
.from("profiles")
|
||||
.select(ACCOUNT_BIRTH_SELECT)
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
logChartViewFailure({
|
||||
reason: "profile_query_error",
|
||||
elapsedMs: Date.now() - started,
|
||||
httpStatus: null,
|
||||
});
|
||||
return { httpStatus: 200, body: failureBody("profile_query_error", user.id) };
|
||||
}
|
||||
|
||||
const profile = profileFromRow(await resolveMissingBirthTimezoneOffset(row, { useActiveDate: true }));
|
||||
return assembleChartView({
|
||||
userId: user.id,
|
||||
profile,
|
||||
postEngine: profile ? cachedPostEngine(user.id, profile) : postEngine,
|
||||
asOf: now.toISOString().slice(0, 10),
|
||||
rateLimited: !limited.ok,
|
||||
layers: input.layers,
|
||||
});
|
||||
const prepared = await prepareChartViewProfile({ row });
|
||||
if (!prepared.ok) {
|
||||
logChartViewFailure({
|
||||
reason: prepared.failure,
|
||||
elapsedMs: Date.now() - started,
|
||||
httpStatus: 200,
|
||||
});
|
||||
return {
|
||||
httpStatus: 200,
|
||||
body: failureBody(prepared.failure, user.id, prepared.birth.fingerprint),
|
||||
};
|
||||
}
|
||||
return assembleChartView({
|
||||
userId: user.id,
|
||||
accountId: user.id,
|
||||
profileFingerprint: prepared.birth.fingerprint,
|
||||
profile: prepared.profile,
|
||||
postEngine: cachedPostEngine(user.id, prepared.profile),
|
||||
asOf: now.toISOString().slice(0, 10),
|
||||
layers: input.layers,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = classifyThrown(error);
|
||||
logChartViewFailure({
|
||||
reason,
|
||||
elapsedMs: Date.now() - started,
|
||||
httpStatus: 200,
|
||||
});
|
||||
return { httpStatus: 200, body: failureBody(reason, user.id) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ export type Account = {
|
||||
hasConfirmedBirthTime: boolean;
|
||||
hasUsableBirthTime: boolean;
|
||||
profile: unknown;
|
||||
chartBirth?: { fingerprint?: string | null } | null;
|
||||
};
|
||||
export type OnboardingStep = "name" | "birth" | "place" | "rectification";
|
||||
export type AccountDialog = "profile" | "chart-library" | "billing" | "general" | "logout";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchChartView } from "@/lib/chart-view-client";
|
||||
import type { ChartViewResponse } from "@/lib/chart-view-contract";
|
||||
import { CHART_VIEW_COPY } from "@/lib/chart-view-labels";
|
||||
import { chartViewFailureResponse } from "@/lib/chart-view-failure";
|
||||
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
|
||||
|
||||
export const REPORTS_WAITING_COPY = "报告列表还没拿到。";
|
||||
@@ -76,15 +76,122 @@ function createMemoryCache<T>(load: () => Promise<T>): MemoryCache<T> {
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableChart(): ChartViewResponse {
|
||||
return { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable };
|
||||
export type ChartSnapshotIdentity = {
|
||||
accountId: string;
|
||||
fingerprint: string;
|
||||
};
|
||||
|
||||
type ChartCacheSlot = {
|
||||
identity: ChartSnapshotIdentity | null;
|
||||
snapshot: ChartPageSnapshot;
|
||||
};
|
||||
|
||||
let chartEpoch = 0;
|
||||
let chartAccountReadGeneration = 0;
|
||||
let pinnedChartIdentity: ChartSnapshotIdentity | null = null;
|
||||
let chartSlot: ChartCacheSlot | null = null;
|
||||
let chartInflight: { epoch: number; promise: Promise<ChartPageSnapshot> } | null = null;
|
||||
|
||||
function sameChartIdentity(left: ChartSnapshotIdentity | null, right: ChartSnapshotIdentity | null): boolean {
|
||||
return Boolean(left && right && left.accountId === right.accountId && left.fingerprint === right.fingerprint);
|
||||
}
|
||||
|
||||
const chartCache = createMemoryCache<ChartPageSnapshot>(async () => {
|
||||
const result = await fetchChartView({});
|
||||
export function readChartSnapshotIdentity(snapshot: ChartPageSnapshot): ChartSnapshotIdentity | null {
|
||||
if (snapshot.kind !== "view") return null;
|
||||
const accountId = typeof snapshot.view.accountId === "string" ? snapshot.view.accountId : "";
|
||||
const fingerprint = typeof snapshot.view.profileFingerprint === "string" ? snapshot.view.profileFingerprint : "";
|
||||
if (!accountId || !fingerprint) return null;
|
||||
return { accountId, fingerprint };
|
||||
}
|
||||
|
||||
function snapshotFromChartResult(result: { httpStatus: number; body: ChartViewResponse | null }): ChartPageSnapshot {
|
||||
if (result.httpStatus === 401) return { kind: "unauthenticated" };
|
||||
return { kind: "view", view: result.body ?? unavailableChart() };
|
||||
});
|
||||
return {
|
||||
kind: "view",
|
||||
view: result.body ?? chartViewFailureResponse("response_schema_failure"),
|
||||
};
|
||||
}
|
||||
|
||||
function pinnedChartFailure(): ChartPageSnapshot {
|
||||
const pin = pinnedChartIdentity;
|
||||
return {
|
||||
kind: "view",
|
||||
view: {
|
||||
...chartViewFailureResponse("response_schema_failure"),
|
||||
...(pin ? { accountId: pin.accountId, profileFingerprint: pin.fingerprint } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rememberChartSnapshot(snapshot: ChartPageSnapshot): ChartPageSnapshot {
|
||||
const identity = readChartSnapshotIdentity(snapshot);
|
||||
chartSlot = {
|
||||
snapshot,
|
||||
identity: identity ?? (pinnedChartIdentity && snapshot.kind === "view" ? { ...pinnedChartIdentity } : null),
|
||||
};
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function chartSnapshotVisible(): ChartPageSnapshot | null {
|
||||
if (!chartSlot) return null;
|
||||
if (chartSlot.snapshot.kind === "unauthenticated") return chartSlot.snapshot;
|
||||
if (!pinnedChartIdentity) return chartSlot.snapshot;
|
||||
if (!chartSlot.identity) {
|
||||
return chartSlot.snapshot.kind === "view" && chartSlot.snapshot.view.status !== "ok"
|
||||
? chartSlot.snapshot
|
||||
: null;
|
||||
}
|
||||
return sameChartIdentity(chartSlot.identity, pinnedChartIdentity) ? chartSlot.snapshot : null;
|
||||
}
|
||||
|
||||
function chartSnapshotMatchesPin(snapshot: ChartPageSnapshot): boolean {
|
||||
if (!pinnedChartIdentity) return true;
|
||||
if (snapshot.kind === "unauthenticated") return true;
|
||||
const identity = readChartSnapshotIdentity(snapshot);
|
||||
if (!identity) return snapshot.kind === "view" && snapshot.view.status !== "ok";
|
||||
return sameChartIdentity(identity, pinnedChartIdentity);
|
||||
}
|
||||
|
||||
async function loadChartSnapshot(started: number): Promise<ChartPageSnapshot> {
|
||||
const first = snapshotFromChartResult(await fetchChartView({}));
|
||||
if (started !== chartEpoch) return refreshChartPage();
|
||||
let snapshot = first;
|
||||
if (pinnedChartIdentity && !chartSnapshotMatchesPin(snapshot)) {
|
||||
snapshot = snapshotFromChartResult(await fetchChartView({}));
|
||||
if (started !== chartEpoch) return refreshChartPage();
|
||||
}
|
||||
if (!chartSnapshotMatchesPin(snapshot)) return rememberChartSnapshot(pinnedChartFailure());
|
||||
return rememberChartSnapshot(snapshot);
|
||||
}
|
||||
|
||||
function refreshChartSnapshot(): Promise<ChartPageSnapshot> {
|
||||
if (chartInflight && chartInflight.epoch === chartEpoch) return chartInflight.promise;
|
||||
const started = chartEpoch;
|
||||
const promise = loadChartSnapshot(started).finally(() => {
|
||||
if (chartInflight?.promise === promise) chartInflight = null;
|
||||
});
|
||||
chartInflight = { epoch: started, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function beginChartCacheAccountRead(): number {
|
||||
return chartAccountReadGeneration;
|
||||
}
|
||||
|
||||
export function accountChartPinStillCurrent(started: number): boolean {
|
||||
return started === chartAccountReadGeneration;
|
||||
}
|
||||
|
||||
export function pinChartSnapshotIdentity(identity: ChartSnapshotIdentity): void {
|
||||
if (!identity.accountId || !identity.fingerprint) return;
|
||||
if (sameChartIdentity(pinnedChartIdentity, identity)) return;
|
||||
pinnedChartIdentity = { accountId: identity.accountId, fingerprint: identity.fingerprint };
|
||||
chartEpoch += 1;
|
||||
chartAccountReadGeneration += 1;
|
||||
if (!sameChartIdentity(chartSlot?.identity ?? null, pinnedChartIdentity)) {
|
||||
chartSlot = null;
|
||||
}
|
||||
}
|
||||
|
||||
const ephemerisCache = createMemoryCache<EphemerisPageSnapshot>(async () => {
|
||||
const response = await fetch("/api/ephemeris", {
|
||||
@@ -144,15 +251,20 @@ const reportsCache = createMemoryCache<ReportsPageSnapshot>(async () => {
|
||||
});
|
||||
|
||||
export function peekChartPage(): ChartPageSnapshot | null {
|
||||
return chartCache.peek();
|
||||
return chartSnapshotVisible();
|
||||
}
|
||||
|
||||
export function writeChartPage(snapshot: ChartPageSnapshot): ChartPageSnapshot {
|
||||
return chartCache.write(snapshot);
|
||||
if (pinnedChartIdentity && !chartSnapshotMatchesPin(snapshot)) {
|
||||
const current = chartSnapshotVisible();
|
||||
if (current) return current;
|
||||
return rememberChartSnapshot(pinnedChartFailure());
|
||||
}
|
||||
return rememberChartSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function refreshChartPage(): Promise<ChartPageSnapshot> {
|
||||
return chartCache.refresh();
|
||||
return refreshChartSnapshot();
|
||||
}
|
||||
|
||||
export function peekEphemerisPage(): EphemerisPageSnapshot | null {
|
||||
@@ -180,13 +292,17 @@ export function refreshReportsPage(): Promise<ReportsPageSnapshot> {
|
||||
}
|
||||
|
||||
export function prefetchSecondaryPage(href: string): void {
|
||||
if (href === "/chart" || href.startsWith("/chart?")) chartCache.prefetch();
|
||||
if (href === "/chart" || href.startsWith("/chart?")) void refreshChartPage();
|
||||
else if (href === "/ephemeris" || href.startsWith("/ephemeris?")) ephemerisCache.prefetch();
|
||||
else if (href === "/reports" || href.startsWith("/reports?")) reportsCache.prefetch();
|
||||
}
|
||||
|
||||
export function resetSecondaryPageDataForTests(): void {
|
||||
chartCache.reset();
|
||||
chartEpoch += 1;
|
||||
chartAccountReadGeneration += 1;
|
||||
pinnedChartIdentity = null;
|
||||
chartSlot = null;
|
||||
chartInflight = null;
|
||||
ephemerisCache.reset();
|
||||
reportsCache.reset();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyAdoptedBirthDateGuard,
|
||||
chartBirthAfterAccountWrite,
|
||||
CHART_BIRTH_FAILURE_REASONS,
|
||||
resolveServerOwnedChartBirth,
|
||||
} from "../src/lib/chart-birth-truth.ts";
|
||||
import { resolveAccountBirthTimeApplicationPatch } from "../src/lib/account-profile-patch.ts";
|
||||
import { BirthProfileTimezoneError } from "../src/lib/birth-profile-timezone.ts";
|
||||
import { assembleChartView, type ChartViewEnginePost } from "../src/lib/chart-view-load.ts";
|
||||
import { chartViewMessageSchema, chartViewOkSchema } from "../src/lib/chart-view-contract.ts";
|
||||
import { chartViewEngineCacheKey } from "../src/lib/chart-view-engine.ts";
|
||||
import {
|
||||
chartViewFailureResponse,
|
||||
chartViewFailureStatus,
|
||||
logChartViewFailure,
|
||||
} from "../src/lib/chart-view-failure.ts";
|
||||
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
|
||||
import { prepareChartViewProfile } from "../src/lib/chart-view-profile-read.ts";
|
||||
import { fetchChartView } from "../src/lib/chart-view-client.ts";
|
||||
import {
|
||||
beginChartCacheAccountRead,
|
||||
accountChartPinStillCurrent,
|
||||
peekChartPage,
|
||||
pinChartSnapshotIdentity,
|
||||
refreshChartPage,
|
||||
resetSecondaryPageDataForTests,
|
||||
writeChartPage,
|
||||
} from "../src/lib/secondary-page-data.ts";
|
||||
import type { ChartViewResponse } from "../src/lib/chart-view-contract.ts";
|
||||
|
||||
const golden = JSON.parse(
|
||||
readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"),
|
||||
) as { chart: Record<string, unknown> };
|
||||
|
||||
const crossMidnight = {
|
||||
name: "Synthetic",
|
||||
birth_date: "2000-06-15",
|
||||
reported_birth_time: "00:10",
|
||||
active_birth_date: "2000-06-14",
|
||||
active_birth_time: "23:55",
|
||||
active_birth_timezone_offset: -5,
|
||||
active_birth_provenance: { contract: "dated-v1", candidate_id: "synthetic-candidate" },
|
||||
birth_time_status: "accepted",
|
||||
birth_time_source: "family_exact",
|
||||
birth_time_period: null,
|
||||
declared_window_start: null,
|
||||
declared_window_end: null,
|
||||
birth_time_clue: null,
|
||||
uncertainty_before_minutes: 15,
|
||||
uncertainty_after_minutes: 15,
|
||||
latitude: 40,
|
||||
longitude: -74,
|
||||
timezone_id: "America/New_York",
|
||||
timezone_offset: -4,
|
||||
ayanamsa: "raman",
|
||||
birth_place_label: "Synthetic place",
|
||||
rectification_case_id: null,
|
||||
birth_time: null,
|
||||
};
|
||||
|
||||
function identityResolver(row: unknown): Promise<unknown> {
|
||||
return Promise.resolve(row);
|
||||
}
|
||||
|
||||
test("account and chart-view share server-owned date, time, offset, and provenance", async () => {
|
||||
const reported = { ...crossMidnight, birth_time_status: "reported" };
|
||||
const accepted = crossMidnight;
|
||||
const confirmed = { ...crossMidnight, birth_time_status: "confirmed" };
|
||||
for (const row of [reported, accepted, confirmed]) {
|
||||
const fromAccount = resolveServerOwnedChartBirth(row);
|
||||
const prepared = await prepareChartViewProfile({ row, resolveTimezone: identityResolver });
|
||||
assert.equal(prepared.ok, fromAccount.chartable);
|
||||
assert.equal(prepared.birth.date, fromAccount.date);
|
||||
assert.equal(prepared.birth.time, fromAccount.time);
|
||||
assert.equal(prepared.birth.timezoneOffset, fromAccount.timezoneOffset);
|
||||
assert.equal(prepared.birth.fingerprint, fromAccount.fingerprint);
|
||||
assert.deepEqual(prepared.birth.provenance, fromAccount.provenance);
|
||||
assert.equal(prepared.birth.failure, fromAccount.failure);
|
||||
if (prepared.ok) {
|
||||
assert.equal(prepared.profile.date, fromAccount.date);
|
||||
assert.equal(prepared.profile.time, fromAccount.time);
|
||||
assert.equal(prepared.profile.timezoneOffset, fromAccount.timezoneOffset);
|
||||
}
|
||||
}
|
||||
|
||||
const reportedBirth = resolveServerOwnedChartBirth(reported);
|
||||
assert.equal(reportedBirth.date, "2000-06-15");
|
||||
assert.equal(reportedBirth.time, "00:10");
|
||||
assert.equal(reportedBirth.timezoneOffset, -4);
|
||||
assert.equal(reportedBirth.adoption, "declared");
|
||||
|
||||
const adoptedBirth = resolveServerOwnedChartBirth(accepted);
|
||||
assert.equal(adoptedBirth.date, "2000-06-14");
|
||||
assert.equal(adoptedBirth.time, "23:55");
|
||||
assert.equal(adoptedBirth.timezoneOffset, -5);
|
||||
assert.equal(adoptedBirth.adoption, "adopted");
|
||||
assert.equal(adoptedBirth.failure, null);
|
||||
});
|
||||
|
||||
test("confirmed ordinary edits keep the active minute and clear a complete adopted tuple together", () => {
|
||||
const confirmed = { ...crossMidnight, birth_time_status: "confirmed" as const };
|
||||
const patch = {
|
||||
birth_date: "2000-06-16",
|
||||
reported_birth_time: "00:20",
|
||||
birth_time_source: "approximate" as const,
|
||||
birth_time_period: null,
|
||||
birth_time_clue: null,
|
||||
uncertainty_before_minutes: 30,
|
||||
uncertainty_after_minutes: 30,
|
||||
};
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, patch), {});
|
||||
const stored = applyAdoptedBirthDateGuard(confirmed, { ...confirmed, ...patch });
|
||||
assert.equal(stored.active_birth_time, "23:55");
|
||||
assert.equal(stored.active_birth_date, null);
|
||||
assert.equal(stored.active_birth_timezone_offset, null);
|
||||
assert.equal(stored.active_birth_provenance, null);
|
||||
const birth = resolveServerOwnedChartBirth(stored);
|
||||
assert.equal(birth.status, "confirmed");
|
||||
assert.equal(birth.time, "23:55");
|
||||
assert.equal(birth.date, "2000-06-16");
|
||||
assert.equal(birth.timezoneOffset, -4);
|
||||
assert.equal(birth.adoption, "legacy");
|
||||
assert.equal(birth.failure, null);
|
||||
const echoed = chartBirthAfterAccountWrite({
|
||||
current: confirmed,
|
||||
written: { ...confirmed, ...patch },
|
||||
returned: stored,
|
||||
});
|
||||
assert.equal(echoed.fingerprint, birth.fingerprint);
|
||||
assert.equal(echoed.date, birth.date);
|
||||
assert.equal(echoed.time, birth.time);
|
||||
assert.equal(echoed.timezoneOffset, birth.timezoneOffset);
|
||||
});
|
||||
|
||||
test("an accepted declaration edit does not stay chartable with a partial adopted tuple", () => {
|
||||
const patch = {
|
||||
birth_date: "2000-06-15",
|
||||
reported_birth_time: "01:10",
|
||||
birth_time_source: "approximate" as const,
|
||||
birth_time_period: null,
|
||||
birth_time_clue: null,
|
||||
uncertainty_before_minutes: 30,
|
||||
uncertainty_after_minutes: 30,
|
||||
};
|
||||
const application = resolveAccountBirthTimeApplicationPatch(crossMidnight, patch);
|
||||
assert.deepEqual(application, {
|
||||
active_birth_time: null,
|
||||
birth_time_status: "reported",
|
||||
rectification_case_id: null,
|
||||
});
|
||||
const current = { ...crossMidnight } as Record<string, unknown>;
|
||||
const stored = applyAdoptedBirthDateGuard(current, { ...current, ...patch, ...application });
|
||||
assert.equal(stored.active_birth_time, null);
|
||||
assert.equal(stored.active_birth_date, null);
|
||||
assert.equal(stored.active_birth_timezone_offset, null);
|
||||
const birth = resolveServerOwnedChartBirth(stored);
|
||||
assert.equal(birth.status, "reported");
|
||||
assert.equal(birth.date, "2000-06-15");
|
||||
assert.equal(birth.time, "01:10");
|
||||
assert.equal(birth.timezoneOffset, -4);
|
||||
assert.notEqual(birth.adoption, "incomplete");
|
||||
assert.equal(birth.failure, null);
|
||||
});
|
||||
|
||||
test("a missing adopted offset is not charted and is not called a profile query or incomplete profile", async () => {
|
||||
let resolverCalls = 0;
|
||||
const partial = { ...crossMidnight, active_birth_timezone_offset: null };
|
||||
const birth = resolveServerOwnedChartBirth(partial);
|
||||
assert.equal(birth.failure, "adopted_calculation_incomplete");
|
||||
assert.equal(birth.chartable, false);
|
||||
assert.equal(birth.date, null);
|
||||
assert.equal(birth.timezoneOffset, null);
|
||||
assert.equal(birth.activeTime, "23:55");
|
||||
const prepared = await prepareChartViewProfile({
|
||||
row: partial,
|
||||
resolveTimezone: async () => {
|
||||
resolverCalls += 1;
|
||||
throw new Error("must not resolve an incomplete adopted tuple");
|
||||
},
|
||||
});
|
||||
assert.equal(prepared.ok, false);
|
||||
if (prepared.ok) return;
|
||||
assert.equal(prepared.failure, "adopted_calculation_incomplete");
|
||||
assert.equal(resolverCalls, 0);
|
||||
assert.equal(chartViewFailureStatus("adopted_calculation_incomplete"), "chart_unavailable");
|
||||
assert.notEqual(chartViewFailureResponse("adopted_calculation_incomplete").status, "birth_profile_incomplete");
|
||||
});
|
||||
|
||||
test("chart-view classifies query, timezone, engine, and schema failures separately", async () => {
|
||||
const query = await prepareChartViewProfile({
|
||||
row: crossMidnight,
|
||||
queryError: { message: "connection reset" },
|
||||
resolveTimezone: async () => {
|
||||
throw new Error("must not read a failed profile query");
|
||||
},
|
||||
});
|
||||
assert.equal(query.ok, false);
|
||||
if (!query.ok) {
|
||||
assert.equal(query.failure, "profile_query_error");
|
||||
assert.notEqual(query.failure, "profile_incomplete");
|
||||
assert.equal(chartViewFailureResponse("profile_query_error").status, "chart_unavailable");
|
||||
assert.match(chartViewFailureResponse("profile_query_error").message, /读不出来/);
|
||||
}
|
||||
|
||||
const missingOffset = {
|
||||
...crossMidnight,
|
||||
birth_time_status: "reported",
|
||||
active_birth_date: null,
|
||||
active_birth_timezone_offset: null,
|
||||
timezone_offset: null,
|
||||
};
|
||||
const timezone = await prepareChartViewProfile({
|
||||
row: missingOffset,
|
||||
resolveTimezone: async () => {
|
||||
throw new BirthProfileTimezoneError();
|
||||
},
|
||||
});
|
||||
assert.equal(timezone.ok, false);
|
||||
if (!timezone.ok) assert.equal(timezone.failure, "timezone_resolver_failure");
|
||||
|
||||
const messages = [
|
||||
"profile_query_error",
|
||||
"profile_incomplete",
|
||||
"adopted_calculation_incomplete",
|
||||
"timezone_resolver_failure",
|
||||
"response_schema_failure",
|
||||
].map((reason) => chartViewFailureResponse(reason as typeof CHART_BIRTH_FAILURE_REASONS[number]).message);
|
||||
assert.equal(new Set(messages).size, messages.length);
|
||||
for (const reason of CHART_BIRTH_FAILURE_REASONS) {
|
||||
const body = chartViewMessageSchema.parse(chartViewFailureResponse(reason));
|
||||
assert.equal(body.reason, reason);
|
||||
assert.doesNotMatch(body.message, /没有可显示内容|过一会儿再打开/);
|
||||
}
|
||||
assert.equal(chartViewFailureResponse("engine_busy").message, CHART_VIEW_COPY.busy);
|
||||
assert.equal(chartViewFailureResponse("engine_timeout").message, CHART_VIEW_COPY.unavailable);
|
||||
assert.equal(chartViewFailureResponse("engine_bad_payload").message, CHART_VIEW_COPY.unavailable);
|
||||
|
||||
const warnings: unknown[] = [];
|
||||
const original = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push(args);
|
||||
};
|
||||
try {
|
||||
logChartViewFailure({ reason: "profile_query_error", elapsedMs: 4, httpStatus: null });
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
const line = JSON.stringify(warnings);
|
||||
assert.match(line, /profile_query_error/);
|
||||
assert.match(line, /\/api\/chart-view/);
|
||||
assert.doesNotMatch(line, /2000-06-15|23:55|Synthetic|-74/);
|
||||
});
|
||||
|
||||
test("cross-midnight chart-view posts the adopted date, minute, and offset and still returns D1", async () => {
|
||||
const prepared = await prepareChartViewProfile({ row: crossMidnight, resolveTimezone: identityResolver });
|
||||
assert.equal(prepared.ok, true);
|
||||
if (!prepared.ok) return;
|
||||
const bodies: Array<{ path: string; body: Record<string, unknown> }> = [];
|
||||
const postEngine: ChartViewEnginePost = async (path, body) => {
|
||||
bodies.push({ path, body });
|
||||
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
|
||||
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||||
};
|
||||
const result = await assembleChartView({
|
||||
userId: "synthetic-user",
|
||||
accountId: "synthetic-user",
|
||||
profileFingerprint: prepared.birth.fingerprint,
|
||||
profile: prepared.profile,
|
||||
postEngine,
|
||||
asOf: "2026-09-22",
|
||||
});
|
||||
const view = chartViewOkSchema.parse(result.body);
|
||||
assert.equal(view.profile.date, "2000-06-14");
|
||||
assert.equal(view.profile.time.slice(0, 5), "23:55");
|
||||
assert.equal(view.profile.timezoneOffset, -5);
|
||||
assert.equal(view.profileFingerprint, prepared.birth.fingerprint);
|
||||
assert.equal(view.accountId, "synthetic-user");
|
||||
const d1 = view.vedic.vargas.find((item) => item.id === "D1");
|
||||
assert.ok(d1);
|
||||
assert.equal(d1?.chart.houses.length, 12);
|
||||
assert.ok(view.vedic.planets.length >= 9);
|
||||
const natal = bodies.find((item) => item.path === "/api/chart");
|
||||
assert.equal(natal?.body.day, 14);
|
||||
assert.equal(natal?.body.hour, 23);
|
||||
assert.equal(natal?.body.minute, 55);
|
||||
assert.equal(natal?.body.tz, -5);
|
||||
assert.deepEqual(bodies.map((item) => item.path), ["/api/chart"]);
|
||||
});
|
||||
|
||||
test("engine cache keys still include date, time, offset, ayanamsa, and node mode", () => {
|
||||
const base = {
|
||||
userId: "synthetic-user",
|
||||
date: "2000-06-14",
|
||||
time: "23:55",
|
||||
latitude: 40,
|
||||
longitude: -74,
|
||||
timezoneOffset: -5,
|
||||
ayanamsa: "raman",
|
||||
nodeMode: "mean",
|
||||
path: "/api/chart",
|
||||
};
|
||||
assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, date: "2000-06-15" }));
|
||||
assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, time: "23:56" }));
|
||||
assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, timezoneOffset: -4 }));
|
||||
assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, ayanamsa: "lahiri" }));
|
||||
assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, nodeMode: "true" }));
|
||||
});
|
||||
|
||||
test("a saved profile pin drops the previous chart snapshot and ignores an in-flight old response", async () => {
|
||||
resetSecondaryPageDataForTests();
|
||||
const oldView = {
|
||||
status: "ok",
|
||||
billed: false,
|
||||
accountId: "acct-a",
|
||||
profileFingerprint: "fingerprint-old",
|
||||
profile: { date: "1990-01-01", time: "01:00", timezoneOffset: 8 },
|
||||
} as ChartViewResponse;
|
||||
writeChartPage({ kind: "view", view: oldView });
|
||||
assert.equal(peekChartPage()?.kind, "view");
|
||||
pinChartSnapshotIdentity({ accountId: "acct-a", fingerprint: "fingerprint-new" });
|
||||
assert.equal(peekChartPage(), null);
|
||||
pinChartSnapshotIdentity({ accountId: "acct-b", fingerprint: "fingerprint-new" });
|
||||
writeChartPage({
|
||||
kind: "view",
|
||||
view: { ...oldView, accountId: "acct-a", profileFingerprint: "fingerprint-new" },
|
||||
});
|
||||
const foreign = peekChartPage();
|
||||
assert.notEqual(foreign?.kind === "view" && foreign.view.status === "ok", true);
|
||||
if (foreign?.kind === "view") {
|
||||
assert.equal(foreign.view.accountId, "acct-b");
|
||||
assert.doesNotMatch(JSON.stringify(foreign.view), /1990-01-01/);
|
||||
}
|
||||
|
||||
resetSecondaryPageDataForTests();
|
||||
let releaseFirst: (response: Response) => void = () => {};
|
||||
let markStarted: () => void = () => {};
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (!url.includes("/api/chart-view")) throw new Error(`unexpected fetch ${url}`);
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
markStarted();
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
status: "chart_unavailable",
|
||||
billed: false,
|
||||
message: "新资料还不能排。",
|
||||
reason: "profile_incomplete",
|
||||
accountId: "acct-a",
|
||||
profileFingerprint: "fingerprint-new",
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const pending = refreshChartPage();
|
||||
await started;
|
||||
pinChartSnapshotIdentity({ accountId: "acct-a", fingerprint: "fingerprint-new" });
|
||||
releaseFirst(new Response(JSON.stringify(oldView), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}));
|
||||
const snapshot = await pending;
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(snapshot.kind, "view");
|
||||
if (snapshot.kind !== "view") return;
|
||||
assert.equal(snapshot.view.profileFingerprint, "fingerprint-new");
|
||||
assert.notEqual(snapshot.view.profileFingerprint, "fingerprint-old");
|
||||
const visible = peekChartPage();
|
||||
assert.equal(visible?.kind, "view");
|
||||
if (visible?.kind === "view") {
|
||||
assert.equal(visible.view.profileFingerprint, "fingerprint-new");
|
||||
assert.doesNotMatch(JSON.stringify(visible.view), /1990-01-01/);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetSecondaryPageDataForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("an account read that overlaps a profile save does not pin the stale fingerprint", () => {
|
||||
resetSecondaryPageDataForTests();
|
||||
const started = beginChartCacheAccountRead();
|
||||
pinChartSnapshotIdentity({ accountId: "acct-a", fingerprint: "fingerprint-new" });
|
||||
assert.equal(accountChartPinStillCurrent(started), false);
|
||||
const reread = beginChartCacheAccountRead();
|
||||
assert.equal(accountChartPinStillCurrent(reread), true);
|
||||
resetSecondaryPageDataForTests();
|
||||
});
|
||||
|
||||
test("chart-view schema failures stay on the page as a named format error", async () => {
|
||||
resetSecondaryPageDataForTests();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => new Response("not-json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
const result = await fetchChartView({});
|
||||
const body = result.body;
|
||||
if (!body || body.status === "ok") {
|
||||
assert.fail("schema failure should stay a chart-view message");
|
||||
}
|
||||
assert.equal(body.reason, "response_schema_failure");
|
||||
assert.match(body.message, /格式对不上/);
|
||||
assert.doesNotMatch(body.message, /没有可显示内容|过一会儿再打开/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetSecondaryPageDataForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("the adopted-date trigger clears the whole tuple and chart-view does not read chart_profiles", () => {
|
||||
const migration = readFileSync(
|
||||
new URL("../supabase/migrations/20260920020000_adopted_birth_date.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const service = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8");
|
||||
const account = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
|
||||
const onboarding = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8");
|
||||
const guard = migration.slice(
|
||||
migration.indexOf("function public.guard_adopted_birth_date"),
|
||||
migration.indexOf("drop trigger if exists zz_guard_adopted_birth_date"),
|
||||
);
|
||||
assert.match(guard, /new\.active_birth_date := null/);
|
||||
assert.match(guard, /new\.active_birth_timezone_offset := null/);
|
||||
assert.match(guard, /new\.active_birth_provenance := null/);
|
||||
assert.doesNotMatch(guard, /new\.active_birth_time := null/);
|
||||
assert.match(service, /const \{ data: row, error \}/);
|
||||
assert.match(service, /profile_query_error/);
|
||||
assert.doesNotMatch(service, /chart_profiles/);
|
||||
assert.match(account, /chartBirth: resolveServerOwnedChartBirth\(profile\)/);
|
||||
assert.match(account, /chartBirthAfterAccountWrite/);
|
||||
assert.match(onboarding, /pinChartSnapshotIdentity/);
|
||||
assert.doesNotMatch(
|
||||
readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"),
|
||||
/pinChartSnapshotIdentity|chartBirth/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user