fix(chart): keep account and chart-view birth truth consistent

This commit is contained in:
jesse-ux
2026-09-22 12:14:50 +08:00
parent dd4141a030
commit cc7ef8a339
21 changed files with 1243 additions and 90 deletions
+8
View File
@@ -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,再划掉本条。
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-22 — 更新出生资料后,星盘跟的是同一份时间
保存出生资料后,星盘不再沿用刚才那张盘。读不到资料、已采用的时间缺时区、时区对不上、盘算不出来,页面上分开说明,不再只剩一句没有内容。Skill 版本不变。
## 2026-09-22 — 普通聊天按会话绑定的人物排盘
普通聊天不再在切换人物后仍用登录用户本人的资料。会话锁定的是本人或当前用户名下的另一份资料;对方资料缺失、被删或不完整时会拦住发送,不会退回本人。已有消息的会话不能改绑。报告、每日星语和生时校正仍只用本人资料。Skill 版本不变。
+16
View File
@@ -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
View File
@@ -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. 星历页
+2 -1
View File
@@ -84,7 +84,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 吻合率低时模型追问要不要把误差放到 ±2 小时 | 按你说的经历,出生时间可能比家人记的偏得更多。放宽后再比一次? | 放宽卡由服务端出,选项写「前后半小时 / 一小时 / 两小时」,不用偏移、误差、置信度、概率。 |
| 只知道傍晚时直接给分钟或采用卡 | 先切成三段(写起止钟点),比完再按分钟收。分不开就说「时段分不开,直接按分钟比」。 | 超过两小时的窗口先走子段,不在 4~6 小时上铺分钟卡。 |
| 记下精确到日的事后不问日子从哪来 | 刚才那个日子是查过记录,还是凭记忆? | 只问日级、只问一次;不答就跳过。 |
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它计算失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
| 没有可显示内容 / 资料还在,过一会儿再打开 | 读不到资料:「星盘资料这会儿读不出来,我们已经记录下来了。」已采用的时间缺时区:「已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。」时区对不上:「出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。」结果格式不对:「星盘结果的格式对不上,我们已经记录下来了。」 | 失败要说出是哪一种。不写「过一会儿再打开」,也不用一句没头没尾的「没有可显示内容」。 |
| 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 |
| Agent 未完成必要的方法与计算步骤,本次不会扣点。(模型其实写过一段) | 把模型写过的那段留下,文末由服务端加一句:「这次没跑完星盘计算,上面是模型直接写的,先看着。」 | 合同没绿但写过字时不得整轮空白。这句不是模型写的。没写过字仍报未完成、不扣点。 |
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
+9 -2
View File
@@ -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)) {
+6 -2
View File
@@ -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");
+276
View File
@@ -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;
}
+7 -2
View File
@@ -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 };
}
+11
View File
@@ -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]);
+53
View File
@@ -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,
});
}
+4
View File
@@ -158,6 +158,10 @@ export const CHART_VIEW_COPY = {
waitingVarga: "这一分盘还没拿到。",
unauthenticated: "请先登录后再看星盘。",
incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。",
profileQueryError: "星盘资料这会儿读不出来,我们已经记录下来了。",
adoptedIncomplete: "已采用的出生时间缺了排盘要用的时区,这张盘先不能算。我们已经记录下来了。",
timezoneFailure: "出生地的时区没有对上,这张盘先不能算。我们已经记录下来了。",
schemaFailure: "星盘结果的格式对不上,我们已经记录下来了。",
rateLimited: "刚才打开得太勤,稍等再试。",
} as const;
+30 -10
View File
@@ -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),
};
}
}
+1
View File
@@ -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 };
}
+80 -59
View File
@@ -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) };
}
}
+1
View File
@@ -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";
+128 -12
View File
@@ -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/,
);
});