Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3893783e4c | ||
|
|
f197774812 | ||
|
|
0a4ba23658 | ||
|
|
85055cf9fa | ||
|
|
4c3485bc23 | ||
|
|
ad8b24bf9b | ||
|
|
66cd60081f | ||
|
|
6dd1ff7d0c | ||
|
|
aae3fea011 | ||
|
|
4cdb997800 | ||
|
|
2748e43871 | ||
|
|
d32ad7b7b1 |
@@ -1,9 +1,20 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-14 — 服务器长期运行不再逐日涨内存;数据库访问少走几趟(待合入 staging)
|
||||
|
||||
- 服务器长期运行不再逐日涨内存;数据库访问少走几趟。Skill 版本不变(BUG-1326~1328、BUG-1346)。
|
||||
|
||||
## 2026-10-14 — 校正每轮重算更快(待合入 staging)
|
||||
|
||||
- 校正每轮重算更快(2 小时窗口约快一倍以上),结果不变。一次要算的分钟数超过 241 会直接拒绝,不再占着计算空位排盘。Skill 版本不 bump(BUG-1323~1325、BUG-1345)。
|
||||
|
||||
## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging)
|
||||
|
||||
- 婚恋结构指数不再写成婚姻稳定、离婚、再婚或建议婚前咨询。中文报告用中文档位,英文报告用英文档位。配偶星分数按 0 到 1 显示,不再写成百分制。
|
||||
- KP 按宫头到下一宫头判宫,报告写明口径。本命宫位表的判宫不变。
|
||||
- 接口里兜底大运的一年改成和推运脚本一样的 365.25 天。
|
||||
- 星历路径只核查,没改。Skill 版本不变(BUG-1318~1321;BUG-1322 仍在核查)。
|
||||
|
||||
## 2026-10-13 — 明说要做生时校正时,第一步就能打开(待合入 staging)
|
||||
|
||||
- 新对话第一句就说「帮我做生时校正 / 生时纠正 / 核对出生时间」,会直接打开校正,只留一句过渡,不先写一段解读。
|
||||
|
||||
@@ -31,3 +31,11 @@ JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=<independent-openssl-rand-base64-32-output>
|
||||
# these server-side values through env_file; they are not NEXT_PUBLIC values.
|
||||
PERSONAL_REPORT_ENABLED=true
|
||||
PERSONAL_REPORT_DAILY_LIMIT=5
|
||||
|
||||
# Local Postgres adapter. Unset or on enables both. Exact off restores the
|
||||
# previous per-statement sequence. Measured default is on (2026-10-14).
|
||||
JYOTISHA_PG_METADATA_CACHE=on
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP=on
|
||||
# Ordinary session reads stay uncached unless this is exactly on. Password
|
||||
# change, account deletion, and admin routes bypass the cache. maxAge is 300s.
|
||||
#IDENTITY_SESSION_COOKIE_CACHE=off
|
||||
|
||||
@@ -194,6 +194,8 @@ SITE_ADDRESS=https://staging.jyotisha.chat
|
||||
|
||||
Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production uses the same architecture only after the reviewed migration and cutover. See `docs/operations/self-hosted-identity.md` for identity validation.
|
||||
|
||||
The web service already loads its env file, so these server-only switches do not need a Compose change. `JYOTISHA_PG_METADATA_CACHE` and `JYOTISHA_PG_MERGED_SESSION_SETUP` default to on when unset; set either to `off` to restore the previous database roundtrip sequence. `IDENTITY_SESSION_COOKIE_CACHE` defaults to off; set it to `on` to cache ordinary user-session reads for 300 seconds. Names and defaults are also in `deploy/.env.staging.identity.example`. Do not put secrets in these three values.
|
||||
|
||||
After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful push gate for the exact SHA and must use the staging workflow ref.
|
||||
|
||||
### First-deploy sequence
|
||||
|
||||
+140
-5
@@ -15304,17 +15304,17 @@
|
||||
## BUG-1140 | staging 镜像打包失败:`frontend/scripts` 下的研究脚本引用了 `frontend/tests` 的夹具
|
||||
|
||||
- 状态:resolved(修复已推 staging;以门禁 publish 通过与 `/api/health` 部署版本为最终证据)
|
||||
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
|
||||
- 首次发现 / 最近更新:2026-10-01 / 2026-10-14
|
||||
- 影响面:Gitea `backend-quality-gate` 的 publish 作业(`deploy/railway-web.Dockerfile` 构建阶段 `npm run build`)。validate 通过、publish 失败,未部署,staging 停在上一版。
|
||||
- 用户现象:盘型口径(BUG-1115~1117、1131)推送后 staging 不更新。
|
||||
- 触发条件:`frontend/scripts/rectification-segment-persisted-replay.ts`(盘型口径研究的 PG 持久回放 harness)`import … from "../tests/helpers/postgres-fixture.ts"`。web 镜像只复制 `frontend/src`、`frontend/scripts` 等,不复制 `frontend/tests`;`frontend/tsconfig.json` 的 include 为 `**/*.ts`,`next build` 类型检查覆盖 scripts,于是 `TS2307 Cannot find module '../tests/helpers/postgres-fixture.ts'`。
|
||||
- 根因:本地与验收机都有 `frontend/tests`,`tsc --noEmit` 与 `next build` 均通过,门禁 validate 也在完整检出上跑,没有任何一步按镜像的复制清单构建。
|
||||
- 修复:harness 移到 `frontend/tests/research/rectification-segment-persisted-replay.ts`(相对路径随之调整),Python driver `scripts/research/varga_resolution_persisted_replay.py` 指向新路径;新增 `frontend/tests/runtime-sources-no-test-imports.test.ts`:`src/` 与 `scripts/` 不得 import / `new URL` 引用 `tests/`。
|
||||
- 验证:按 `railway-web.Dockerfile` 的 COPY 清单从提交 `git archive` 出构建目录,修复前 `npm run build` 报上述 TS2307、exit 1,修复后 exit 0、`/` Static;守卫测试 2/2,且对旧 harness 文本判定为违规;移动后 harness 1 例 ±10 冒烟 `runner_exit=0`。
|
||||
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。
|
||||
- 相关记录:BUG-1115~1117、BUG-1131。
|
||||
- 复发自:无
|
||||
- 修复版本:`codex/staging-publish-fix-20261001`。
|
||||
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。2026-10-14 这道守卫在合入前拦住了同一形状:issue #58 的往返对照脚本曾放在 `frontend/scripts/` 并导入测试夹具。脚本改放到 `frontend/tests/bench-local-pg-roundtrips.ts`,文件名不是 `.test.ts`,不会被全量测试收进去。业务代码没有改。原任务书里的旧路径保持不动。
|
||||
- 相关记录:BUG-1115~1117、BUG-1131、BUG-1327。
|
||||
- 复发自:无。2026-10-14 是同一形状,被守卫拦住,未进测试环境。
|
||||
- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。
|
||||
|
||||
## BUG-1141 | 生时校正缺少度数级时间信号(角度类技法研究)
|
||||
|
||||
@@ -17720,6 +17720,81 @@
|
||||
- 复发自:BUG-1306。
|
||||
- 修复版本:分支 `codex/rectification-step0-entry-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1318 | 婚恋评级仍写成稳定或婚前咨询,配偶星分数被写成百分制
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `f891a2c1`。任务书 `docs/tasks/TASK-upstream-sync7-20261014.md` T1。
|
||||
- 现象:D9 综合评级仍写「婚姻稳定」「强烈建议婚前咨询」。这份文字进全读导出包,模型能读到。配偶星证据行把 0 到 1 的指数写成「婚姻质量 x/100」。
|
||||
- 触发条件:算出婚恋几何指数,或关系时机证据带出配偶星。
|
||||
- 根因:同步第六轮按标题筛提交时,漏了 10-02 到 10-03 的这条措辞修正。分数行把 0 到 1 当成了百分制。
|
||||
- 修复:五档改成证据档位,并写明这是结构指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。中文报告用中文档位,英文报告用英文档位。证据行改成「婚姻质量指数 x(0到1)」,不乘 100。
|
||||
- 验证:`tests/test_d9_evidence_bands.py` 3 通过。公开盘中英文各一条快照。配偶星行不再出现 `/100`。
|
||||
- 防复发:五档文字不得含「稳定、离婚、再婚、婚前咨询、几段」;快照锁住公开盘的两句。
|
||||
- 相关记录:BUG-1183。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
|
||||
|
||||
## BUG-1319 | KP 落宫沿用本命中点判宫,传了宫头也不声明口径
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `0337b77f`。任务书 T2。
|
||||
- 现象:KP 用的行星落宫和本命 Bhava Chalit 表一样,按相邻宫头的中点判宫。宫头传进 KP 分析时,结果不写明判宫口径。
|
||||
- 触发条件:打开 KP。本命 Bhava Chalit 表不走这条。
|
||||
- 根因:同步第六轮漏了夹在上游隐私提交里的判宫修正。本命表和 KP 共用了中点函数。
|
||||
- 修复:KP 改成宫头到下一宫头。本命表默认仍是中点,宫位数字不变。传了宫头却不声明口径就拒绝;没传宫头记整宫代理。KP 报告写出判宫口径。
|
||||
- 验证:`tests/test_kp_cusp_house_assignment.py` 3 通过。同一颗星在两种口径下落入不同宫;默认中点与改前一致。
|
||||
- 防复发:未知口径直接报错;报告正文必须出现 House occupancy basis。
|
||||
- 相关记录:BUG-1318。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
|
||||
|
||||
## BUG-1320 | 瑜伽角宫主和三方主的顺序随进程哈希变化
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `0337b77f`。任务书 T3。
|
||||
- 现象:同一张盘的角宫主、三方主列表用了集合去重。集合顺序会跟着进程哈希变,瑜伽参与者顺序不稳定。
|
||||
- 触发条件:规则表达式读取这两份宫主列表。
|
||||
- 根因:去重用了不保证顺序的集合。同步第六轮漏了上游把它们改成排序的那一处。
|
||||
- 修复:两处都改成先去重再按名字排序。
|
||||
- 验证:`tests/test_yoga_lord_order_stable.py` 1 通过。哈希种子 0 和 1 两个进程算出的参与者顺序相同。
|
||||
- 防复发:两个子进程的名单必须逐项相同,并且等于排好序的名单。
|
||||
- 相关记录:BUG-1319。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
|
||||
|
||||
## BUG-1321 | 接口里的大运年长和推运脚本差了一点,起止会对不齐
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `4d543209`。任务书 T4。
|
||||
- 现象:接口的兜底大运和「进入本运几年」用 365.25636 天为一年。同一条时间线的推运脚本用 365.25 天。年限一长,日期会差一天。
|
||||
- 触发条件:走兜底大运时间线,或计算进入当前大运的年数。
|
||||
- 根因:两处常量没有跟着推运脚本改。同步第六轮漏了上游 10-03 的这一处。
|
||||
- 修复:这两处改成 365.25,与推运脚本相同。其它大运系统的年长不动。
|
||||
- 验证:`tests/test_dasha_year_length_alignment.py` 2 通过。同一大运起止相差 0 天。158 年的旧常量会落到下一天,新常量不会。
|
||||
- 防复发:兜底时间线必须和推运脚本的起止是同一天;进入本运的年数按 365.25 除。
|
||||
- 相关记录:BUG-1320。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
|
||||
|
||||
## BUG-1322 | 星历路径被设成空字符串后,计算退回低精度星历
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `e1909724` 点出的同类问题。任务书 T5,本轮只核查、不改代码。
|
||||
- 现象:排盘和过境入口都把星历路径设成空字符串。本机随后计算太阳位置时,返回标记含低精度星历位,不含星历文件位。
|
||||
- 触发条件:调用排盘或过境计算。本轮没有改这条路径。
|
||||
- 根因:尚未在生产镜像里核实。本机仓库里的星历文件只在参考资料目录,不在计算进程会去找的路径上。空路径是进程全局的,后一次调用会盖掉前面设过的路径。
|
||||
- 修复:未改。建议另开一单:镜像里放一份星历文件,路径显式设上,并断言返回标记不再是低精度星历。
|
||||
- 验证:本机一次探针。生产镜像未打开,不能当作已确认。
|
||||
- 防复发:未做。等修复单再加断言。
|
||||
- 相关记录:BUG-1321。
|
||||
- 复发自:无。
|
||||
- 修复版本:未修复。
|
||||
|
||||
## BUG-1323 | 校正引导窗口按候选两两比较,窗口一长就平方变慢
|
||||
|
||||
- 状态:fixed-pending-verify(尚未合入 staging)
|
||||
@@ -17765,6 +17840,51 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1326 | 个人报告工人空转时,取消监听一直留在进程上
|
||||
|
||||
- 状态:resolved(代码+回归测试),生产内存曲线待部署后观察
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #58。任务书 T1。
|
||||
- 现象:报告工人长时间没有活干时,进程内存逐日上涨。取消这次工人时,之前每一次等待都还挂着一个取消监听。
|
||||
- 触发条件:工人循环用进程级取消信号,两次领任务之间按间隔等待。等待到点之后监听不摘。
|
||||
- 根因:等待用了「只触发一次」的取消监听。这个选项只在真的取消时摘掉监听。计时器自己到点时,监听还留在信号上。信号活得和进程一样长,监听就一直积着。
|
||||
- 修复:计时器到点时先摘掉监听再结束等待;真的取消时清掉计时器并结束等待。已经取消的信号仍立刻返回。计时器仍不挡住进程退出。引导打分里同一写法的等待也改成到点摘监听。
|
||||
- 验证:`frontend/tests/personal-report-worker-sleep-listener.test.ts` 4 通过。空转和抛错各 200 次后,取消前监听数是 0,取消后循环马上结束。修之前的同形等待在第 200 次时留下 199 个监听(断言 `199 !== 0`)。
|
||||
- 防复发:上述测试锁住「200 次之后监听数为 0」。
|
||||
- 相关记录:BUG-1328。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1327 | 本地数据库每一问都重复查表结构和设置身份
|
||||
|
||||
- 状态:resolved(代码+回归测试)。2026-10-14 对照通过后,两个开关默认打开。
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #58。任务书 T2。
|
||||
- 现象:同一次页面操作里,本地数据库要来回很多趟。表有哪些列、主键是什么、函数怎么调用,每次都现查。设置身份也要单独走一趟。
|
||||
- 触发条件:网页通过本地 Postgres 适配层读写真业务表。
|
||||
- 根因:元数据没有进程内缓存。设置角色和两条身份参数各是一条语句。事务的开始和提交仍各走一趟,这是故意留下的。
|
||||
- 修复:`JYOTISHA_PG_METADATA_CACHE` 缓存列类型、主键和函数签名,300 秒过期;缓存键带连接串,列和函数再带角色。不缓存空结果,也不缓存「函数不存在」。业务语句报列、表、函数或类型不匹配时,清掉该连接串的全部元数据并照旧返回错误,不自动重试。关掉某个连接或全部连接时,对应元数据一起清掉。`JYOTISHA_PG_MERGED_SESSION_SETUP` 把设置角色和两条身份合成一条参数化语句。角色仍只允许既有的两个业务角色。开始和提交不并进这条语句。两个开关都写成 `off` 时,语句顺序与改前一致。未设置或 `on` 为打开。只读且没有身份时省掉事务的做法已否决:那会把角色留在连接上,还回池子后可能给下一个请求。
|
||||
- 验证:不连数据库的往返单测锁住关闭时 6 条、打开且命中缓存时 4 条。真库测试确认事务内身份生效,连接归还后再借出时身份已空。对照脚本第二次(带三组原始数)整段往返 49 降到 32,耗时中位数 75.84 毫秒降到 36.48 毫秒,返回内容逐字节一致。第一次同样是 49 降到 32。`npm run test:db` 两轮都是 81 项、通过 68、失败 13、取消 0。失败名称与 Windows 已知清单逐条相同,没有多出来的。
|
||||
- 防复发:往返单测锁住关闭时的语句顺序,以及角色、参数名、报错、过期和关闭连接都会让缓存失效。
|
||||
- 相关记录:BUG-166、BUG-578、BUG-596、BUG-926。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1328 | 三张只进不出的内存表会随运行变大
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #58。任务书 T3。
|
||||
- 现象:校正交付去重、重算尝试和请求限流各有一张进程内的表。旧条目读的时候已经不算数,却不删除,进程活得越久表越大。
|
||||
- 触发条件:长时间有人做校正或调用限流接口。
|
||||
- 根因:写入只增加,没有过期清理,也没有条数上限。
|
||||
- 修复:交付记录超过 60 秒就删,最多留 1 万条。重算记录改存时间,超过 6 小时就删,最多留 5 千条。限流按本进程见过的最大窗口定期删掉已经出窗的键;1 小时窗口的键不会在 61 秒时被删。限流的对外类型不变,调用方仍可传入自己的表。
|
||||
- 验证:交付、重算、限流的新测试通过。窗口内的允许或拒绝与原来的断言一致,没有改旧断言。同一道校正题在 6 小时内仍不会重算第二次;超过 6 小时或被挤出后可能再算一次,这比「进程一重启就可能再算」更收敛。
|
||||
- 防复发:硬上限和过期都有测试。1 小时窗口的键在短窗口清理后必须还在。
|
||||
- 相关记录:BUG-1326。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1345 | 打分接口没有候选数上限,超宽窗口会占着重计算槽
|
||||
|
||||
- 状态:fixed-pending-verify(尚未合入 staging)
|
||||
@@ -17780,3 +17900,18 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1346 | 每次带登录的请求都重新读一次会话
|
||||
|
||||
- 状态:resolved(代码+回归测试)。开关默认关闭:查库次数已下降,真实身份库的耗时对照还没做,未达到默认打开的门槛。
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #58。产品负责人 2026-10-14 决定可以开。任务书 T6。
|
||||
- 现象:每个已登录请求都到身份库读一次会话。
|
||||
- 触发条件:用户端 Better Auth 配置没有会话读缓存。后台没有另一份登录配置。
|
||||
- 根因:用户端会话配置未启用 `cookieCache`。
|
||||
- 修复:只有环境变量 `IDENTITY_SESSION_COOKIE_CACHE` 精确等于 `on` 时,用户端会话才缓存 300 秒。未设置或其它值与现在一样,每次读库。改密码、注销账户、以及全部后台入口每次都跳过缓存。改邮箱、两步验证和后台插件自带的敏感入口本来就会重新读库,这次没有改它们。咨询里的注销检查和后台的封禁检查仍每次查库。会话能活多久、改密码后是否作废、Cookie 的安全属性、表结构都没改。代价:缓存打开后,一台设备改了密码,其它设备上的旧登录最多还能再浏览 300 秒,这段时间做不了改密码和注销。
|
||||
- 验证:`frontend/tests/identity-session-cookie-cache.test.ts` 4 通过。缓存打开时连续 3 次普通读取只查会话 1 次,敏感读取每次都查;缓存关闭时 3 次都查。会话被清掉后,敏感读取立即无效,普通读取在 300 秒内仍有效,超过后无效。
|
||||
- 防复发:上述测试锁住开关关闭时不出现缓存配置,以及 300 秒后普通读取失效。
|
||||
- 相关记录:BUG-1327。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# 进度 · 服务器内存与数据库往返(2026-10-13)
|
||||
|
||||
issue #58。分支 `codex/node-runtime-resources-20261013`。
|
||||
|
||||
2026-10-14 按修复单把往返对照脚本从 `frontend/scripts/bench-local-pg-roundtrips.ts` 挪到 `frontend/tests/bench-local-pg-roundtrips.ts`。文件名不是 `.test.ts`,全量测试不会自动收进去。业务代码没有改,对照数字没有重跑。原任务书里的旧路径保持不动。
|
||||
|
||||
任务书基线写的是 `77057a819`,计划评论里的基线是 `2994588ad4d858dead4835943e5883c5aa2b03f8`。本分支实际从当时的 `origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f` 拉出。没有因此停下来问产品,改动仍按任务书做。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- 报告工人的等待:计时器到点就摘掉取消监听。引导打分里同一段等待也摘掉。空转不再往进程上堆监听。
|
||||
- 本地数据库:列、主键、函数签名缓存 300 秒;设置角色和两条身份合成一条语句。开始和提交仍分开。两个开关默认打开,写成 `off` 时语句顺序与改前一致。
|
||||
- 三张内存表:交付记录超过 60 秒删除,最多 1 万条;重算记录超过 6 小时删除,最多 5 千条;限流按见过的最大窗口清理已出窗的键。
|
||||
- 登录读缓存:只有 `IDENTITY_SESSION_COOKIE_CACHE=on` 才缓存 300 秒。默认关闭。
|
||||
|
||||
## 不做的
|
||||
|
||||
只读、又没有身份时省掉事务:不做。那种做法只能用会话级角色,归还连接前再清。往返数不降,而且一旦没清掉,下一次请求会带着上一次的角色。违反「连接还回池子时不能带着角色或身份」。
|
||||
|
||||
`begin` 没有并进一条多语句查询。
|
||||
|
||||
## 语义变化
|
||||
|
||||
同一道校正题、同一份证据,6 小时后或因为超过 5 千条被挤出后,可能再重算一次。这比「进程重启后才会再算」更收敛。60 秒内的交付去重、窗口内的限流允许或拒绝,都没有改。
|
||||
|
||||
## 基线失败
|
||||
|
||||
修之前的等待写法,在第 200 次空转时取消信号上还有 199 个监听。同形脚本输出:`listenersAt200=199`,`assertion failed: 199 !== 0`。修之后的测试断言是 0,并且通过。
|
||||
|
||||
## 登录读取的调用点
|
||||
|
||||
敏感,每次跳过缓存:
|
||||
|
||||
- 改密码:`frontend/src/app/api/account/password/route.ts`
|
||||
- 注销账户:`frontend/src/app/api/account/deletion/route.ts`
|
||||
- 全部后台入口:`frontend/src/lib/admin/auth.ts`
|
||||
|
||||
其余业务接口走 `createServerSupabaseClient()`,不带跳过参数。开关打开后,这些普通读取可以走 300 秒缓存。
|
||||
|
||||
改邮箱、两步验证、后台插件自带的敏感入口本来就会重新读库,这次没改。咨询里检查「正在注销」的那次调用,以及后台查封禁的语句,都没有改成缓存。
|
||||
|
||||
## 对照结论
|
||||
|
||||
详见 `docs/testing/node-runtime-resources-ab-20261013.md`。两个数据库开关一起:往返 49 → 32(低于 70%),耗时中位数 75.84 → 36.48 毫秒(下降 51.9%),返回内容一致。`npm run test:db` 开关关、开关开各一轮,都是 81 项、通过 68、失败 13、取消 0。失败名称与 `tools/agent-loop/windows-known-failures.txt` 的 13 条相同,没有多出来的。运行器因此退出码是 1,这 13 条是 Windows 上原有的。两个数据库开关默认打开。
|
||||
|
||||
登录读缓存的查库次数在测试里从 3 降到 1,但没有对真实身份库做耗时对照,默认保持关闭。要打开,把 `IDENTITY_SESSION_COOKIE_CACHE` 设成 `on`。
|
||||
|
||||
网页容器已经用 env 文件读环境变量,所以这三个名字不必改 Compose。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
|
||||
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning,未改 |
|
||||
| 本单测试 | `tsx --test` 任务书点名的文件加登录缓存测试 | 134 通过,0 失败,0 取消 |
|
||||
| 真库身份 | `database-local-postgres-session-setup.test.ts` | 1 通过。事务内角色和身份生效,连接归还后再借出时身份已空 |
|
||||
| 数据库套件 | `npm run test:db`,两开关先关后开 | 各 81 / 通过 68 / 失败 13 / 取消 0,名称与已知清单一致 |
|
||||
| 往返对照 | `tests/bench-local-pg-roundtrips.ts`(按修复单从 `scripts/` 挪来) | 见上,`payloadMismatch` false。数字未重跑 |
|
||||
|
||||
没有跑前端全量 `npm test`、Python 快速门、`next build`。生产内存曲线要等部署后看。真机两台设备改密码的步骤在 `docs/testing/node-runtime-resources-20261013.md`,登录缓存默认关着,那份清单的第 2 步要先把开关打开才看得到。
|
||||
|
||||
## 2026-10-14 修复单
|
||||
|
||||
按 `docs/tasks/TASK-node-runtime-resources-fix-20261014.md`:先合并最新 `origin/staging`,再把对照脚本挪进测试目录。业务代码没有改。对照数字没有重跑。
|
||||
|
||||
和上一版 `4c3485bc` 比,网页业务目录里多出来的只有合并进来的校正提速改动(`frontend/src/lib/rectification-agentic/v9/engine-client.ts`),不是这单写的。这单自己的提交不碰 `frontend/src`。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
|
||||
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning,未改 |
|
||||
| 镜像导入守卫 | `tsx --test tests/runtime-sources-no-test-imports.test.ts` | 2 通过,0 失败,0 取消 |
|
||||
@@ -0,0 +1,41 @@
|
||||
# PROGRESS · 上游同步第七轮(2026-10-14)
|
||||
|
||||
- 分支:`codex/upstream-sync7-20261014`
|
||||
- 基线:任务书写的是 `77057a81`。开工时 `origin/staging` 已到 `99ef1bbd`,本分支从这里建。
|
||||
- 结论:T1 到 T4 已做完并推这个分支。T5 只核查,代码没改。星历在这台电脑上会退回低精度,生产镜像没打开,记 BUG-1322,状态是核查中。
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| T1 婚恋断语 | 五档改成证据档位,中文给中文报告,英文给英文报告。写明不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。配偶星那一行改成 0 到 1 的指数,不再写 `/100`。BUG-1318,上游 `f891a2c1`。 |
|
||||
| T2 KP 判宫 | KP 按宫头到下一宫头。本命宫位表仍按中点,默认宫位不变。传了宫头却不说口径就拒绝;没传记整宫代理。报告多一行判宫口径。BUG-1319,上游 `0337b77f`。 |
|
||||
| T3 瑜伽排序 | 角宫主、三方主先去重再按名字排序。BUG-1320,上游 `0337b77f`。 |
|
||||
| T4 大运年长 | 接口里两处 365.25636 改成 365.25,和推运脚本同一天。别的大运系统没动。BUG-1321,上游 `4d543209`。 |
|
||||
| T5 星历 | 见下。不改代码。BUG-1322,核查中。 |
|
||||
|
||||
## 检查
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 婚恋档位 | `pytest tests/test_d9_evidence_bands.py` | 3 通过 |
|
||||
| KP 判宫 | `pytest tests/test_kp_cusp_house_assignment.py` | 3 通过 |
|
||||
| 本命宫位表 | `pytest tests/test_bhava_chalit.py` | 通过(与上面一批共 20 通过、0 失败) |
|
||||
| 几何指数旧测试 | `pytest tests/test_marriage_counting_geometry_only.py` | 通过(同上) |
|
||||
| 瑜伽顺序 | `pytest tests/test_yoga_lord_order_stable.py` | 1 通过 |
|
||||
| 大运年长 | `pytest tests/test_dasha_year_length_alignment.py` | 2 通过 |
|
||||
| 接口增长合同 | `pytest tests/test_api_server_growth_contract.py` | 通过 |
|
||||
| KP 报告标题回归 | `pytest tests/test_report_longform_parity.py` 里 KP 那一条 | 通过 |
|
||||
| 快速门、前端全量 | 未跑 | 按执行分工,留给验收方 |
|
||||
| 数据库 | 未跑 | 没动表 |
|
||||
|
||||
## T5 星历核查
|
||||
|
||||
本机把路径设成空字符串后,算太阳位置的返回标记带低精度星历,不带星历文件。仓库里的星历文件在参考资料目录,计算时用不到。排盘和过境两处都设成同一个空路径,所以这两处彼此一致,但都会退回低精度。路径是整个进程共用的,谁后设谁生效。
|
||||
|
||||
生产镜像这台电脑打不开,不能说线上也是这样。健康检查只看星历库能不能导入,看不出用的是哪一套。建议另开一单再改。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 关系时机里还有一处用 65 和 45 去比这个 0 到 1 的指数。任务书只允许改配偶星那一行文字,所以没动。它现在加不上那一档分数。建议另开小单。
|
||||
- 生时校正打分文件没有改。
|
||||
@@ -331,7 +331,7 @@
|
||||
| `TASK-rectification-probe-supply-research-20260913.md` | — | 研究单:六题后引擎在剩余候选上再出带年月题的四种放宽规则,20 例公开 AA 数据离线量收益,有收益才立实现单 | 已合入(离线测量,不改线上) | `b063c668` |
|
||||
| `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` |
|
||||
| `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) |
|
||||
| `TASK-upstream-sync7-20261014.md` | — | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待执行 | — |
|
||||
| `TASK-upstream-sync7-20261014.md` | [PROGRESS-upstream-sync7-20261014.md](PROGRESS-upstream-sync7-20261014.md) | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待验收 | `codex/upstream-sync7-20261014`(BUG-1318~1321 已改;BUG-1322 星历路径核查中,未改代码) |
|
||||
| `TASK-beta-main-path-20261014.md` | — | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待执行 | — |
|
||||
| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — |
|
||||
| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
|
||||
@@ -468,8 +468,10 @@
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-rect-scoring-perf-20261013.md` | `PROGRESS-rect-scoring-perf-20261013.md` | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待验收 | `codex/rect-scoring-perf-20261013` |
|
||||
| `TASK-node-runtime-resources-20261013.md` | — | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待领取 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-node-runtime-resources-20261013.md` | `PROGRESS-node-runtime-resources-20261013.md` | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待验收 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-node-runtime-resources-fix-20261014.md` | `PROGRESS-node-runtime-resources-20261013.md` | **#58 第 1 次打回**:`4c3485bc` 其余全过;压测脚本放在 `frontend/scripts/` 却从 `frontend/tests/` 导入,镜像构建会失败(BUG-1140 形状,任务书指定的路径有误)。挪到 `frontend/tests/` 下非 `.test` 文件名 | 待验收 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-home-sidebar-rerender-fix-20261014.md` | — | **#59 第 1 次打回**:`63509431` 其余全过;`chat-navigation-a11y-contract` 按源码文本断言 `onOpenBilling` 旧写法未跟改(§7.8) | 待领取 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-api-disk-cache-retention-20261013.md` | — | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待领取 | `codex/api-disk-cache-retention-20261013` |
|
||||
| `TASK-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-20261013` |
|
||||
| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# TASK · 侧栏重渲染修复单(issue #59,第 1 次打回)
|
||||
|
||||
- 日期:2026-10-14
|
||||
- 原任务书:`docs/tasks/TASK-home-sidebar-rerender-20261013.md`
|
||||
- 被验 SHA:`63509431529d212c7847371e7717660d823b40e6`(`codex/home-sidebar-rerender-20261013`)
|
||||
- 执行方:Grok,同一分支续做(落后 staging 时用 merge,不改写已推历史)
|
||||
- BUG 编号:沿用 BUG-1329
|
||||
|
||||
## 1. 验收结论:一项未通过,其余全部通过
|
||||
|
||||
| 检查 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 前端全量 `npm test`(与 staging 基线逐名比对) | **未通过:新增 1 条失败** | `frontend/tests/chat-navigation-a11y-contract.test.ts`「in-app destinations navigate client-side so the chat survives the round trip」:`The input did not match the regular expression /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/` |
|
||||
| T1 稳定包装 + 比较后发布 | 通过 | 逐行看过:`latestRef` 在无依赖 effect 里写,发布 effect 在其后;未冻结首次闭包;`HomeProbe` 的故意不稳定输入未改 |
|
||||
| T2 `visibleSessions` 记忆化 | 通过 | — |
|
||||
| T3 `SidebarProvider` context 记忆化 | 通过 | — |
|
||||
| 改既有断言 | 通过 | `session-list-lifecycle` 三栏齐全,且加强了(再改一版后仍调到最新) |
|
||||
| tsc / lint | 通过 | 0 错 / 0 error |
|
||||
|
||||
**根因**:`use-home-shell-registration.ts` 把 `onOpenBilling` 从 `() => openAccountDialog("billing", { source: "account-menu" })` 改成了 `() => { latestRef.current?.openAccountDialog("billing", { source: "account-menu" }); }`,而 `chat-navigation-a11y-contract.test.ts` 第 42 行按源码文本断言旧写法(AGENTS.md §7.8:改写语句前要 grep 按源码文本断言的合同测试,同一轮跟着改)。
|
||||
|
||||
## 2. 修法要求
|
||||
|
||||
1. 只改 `frontend/tests/chat-navigation-a11y-contract.test.ts` 第 42 行这条断言,按 §7.3 写「原值 / 新值 / 原因」三栏。新正则要**仍然锁住意图**:账户菜单里的「充值」打开 billing 且 `source: "account-menu"`。例如匹配 `onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}`;不得放宽成只查 `"billing"`。
|
||||
2. 再 grep 一遍:`grep -rn "onOpenProfile\|onOpenGeneral\|onOpenLogout\|onNewChat\|onSelectSession\|onLoadMore" frontend/tests`,有按旧写法断言源码文本的同样处理(本次全量里只红了这一条,但要确认)。
|
||||
3. 业务代码不改。先把最新 `origin/staging` 合并进分支(用 merge)。
|
||||
4. PROGRESS 末尾记一行。
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
- 前端全量 `npm test` 失败清单与 staging 基线逐名相同;
|
||||
- `git diff 63509431..<新 SHA> -- frontend/src` 为空。
|
||||
|
||||
## 4. 让步顺序
|
||||
|
||||
无。第 1 次打回;再打回会停下等产品负责人。
|
||||
@@ -0,0 +1,39 @@
|
||||
# TASK · 服务器内存与数据库往返修复单(issue #58,第 1 次打回)
|
||||
|
||||
- 日期:2026-10-14
|
||||
- 原任务书:`docs/tasks/TASK-node-runtime-resources-20261013.md`
|
||||
- 被验 SHA:`4c3485bc2352e8f9de097d81d9979092ee2bb280`(`codex/node-runtime-resources-20261013`)
|
||||
- 执行方:Grok,同一分支续做(落后 staging 时用 merge,不改写已推历史)
|
||||
- BUG 编号:沿用 BUG-1326~1328、1346
|
||||
|
||||
## 1. 验收结论:一项未通过,其余全部通过
|
||||
|
||||
| 检查 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 前端全量 `npm test`(与 staging 基线逐名比对) | **未通过:新增 1 条真实失败** | `runtime-sources-no-test-imports.test.ts`「files copied into the web image never import from frontend/tests」:`actual: ['scripts/bench-local-pg-roundtrips.ts']` |
|
||||
| 同上,另一条新增 | 环境缺口(不算未通过) | `database-local-postgres-session-setup.test.ts` 报 `spawnSync docker ENOENT`,本机无 Docker;以 Grok 的 test:db 回报为准 |
|
||||
| T1 报告 worker 摘监听 / T1b | 通过 | 代码与新测试逐行看过 |
|
||||
| T2 元数据缓存 + 合并身份设置 | 通过 | 角色仍只来自构造函数白名单;只缓存表结构;`off` 走原语句顺序;A/B 记录 49→32 往返、中位数降 51.9%、逐字节一致 |
|
||||
| T3 三张内存表 | 通过 | — |
|
||||
| T6 登录读缓存 | 通过(默认关,理由成立) | 改密码 / 注销 / 后台三处已强制绕过;默认关是因为没有身份库真实耗时对照,符合原任务书门槛 |
|
||||
| tsc / lint / next build | 通过 | 0 错 / 0 error / `/` 仍 Static |
|
||||
| 越界 / 隐私 | 通过 | — |
|
||||
|
||||
**根因**:`frontend/scripts/bench-local-pg-roundtrips.ts` 第 16 行 `import { startPostgresFixture } from "../tests/helpers/postgres-fixture.ts";`。web 镜像只复制 `frontend/src` 与 `frontend/scripts`,不复制 `frontend/tests`,而 `next build` 会类型检查 `frontend/` 下所有 `.ts`——推上 staging 后镜像构建会失败(BUG-1140,同一形状)。**这条路径是原任务书 T4 指定的,错在任务书,不是执行偏差。**
|
||||
|
||||
## 2. 修法要求
|
||||
|
||||
1. 把脚本挪到 `frontend/tests/bench-local-pg-roundtrips.ts`(文件名**不得**以 `.test.ts` 结尾,避免被 `npm test` 自动收进去),导入改成 `./helpers/postgres-fixture.ts`;脚本头注释里的运行命令改成 `npx tsx tests/bench-local-pg-roundtrips.ts`。
|
||||
2. 同步改引用:`docs/tasks/PROGRESS-node-runtime-resources-20261013.md`、`docs/testing/node-runtime-resources-ab-20261013.md`。原任务书不改(历史记录),在 PROGRESS 里写一行「按修复单挪位置」。
|
||||
3. 不改任何业务代码;A/B 数字不用重跑。
|
||||
4. 先把最新 `origin/staging` 合并进分支(用 merge)。
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
- `frontend/tests/runtime-sources-no-test-imports.test.ts` 通过;
|
||||
- 前端全量 `npm test` 的失败清单只剩 staging 基线那 24 条 + 本单新增的 Docker 测试(无 Docker 时);
|
||||
- `git diff 4c3485bc2..<新 SHA> -- frontend/src` 为空(业务代码一字未动)。
|
||||
|
||||
## 4. 让步顺序
|
||||
|
||||
无。这是第 1 次打回;再打回会停下等产品负责人。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 真机清单 · 登录缓存与长时间运行(2026-10-13)
|
||||
|
||||
对应 BUG-1326、BUG-1346。在 staging 上用两个浏览器(或一台电脑、一台手机)登录同一个测试账号。不要把账号、口令或页面原文记进仓库。
|
||||
|
||||
本轮代码里,登录读缓存默认关闭。要做下面第 2–4 步,服务器环境变量 `IDENTITY_SESSION_COOKIE_CACHE` 必须是 `on`。未设置或其它值时,改密码后另一台设备会马上要求重新登录,第 2 步不会出现「还能浏览大约 5 分钟」。
|
||||
|
||||
| 步 | 怎么做 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 两台设备都登录,各自打开首页并翻一两页 | 都能正常看到自己的会话,不要求反复登录 |
|
||||
| 2 | 设备 A 修改密码。5 分钟内用设备 B 只浏览首页、会话列表和星盘,不要改密码、不要注销 | 设备 B 在这 5 分钟里仍能浏览 |
|
||||
| 3 | 仍在这 5 分钟内,用设备 B 尝试修改密码或申请注销账户 | 被拒绝,不能拿旧登录做这两件事 |
|
||||
| 4 | 等到改密码已超过 5 分钟,再用设备 B 刷新 | 被要求重新登录 |
|
||||
| 5 | 用新密码在设备 B 重新登录,再浏览一次 | 能进入,会话列表还在 |
|
||||
@@ -0,0 +1,21 @@
|
||||
# 本地数据库往返对照(2026-10-14)
|
||||
|
||||
环境:Windows,Docker 29.8.0,Node v22.23.2。脚本 `frontend/tests/bench-local-pg-roundtrips.ts`(2026-10-14 按修复单从 `frontend/scripts/` 挪来,数字未重跑)。库是测试用的临时 Postgres,数据是虚构用户。每组 50 遍,重复 3 组,先各热身 1 遍。往返数是每组平均值的中位数。耗时中位数是三组中位数的中位数。p95 是全部 150 遍的单遍耗时。
|
||||
|
||||
判定用第二次运行(输出里带三组原始数)。第一次运行同样是 49 降到 32,中位数 89.51 毫秒降到 32.70 毫秒,内容一致,方向相同。
|
||||
|
||||
| 模式 | 开关 | 往返 | 中位数(毫秒) | p95(毫秒) | 三组(往返 / 中位数毫秒) |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| A | 两个都关 | 49 | 75.84 | 88.25 | 49 / 82.51;49 / 75.84;49 / 73.81 |
|
||||
| B | 两个都开 | 32 | 36.48 | 39.47 | 32 / 36.66;32 / 36.48;32 / 35.71 |
|
||||
| B1 | 只开元数据缓存 | 40 | 40.54 | 44.37 | 40 / 41.56;40 / 40.54;40 / 39.86 |
|
||||
| B2 | 只开合并身份设置 | 41 | 67.83 | 72.75 | 41 / 68.46;41 / 66.47;41 / 67.83 |
|
||||
|
||||
- 往返:32 ≤ 49 的 70%(34.3)。通过。
|
||||
- 耗时:下降 (75.84 − 36.48) / 75.84 = 51.9%,不低于 15%。通过。
|
||||
- 四种模式的业务返回逐字节一致(`payloadMismatch` 为 false)。通过。
|
||||
- 只开其中一个时,往返都还高于 70% 那一刀;只合并身份设置时,这一次耗时只降了约 10.6%。两个一起才过门槛,所以两个都默认打开。写成 `off` 仍走改前的语句顺序。
|
||||
|
||||
登录读缓存没有放进这次真库计时。内存里的登录测试看到普通读取的查库次数从 3 降到 1,但那不是身份库的耗时对照,所以 `IDENTITY_SESSION_COOKIE_CACHE` 保持默认关闭。
|
||||
|
||||
`npm run test:db` 两轮都是 81 项、通过 68、失败 13、取消 0。失败名称与 `tools/agent-loop/windows-known-failures.txt` 的 13 条逐条相同,没有多出来的。因此两个数据库开关默认打开。
|
||||
@@ -21,7 +21,7 @@ export const dynamic = "force-dynamic";
|
||||
*/
|
||||
|
||||
async function currentUserId(): Promise<string | null> {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const supabase = await createServerSupabaseClient({ disableCookieCache: true });
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
return error || !user ? null : user.id;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ async function userSession(request: Request) {
|
||||
return null;
|
||||
}
|
||||
const services = getIdentityAuthServices();
|
||||
const session = await services.user.api.getSession({ headers: request.headers });
|
||||
const session = await services.user.api.getSession({
|
||||
headers: request.headers,
|
||||
query: { disableCookieCache: true },
|
||||
});
|
||||
return session ? { services, session } : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function requirePermission(
|
||||
requireIdentityServerSession(
|
||||
getIdentityAuthServices().user.api,
|
||||
adminHeaders,
|
||||
{ disableCookieCache: true },
|
||||
),
|
||||
loadAdminSession,
|
||||
});
|
||||
|
||||
@@ -34,10 +34,14 @@ export async function runBirthTimeScoringPoll(input: PollInput): Promise<PollRes
|
||||
export function scoringPollDelay(attempt: number, signal: AbortSignal): Promise<void> {
|
||||
const duration = Math.min(400 * (2 ** attempt), 3_200);
|
||||
return new Promise((resolve) => {
|
||||
const timer = window.setTimeout(resolve, duration);
|
||||
signal.addEventListener("abort", () => {
|
||||
const onAbort = () => {
|
||||
window.clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
};
|
||||
const timer = window.setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, duration);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,7 +169,18 @@ export function upsertConflictColumns(options?: { onConflict?: string }): string
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function primaryKeyColumns(client: PoolClient, table: string): Promise<string[]> {
|
||||
async function primaryKeyColumns(
|
||||
client: PoolClient,
|
||||
table: string,
|
||||
scope: MetadataScope,
|
||||
): Promise<string[]> {
|
||||
const cacheKey = metadataCacheEnabled()
|
||||
? `${scope.connectionString}|${table}`
|
||||
: null;
|
||||
if (cacheKey) {
|
||||
const cached = readTimed(metadataCache().primaryKeys, cacheKey);
|
||||
if (cached) return [...cached];
|
||||
}
|
||||
identifier(table);
|
||||
const result = await client.query<{ column_name: string }>(
|
||||
`
|
||||
@@ -182,7 +193,9 @@ async function primaryKeyColumns(client: PoolClient, table: string): Promise<str
|
||||
`,
|
||||
[table],
|
||||
);
|
||||
return result.rows.map((row) => row.column_name);
|
||||
const columns = result.rows.map((row) => row.column_name);
|
||||
if (cacheKey && columns.length > 0) writeTimed(metadataCache().primaryKeys, cacheKey, [...columns]);
|
||||
return columns;
|
||||
}
|
||||
|
||||
function queryError(error: unknown): QueryError {
|
||||
@@ -220,7 +233,115 @@ function localDataPool(connectionString: string): Pool {
|
||||
return pool;
|
||||
}
|
||||
|
||||
const METADATA_TTL_MS = 300_000;
|
||||
const METADATA_INVALIDATING_CODES = new Set(["42703", "42P01", "42883", "42804"]);
|
||||
|
||||
type MetadataScope = Readonly<{
|
||||
connectionString: string;
|
||||
role: LocalDatabaseRole | null;
|
||||
}>;
|
||||
|
||||
type TimedCacheEntry<T> = Readonly<{ value: T; storedAt: number }>;
|
||||
|
||||
type LocalPostgresMetadataCache = {
|
||||
columns: Map<string, TimedCacheEntry<Map<string, string>>>;
|
||||
primaryKeys: Map<string, TimedCacheEntry<string[]>>;
|
||||
functions: Map<string, TimedCacheEntry<FunctionMetadata>>;
|
||||
};
|
||||
|
||||
const metadataGlobal = globalThis as typeof globalThis & {
|
||||
jyotishaLocalPostgresMetadata?: LocalPostgresMetadataCache;
|
||||
jyotishaLocalPostgresMetadataNow?: () => number;
|
||||
};
|
||||
|
||||
function metadataCache(): LocalPostgresMetadataCache {
|
||||
metadataGlobal.jyotishaLocalPostgresMetadata ??= {
|
||||
columns: new Map(),
|
||||
primaryKeys: new Map(),
|
||||
functions: new Map(),
|
||||
};
|
||||
return metadataGlobal.jyotishaLocalPostgresMetadata;
|
||||
}
|
||||
|
||||
function metadataNow(): number {
|
||||
return metadataGlobal.jyotishaLocalPostgresMetadataNow?.() ?? Date.now();
|
||||
}
|
||||
|
||||
function metadataCacheEnabled(): boolean {
|
||||
// 2026-10-14 A/B passed. Unset means on. Exact "off" keeps the old query sequence.
|
||||
return process.env.JYOTISHA_PG_METADATA_CACHE !== "off";
|
||||
}
|
||||
|
||||
function mergedSessionSetupEnabled(): boolean {
|
||||
return process.env.JYOTISHA_PG_MERGED_SESSION_SETUP !== "off";
|
||||
}
|
||||
|
||||
function roleCacheKey(role: LocalDatabaseRole | null): string {
|
||||
return role ?? "owner";
|
||||
}
|
||||
|
||||
function readTimed<T>(
|
||||
store: Map<string, TimedCacheEntry<T>>,
|
||||
key: string,
|
||||
): T | undefined {
|
||||
const entry = store.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (metadataNow() - entry.storedAt >= METADATA_TTL_MS) {
|
||||
store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
function writeTimed<T>(
|
||||
store: Map<string, TimedCacheEntry<T>>,
|
||||
key: string,
|
||||
value: T,
|
||||
): void {
|
||||
store.set(key, { value, storedAt: metadataNow() });
|
||||
}
|
||||
|
||||
function dropMetadataForConnection(connectionString: string): void {
|
||||
const cache = metadataGlobal.jyotishaLocalPostgresMetadata;
|
||||
if (!cache) return;
|
||||
const prefix = `${connectionString}|`;
|
||||
for (const store of [cache.columns, cache.primaryKeys, cache.functions]) {
|
||||
for (const key of store.keys()) {
|
||||
if (key.startsWith(prefix)) store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function postgresErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
|
||||
export function clearLocalPostgresMetadataCacheForTests(): void {
|
||||
metadataGlobal.jyotishaLocalPostgresMetadata = {
|
||||
columns: new Map(),
|
||||
primaryKeys: new Map(),
|
||||
functions: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function setLocalPostgresMetadataClockForTests(now: (() => number) | null): void {
|
||||
if (now) metadataGlobal.jyotishaLocalPostgresMetadataNow = now;
|
||||
else delete metadataGlobal.jyotishaLocalPostgresMetadataNow;
|
||||
}
|
||||
|
||||
export function installLocalPostgresPoolForTests(connectionString: string, pool: Pool): void {
|
||||
poolGlobal.jyotishaLocalDataPools ??= new Map();
|
||||
poolGlobal.jyotishaLocalDataPools.set(connectionString, pool);
|
||||
}
|
||||
|
||||
export function localPostgresPoolForTests(connectionString: string): Pool {
|
||||
return localDataPool(connectionString);
|
||||
}
|
||||
|
||||
export async function closeLocalPostgresDataPool(connectionString: string): Promise<void> {
|
||||
dropMetadataForConnection(connectionString);
|
||||
const pools = poolGlobal.jyotishaLocalDataPools;
|
||||
const pool = pools?.get(connectionString);
|
||||
if (!pool) return;
|
||||
@@ -229,6 +350,11 @@ export async function closeLocalPostgresDataPool(connectionString: string): Prom
|
||||
}
|
||||
|
||||
export async function closeLocalPostgresDataPools(): Promise<void> {
|
||||
metadataGlobal.jyotishaLocalPostgresMetadata = {
|
||||
columns: new Map(),
|
||||
primaryKeys: new Map(),
|
||||
functions: new Map(),
|
||||
};
|
||||
const pools = poolGlobal.jyotishaLocalDataPools;
|
||||
poolGlobal.jyotishaLocalDataPools = new Map();
|
||||
if (!pools) return;
|
||||
@@ -237,6 +363,7 @@ export async function closeLocalPostgresDataPools(): Promise<void> {
|
||||
|
||||
async function inBusinessTransaction<T>(
|
||||
pool: Pool,
|
||||
connectionString: string,
|
||||
identity: LocalIdentity,
|
||||
role: LocalDatabaseRole | null,
|
||||
run: (client: PoolClient) => Promise<T>,
|
||||
@@ -244,15 +371,32 @@ async function inBusinessTransaction<T>(
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
if (role !== null) await client.query(`set local role ${role}`);
|
||||
await client.query(
|
||||
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
|
||||
[identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
if (mergedSessionSetupEnabled()) {
|
||||
if (role !== null) {
|
||||
await client.query(
|
||||
"select set_config('role', $1, true), set_config('request.jwt.claim.sub', $2, true), set_config('request.jwt.claim.email', $3, true)",
|
||||
[role, identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
|
||||
[identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (role !== null) await client.query(`set local role ${role}`);
|
||||
await client.query(
|
||||
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
|
||||
[identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
}
|
||||
const result = await run(client);
|
||||
await client.query("commit");
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (METADATA_INVALIDATING_CODES.has(postgresErrorCode(error) ?? "")) {
|
||||
dropMetadataForConnection(connectionString);
|
||||
}
|
||||
await client.query("rollback").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -263,7 +407,15 @@ async function inBusinessTransaction<T>(
|
||||
async function columnTypes(
|
||||
client: PoolClient,
|
||||
table: string,
|
||||
scope: MetadataScope,
|
||||
): Promise<Map<string, string>> {
|
||||
const cacheKey = metadataCacheEnabled()
|
||||
? `${scope.connectionString}|${roleCacheKey(scope.role)}|${table}`
|
||||
: null;
|
||||
if (cacheKey) {
|
||||
const cached = readTimed(metadataCache().columns, cacheKey);
|
||||
if (cached) return new Map(cached);
|
||||
}
|
||||
const result = await client.query<{ column_name: string; udt_name: string }>(
|
||||
`
|
||||
select column_name, udt_name
|
||||
@@ -272,7 +424,9 @@ async function columnTypes(
|
||||
`,
|
||||
[table],
|
||||
);
|
||||
return new Map(result.rows.map((row) => [row.column_name, row.udt_name]));
|
||||
const types = new Map(result.rows.map((row) => [row.column_name, row.udt_name]));
|
||||
if (cacheKey && types.size > 0) writeTimed(metadataCache().columns, cacheKey, new Map(types));
|
||||
return types;
|
||||
}
|
||||
|
||||
function databaseValue(type: string | undefined, value: unknown): unknown {
|
||||
@@ -314,6 +468,7 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly connectionString: string,
|
||||
private readonly identity: LocalIdentity,
|
||||
private readonly role: LocalDatabaseRole | null,
|
||||
private readonly table: string,
|
||||
@@ -516,10 +671,14 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
try {
|
||||
return await inBusinessTransaction(
|
||||
this.pool,
|
||||
this.connectionString,
|
||||
this.identity,
|
||||
this.role,
|
||||
async (client) => {
|
||||
const types = await columnTypes(client, this.table);
|
||||
const types = await columnTypes(client, this.table, {
|
||||
connectionString: this.connectionString,
|
||||
role: this.role,
|
||||
});
|
||||
const parameters: unknown[] = [];
|
||||
let sql: string;
|
||||
|
||||
@@ -563,7 +722,10 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
if (this.mutation.kind === "upsert") {
|
||||
let conflict = this.mutation.conflict;
|
||||
if (conflict.length === 0) {
|
||||
conflict = await primaryKeyColumns(client, this.table);
|
||||
conflict = await primaryKeyColumns(client, this.table, {
|
||||
connectionString: this.connectionString,
|
||||
role: this.role,
|
||||
});
|
||||
}
|
||||
if (conflict.length === 0) {
|
||||
throw new Error("upsert requires a conflict target");
|
||||
@@ -648,7 +810,21 @@ async function functionMetadata(
|
||||
client: PoolClient,
|
||||
functionName: string,
|
||||
argumentNames: readonly string[],
|
||||
scope: MetadataScope,
|
||||
): Promise<FunctionMetadata> {
|
||||
const cacheKey = metadataCacheEnabled()
|
||||
? `${scope.connectionString}|${roleCacheKey(scope.role)}|${functionName}|${[...argumentNames].sort().join(",")}`
|
||||
: null;
|
||||
if (cacheKey) {
|
||||
const cached = readTimed(metadataCache().functions, cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
argument_names: cached.argument_names ? [...cached.argument_names] : null,
|
||||
argument_types: [...cached.argument_types],
|
||||
};
|
||||
}
|
||||
}
|
||||
const result = await client.query<FunctionMetadata>(
|
||||
`
|
||||
select
|
||||
@@ -671,6 +847,13 @@ async function functionMetadata(
|
||||
);
|
||||
const metadata = result.rows[0];
|
||||
if (!metadata) throw new Error("database function not found");
|
||||
if (cacheKey) {
|
||||
writeTimed(metadataCache().functions, cacheKey, {
|
||||
...metadata,
|
||||
argument_names: metadata.argument_names ? [...metadata.argument_names] : null,
|
||||
argument_types: [...metadata.argument_types],
|
||||
});
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@@ -691,12 +874,14 @@ export class LocalPostgresDataClient {
|
||||
}>;
|
||||
|
||||
private readonly pool: Pool;
|
||||
private readonly connectionString: string;
|
||||
|
||||
constructor(
|
||||
connectionString: string,
|
||||
private readonly identity: LocalIdentity,
|
||||
private readonly role: LocalDatabaseRole | null,
|
||||
) {
|
||||
this.connectionString = connectionString;
|
||||
if (role !== null && role !== "authenticated" && role !== "service_role") {
|
||||
throw new Error("unsupported database role");
|
||||
}
|
||||
@@ -709,6 +894,7 @@ export class LocalPostgresDataClient {
|
||||
from(table: string) {
|
||||
return new LocalPostgresQueryBuilder(
|
||||
this.pool,
|
||||
this.connectionString,
|
||||
this.identity,
|
||||
this.role,
|
||||
table,
|
||||
@@ -723,12 +909,16 @@ export class LocalPostgresDataClient {
|
||||
identifier(functionName);
|
||||
return await inBusinessTransaction(
|
||||
this.pool,
|
||||
this.connectionString,
|
||||
this.identity,
|
||||
this.role,
|
||||
async (client) => {
|
||||
const names = Object.keys(args);
|
||||
names.forEach(identifier);
|
||||
const metadata = await functionMetadata(client, functionName, names);
|
||||
const metadata = await functionMetadata(client, functionName, names, {
|
||||
connectionString: this.connectionString,
|
||||
role: this.role,
|
||||
});
|
||||
const typeByName = new Map(
|
||||
(metadata.argument_names ?? []).map((name, index) => [
|
||||
name,
|
||||
|
||||
@@ -173,13 +173,17 @@ function defaultRetryDelayMs(job: PersonalReportJobRecord): number {
|
||||
async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = globalThis.setTimeout(resolve, milliseconds);
|
||||
const candidate = timer as ReturnType<typeof globalThis.setTimeout> & { unref?: () => void };
|
||||
candidate.unref?.();
|
||||
signal?.addEventListener("abort", () => {
|
||||
const onAbort = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
};
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
const candidate = timer as ReturnType<typeof globalThis.setTimeout> & { unref?: () => void };
|
||||
candidate.unref?.();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -188,12 +188,47 @@ export async function rescoreMinuteAfterWindowChange(
|
||||
|
||||
export { persistBlockScanPayload } from "./score-persist.ts";
|
||||
|
||||
const rescoreAttempts = new Map<string, true>();
|
||||
const RESCORE_ATTEMPT_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const RESCORE_ATTEMPT_CAP = 5_000;
|
||||
|
||||
const rescoreAttempts = new Map<string, number>();
|
||||
|
||||
export function resetStaleMinuteRescoreAttemptsForTests(): void {
|
||||
rescoreAttempts.clear();
|
||||
}
|
||||
|
||||
function pruneRescoreAttempts(now: number): void {
|
||||
for (const [key, at] of rescoreAttempts) {
|
||||
if (now - at > RESCORE_ATTEMPT_TTL_MS) rescoreAttempts.delete(key);
|
||||
}
|
||||
while (rescoreAttempts.size > RESCORE_ATTEMPT_CAP) {
|
||||
const oldest = rescoreAttempts.keys().next().value;
|
||||
if (oldest === undefined) return;
|
||||
rescoreAttempts.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when this key should be scored now. A fresh key is remembered. */
|
||||
function claimRescoreAttempt(key: string, now: number): boolean {
|
||||
pruneRescoreAttempts(now);
|
||||
const existing = rescoreAttempts.get(key);
|
||||
if (existing !== undefined && now - existing <= RESCORE_ATTEMPT_TTL_MS) return false;
|
||||
if (!rescoreAttempts.has(key) && rescoreAttempts.size >= RESCORE_ATTEMPT_CAP) {
|
||||
const oldest = rescoreAttempts.keys().next().value;
|
||||
if (oldest !== undefined) rescoreAttempts.delete(oldest);
|
||||
}
|
||||
rescoreAttempts.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function noteRescoreAttemptForTests(key: string, at: number): void {
|
||||
claimRescoreAttempt(key, at);
|
||||
}
|
||||
|
||||
export function rescoreAttemptCountForTests(): number {
|
||||
return rescoreAttempts.size;
|
||||
}
|
||||
|
||||
function rescoreAttemptKey(caseId: string, fingerprint: string, askedTurnId?: string | null): string {
|
||||
return `${caseId}:${askedTurnId ?? ""}:${fingerprint}`;
|
||||
}
|
||||
@@ -219,10 +254,9 @@ export async function rescoreStaleMinuteSnapshotIfNeeded(input: {
|
||||
}
|
||||
const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
|
||||
const key = rescoreAttemptKey(input.caseId, fingerprint, input.askedTurnId);
|
||||
if (rescoreAttempts.has(key)) {
|
||||
if (!claimRescoreAttempt(key, Date.now())) {
|
||||
return { dossier, snapshotCurrent: false, rescoreAttempted: false };
|
||||
}
|
||||
rescoreAttempts.set(key, true);
|
||||
try {
|
||||
await rescoreMinuteAfterWindowChange(input.accounting, input.userId, input.caseId, {
|
||||
keepAnswers: true,
|
||||
|
||||
@@ -6,11 +6,30 @@
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
const DELIVERY_MARK_CAP = 10_000;
|
||||
|
||||
type DeliveryMark = Readonly<{ resultId: string; at: number; narration?: string }>;
|
||||
|
||||
const delivered = new Map<string, DeliveryMark>();
|
||||
|
||||
function pruneDelivered(now: number): void {
|
||||
for (const [caseId, mark] of delivered) {
|
||||
if (now - mark.at > WINDOW_MS) delivered.delete(caseId);
|
||||
}
|
||||
}
|
||||
|
||||
function makeRoomForDeliveryMark(): void {
|
||||
while (delivered.size >= DELIVERY_MARK_CAP) {
|
||||
const oldest = delivered.keys().next().value;
|
||||
if (oldest === undefined) return;
|
||||
delivered.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function deliveryTurnGuardSizeForTests(): number {
|
||||
return delivered.size;
|
||||
}
|
||||
|
||||
export type DeliveryTurnTrigger =
|
||||
| "finalizeSuccessfulTurnExit"
|
||||
| "persistExhaustionGateTurn"
|
||||
@@ -39,8 +58,10 @@ export function markDeliveryTurn(input: {
|
||||
}): void {
|
||||
if (!input.caseId || !input.resultId) return;
|
||||
const now = input.now ?? Date.now();
|
||||
pruneDelivered(now);
|
||||
const narration = input.narration?.replace(/\s+/g, " ").trim() || undefined;
|
||||
const existing = delivered.get(input.caseId);
|
||||
if (!existing) makeRoomForDeliveryMark();
|
||||
if (existing && existing.resultId === input.resultId && now - existing.at <= WINDOW_MS) {
|
||||
if (existing.narration) return;
|
||||
delivered.set(input.caseId, {
|
||||
|
||||
@@ -32,9 +32,36 @@ function defaultStore(): RequestRateLimitStore {
|
||||
return state.jyotishaRequestRateLimits;
|
||||
}
|
||||
|
||||
let seenMaxWindowMs = 0;
|
||||
let consumeCalls = 0;
|
||||
|
||||
export function resetRequestRateLimitMaintenanceForTests(): void {
|
||||
seenMaxWindowMs = 0;
|
||||
consumeCalls = 0;
|
||||
}
|
||||
|
||||
function sweepExpiredRateLimitKeys(
|
||||
store: RequestRateLimitStore,
|
||||
now: number,
|
||||
maxWindowMs: number,
|
||||
): void {
|
||||
for (const [key, stamps] of store) {
|
||||
let newest = Number.NEGATIVE_INFINITY;
|
||||
for (const stamp of stamps) {
|
||||
if (stamp > newest) newest = stamp;
|
||||
}
|
||||
if (!Number.isFinite(newest) || newest <= now - maxWindowMs) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision {
|
||||
if (input.windowMs > seenMaxWindowMs) seenMaxWindowMs = input.windowMs;
|
||||
consumeCalls += 1;
|
||||
const now = input.now ?? Date.now();
|
||||
const store = input.store ?? defaultStore();
|
||||
if (consumeCalls % 1_000 === 0 || store.size > 5_000) {
|
||||
sweepExpiredRateLimitKeys(store, now, seenMaxWindowMs);
|
||||
}
|
||||
const windowStart = now - input.windowMs;
|
||||
const recent = (store.get(input.key) ?? []).filter((timestamp) => timestamp > windowStart);
|
||||
if (recent.length >= input.limit) {
|
||||
|
||||
@@ -9,12 +9,15 @@ import { getIdentityAuthServices } from "@/modules/identity/auth";
|
||||
import { readIdentitySession } from "@/modules/identity/session";
|
||||
import { getSupabasePublicConfig } from "./config";
|
||||
|
||||
export async function createServerSupabaseClient() {
|
||||
export async function createServerSupabaseClient(options?: {
|
||||
readonly disableCookieCache?: boolean;
|
||||
}) {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
const requestHeaders = new Headers(await headers());
|
||||
const session = await readIdentitySession(
|
||||
getIdentityAuthServices().user.api,
|
||||
requestHeaders,
|
||||
options?.disableCookieCache ? { disableCookieCache: true } : undefined,
|
||||
);
|
||||
return createLocalPostgresDataClient(
|
||||
readDatabaseUrl(process.env, "APP_DATABASE_URL"),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import type { Pool } from "pg";
|
||||
import type { BetterAuthOptions } from "better-auth";
|
||||
import { admin, emailOTP, twoFactor, type EmailOTPOptions } from "better-auth/plugins";
|
||||
|
||||
@@ -11,10 +10,19 @@ export type AdminUserAuthorizer = (userId: string) => Promise<boolean>;
|
||||
|
||||
interface BuildAuthOptionsInput {
|
||||
config: SelfHostedIdentityConfig;
|
||||
database: Pool;
|
||||
database: BetterAuthOptions["database"];
|
||||
emailSender: EmailOtpSender;
|
||||
}
|
||||
|
||||
export const IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS = 300;
|
||||
|
||||
/** Server-only. Unset or any value other than "on" keeps today's uncached session reads. */
|
||||
export function identitySessionCookieCacheEnabled(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return env.IDENTITY_SESSION_COOKIE_CACHE === "on";
|
||||
}
|
||||
|
||||
function otpIdempotencyKey(
|
||||
secret: string,
|
||||
email: string,
|
||||
@@ -86,7 +94,15 @@ export function buildAuthOptions({
|
||||
trustedOrigins: [config.userOrigin, config.adminOrigin],
|
||||
telemetry: { enabled: false },
|
||||
user: identityModelMapping.user,
|
||||
session: identityModelMapping.session,
|
||||
session: identitySessionCookieCacheEnabled()
|
||||
? {
|
||||
...identityModelMapping.session,
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS,
|
||||
},
|
||||
}
|
||||
: identityModelMapping.session,
|
||||
account: identityModelMapping.account,
|
||||
verification: identityModelMapping.verification,
|
||||
rateLimit: {
|
||||
|
||||
@@ -18,8 +18,15 @@ export interface IdentityServerSession extends IdentitySession {
|
||||
sessionToken: string;
|
||||
}
|
||||
|
||||
export interface IdentitySessionQuery {
|
||||
readonly disableCookieCache?: boolean;
|
||||
}
|
||||
|
||||
export interface IdentitySessionReader {
|
||||
getSession(input: { headers: Headers }): Promise<RawIdentitySession | null>;
|
||||
getSession(input: {
|
||||
headers: Headers;
|
||||
query?: IdentitySessionQuery;
|
||||
}): Promise<RawIdentitySession | null>;
|
||||
}
|
||||
|
||||
export class IdentityAuthorizationError extends Error {
|
||||
@@ -43,8 +50,12 @@ function parseRoles(role: string | null | undefined): string[] {
|
||||
export async function readIdentityServerSession(
|
||||
reader: IdentitySessionReader,
|
||||
requestHeaders: Headers,
|
||||
options?: IdentitySessionQuery,
|
||||
): Promise<IdentityServerSession | null> {
|
||||
const value = await reader.getSession({ headers: requestHeaders });
|
||||
const value = await reader.getSession({
|
||||
headers: requestHeaders,
|
||||
...(options?.disableCookieCache ? { query: { disableCookieCache: true } } : {}),
|
||||
});
|
||||
if (!value) return null;
|
||||
|
||||
const expiresAt = new Date(value.session.expiresAt);
|
||||
@@ -71,16 +82,18 @@ export async function readIdentityServerSession(
|
||||
export async function readIdentitySession(
|
||||
reader: IdentitySessionReader,
|
||||
requestHeaders: Headers,
|
||||
options?: IdentitySessionQuery,
|
||||
): Promise<IdentitySession | null> {
|
||||
const session = await readIdentityServerSession(reader, requestHeaders);
|
||||
const session = await readIdentityServerSession(reader, requestHeaders, options);
|
||||
return session ? { user: session.user, expiresAt: session.expiresAt } : null;
|
||||
}
|
||||
|
||||
export async function requireIdentityServerSession(
|
||||
reader: IdentitySessionReader,
|
||||
requestHeaders: Headers,
|
||||
options?: IdentitySessionQuery,
|
||||
): Promise<IdentityServerSession> {
|
||||
const session = await readIdentityServerSession(reader, requestHeaders);
|
||||
const session = await readIdentityServerSession(reader, requestHeaders, options);
|
||||
if (!session) throw new IdentityAuthorizationError("Authentication required", 401);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Count local-Postgres adapter roundtrips for a fictional homepage plus consult write.
|
||||
* Run from frontend/: npx tsx tests/bench-local-pg-roundtrips.ts
|
||||
* Prints JSON. Does not print connection strings.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import type { Pool, PoolClient } from "pg";
|
||||
|
||||
import {
|
||||
closeLocalPostgresDataPools,
|
||||
clearLocalPostgresMetadataCacheForTests,
|
||||
createLocalPostgresDataClient,
|
||||
localPostgresPoolForTests,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const userId = "018f4e6d-7a11-7000-8000-0000000000bb";
|
||||
const sessionId = "018f4e6d-7a11-7000-8000-0000000000cc";
|
||||
const chartId = "018f4e6d-7a11-7000-8000-0000000000dd";
|
||||
|
||||
const schemaSql = `
|
||||
create table if not exists public.profiles (
|
||||
id uuid primary key,
|
||||
display_name text
|
||||
);
|
||||
create table if not exists public.chart_profiles (
|
||||
id uuid primary key,
|
||||
user_id uuid not null,
|
||||
label text,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create table if not exists public.chat_sessions (
|
||||
id uuid primary key,
|
||||
user_id uuid not null,
|
||||
title text,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
grant usage on schema public to authenticated, service_role;
|
||||
grant select, insert, update, delete on public.profiles, public.chart_profiles, public.chat_sessions to authenticated, service_role;
|
||||
insert into public.profiles (id, display_name) values ('${userId}', 'Fictional Person')
|
||||
on conflict (id) do nothing;
|
||||
insert into public.chart_profiles (id, user_id, label) values ('${chartId}', '${userId}', 'home')
|
||||
on conflict (id) do nothing;
|
||||
insert into public.chat_sessions (id, user_id, title) values ('${sessionId}', '${userId}', 'consult')
|
||||
on conflict (id) do nothing;
|
||||
create or replace function public.session_probe()
|
||||
returns table(db_user text)
|
||||
language sql stable
|
||||
as $$ select current_user::text $$;
|
||||
grant execute on function public.session_probe() to service_role, authenticated;
|
||||
`;
|
||||
|
||||
type Mode = { cache: "on" | "off"; merged: "on" | "off" };
|
||||
|
||||
function percentile(values: number[], ratio: number): number {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(ratio * sorted.length) - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
||||
}
|
||||
|
||||
const benchCounter = { roundtrips: 0 };
|
||||
const queryWrapped = new WeakSet<object>();
|
||||
|
||||
async function withCountingPools(
|
||||
urls: readonly string[],
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<{ roundtrips: number; elapsedMs: number; payload: string }> {
|
||||
const restores: Array<() => void> = [];
|
||||
benchCounter.roundtrips = 0;
|
||||
for (const url of urls) {
|
||||
const pool = localPostgresPoolForTests(url) as Pool & {
|
||||
connect: () => Promise<PoolClient>;
|
||||
};
|
||||
const original = pool.connect.bind(pool);
|
||||
pool.connect = async () => {
|
||||
const client = await original();
|
||||
if (!queryWrapped.has(client)) {
|
||||
queryWrapped.add(client);
|
||||
const query = client.query.bind(client) as (input: unknown, values?: unknown) => Promise<unknown>;
|
||||
client.query = ((input: unknown, values?: unknown) => {
|
||||
benchCounter.roundtrips += 1;
|
||||
return values === undefined ? query(input) : query(input, values);
|
||||
}) as PoolClient["query"];
|
||||
}
|
||||
return client;
|
||||
};
|
||||
restores.push(() => {
|
||||
pool.connect = original;
|
||||
});
|
||||
}
|
||||
const started = performance.now();
|
||||
try {
|
||||
const payload = await run();
|
||||
return {
|
||||
roundtrips: benchCounter.roundtrips,
|
||||
elapsedMs: performance.now() - started,
|
||||
payload: JSON.stringify(payload),
|
||||
};
|
||||
} finally {
|
||||
for (const restore of restores) restore();
|
||||
}
|
||||
}
|
||||
|
||||
async function sequence(url: string, serviceUrl: string): Promise<unknown[]> {
|
||||
const user = createLocalPostgresDataClient(url, { id: userId, email: "person@example.com" }, "authenticated");
|
||||
const service = createLocalPostgresDataClient(serviceUrl, null, "service_role");
|
||||
const profile = await user.from("profiles").select("id,display_name").eq("id", userId).maybeSingle();
|
||||
const charts = await user.from("chart_profiles").select("id,label").eq("user_id", userId).order("updated_at", { ascending: false }).limit(40);
|
||||
const sessions = await user.from("chat_sessions").select("id,title").eq("user_id", userId).order("updated_at", { ascending: false }).limit(40);
|
||||
const updated = await user.from("chat_sessions").update({ title: "consult-bench" }).eq("id", sessionId);
|
||||
const upsertConflict = await user.from("chat_sessions").upsert(
|
||||
{ id: sessionId, user_id: userId, title: "consult-bench" },
|
||||
{ onConflict: "id" },
|
||||
);
|
||||
const upsertKey = await user.from("chat_sessions").upsert({ id: sessionId, user_id: userId, title: "consult-bench" });
|
||||
const rpc = await service.rpc("session_probe");
|
||||
const serviceRead = await service.from("profiles").select("id").eq("id", userId).maybeSingle();
|
||||
return [profile, charts, sessions, updated, upsertConflict, upsertKey, rpc, serviceRead].map((result) => ({
|
||||
data: result.data ?? null,
|
||||
error: result.error ?? null,
|
||||
count: "count" in result ? result.count ?? null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function measure(url: string, serviceUrl: string, mode: Mode): Promise<{
|
||||
roundtrips: number;
|
||||
medianMs: number;
|
||||
p95Ms: number;
|
||||
payload: string;
|
||||
groups: Array<{ roundtrips: number; medianMs: number }>;
|
||||
}> {
|
||||
process.env.JYOTISHA_PG_METADATA_CACHE = mode.cache;
|
||||
process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = mode.merged;
|
||||
clearLocalPostgresMetadataCacheForTests();
|
||||
await sequence(url, serviceUrl);
|
||||
const groupMedians: number[] = [];
|
||||
const groupRoundtrips: number[] = [];
|
||||
const allTimes: number[] = [];
|
||||
let payload = "";
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const group: number[] = [];
|
||||
let roundtrips = 0;
|
||||
for (let repeat = 0; repeat < 50; repeat += 1) {
|
||||
const sample = await withCountingPools([url, serviceUrl], () => sequence(url, serviceUrl));
|
||||
group.push(sample.elapsedMs);
|
||||
allTimes.push(sample.elapsedMs);
|
||||
roundtrips += sample.roundtrips;
|
||||
payload = sample.payload;
|
||||
}
|
||||
groupMedians.push(median(group));
|
||||
groupRoundtrips.push(Math.round(roundtrips / 50));
|
||||
}
|
||||
return {
|
||||
roundtrips: median(groupRoundtrips),
|
||||
medianMs: median(groupMedians),
|
||||
p95Ms: percentile(allTimes, 0.95),
|
||||
payload,
|
||||
groups: groupRoundtrips.map((roundtrips, index) => ({
|
||||
roundtrips,
|
||||
medianMs: Number((groupMedians[index] ?? 0).toFixed(2)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const docker = spawnSync("docker", ["info"], { encoding: "utf8" });
|
||||
if (docker.status !== 0) {
|
||||
console.log(JSON.stringify({ ok: false, reason: "docker-unavailable" }));
|
||||
process.exit(2);
|
||||
}
|
||||
const fixture = startPostgresFixture();
|
||||
const appUrl = fixture.connectionUrl("app_runtime", "app-runtime-test-password");
|
||||
const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password");
|
||||
try {
|
||||
fixture.psqlScriptAs("postgres", "postgres-test-password", schemaSql);
|
||||
const modes: Record<string, Mode> = {
|
||||
A: { cache: "off", merged: "off" },
|
||||
B: { cache: "on", merged: "on" },
|
||||
B1: { cache: "on", merged: "off" },
|
||||
B2: { cache: "off", merged: "on" },
|
||||
};
|
||||
const results: Record<string, { roundtrips: number; medianMs: number; p95Ms: number; groups: Array<{ roundtrips: number; medianMs: number }> }> = {};
|
||||
let payloadA = "";
|
||||
let payloadMismatch = false;
|
||||
for (const [name, mode] of Object.entries(modes)) {
|
||||
const measured = await measure(appUrl, serviceUrl, mode);
|
||||
results[name] = {
|
||||
roundtrips: measured.roundtrips,
|
||||
medianMs: Number(measured.medianMs.toFixed(2)),
|
||||
p95Ms: Number(measured.p95Ms.toFixed(2)),
|
||||
groups: measured.groups,
|
||||
};
|
||||
if (name === "A") payloadA = measured.payload;
|
||||
else if (measured.payload !== payloadA) payloadMismatch = true;
|
||||
clearLocalPostgresMetadataCacheForTests();
|
||||
await closeLocalPostgresDataPools();
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
repeats: 3,
|
||||
repsPerGroup: 50,
|
||||
payloadMismatch,
|
||||
results,
|
||||
node: process.version,
|
||||
}));
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
fixture.stop();
|
||||
delete process.env.JYOTISHA_PG_METADATA_CACHE;
|
||||
delete process.env.JYOTISHA_PG_MERGED_SESSION_SETUP;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "bench failed";
|
||||
console.log(JSON.stringify({ ok: false, reason: message }));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
closeLocalPostgresDataPools,
|
||||
createLocalPostgresDataClient,
|
||||
localPostgresPoolForTests,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const probeSql = `
|
||||
create or replace function public.session_probe()
|
||||
returns table(db_user text, claim_sub text)
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select current_user::text, current_setting('request.jwt.claim.sub', true);
|
||||
$$;
|
||||
grant usage on schema public to authenticated, service_role;
|
||||
grant execute on function public.session_probe() to authenticated, service_role;
|
||||
`;
|
||||
|
||||
test("merged session setup applies the role only inside the transaction", async () => {
|
||||
const previous = {
|
||||
cache: process.env.JYOTISHA_PG_METADATA_CACHE,
|
||||
merged: process.env.JYOTISHA_PG_MERGED_SESSION_SETUP,
|
||||
};
|
||||
process.env.JYOTISHA_PG_METADATA_CACHE = "on";
|
||||
process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = "on";
|
||||
const fixture = startPostgresFixture();
|
||||
const identity = {
|
||||
id: "018f4e6d-7a11-7000-8000-0000000000aa",
|
||||
email: "person@example.com",
|
||||
};
|
||||
try {
|
||||
fixture.psqlScriptAs("postgres", "postgres-test-password", probeSql);
|
||||
const url = fixture.connectionUrl("app_runtime", "app-runtime-test-password");
|
||||
const client = createLocalPostgresDataClient(url, identity, "authenticated");
|
||||
const probe = await client.rpc("session_probe");
|
||||
assert.equal(probe.error, null, probe.error?.message);
|
||||
const row = Array.isArray(probe.data) ? probe.data[0] as { db_user?: string; claim_sub?: string } : null;
|
||||
assert.equal(row?.db_user, "authenticated");
|
||||
assert.equal(row?.claim_sub, identity.id);
|
||||
|
||||
const pool = localPostgresPoolForTests(url);
|
||||
const borrowed = await pool.connect();
|
||||
try {
|
||||
const after = await borrowed.query<{ db_user: string; claim_sub: string | null }>(
|
||||
"select current_user as db_user, current_setting('request.jwt.claim.sub', true) as claim_sub",
|
||||
);
|
||||
assert.equal(after.rows[0]?.db_user, "app_runtime");
|
||||
assert.equal(after.rows[0]?.claim_sub ?? "", "");
|
||||
} finally {
|
||||
borrowed.release();
|
||||
}
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
fixture.stop();
|
||||
if (previous.cache === undefined) delete process.env.JYOTISHA_PG_METADATA_CACHE;
|
||||
else process.env.JYOTISHA_PG_METADATA_CACHE = previous.cache;
|
||||
if (previous.merged === undefined) delete process.env.JYOTISHA_PG_MERGED_SESSION_SETUP;
|
||||
else process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = previous.merged;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
alreadyDelivered,
|
||||
deliveryCarrierNarration,
|
||||
deliveryTurnGuardSizeForTests,
|
||||
markDeliveryTurn,
|
||||
resetDeliveryTurnGuardForTests,
|
||||
} from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
|
||||
|
||||
test("a delivery mark older than 60 seconds is removed on the next write", () => {
|
||||
resetDeliveryTurnGuardForTests();
|
||||
markDeliveryTurn({
|
||||
caseId: "case-a",
|
||||
resultId: "result-a",
|
||||
now: 0,
|
||||
narration: "已经交付这段说明",
|
||||
});
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-a",
|
||||
resultId: "result-a",
|
||||
hasUserMessage: false,
|
||||
now: 60_000,
|
||||
}), true);
|
||||
assert.equal(deliveryCarrierNarration({
|
||||
caseId: "case-a",
|
||||
resultId: "result-a",
|
||||
now: 60_000,
|
||||
}), "已经交付这段说明");
|
||||
|
||||
markDeliveryTurn({ caseId: "case-b", resultId: "result-b", now: 60_001 });
|
||||
assert.equal(deliveryTurnGuardSizeForTests(), 1);
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-a",
|
||||
resultId: "result-a",
|
||||
hasUserMessage: false,
|
||||
now: 60_001,
|
||||
}), false);
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-b",
|
||||
resultId: "result-b",
|
||||
hasUserMessage: false,
|
||||
now: 70_000,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("delivery marks stay within the hard cap", () => {
|
||||
resetDeliveryTurnGuardForTests();
|
||||
for (let index = 0; index < 10_001; index += 1) {
|
||||
markDeliveryTurn({ caseId: `case-${index}`, resultId: "result", now: 1_000 });
|
||||
}
|
||||
assert.ok(deliveryTurnGuardSizeForTests() <= 10_000);
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-10000",
|
||||
resultId: "result",
|
||||
hasUserMessage: false,
|
||||
now: 1_000,
|
||||
}), true);
|
||||
resetDeliveryTurnGuardForTests();
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { memoryAdapter, type MemoryDB } from "../node_modules/better-auth/node_modules/@better-auth/memory-adapter/dist/index.mjs";
|
||||
import type { BetterAuthOptions } from "better-auth";
|
||||
|
||||
import {
|
||||
buildAuthOptions,
|
||||
IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS,
|
||||
} from "../src/modules/identity/auth-factory.ts";
|
||||
import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts";
|
||||
import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts";
|
||||
import {
|
||||
readIdentitySession,
|
||||
requireIdentityServerSession,
|
||||
type IdentitySessionReader,
|
||||
} from "../src/modules/identity/session.ts";
|
||||
|
||||
const config: SelfHostedIdentityConfig = {
|
||||
provider: "self-hosted",
|
||||
databaseUrl: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha",
|
||||
userOrigin: "https://staging.jyotisha.chat",
|
||||
adminOrigin: "https://admin.staging.jyotisha.chat",
|
||||
userSecret: "user-secret-that-is-at-least-32-bytes-long",
|
||||
resendApiKey: "re_test",
|
||||
resendFrom: "Jyotisha <login@staging.jyotisha.chat>",
|
||||
testOtp: null,
|
||||
};
|
||||
|
||||
function withCacheFlag(value: string | undefined, run: () => void): void {
|
||||
const previous = process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
if (value === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
else process.env.IDENTITY_SESSION_COOKIE_CACHE = value;
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
else process.env.IDENTITY_SESSION_COOKIE_CACHE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test("user auth omits cookie cache unless the server switch is on", () => {
|
||||
withCacheFlag(undefined, () => {
|
||||
const options = buildAuthOptions({
|
||||
config,
|
||||
database: memoryAdapter({}),
|
||||
emailSender: new FakeEmailOtpSender(),
|
||||
});
|
||||
assert.equal(options.advanced?.cookiePrefix, "jyotisha-user");
|
||||
assert.equal(options.session && "cookieCache" in options.session, false);
|
||||
});
|
||||
withCacheFlag("on", () => {
|
||||
const options = buildAuthOptions({
|
||||
config,
|
||||
database: memoryAdapter({}),
|
||||
emailSender: new FakeEmailOtpSender(),
|
||||
});
|
||||
assert.deepEqual(options.session?.cookieCache, {
|
||||
enabled: true,
|
||||
maxAge: IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("sensitive session readers pass disableCookieCache and ordinary readers do not", async () => {
|
||||
const queries: Array<{ disableCookieCache?: boolean }> = [];
|
||||
const reader: IdentitySessionReader = {
|
||||
async getSession(input) {
|
||||
queries.push({ disableCookieCache: input.query?.disableCookieCache });
|
||||
return {
|
||||
session: {
|
||||
id: "session-1",
|
||||
token: "session-token",
|
||||
expiresAt: new Date("2030-01-01T00:00:00.000Z"),
|
||||
},
|
||||
user: {
|
||||
id: "018f4e6d-7a11-7000-8000-000000000001",
|
||||
email: "person@example.com",
|
||||
emailVerified: true,
|
||||
name: "Person",
|
||||
image: null,
|
||||
role: "user",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
await readIdentitySession(reader, new Headers());
|
||||
await requireIdentityServerSession(reader, new Headers(), { disableCookieCache: true });
|
||||
assert.deepEqual(queries, [
|
||||
{ disableCookieCache: undefined },
|
||||
{ disableCookieCache: true },
|
||||
]);
|
||||
});
|
||||
|
||||
type SetCookieHeaders = Headers & { getSetCookie?: () => string[] };
|
||||
|
||||
function cookieHeaderFrom(response: Response, previous = ""): string {
|
||||
const headers = response.headers as SetCookieHeaders;
|
||||
const setCookies = headers.getSetCookie?.()
|
||||
?? (headers.get("set-cookie")?.split(/,(?=\s*[^;,=\s]+=[^;,]*)/g) ?? []);
|
||||
const fresh = setCookies
|
||||
.map((value) => value.split(";", 1)[0] ?? "")
|
||||
.filter((value) => value.includes("=") && value.slice(value.indexOf("=") + 1).length > 0);
|
||||
const jar = new Map<string, string>();
|
||||
for (const part of previous.split("; ").filter(Boolean)) {
|
||||
jar.set(part.slice(0, part.indexOf("=")), part);
|
||||
}
|
||||
for (const part of fresh) jar.set(part.slice(0, part.indexOf("=")), part);
|
||||
return [...jar.values()].join("; ");
|
||||
}
|
||||
|
||||
function createCacheAuth(enabled: boolean) {
|
||||
const db: MemoryDB = {
|
||||
users: [],
|
||||
sessions: [],
|
||||
accounts: [],
|
||||
verifications: [],
|
||||
two_factors: [],
|
||||
otp_rate_limits: [],
|
||||
};
|
||||
const sessionReads: string[] = [];
|
||||
const factory = memoryAdapter(db);
|
||||
const database = ((options: BetterAuthOptions) => {
|
||||
const adapter = factory(options);
|
||||
return {
|
||||
...adapter,
|
||||
async findOne<T>(data: { model: string }) {
|
||||
if (data.model === "session" || data.model === "sessions") sessionReads.push(data.model);
|
||||
return adapter.findOne<T>(data as never);
|
||||
},
|
||||
};
|
||||
}) as BetterAuthOptions["database"];
|
||||
const previous = process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
process.env.IDENTITY_SESSION_COOKIE_CACHE = enabled ? "on" : "off";
|
||||
try {
|
||||
const built = buildAuthOptions({
|
||||
config,
|
||||
database,
|
||||
emailSender: new FakeEmailOtpSender(),
|
||||
});
|
||||
const auth = betterAuth({
|
||||
...built,
|
||||
emailAndPassword: {
|
||||
...built.emailAndPassword,
|
||||
enabled: true,
|
||||
disableSignUp: false,
|
||||
},
|
||||
});
|
||||
return { auth, db, sessionReads };
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
else process.env.IDENTITY_SESSION_COOKIE_CACHE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
type CacheAuth = ReturnType<typeof createCacheAuth>["auth"];
|
||||
|
||||
async function signIn(auth: CacheAuth): Promise<string> {
|
||||
const email = "person@example.com";
|
||||
const password = "long-enough-password";
|
||||
const origin = config.userOrigin;
|
||||
const signedUp = await auth.api.signUpEmail({
|
||||
body: { email, password, name: "Person" },
|
||||
headers: new Headers({ origin }),
|
||||
asResponse: true,
|
||||
});
|
||||
assert.equal(signedUp.status, 200, await signedUp.clone().text());
|
||||
let cookie = cookieHeaderFrom(signedUp);
|
||||
if (!cookie.includes("session_token")) {
|
||||
const signedIn = await auth.api.signInEmail({
|
||||
body: { email, password },
|
||||
headers: new Headers({ origin }),
|
||||
asResponse: true,
|
||||
});
|
||||
assert.equal(signedIn.status, 200, await signedIn.clone().text());
|
||||
cookie = cookieHeaderFrom(signedIn, cookie);
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function readSession(
|
||||
auth: CacheAuth,
|
||||
cookie: string,
|
||||
disableCookieCache = false,
|
||||
): Promise<{ cookie: string; status: number; userId: string | null }> {
|
||||
const response = await auth.api.getSession({
|
||||
headers: new Headers({ cookie, origin: config.userOrigin }),
|
||||
query: disableCookieCache ? { disableCookieCache: true } : undefined,
|
||||
asResponse: true,
|
||||
});
|
||||
const body = await response.json() as { user?: { id?: string } } | null;
|
||||
return {
|
||||
cookie: cookieHeaderFrom(response, cookie),
|
||||
status: response.status,
|
||||
userId: body?.user?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
test("cookie cache serves three ordinary reads from one database read", async () => {
|
||||
const { auth, db, sessionReads } = createCacheAuth(true);
|
||||
const cookie = await signIn(auth);
|
||||
sessionReads.length = 0;
|
||||
let current = cookie
|
||||
.split("; ")
|
||||
.filter((part) => !part.slice(0, part.indexOf("=")).includes("session_data"))
|
||||
.join("; ");
|
||||
const reads: Array<string | null> = [];
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const result = await readSession(auth, current);
|
||||
current = result.cookie;
|
||||
reads.push(result.userId);
|
||||
}
|
||||
assert.deepEqual(reads, [reads[0], reads[0], reads[0]]);
|
||||
assert.ok(reads[0]);
|
||||
assert.equal(sessionReads.length, 1);
|
||||
const cachedCookie = current;
|
||||
|
||||
sessionReads.length = 0;
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const result = await readSession(auth, cachedCookie, true);
|
||||
assert.ok(result.userId);
|
||||
}
|
||||
assert.equal(sessionReads.length, 3);
|
||||
|
||||
const sessions = db.sessions ?? db.session;
|
||||
assert.ok(Array.isArray(sessions));
|
||||
sessions.length = 0;
|
||||
const sensitive = await readSession(auth, cachedCookie, true);
|
||||
assert.equal(sensitive.userId, null);
|
||||
const ordinary = await readSession(auth, cachedCookie, false);
|
||||
assert.equal(ordinary.userId, reads[0]);
|
||||
|
||||
const realNow = Date.now;
|
||||
const started = realNow();
|
||||
Date.now = () => started + (IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS + 1) * 1000;
|
||||
try {
|
||||
const expired = await readSession(auth, cachedCookie, false);
|
||||
assert.equal(expired.userId, null);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("cookie cache off reads the database on every ordinary request", async () => {
|
||||
const { auth, sessionReads } = createCacheAuth(false);
|
||||
const cookie = await signIn(auth);
|
||||
sessionReads.length = 0;
|
||||
let current = cookie
|
||||
.split("; ")
|
||||
.filter((part) => !part.slice(0, part.indexOf("=")).includes("session_data"))
|
||||
.join("; ");
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const result = await readSession(auth, current);
|
||||
assert.ok(result.userId);
|
||||
current = result.cookie;
|
||||
}
|
||||
assert.equal(sessionReads.length, 3);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import {
|
||||
clearLocalPostgresMetadataCacheForTests,
|
||||
closeLocalPostgresDataPool,
|
||||
createLocalPostgresDataClient,
|
||||
installLocalPostgresPoolForTests,
|
||||
setLocalPostgresMetadataClockForTests,
|
||||
type LocalDatabaseRole,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
|
||||
type QueryCall = { sql: string; params: readonly unknown[] };
|
||||
|
||||
function tag(sql: string): string {
|
||||
const compact = sql.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
if (compact === "begin") return "begin";
|
||||
if (compact === "commit") return "commit";
|
||||
if (compact === "rollback") return "rollback";
|
||||
if (compact.startsWith("set local role")) return "set local role";
|
||||
if (compact.includes("set_config('role'")) return "set_config(role)";
|
||||
if (compact.includes("set_config('request.jwt.claim.sub'")) return "set_config";
|
||||
if (compact.includes("information_schema.columns")) return "information_schema";
|
||||
if (compact.includes("from pg_index")) return "primary_key";
|
||||
if (compact.includes("from pg_proc")) return "pg_proc";
|
||||
return "business";
|
||||
}
|
||||
|
||||
function installFakePool(
|
||||
url: string,
|
||||
options: {
|
||||
columns?: ReadonlyArray<{ column_name: string; udt_name: string }> | null;
|
||||
primaryKeys?: readonly string[] | null;
|
||||
functions?: ReadonlyArray<Record<string, unknown>> | null;
|
||||
failBusinessCode?: () => string | null;
|
||||
} = {},
|
||||
): QueryCall[] {
|
||||
const calls: QueryCall[] = [];
|
||||
const client = {
|
||||
async query(sql: string, params: readonly unknown[] = []) {
|
||||
calls.push({ sql, params });
|
||||
const label = tag(sql);
|
||||
if (label === "information_schema") {
|
||||
const rows = options.columns === null
|
||||
? []
|
||||
: [...(options.columns ?? [{ column_name: "id", udt_name: "uuid" }])];
|
||||
return { rows, rowCount: rows.length };
|
||||
}
|
||||
if (label === "primary_key") {
|
||||
const names = options.primaryKeys === null ? [] : [...(options.primaryKeys ?? ["id"])];
|
||||
const rows = names.map((column_name) => ({ column_name }));
|
||||
return { rows, rowCount: rows.length };
|
||||
}
|
||||
if (label === "pg_proc") {
|
||||
const rows = options.functions === null
|
||||
? []
|
||||
: [...(options.functions ?? [{
|
||||
proretset: false,
|
||||
return_type: "text",
|
||||
argument_names: ["left_name", "right_name"],
|
||||
argument_types: ["text", "text"],
|
||||
}])];
|
||||
return { rows, rowCount: rows.length };
|
||||
}
|
||||
if (label === "business") {
|
||||
const code = options.failBusinessCode?.() ?? null;
|
||||
if (code) {
|
||||
const error = new Error("business sql failed") as Error & { code: string };
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
if (sql.toLowerCase().includes(" as value")) return { rows: [{ value: "ok" }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
release() {},
|
||||
};
|
||||
const pool = {
|
||||
async connect() {
|
||||
return client;
|
||||
},
|
||||
async end() {},
|
||||
};
|
||||
installLocalPostgresPoolForTests(url, pool as unknown as Pool);
|
||||
return calls;
|
||||
}
|
||||
|
||||
async function withSwitches(
|
||||
values: Record<string, string | undefined>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
clearLocalPostgresMetadataCacheForTests();
|
||||
setLocalPostgresMetadataClockForTests(null);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const [key, value] of previous) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
clearLocalPostgresMetadataCacheForTests();
|
||||
setLocalPostgresMetadataClockForTests(null);
|
||||
}
|
||||
}
|
||||
|
||||
const identity = { id: "018f4e6d-7a11-7000-8000-0000000000aa", email: "person@example.com" };
|
||||
|
||||
function clientFor(url: string, role: LocalDatabaseRole | null = "authenticated") {
|
||||
return createLocalPostgresDataClient(url, role === "service_role" ? null : identity, role);
|
||||
}
|
||||
|
||||
test("metadata cache and merged session setup stay off the baseline query sequence", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "off",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-off@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url);
|
||||
const client = clientFor(url);
|
||||
const first = await client.from("profiles").select("id").eq("id", identity.id);
|
||||
assert.equal(first.error, null);
|
||||
assert.deepEqual(calls.map((call) => tag(call.sql)), [
|
||||
"begin",
|
||||
"set local role",
|
||||
"set_config",
|
||||
"information_schema",
|
||||
"business",
|
||||
"commit",
|
||||
]);
|
||||
await client.from("profiles").select("id").eq("id", identity.id);
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test("cached metadata and merged session setup drop a repeat select and rpc to four roundtrips", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "on",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-on@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url);
|
||||
const client = clientFor(url);
|
||||
await client.from("profiles").select("id").eq("id", identity.id);
|
||||
calls.length = 0;
|
||||
await client.from("profiles").select("id").eq("id", identity.id);
|
||||
assert.deepEqual(calls.map((call) => tag(call.sql)), [
|
||||
"begin",
|
||||
"set_config(role)",
|
||||
"business",
|
||||
"commit",
|
||||
]);
|
||||
|
||||
await client.rpc("sample_probe", { left_name: "a", right_name: "b" });
|
||||
calls.length = 0;
|
||||
const rpc = await client.rpc("sample_probe", { left_name: "a", right_name: "b" });
|
||||
assert.equal(rpc.error, null);
|
||||
assert.deepEqual(calls.map((call) => tag(call.sql)), [
|
||||
"begin",
|
||||
"set_config(role)",
|
||||
"business",
|
||||
"commit",
|
||||
]);
|
||||
|
||||
await client.from("profiles").upsert({ id: identity.id });
|
||||
calls.length = 0;
|
||||
await client.from("profiles").upsert({ id: identity.id });
|
||||
assert.deepEqual(calls.map((call) => tag(call.sql)), [
|
||||
"begin",
|
||||
"set_config(role)",
|
||||
"business",
|
||||
"commit",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("column cache is per role and function cache is per argument names", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "on",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-keys@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url);
|
||||
const authenticated = clientFor(url, "authenticated");
|
||||
const service = clientFor(url, "service_role");
|
||||
await authenticated.from("profiles").select("id");
|
||||
calls.length = 0;
|
||||
await service.from("profiles").select("id");
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
|
||||
await authenticated.rpc("sample_probe", { left_name: "a" });
|
||||
calls.length = 0;
|
||||
await authenticated.rpc("sample_probe", { right_name: "b" });
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "pg_proc").length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("undefined column, expiry, pool close, and empty metadata all miss the cache", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-invalidate@127.0.0.1:1/jyotisha";
|
||||
let failuresLeft = 1;
|
||||
const calls = installFakePool(url, {
|
||||
failBusinessCode: () => {
|
||||
if (failuresLeft <= 0) return null;
|
||||
failuresLeft -= 1;
|
||||
return "42703";
|
||||
},
|
||||
});
|
||||
const client = clientFor(url);
|
||||
const failed = await client.from("profiles").select("id");
|
||||
assert.equal(failed.error?.code, "42703");
|
||||
calls.length = 0;
|
||||
const retried = await client.from("profiles").select("id");
|
||||
assert.equal(retried.error, null);
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("metadata older than 300 seconds is queried again", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-ttl@127.0.0.1:1/jyotisha";
|
||||
let now = 10_000;
|
||||
setLocalPostgresMetadataClockForTests(() => now);
|
||||
const calls = installFakePool(url);
|
||||
const client = clientFor(url);
|
||||
await client.from("profiles").select("id");
|
||||
calls.length = 0;
|
||||
await client.from("profiles").select("id");
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 0);
|
||||
now += 300_000;
|
||||
calls.length = 0;
|
||||
await client.from("profiles").select("id");
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("an empty column list and a missing function are not cached", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-empty@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url, { columns: null, functions: null, primaryKeys: null });
|
||||
const client = clientFor(url);
|
||||
await client.from("profiles").select("id");
|
||||
calls.length = 0;
|
||||
await client.from("profiles").select("id");
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
|
||||
const missing = await client.rpc("missing_probe", { left_name: "a" });
|
||||
assert.match(missing.error?.message ?? "", /not found/);
|
||||
calls.length = 0;
|
||||
await client.rpc("missing_probe", { left_name: "a" });
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "pg_proc").length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("closing the pool drops that url from the metadata cache", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-close@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url);
|
||||
const client = clientFor(url);
|
||||
await client.from("profiles").select("id");
|
||||
await closeLocalPostgresDataPool(url);
|
||||
const reopenedCalls = installFakePool(url);
|
||||
const reopened = clientFor(url);
|
||||
await reopened.from("profiles").select("id");
|
||||
assert.equal(reopenedCalls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
assert.ok(calls.length > 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { getEventListeners } from "node:events";
|
||||
import test from "node:test";
|
||||
|
||||
import { scoringPollDelay } from "../src/lib/birth-time-guided-polling.ts";
|
||||
import { runPersonalReportWorkerLoop } from "../src/lib/personal-report-worker-core.ts";
|
||||
|
||||
const idle = {
|
||||
recovery: { retryingCount: 0, failedCount: 0 },
|
||||
outcome: "idle" as const,
|
||||
jobId: null,
|
||||
};
|
||||
|
||||
function keepAlive(): () => void {
|
||||
const timer = setInterval(() => {}, 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
test("idle report-worker sleeps do not leave abort listeners", async () => {
|
||||
const stop = keepAlive();
|
||||
const controller = new AbortController();
|
||||
let ticks = 0;
|
||||
let listenersAt200 = -1;
|
||||
let abortedAt = 0;
|
||||
try {
|
||||
await runPersonalReportWorkerLoop(
|
||||
{
|
||||
async tick() {
|
||||
ticks += 1;
|
||||
if (ticks === 200) {
|
||||
listenersAt200 = getEventListeners(controller.signal, "abort").length;
|
||||
abortedAt = Date.now();
|
||||
controller.abort();
|
||||
}
|
||||
return idle;
|
||||
},
|
||||
},
|
||||
{ pollIntervalMs: 1, signal: controller.signal },
|
||||
);
|
||||
assert.equal(listenersAt200, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
assert.ok(Date.now() - abortedAt < 250);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("thrown report-worker ticks do not leave abort listeners", async () => {
|
||||
const stop = keepAlive();
|
||||
const controller = new AbortController();
|
||||
let ticks = 0;
|
||||
let listenersAt200 = -1;
|
||||
let abortedAt = 0;
|
||||
try {
|
||||
await runPersonalReportWorkerLoop(
|
||||
{
|
||||
async tick() {
|
||||
ticks += 1;
|
||||
if (ticks === 200) {
|
||||
listenersAt200 = getEventListeners(controller.signal, "abort").length;
|
||||
abortedAt = Date.now();
|
||||
controller.abort();
|
||||
}
|
||||
throw new Error("tick failed");
|
||||
},
|
||||
},
|
||||
{ pollIntervalMs: 1, signal: controller.signal },
|
||||
);
|
||||
assert.equal(listenersAt200, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
assert.ok(Date.now() - abortedAt < 250);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("scoring poll delay removes its abort listener when the timer fires", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
};
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
await scoringPollDelay(0, controller.signal);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test("scoring poll delay resolves as soon as the signal aborts", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
};
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const started = Date.now();
|
||||
const pending = scoringPollDelay(0, controller.signal);
|
||||
controller.abort();
|
||||
await pending;
|
||||
assert.ok(Date.now() - started < 200);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { consumeRequestRateLimit } from "../src/lib/request-rate-limit.ts";
|
||||
import {
|
||||
consumeRequestRateLimit,
|
||||
resetRequestRateLimitMaintenanceForTests,
|
||||
} from "../src/lib/request-rate-limit.ts";
|
||||
|
||||
test("a user is allowed up to the window limit and then must wait", () => {
|
||||
const store = new Map<string, number[]>();
|
||||
@@ -46,3 +49,64 @@ test("a user is allowed up to the window limit and then must wait", () => {
|
||||
assert.deepEqual(otherUser, { ok: true });
|
||||
assert.deepEqual(afterWindow, { ok: true });
|
||||
});
|
||||
|
||||
test("keys past the longest seen window are swept, and a one-hour key survives 61 seconds", () => {
|
||||
resetRequestRateLimitMaintenanceForTests();
|
||||
const shortWindow = new Map<string, number[]>();
|
||||
consumeRequestRateLimit({
|
||||
key: "old",
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
now: 0,
|
||||
store: shortWindow,
|
||||
});
|
||||
for (let index = 0; index < 998; index += 1) {
|
||||
consumeRequestRateLimit({
|
||||
key: `pad:${index}`,
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
now: 0,
|
||||
store: shortWindow,
|
||||
});
|
||||
}
|
||||
consumeRequestRateLimit({
|
||||
key: "trigger",
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
now: 61_000,
|
||||
store: shortWindow,
|
||||
});
|
||||
assert.equal(shortWindow.has("old"), false);
|
||||
assert.equal(shortWindow.get("old"), undefined);
|
||||
assert.equal(shortWindow.has("trigger"), true);
|
||||
assert.equal(shortWindow.size, 1);
|
||||
|
||||
resetRequestRateLimitMaintenanceForTests();
|
||||
const mixed = new Map<string, number[]>();
|
||||
consumeRequestRateLimit({
|
||||
key: "synastry:person",
|
||||
limit: 8,
|
||||
windowMs: 60 * 60_000,
|
||||
now: 0,
|
||||
store: mixed,
|
||||
});
|
||||
consumeRequestRateLimit({
|
||||
key: "ephemeris:person",
|
||||
limit: 30,
|
||||
windowMs: 60_000,
|
||||
now: 0,
|
||||
store: mixed,
|
||||
});
|
||||
for (let index = 0; index < 998; index += 1) {
|
||||
consumeRequestRateLimit({
|
||||
key: `later:${index}`,
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
now: 61_000,
|
||||
store: mixed,
|
||||
});
|
||||
}
|
||||
assert.equal(mixed.has("synastry:person"), true);
|
||||
assert.equal(mixed.has("ephemeris:person"), true);
|
||||
resetRequestRateLimitMaintenanceForTests();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
noteRescoreAttemptForTests,
|
||||
rescoreAttemptCountForTests,
|
||||
resetStaleMinuteRescoreAttemptsForTests,
|
||||
} from "../src/lib/rectification-agentic/v9/block-scan-answer.ts";
|
||||
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
test("rescore attempts stay within 5000 entries", () => {
|
||||
resetStaleMinuteRescoreAttemptsForTests();
|
||||
for (let index = 0; index < 5_001; index += 1) {
|
||||
noteRescoreAttemptForTests(`case-${index}:turn:fingerprint`, 1_000);
|
||||
}
|
||||
assert.ok(rescoreAttemptCountForTests() <= 5_000);
|
||||
resetStaleMinuteRescoreAttemptsForTests();
|
||||
});
|
||||
|
||||
test("a rescore attempt older than six hours is dropped on the next write", () => {
|
||||
resetStaleMinuteRescoreAttemptsForTests();
|
||||
noteRescoreAttemptForTests("case-old:turn:fingerprint", 0);
|
||||
noteRescoreAttemptForTests("case-fresh:turn:fingerprint", SIX_HOURS_MS);
|
||||
assert.equal(rescoreAttemptCountForTests(), 2);
|
||||
noteRescoreAttemptForTests("case-new:turn:fingerprint", SIX_HOURS_MS + 1);
|
||||
assert.equal(rescoreAttemptCountForTests(), 2);
|
||||
noteRescoreAttemptForTests("case-fresh:turn:fingerprint", SIX_HOURS_MS + 1);
|
||||
assert.equal(rescoreAttemptCountForTests(), 2);
|
||||
resetStaleMinuteRescoreAttemptsForTests();
|
||||
});
|
||||
+23
-2
@@ -272,6 +272,20 @@ class BhavaChalitCalculator:
|
||||
# fallback: 最近的宫
|
||||
return 1
|
||||
|
||||
def _planet_bhava_from_cusps(self, planet_lon: float, cusps: List[float]) -> int:
|
||||
"""KP 宫头到宫头:宫 i(0 起)的范围是 cusp[i] → cusp[(i+1)%12]。
|
||||
|
||||
落在下一宫头上的点归下一宫。本命 Bhava Chalit 表不用这条。
|
||||
"""
|
||||
for i in range(12):
|
||||
start = cusps[i]
|
||||
end = cusps[(i + 1) % 12]
|
||||
arc = _angular_dist(start, end)
|
||||
pos = _angular_dist(start, planet_lon)
|
||||
if arc > 0 and pos < arc:
|
||||
return i + 1
|
||||
return 1
|
||||
|
||||
def _planet_rashi_house(self, planet_lon: float, asc_lon: float) -> int:
|
||||
"""整宫制宫位 (Rashi house)。"""
|
||||
p_si = _sign_idx(planet_lon)
|
||||
@@ -330,7 +344,8 @@ class BhavaChalitCalculator:
|
||||
asc_lon: float, mc_lon: float,
|
||||
house_system: str = 'sripati',
|
||||
jd: float = None, lat: float = None,
|
||||
lon: float = None) -> Dict:
|
||||
lon: float = None,
|
||||
house_assignment: str = 'sandhi') -> Dict:
|
||||
"""根据 Bhava 边界重新分配行星宫位。
|
||||
|
||||
Parameters
|
||||
@@ -346,13 +361,18 @@ class BhavaChalitCalculator:
|
||||
dict with keys:
|
||||
house_system, boundaries, planets
|
||||
"""
|
||||
if house_assignment not in ('sandhi', 'cusp_to_cusp'):
|
||||
raise ValueError("house_assignment must be 'sandhi' or 'cusp_to_cusp'")
|
||||
cusps = self.calculate_cusps(asc_lon, mc_lon, house_system,
|
||||
jd, lat, lon)
|
||||
sandhis = self.calculate_sandhis(cusps)
|
||||
|
||||
planets = {}
|
||||
for pname, plon in planet_lons.items():
|
||||
bhava = self._planet_bhava(plon, sandhis)
|
||||
if house_assignment == 'cusp_to_cusp':
|
||||
bhava = self._planet_bhava_from_cusps(plon, cusps)
|
||||
else:
|
||||
bhava = self._planet_bhava(plon, sandhis)
|
||||
rashi = self._planet_rashi_house(plon, asc_lon)
|
||||
si = _sign_idx(plon)
|
||||
deg_in_sign = plon - si * 30
|
||||
@@ -380,6 +400,7 @@ class BhavaChalitCalculator:
|
||||
return {
|
||||
'house_system': house_system,
|
||||
'house_system_desc': self.HOUSE_SYSTEMS.get(house_system, ''),
|
||||
'house_assignment': house_assignment,
|
||||
'ascendant_lon': round(asc_lon, 4),
|
||||
'mc_lon': round(mc_lon, 4),
|
||||
'planets': planets,
|
||||
|
||||
@@ -6378,7 +6378,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
periods = []
|
||||
current = birth_dt
|
||||
for lord in DASHA_ORDER:
|
||||
end_date = current + timedelta(days=period_years * 365.25636)
|
||||
end_date = current + timedelta(days=period_years * 365.25)
|
||||
periods.append({
|
||||
'lord': lord,
|
||||
'years': period_years,
|
||||
@@ -7398,7 +7398,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
current_idx, current_md = analyzer.find_current(timeline, today)
|
||||
antardashas = analyzer.build_antardasha(current_md)
|
||||
current_ad = analyzer.find_current_sub(antardashas, today)
|
||||
years_into_md = max(0.0, (today - current_md['start']).days / 365.25636)
|
||||
years_into_md = max(0.0, (today - current_md['start']).days / 365.25)
|
||||
five_levels = enhanced.calculate_five_level_dasha(current_md['lord'], years_into_md)
|
||||
formatted_timeline = [self._format_datetime_period(period) for period in timeline]
|
||||
formatted_antardashas = [self._format_datetime_period(period) for period in antardashas]
|
||||
@@ -7551,7 +7551,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
evidence.append({
|
||||
'label': 'Darakaraka (DK)',
|
||||
'value': f"{dk_planet} · H{darakaraka.get('dk_house', '-')}",
|
||||
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量 {darakaraka.get('marriage_quality_score', '-')}/100",
|
||||
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量指数 {darakaraka.get('marriage_quality_score', '-')}(0到1)",
|
||||
})
|
||||
except Exception as exc:
|
||||
darakaraka = {'error': str(exc), 'dk_planet': dk_planet}
|
||||
|
||||
@@ -13963,6 +13963,7 @@ def cmd_kp(args):
|
||||
)
|
||||
bhava_chart = kp_house_calculator.get_bhava_chalit_chart(
|
||||
planet_lons, float(asc_lon), 0.0, 'placidus', jd, args.lat, args.lon,
|
||||
house_assignment='cusp_to_cusp',
|
||||
)
|
||||
bhava_planets = bhava_chart.get('planets') if isinstance(bhava_chart.get('planets'), dict) else {}
|
||||
|
||||
@@ -13977,7 +13978,12 @@ def cmd_kp(args):
|
||||
'house': bhava_row.get('bhava_house', pdata.get('house', 1)),
|
||||
}
|
||||
|
||||
result = calc_kp_analysis(planet_positions, asc_sign, house_cusps=boundaries.get('cusps'))
|
||||
result = calc_kp_analysis(
|
||||
planet_positions,
|
||||
asc_sign,
|
||||
house_cusps=boundaries.get('cusps'),
|
||||
house_occupancy_basis='cusp_to_cusp',
|
||||
)
|
||||
moon_lon = planets.get('Moon', {}).get('degree_raw', planets.get('Moon', {}).get('degree', 0.0)) if isinstance(planets.get('Moon'), dict) else 0.0
|
||||
try:
|
||||
birth_moment = datetime(int(args.year), int(args.month), int(args.day), int(args.hour), int(getattr(args, 'minute', 0) or 0))
|
||||
|
||||
@@ -371,6 +371,7 @@ def calc_kp_analysis(
|
||||
planet_positions: Dict,
|
||||
asc_sign: str = 'Aries',
|
||||
house_cusps: Optional[List[float]] = None,
|
||||
house_occupancy_basis: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
完整KP分析(基于 diliprk/VedicAstro MIT 算法)。
|
||||
@@ -379,6 +380,7 @@ def calc_kp_analysis(
|
||||
planet_positions: 行星位置 {planet: {'sign': str, 'degree': float, 'house': int}}
|
||||
asc_sign: 上升星座名称(无显式宫头时用于 whole-sign 代理)
|
||||
house_cusps: 可选的 12 个实际宫头黄经。提供时优先用于 KP 宫头与显著星。
|
||||
house_occupancy_basis: 传了宫头就必须声明判宫口径;没传宫头时记 whole_sign_proxy。
|
||||
|
||||
Returns:
|
||||
完整KP分析结果
|
||||
@@ -405,8 +407,11 @@ def calc_kp_analysis(
|
||||
}
|
||||
|
||||
explicit_cusps = house_cusps is not None
|
||||
if explicit_cusps and not house_occupancy_basis:
|
||||
raise ValueError('house_occupancy_basis is required when house_cusps are provided')
|
||||
if explicit_cusps and len(house_cusps) != 12:
|
||||
raise ValueError('house_cusps must contain exactly 12 longitudes')
|
||||
occupancy_basis = house_occupancy_basis if explicit_cusps else 'whole_sign_proxy'
|
||||
|
||||
# 2. 构建宫位信息(含 KP lords)。无实际宫头时保留历史 whole-sign 中点代理。
|
||||
houses = []
|
||||
@@ -438,6 +443,7 @@ def calc_kp_analysis(
|
||||
'source': 'diliprk/VedicAstro MIT License',
|
||||
'maturity_profile': kp_maturity_profile(),
|
||||
'house_basis': 'explicit_cusps' if explicit_cusps else 'whole_sign_proxy',
|
||||
'house_occupancy_basis': occupancy_basis,
|
||||
'planets': {pname: {'kp_lords': data['kp_lords'], 'significators': planet_sig.get(pname, {})}
|
||||
for pname, data in kp_planets.items()},
|
||||
'houses': {h['house']: {'sign': h['sign'], 'cusp_longitude': h['cusp_longitude'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})}
|
||||
@@ -494,6 +500,7 @@ def build_kp_report_pack(
|
||||
f"- claim_status: `{maturity.get('claim_status')}`",
|
||||
f"- truth_matrix_allowed: `{maturity.get('truth_matrix_allowed')}`",
|
||||
f"- closed_oracle_parity_assets: `{', '.join(closed_parity_assets.keys())}`",
|
||||
f"- House occupancy basis: `{kp_analysis.get('house_occupancy_basis') or 'whole_sign_proxy'}`",
|
||||
f"- planet_count: `{len(planets)}`",
|
||||
f"- house_count: `{len(houses)}`",
|
||||
f"- dba_period_count: `{len(periods)}`",
|
||||
|
||||
@@ -21,6 +21,23 @@ from typing import Dict, Optional, List
|
||||
SIGN_CN = ['白羊座','金牛座','双子座','巨蟹座','狮子座','处女座',
|
||||
'天秤座','天蝎座','射手座','摩羯座','水瓶座','双鱼座']
|
||||
|
||||
# D9 结构证据档位(BUG-1318)。中文给报告正文,英文键与英文短句给英文版。
|
||||
# 不写婚姻稳定、离婚、再婚、段数或婚前咨询。
|
||||
D9_CLAIM_BOUNDARY = (
|
||||
'D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询'
|
||||
)
|
||||
D9_CLAIM_BOUNDARY_EN = (
|
||||
'D9 structural evidence index. It does not predict marriage count, stability, '
|
||||
'divorce, or partner quality, and it does not suggest premarital counseling.'
|
||||
)
|
||||
D9_EVIDENCE_BANDS = (
|
||||
('supportive_evidence_stronger', '支持证据较强', 'Stronger supportive evidence'),
|
||||
('supportive_evidence_mixed_positive', '支持证据偏正', 'Mixed supportive evidence'),
|
||||
('mixed_evidence', '证据混杂', 'Mixed evidence'),
|
||||
('pressure_evidence_mixed', '压力证据偏杂', 'Mixed pressure evidence'),
|
||||
('pressure_evidence_stronger', '压力证据较强', 'Stronger pressure evidence'),
|
||||
)
|
||||
|
||||
|
||||
def sign_of(lon: float) -> int:
|
||||
"""从黄道经度计算星座序号 (0-11)"""
|
||||
@@ -348,25 +365,42 @@ def _assess_d9_marriage_quality(
|
||||
quality_points -= 1
|
||||
factors.append("D9 Ketu在7宫 → 关系中的分离倾向")
|
||||
|
||||
# 综合评级
|
||||
# 证据档位,不是婚姻结局(BUG-1318,上游 f891a2c1)。
|
||||
if quality_points >= 4:
|
||||
rating = "高(关系质量好,伴侣支持强,婚姻稳定)"
|
||||
band = D9_EVIDENCE_BANDS[0]
|
||||
elif quality_points >= 1:
|
||||
rating = "中上(关系质量尚可,需经营但基础好)"
|
||||
band = D9_EVIDENCE_BANDS[1]
|
||||
elif quality_points >= 0:
|
||||
rating = "中(关系质量一般,需用心经营)"
|
||||
band = D9_EVIDENCE_BANDS[2]
|
||||
elif quality_points >= -2:
|
||||
rating = "中下(关系有挑战,需提前沟通和调整期望)"
|
||||
band = D9_EVIDENCE_BANDS[3]
|
||||
else:
|
||||
rating = "低(关系质量有重大挑战,强烈建议婚前咨询)"
|
||||
band = D9_EVIDENCE_BANDS[4]
|
||||
|
||||
return {
|
||||
'quality_rating': rating,
|
||||
'quality_band': band[0],
|
||||
'quality_rating': band[1],
|
||||
'quality_rating_en': band[2],
|
||||
'quality_points': quality_points,
|
||||
'factors': factors,
|
||||
'claim_boundary': D9_CLAIM_BOUNDARY,
|
||||
'claim_boundary_en': D9_CLAIM_BOUNDARY_EN,
|
||||
}
|
||||
|
||||
|
||||
def render_d9_evidence_report(quality: Dict, language: str = 'zh') -> str:
|
||||
"""中文报告用证据档位短句;英文版用英文短句。两边都带同一条边界。"""
|
||||
if not isinstance(quality, dict):
|
||||
quality = {}
|
||||
if language == 'en':
|
||||
band = quality.get('quality_rating_en') or quality.get('quality_band') or ''
|
||||
boundary = quality.get('claim_boundary_en') or D9_CLAIM_BOUNDARY_EN
|
||||
return f"D9 evidence band: {band}\n{boundary}"
|
||||
band = quality.get('quality_rating') or ''
|
||||
boundary = quality.get('claim_boundary') or D9_CLAIM_BOUNDARY
|
||||
return f"D9 证据档位:{band}\n{boundary}"
|
||||
|
||||
|
||||
def _marriage_recommendations(base: Dict, d9_quality: Dict) -> List[str]:
|
||||
"""几何指数与伴侣数量、事件断语分开(BUG-1183);不随指数大小给不同结论。"""
|
||||
return [
|
||||
|
||||
@@ -2140,6 +2140,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
'### p123 KP Chart / Cusps',
|
||||
'',
|
||||
f"- House basis:{_cell(kp.get('house_basis'))}",
|
||||
f"- House occupancy basis:{_cell(kp.get('house_occupancy_basis'))}",
|
||||
'',
|
||||
'| House | Sign | Cusp longitude | KP lord chain |',
|
||||
'|-------|------|----------------|---------------|',
|
||||
|
||||
@@ -1687,11 +1687,13 @@ class YogaEngine:
|
||||
# 辅助:获取角宫主星列表
|
||||
def _kendra_lords_list():
|
||||
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
|
||||
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
|
||||
# set 顺序随进程哈希变化;排序后同一张盘的角宫主参与者顺序才稳定。
|
||||
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
|
||||
# 辅助:获取三方宫主星列表
|
||||
def _trikona_lords_list():
|
||||
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
|
||||
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
|
||||
# set 顺序随进程哈希变化;排序后同一张盘的三方主参与者顺序才稳定。
|
||||
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
|
||||
|
||||
safe_globals = {
|
||||
# 安全内置函数:只开放规则表达式需要的纯函数,避免 custom 规则因 all/any/len/set 缺失而静默失效。
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""BUG-1318: D9 marriage rating is an evidence band, not an outcome claim.
|
||||
|
||||
Upstream f891a2c1. The five bands must not say the relationship is stable,
|
||||
ending, repeated, or in need of premarital counseling. The DK evidence line
|
||||
keeps the 0-1 index and does not print a fake /100 score.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
SCRIPTS = os.path.join(ROOT, "scripts")
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
from jyotish_engine import compute_chart_data # noqa: E402
|
||||
|
||||
|
||||
def _api_handler():
|
||||
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
|
||||
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
|
||||
spec = importlib.util.spec_from_file_location("_api_security_helpers", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module._handler()
|
||||
from marriage_counting import ( # noqa: E402
|
||||
D9_CLAIM_BOUNDARY,
|
||||
D9_EVIDENCE_BANDS,
|
||||
_assess_d9_marriage_quality,
|
||||
marriage_counting_full_analysis,
|
||||
render_d9_evidence_report,
|
||||
)
|
||||
from varga import calc_varga # noqa: E402
|
||||
|
||||
BANNED = re.compile(r"稳定|离婚|再婚|婚前咨询|几段")
|
||||
|
||||
|
||||
def _quality(points_chart: dict) -> dict:
|
||||
return _assess_d9_marriage_quality(points_chart, None)
|
||||
|
||||
|
||||
def test_five_bands_have_no_outcome_claims_and_keep_the_boundary() -> None:
|
||||
samples = {
|
||||
"supportive_evidence_stronger": {"Venus": 331.0, "Mars": 271.0, "Jupiter": 91.0},
|
||||
"supportive_evidence_mixed_positive": {"Venus": 31.0},
|
||||
"mixed_evidence": {"Venus": 10.0},
|
||||
"pressure_evidence_mixed": {"Venus": 151.0},
|
||||
"pressure_evidence_stronger": {"Venus": 151.0, "Mars": 91.0, "Jupiter": 271.0},
|
||||
}
|
||||
seen = []
|
||||
for key, chart in samples.items():
|
||||
quality = _quality(chart)
|
||||
assert quality["quality_band"] == key
|
||||
assert quality["claim_boundary"] == D9_CLAIM_BOUNDARY
|
||||
assert quality["claim_boundary_en"]
|
||||
assert not BANNED.search(quality["quality_rating"])
|
||||
assert not BANNED.search(quality["quality_rating_en"])
|
||||
seen.append(quality["quality_band"])
|
||||
assert seen == [band[0] for band in D9_EVIDENCE_BANDS]
|
||||
|
||||
|
||||
def test_public_chart_report_snapshots_in_both_languages() -> None:
|
||||
cases = json.loads(
|
||||
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
|
||||
)["cases"]
|
||||
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
|
||||
assert birth["source"]["rodden_rating"] == "AA"
|
||||
year, month, day = (int(part) for part in birth["date"].split("-"))
|
||||
hour, minute = (int(part) for part in birth["time"].split(":"))
|
||||
chart, *_ = compute_chart_data(
|
||||
year, month, day, hour, minute,
|
||||
birth["latitude"], birth["longitude"], birth["timezone_offset"],
|
||||
ayanamsa_name="raman",
|
||||
)
|
||||
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
|
||||
d9 = {}
|
||||
for name, lon in longitudes.items():
|
||||
divisional = calc_varga(lon, 9)
|
||||
d9[name] = divisional["sign_idx"] * 30 + divisional["degree_in_sign"]
|
||||
result = marriage_counting_full_analysis(chart["houses"]["house_7"]["lord"], longitudes, d9)
|
||||
quality = result["d9_marriage_quality"]
|
||||
assert quality["quality_band"] == "mixed_evidence"
|
||||
assert quality["quality_points"] == 0
|
||||
chinese = render_d9_evidence_report(quality, "zh")
|
||||
english = render_d9_evidence_report(quality, "en")
|
||||
assert chinese == (
|
||||
"D9 证据档位:证据混杂\n"
|
||||
"D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询"
|
||||
)
|
||||
assert english == (
|
||||
"D9 evidence band: Mixed evidence\n"
|
||||
"D9 structural evidence index. It does not predict marriage count, stability, "
|
||||
"divorce, or partner quality, and it does not suggest premarital counseling."
|
||||
)
|
||||
assert "/100" not in chinese
|
||||
assert "/100" not in english
|
||||
|
||||
|
||||
def test_dk_evidence_line_prints_the_zero_to_one_index() -> None:
|
||||
cases = json.loads(
|
||||
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
|
||||
)["cases"]
|
||||
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
|
||||
year, month, day = (int(part) for part in birth["date"].split("-"))
|
||||
hour, minute = (int(part) for part in birth["time"].split(":"))
|
||||
chart, asc_idx, *_ = compute_chart_data(
|
||||
year, month, day, hour, minute,
|
||||
birth["latitude"], birth["longitude"], birth["timezone_offset"],
|
||||
ayanamsa_name="raman",
|
||||
)
|
||||
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
|
||||
handler = _api_handler()
|
||||
evidence = handler._compute_relationship_timing_evidence(
|
||||
{},
|
||||
chart["planets"],
|
||||
longitudes,
|
||||
asc_idx,
|
||||
chart["ascendant"]["sign"],
|
||||
)
|
||||
notes = [row["note"] for row in evidence["evidence"] if row.get("label") == "Darakaraka (DK)"]
|
||||
assert len(notes) == 1
|
||||
assert "/100" not in notes[0]
|
||||
assert "婚姻质量指数" in notes[0]
|
||||
assert "(0到1)" in notes[0]
|
||||
score = evidence["darakaraka"]["marriage_quality_score"]
|
||||
assert isinstance(score, float)
|
||||
assert 0.0 <= score <= 1.0
|
||||
assert str(score) in notes[0]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""BUG-1321: the API fallback dasha year matches dasha_analyzer's 365.25 days.
|
||||
|
||||
Upstream 4d543209. The two 365.25636 constants in the API server made the
|
||||
fallback timeline and the years-into-mahadasha figure drift from the analyzer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra # noqa: E402
|
||||
|
||||
|
||||
def _api_handler():
|
||||
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
|
||||
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
|
||||
spec = importlib.util.spec_from_file_location("_api_security_helpers_dasha", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module._handler()
|
||||
|
||||
|
||||
def test_fallback_timeline_matches_analyzer_to_the_day() -> None:
|
||||
birth = datetime(2000, 1, 1)
|
||||
nak_info, progress, _pada = lon_to_nakshatra(0.0)
|
||||
timeline, _elapsed, _remaining, _lord = build_dasha_timeline("2000-01-01", nak_info, progress)
|
||||
handler = _api_handler()
|
||||
years = timeline[0]["years"]
|
||||
periods = handler._fallback_dasha_periods(birth, "generic", {"years": years * 9})
|
||||
assert periods[0]["lord"] == timeline[0]["lord"]
|
||||
assert periods[0]["start"] == timeline[0]["start"].strftime("%Y-%m-%d")
|
||||
assert periods[0]["end"] == timeline[0]["end"].strftime("%Y-%m-%d")
|
||||
fallback_days = (
|
||||
datetime.strptime(periods[0]["end"], "%Y-%m-%d")
|
||||
- datetime.strptime(periods[0]["start"], "%Y-%m-%d")
|
||||
).days
|
||||
analyzer_days = (timeline[0]["end"].date() - timeline[0]["start"].date()).days
|
||||
assert fallback_days - analyzer_days == 0
|
||||
|
||||
long_periods = handler._fallback_dasha_periods(birth, "generic", {"years": 1422})
|
||||
expected = (birth + timedelta(days=158 * 365.25)).strftime("%Y-%m-%d")
|
||||
stale = (birth + timedelta(days=158 * 365.25636)).strftime("%Y-%m-%d")
|
||||
assert expected != stale
|
||||
assert long_periods[0]["end"] == expected
|
||||
|
||||
|
||||
def test_years_into_mahadasha_uses_the_analyzer_year() -> None:
|
||||
birth = datetime(2000, 1, 1)
|
||||
today = datetime(2003, 1, 1)
|
||||
handler = _api_handler()
|
||||
layer = handler._compute_vimshottari_analysis_layer(birth, 0.0, current_date="2003-01-01")
|
||||
days = (today - birth).days
|
||||
years_into = layer["five_levels"]["mahadasha"]["years_into_period"]
|
||||
assert years_into == days / 365.25
|
||||
assert years_into != days / 365.25636
|
||||
@@ -0,0 +1,58 @@
|
||||
"""BUG-1319: KP house occupancy is cusp-to-cusp and must be declared.
|
||||
|
||||
Upstream 0337b77f. Natal Bhava Chalit stays on the sandhi midpoint rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
from bhava_chalit import BhavaChalitCalculator # noqa: E402
|
||||
from kp_system import build_kp_report_pack, calc_kp_analysis # noqa: E402
|
||||
|
||||
|
||||
def test_cusp_to_cusp_differs_from_sandhi_and_default_houses_stay() -> None:
|
||||
calc = BhavaChalitCalculator()
|
||||
planets = {"Sun": 40.0}
|
||||
sandhi = calc.get_bhava_chalit_chart(planets, 15.0, 270.0, "equal")
|
||||
explicit = calc.get_bhava_chalit_chart(
|
||||
planets, 15.0, 270.0, "equal", house_assignment="sandhi",
|
||||
)
|
||||
cusp = calc.get_bhava_chalit_chart(
|
||||
planets, 15.0, 270.0, "equal", house_assignment="cusp_to_cusp",
|
||||
)
|
||||
assert sandhi["house_assignment"] == "sandhi"
|
||||
assert explicit["planets"]["Sun"]["bhava_house"] == sandhi["planets"]["Sun"]["bhava_house"] == 2
|
||||
assert cusp["house_assignment"] == "cusp_to_cusp"
|
||||
assert cusp["planets"]["Sun"]["bhava_house"] == 1
|
||||
assert cusp["planets"]["Sun"]["bhava_house"] != sandhi["planets"]["Sun"]["bhava_house"]
|
||||
|
||||
|
||||
def test_unknown_house_assignment_is_rejected() -> None:
|
||||
calc = BhavaChalitCalculator()
|
||||
with pytest.raises(ValueError, match="house_assignment"):
|
||||
calc.get_bhava_chalit_chart({"Sun": 10.0}, 15.0, 270.0, "equal", house_assignment="whole")
|
||||
|
||||
|
||||
def test_kp_cusps_require_an_occupancy_basis_and_the_report_names_it() -> None:
|
||||
planets = {"Sun": {"sign": "Taurus", "degree": 10.0, "house": 2}}
|
||||
cusps = [15.0 + 30.0 * index for index in range(12)]
|
||||
with pytest.raises(ValueError, match="house_occupancy_basis"):
|
||||
calc_kp_analysis(planets, "Aries", house_cusps=cusps)
|
||||
with pytest.raises(ValueError, match="house_occupancy_basis"):
|
||||
calc_kp_analysis(planets, "Aries", house_cusps=cusps, house_occupancy_basis="")
|
||||
declared = calc_kp_analysis(
|
||||
planets, "Aries", house_cusps=cusps, house_occupancy_basis="cusp_to_cusp",
|
||||
)
|
||||
assert declared["house_occupancy_basis"] == "cusp_to_cusp"
|
||||
proxy = calc_kp_analysis(planets, "Aries")
|
||||
assert proxy["house_occupancy_basis"] == "whole_sign_proxy"
|
||||
markdown = build_kp_report_pack(declared)["exports"]["markdown"]
|
||||
assert "House occupancy basis: `cusp_to_cusp`" in markdown
|
||||
@@ -0,0 +1,72 @@
|
||||
"""BUG-1320: kendra and trikona lord lists do not follow PYTHONHASHSEED.
|
||||
|
||||
Upstream 0337b77f. Two processes with different hash seeds must list the
|
||||
same yoga participants in the same order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
_PROBE = r"""
|
||||
import json
|
||||
import sys
|
||||
sys.path.insert(0, "scripts")
|
||||
from yoga_engine import YogaContext, YogaEngine, detect_yogas
|
||||
|
||||
planets = {
|
||||
"Sun": {"sign": "Leo", "house": 5, "degree": 10},
|
||||
"Moon": {"sign": "Cancer", "house": 4, "degree": 5},
|
||||
"Mars": {"sign": "Aries", "house": 1, "degree": 8},
|
||||
"Mercury": {"sign": "Virgo", "house": 6, "degree": 12},
|
||||
"Jupiter": {"sign": "Sagittarius", "house": 9, "degree": 15},
|
||||
"Venus": {"sign": "Libra", "house": 7, "degree": 3},
|
||||
"Saturn": {"sign": "Capricorn", "house": 10, "degree": 20},
|
||||
"Rahu": {"sign": "Taurus", "house": 2, "degree": 9},
|
||||
"Ketu": {"sign": "Scorpio", "house": 8, "degree": 9},
|
||||
}
|
||||
rules = "references/yoga_rules.json"
|
||||
engine = YogaEngine(rules)
|
||||
ctx = YogaContext(planets, "Aries")
|
||||
namespace = engine._build_custom_exec_globals(ctx, {}, {})
|
||||
rows = detect_yogas(planets, "Aries", rules)
|
||||
print(json.dumps({
|
||||
"kendra": namespace["kendra_lords_list"](),
|
||||
"trikona": namespace["trikona_lords_list"](),
|
||||
"participants": [
|
||||
[row.get("rule_id"), str(row.get("combination"))]
|
||||
for row in rows
|
||||
],
|
||||
}, ensure_ascii=False))
|
||||
"""
|
||||
|
||||
|
||||
def _run(seed: str) -> dict:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONHASHSEED"] = seed
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", _PROBE],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_yoga_participants_keep_the_same_order_across_hash_seeds() -> None:
|
||||
first = _run("0")
|
||||
second = _run("1")
|
||||
assert first == second
|
||||
assert first["kendra"] == sorted(first["kendra"])
|
||||
assert first["trikona"] == sorted(first["trikona"])
|
||||
assert first["kendra"] == ["Mars", "Moon", "Saturn", "Venus"]
|
||||
assert first["trikona"] == ["Jupiter", "Mars", "Sun"]
|
||||
Reference in New Issue
Block a user