forked from wangziqi/gongxue-base
chore: gate production postgres tuning and taro handoff
This commit is contained in:
25
README.md
25
README.md
@@ -70,6 +70,7 @@
|
|||||||
- `docs/refactor/legacy-feature-gap-matrix.md`
|
- `docs/refactor/legacy-feature-gap-matrix.md`
|
||||||
- `docs/refactor/supabase-frontend-access-strategy.md`
|
- `docs/refactor/supabase-frontend-access-strategy.md`
|
||||||
- `docs/refactor/taro-frontend-integration.md`
|
- `docs/refactor/taro-frontend-integration.md`
|
||||||
|
- `docs/refactor/taro-production-integration-checklist.md`
|
||||||
- `docs/refactor/multitenant-auth-security-contract.md`
|
- `docs/refactor/multitenant-auth-security-contract.md`
|
||||||
- `docs/refactor/next-development-todo.md`
|
- `docs/refactor/next-development-todo.md`
|
||||||
- `docs/refactor/blueprint-coverage.md`
|
- `docs/refactor/blueprint-coverage.md`
|
||||||
@@ -460,7 +461,15 @@ cp docs/refactor/production-launch-evidence.template.json docs/refactor/producti
|
|||||||
npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json
|
npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`production-launch-evidence.json` 不入 Git,里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过:`readiness:production`、`readiness:production:db`、真实 `smoke:auth:remote`、`test:rls`、PocketBase production dry-run、`pb:import:validate`、`pb:import:sample`、真实数据 API 读路径压测、API/worker/Taro 构建、`smoke:taro:h5`、`smoke:taro:h5:interaction`、严格 `taro-h5-release-guardrails-test --require-runtime-config`、`audit:runtime`、真实 `@codex-security` 扫描,以及备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。
|
`production-launch-evidence.json` 不入 Git,里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过:
|
||||||
|
|
||||||
|
- `readiness:production`、`readiness:production:db`、严格 `perf:postgres:evidence -- --strict`。
|
||||||
|
- 真实 `smoke:auth:remote`、`test:rls`。
|
||||||
|
- PocketBase production dry-run、`pb:import:validate`、`pb:import:sample`。
|
||||||
|
- 真实数据 API 读路径压测、API/worker/Taro 构建。
|
||||||
|
- `smoke:taro:h5`、`smoke:taro:h5:interaction`、严格 `taro-h5-release-guardrails-test --require-runtime-config`。
|
||||||
|
- `audit:runtime`、真实 `@codex-security` 扫描。
|
||||||
|
- 备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。
|
||||||
|
|
||||||
补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime`、`npm run test:readiness`、`npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。
|
补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime`、`npm run test:readiness`、`npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。
|
||||||
|
|
||||||
@@ -573,6 +582,20 @@ $env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
|
|||||||
npm run perf:postgres:evidence
|
npm run perf:postgres:evidence
|
||||||
```
|
```
|
||||||
|
|
||||||
|
生产上线前必须用严格模式生成门禁摘要:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PG_TUNING_PROFILE="shared-host"
|
||||||
|
npm run perf:postgres:evidence -- --strict --json
|
||||||
|
Remove-Item Env:\PG_TUNING_PROFILE
|
||||||
|
```
|
||||||
|
|
||||||
|
严格模式会检查 4 核 16G profile、`pending_restart=0`、`pg_stat_statements` 可用、`jit=off`,以及 API 请求相关超时不为 0。需要生成可人工复核的 `ALTER SYSTEM` SQL 时运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run perf:postgres:sql -- --profile=shared-host
|
||||||
|
```
|
||||||
|
|
||||||
上线前角色旅程烟测:
|
上线前角色旅程烟测:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
- Taro 启动、租户解析、请求封装、页面/API 映射、跨端注意事项。
|
- Taro 启动、租户解析、请求封装、页面/API 映射、跨端注意事项。
|
||||||
7. `docs/refactor/taro-h5-deployment.md`
|
7. `docs/refactor/taro-h5-deployment.md`
|
||||||
- H5 三域名部署、`runtime-config.json`、Nginx history fallback、缓存、CSP 和 CORS 边界。
|
- H5 三域名部署、`runtime-config.json`、Nginx history fallback、缓存、CSP 和 CORS 边界。
|
||||||
8. `docs/refactor/multitenant-auth-security-contract.md`
|
8. `docs/refactor/taro-production-integration-checklist.md`
|
||||||
|
- 正式接 Supabase Auth、三套 H5、真实 provider、runtime-config 和上线门禁时逐项对照。
|
||||||
|
9. `docs/refactor/multitenant-auth-security-contract.md`
|
||||||
- 多租户、鉴权、权限、资源签名和生产安全红线。
|
- 多租户、鉴权、权限、资源签名和生产安全红线。
|
||||||
9. `docs/refactor/content-import-contract.md`
|
10. `docs/refactor/content-import-contract.md`
|
||||||
- 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。
|
- 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。
|
||||||
10. `docs/refactor/production-launch-evidence.template.json`
|
11. `docs/refactor/production-launch-evidence.template.json`
|
||||||
- 上线前证据文件模板;真实生产验收结果填入 `production-launch-evidence.json` 后运行 `npm run launch:gate`,该真实证据文件不入 Git。
|
- 上线前证据文件模板;真实生产验收结果填入 `production-launch-evidence.json` 后运行 `npm run launch:gate`,该真实证据文件不入 Git。
|
||||||
|
|
||||||
## 当前可进入的前端工作
|
## 当前可进入的前端工作
|
||||||
|
|||||||
@@ -91,11 +91,11 @@
|
|||||||
- 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。
|
- 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。
|
||||||
- Auth/JWKS 上云后必须临时设置 `AUTH_SMOKE_*` 环境变量并运行 `npm run smoke:auth:remote`,真实 access token 不得写入仓库、前端配置或日志。
|
- Auth/JWKS 上云后必须临时设置 `AUTH_SMOKE_*` 环境变量并运行 `npm run smoke:auth:remote`,真实 access token 不得写入仓库、前端配置或日志。
|
||||||
- 本地/预生产必须同时跑 `npm run test:rls`,它验证运行时 JWT claim 下的租户隔离,和 `readiness:production:db` 的静态 policy 检查互补。
|
- 本地/预生产必须同时跑 `npm run test:rls`,它验证运行时 JWT claim 下的租户隔离,和 `readiness:production:db` 的静态 policy 检查互补。
|
||||||
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
|
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、PostgreSQL 4c16g 严格调参证据、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
|
||||||
- 确认数据库迁移流程、备份恢复、日志、告警。
|
- 确认数据库迁移流程、备份恢复、日志、告警。
|
||||||
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
||||||
- 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run smoke:launch-persona`、`npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction`、`node scripts/taro-api-contract-test.js`、`node scripts/taro-persona-contract-test.js` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 03:58 受限 API 容器真实迁移库复核中,30 worker/120s 只读为 44,038 请求、0 错误、366.27 req/s、P95 185.80ms、P99 254.08ms;50 worker/60s/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms、P99 270.94ms;100 worker/60s/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms、P99 461.22ms;150 worker/60s/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms、P99 743.19ms。压测先抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,但 API 容器限制为 2 CPU/4G,舒适观察区暂按 50 到 100 个无停顿 worker 估算,150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,000 到 8,400 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。
|
- 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run perf:postgres:sql`、`npm run smoke:launch-persona`、`npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction`、`node scripts/taro-api-contract-test.js`、`node scripts/taro-persona-contract-test.js` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 4c16g profile 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 03:58 受限 API 容器真实迁移库复核中,30 worker/120s 只读为 44,038 请求、0 错误、366.27 req/s、P95 185.80ms、P99 254.08ms;50 worker/60s/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms、P99 270.94ms;100 worker/60s/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms、P99 461.22ms;150 worker/60s/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms、P99 743.19ms。压测先抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,但 API 容器限制为 2 CPU/4G,舒适观察区暂按 50 到 100 个无停顿 worker 估算,150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,000 到 8,400 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。
|
||||||
- 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。
|
- 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数,执行 `PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json` 并通过后,再按 6/30/50/100 阶梯并发复跑容量报告并归档到本地上线证据。
|
||||||
- 本轮剩余功能和容量复核已经整理到 `docs/refactor/backend-open-items-and-capacity-20260701.md`。后续不要再把学生头像上传或默认排行榜当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。
|
- 本轮剩余功能和容量复核已经整理到 `docs/refactor/backend-open-items-and-capacity-20260701.md`。后续不要再把学生头像上传或默认排行榜当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。
|
||||||
|
|
||||||
### P1 商用功能完善
|
### P1 商用功能完善
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ npm run perf:postgres:evidence
|
|||||||
|
|
||||||
该脚本会把关键 `pg_settings`、连接等待、缓存命中、大表大小和可选 `pg_stat_statements` Top SQL 输出到 `docs/refactor/launch-artifacts/`。调参前后各跑一次,配合 API 压测报告判断是否真正改善。
|
该脚本会把关键 `pg_settings`、连接等待、缓存命中、大表大小和可选 `pg_stat_statements` Top SQL 输出到 `docs/refactor/launch-artifacts/`。调参前后各跑一次,配合 API 压测报告判断是否真正改善。
|
||||||
|
|
||||||
|
生产上线前必须用严格模式跑一次,并把摘要填入 `production-launch-evidence.json` 的 `postgres.tuning-evidence`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json
|
||||||
|
```
|
||||||
|
|
||||||
|
严格模式要求 4c16g profile 范围内、无 `pending_restart`、`pg_stat_statements` 可用、`jit=off`,且 API 请求相关超时不为 0。需要生成 `ALTER SYSTEM` SQL 时使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run perf:postgres:sql -- --profile=shared-host
|
||||||
|
```
|
||||||
|
|
||||||
角色旅程烟测:
|
角色旅程烟测:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -135,7 +147,7 @@ npm run perf:api:local
|
|||||||
|
|
||||||
## 4 核 16G 阶梯压测建议
|
## 4 核 16G 阶梯压测建议
|
||||||
|
|
||||||
在云服务器上先按 `docs/refactor/postgresql-4c16g-tuning.md` 配置 shared-host 起步值,再跑以下矩阵。每轮之间间隔 2 到 5 分钟,观察 CPU、内存、磁盘 I/O、连接数和慢 SQL。
|
在云服务器上先按 `docs/refactor/postgresql-4c16g-tuning.md` 配置 shared-host 起步值,执行 `perf:postgres:evidence -- --strict` 通过,再跑以下矩阵。每轮之间间隔 2 到 5 分钟,观察 CPU、内存、磁盘 I/O、连接数和慢 SQL。
|
||||||
|
|
||||||
| 场景 | 并发 | 时长 | 写入 | 用途 |
|
| 场景 | 并发 | 时长 | 写入 | 用途 |
|
||||||
| --- | ---: | ---: | --- | --- |
|
| --- | ---: | ---: | --- | --- |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PostgreSQL 4 核 16G 生产调参基线
|
# PostgreSQL 4 核 16G 生产调参基线
|
||||||
|
|
||||||
更新时间:2026-06-30
|
更新时间:2026-07-01
|
||||||
|
|
||||||
这份文档用于后续把 Supabase/PostgreSQL 自托管到 4 核 16G 云服务器时做生产起步配置。目标是先给题库 SaaS 一个安全、可回滚、可观测的基线,而不是追求一次性压满硬件。
|
这份文档用于后续把 Supabase/PostgreSQL 自托管到 4 核 16G 云服务器时做生产起步配置。目标是先给题库 SaaS 一个安全、可回滚、可观测的基线,而不是追求一次性压满硬件。
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
- PostgreSQL 官方 WAL/Checkpoint:https://www.postgresql.org/docs/current/runtime-config-wal.html
|
- PostgreSQL 官方 WAL/Checkpoint:https://www.postgresql.org/docs/current/runtime-config-wal.html
|
||||||
- PostgreSQL 官方 Connections:https://www.postgresql.org/docs/current/runtime-config-connection.html
|
- PostgreSQL 官方 Connections:https://www.postgresql.org/docs/current/runtime-config-connection.html
|
||||||
|
|
||||||
说明:postgresqlco.nf 的页面可作为参数分类和调参入口参考;具体参数语义、重启要求和风险以 PostgreSQL 官方文档为准。
|
说明:postgresqlco.nf 的页面可作为参数分类和调参入口参考;具体参数语义、重启要求和风险以 PostgreSQL 官方文档为准。当前 Codex 环境访问 `https://postgresqlco.nf/tuning-guide` 会返回 403,因此落地值不直接抓取该站页面,而是把它作为导航来源,并以 PostgreSQL 官方文档和本项目真实压测结果共同校验。
|
||||||
|
|
||||||
## 适用前提
|
## 适用前提
|
||||||
|
|
||||||
@@ -65,6 +65,26 @@ npm run perf:postgres:evidence
|
|||||||
|
|
||||||
上云后建议顺序是:先采集一次默认值,应用本文件 shared-host 参数并重启需要重启的项,再采集一次,然后跑 API 阶梯压测。调参证据和压测报告一起进入本地 `production-launch-evidence.json`,不要提交真实证据文件。
|
上云后建议顺序是:先采集一次默认值,应用本文件 shared-host 参数并重启需要重启的项,再采集一次,然后跑 API 阶梯压测。调参证据和压测报告一起进入本地 `production-launch-evidence.json`,不要提交真实证据文件。
|
||||||
|
|
||||||
|
生产上线门禁使用严格模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json
|
||||||
|
```
|
||||||
|
|
||||||
|
严格模式会按 `scripts/lib/postgres-tuning-profile.js` 检查 4 核 16G profile:
|
||||||
|
|
||||||
|
- `jit=off`。
|
||||||
|
- `statement_timeout`、`idle_in_transaction_session_timeout`、`lock_timeout` 不得为 0。
|
||||||
|
- 所有 profile 参数必须在允许范围内。
|
||||||
|
- `pending_restart` 必须为 0。
|
||||||
|
- `pg_stat_statements` 必须可用。
|
||||||
|
|
||||||
|
如果 PostgreSQL 是独立数据库主机,可改用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PG_TUNING_PROFILE=dedicated-db npm run perf:postgres:evidence -- --strict --json
|
||||||
|
```
|
||||||
|
|
||||||
## 应用连接池边界
|
## 应用连接池边界
|
||||||
|
|
||||||
4 核机器的关键不是把 `max_connections` 拉大,而是控制同时活跃 SQL 的数量。
|
4 核机器的关键不是把 `max_connections` 拉大,而是控制同时活跃 SQL 的数量。
|
||||||
@@ -107,7 +127,13 @@ where name in (
|
|||||||
order by name;
|
order by name;
|
||||||
```
|
```
|
||||||
|
|
||||||
同机部署推荐先执行:
|
同机部署推荐先生成 SQL 后人工复核再执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run perf:postgres:sql -- --profile=shared-host
|
||||||
|
```
|
||||||
|
|
||||||
|
等价的 shared-host 起步 SQL 如下:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
alter system set max_connections = '80';
|
alter system set max_connections = '80';
|
||||||
|
|||||||
@@ -29,6 +29,20 @@
|
|||||||
"blocker": 0
|
"blocker": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "postgres.tuning-evidence",
|
||||||
|
"status": "pass",
|
||||||
|
"command": "PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json > docs/refactor/launch-artifacts/postgres-tuning-evidence.json",
|
||||||
|
"completedAt": "2026-06-30T10:08:00+08:00",
|
||||||
|
"artifact": "launch-artifacts/postgres-tuning-evidence.json",
|
||||||
|
"summary": {
|
||||||
|
"status": "pass",
|
||||||
|
"profile": "shared-host",
|
||||||
|
"failures": 0,
|
||||||
|
"pgStatStatementsAvailable": true,
|
||||||
|
"pendingRestart": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "auth.remote-smoke",
|
"id": "auth.remote-smoke",
|
||||||
"status": "pass",
|
"status": "pass",
|
||||||
|
|||||||
108
docs/refactor/taro-production-integration-checklist.md
Normal file
108
docs/refactor/taro-production-integration-checklist.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Taro 生产接入检查清单
|
||||||
|
|
||||||
|
更新时间:2026-07-01
|
||||||
|
|
||||||
|
这份清单给前端同事和后续 AI 使用。目标是让 `apps/taro` 的 H5 学生端、租户后台、平台后台按当前 Supabase/PostgreSQL 新后端上线,后续再扩展微信小程序。旧小程序前端文件在 `F:\project\参考\旧题库小程序前端文件`,只作为视觉、交互状态和微信平台能力参考,不继承旧 PocketBase 直连、旧 token、旧安全假设。
|
||||||
|
|
||||||
|
## 总原则
|
||||||
|
|
||||||
|
- Taro 负责 UI、路由、交互、公开 runtime config 和 Supabase Auth session。
|
||||||
|
- 复杂业务默认调用 `apps/api`,不要让页面直接写 Supabase 业务表。
|
||||||
|
- Supabase client 可用于 Auth session、可选 Realtime、公开只读 view/table 或经过 RLS/RPC 评审的低风险功能。
|
||||||
|
- 支付、短信、微信/QQ OAuth、订单、权益、激活码、优惠券、CRM、AI、导入、私有对象存储签名必须走 `apps/api`、Edge Function 或 worker。
|
||||||
|
- 页面代码不得覆盖 `Authorization`、`x-tenant-id`,不得发送 `x-user-id`。
|
||||||
|
- 前端只做可见性优化,权限最终以后端/RLS/RPC 校验为准。
|
||||||
|
|
||||||
|
## 三套 H5 发布目录
|
||||||
|
|
||||||
|
建议三套 H5 分域部署,不共用 web root:
|
||||||
|
|
||||||
|
| 门户 | 推荐域名 | 构建命令 | runtime-config |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 学生端 | `https://www.example.com` 或租户自定义域名 | `npm run build:taro:h5:student` | `apps/taro/deploy/h5-student.runtime-config.example.json` |
|
||||||
|
| 租户后台 | `https://admin.example.com` | `npm run build:taro:h5:tenant` | `apps/taro/deploy/h5-tenant-admin.runtime-config.example.json` |
|
||||||
|
| 平台后台 | `https://console.example.com` | `npm run build:taro:h5:platform` | `apps/taro/deploy/h5-platform-admin.runtime-config.example.json` |
|
||||||
|
|
||||||
|
每个发布目录根部必须放置独立的 `runtime-config.json`,只允许公开字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"portal": "student",
|
||||||
|
"apiBaseUrl": "https://api.example.com",
|
||||||
|
"supabaseUrl": "https://supabase.example.com",
|
||||||
|
"supabasePublishableKey": "sb_publishable_xxx",
|
||||||
|
"tenantCode": "optional-tenant-slug"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止出现在 `runtime-config.json`、Taro 环境变量、源码和构建产物中的内容:
|
||||||
|
|
||||||
|
- Supabase service role / secret key。
|
||||||
|
- 数据库连接串。
|
||||||
|
- 短信、OAuth、支付、对象存储、CRM、AI 的 secret/private key。
|
||||||
|
- 真实用户 token、测试 access token、平台本地管理 key。
|
||||||
|
|
||||||
|
## 生产接入步骤
|
||||||
|
|
||||||
|
1. 配好三套 H5 的 `runtime-config.json`,确认 `apiBaseUrl`、`supabaseUrl` 都是 HTTPS。
|
||||||
|
2. H5 登录优先使用 Supabase Auth access token;迁移期 `tk_` session 只用于本地或内网联调。
|
||||||
|
3. 学生端先走完整路径:解析租户、登录、选择地区、进入题库、创建练习、答题、收藏、交卷、查看报告、错题/收藏复习、背单词、知识手册、资料预览/下载、视频授权、会员下单、订单轮询。
|
||||||
|
4. 租户后台先走完整路径:权限加载、学生运营、题库导入/任务/问题行、公共题库采纳、营销中心、财务运营、主题发布、角色模板和成员绑定。
|
||||||
|
5. 平台后台先走完整路径:平台权限加载、创建租户、租户详情、账务资料、订阅/账单/用量、公共题库授权、平台员工、审计和告警。
|
||||||
|
6. 所有真实 provider 密钥只配置在后端 `.env`、`app_private.tenant_secrets`、`app_private.platform_secrets` 或生产 KMS/Vault,不进入 Taro。
|
||||||
|
7. 对象存储私有资源必须通过 `content_assets` 台账和后端短签名;前端只展示签名 URL、过期时间、`watermark.traceId` 和水印容器。
|
||||||
|
8. 支付页面只展示后端返回的支付参数、订单状态和权益结果;金额、套餐、优惠、权益最终以后端返回为准。
|
||||||
|
|
||||||
|
## 必跑检查
|
||||||
|
|
||||||
|
页面、API service、路由、runtime config 或发布目录有任何变化时,至少运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check:taro
|
||||||
|
npm run test:readiness
|
||||||
|
npm run smoke:taro:h5
|
||||||
|
npm run smoke:taro:h5:interaction
|
||||||
|
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config
|
||||||
|
```
|
||||||
|
|
||||||
|
后端、RLS、provider、对象存储或生产配置有变化时,还要运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run readiness:production
|
||||||
|
npm run readiness:production:db
|
||||||
|
npm run smoke:auth:remote
|
||||||
|
npm run test:rls
|
||||||
|
npm run audit:runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
正式上线前,三套 H5 严格发布证据、真实 Auth/RLS、真实 provider 抽样、对象存储控制、支付对账、PostgreSQL 严格调参证据和真实数据压测都要写入本地 `docs/refactor/production-launch-evidence.json`,再运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 小程序后续兼容重点
|
||||||
|
|
||||||
|
小程序不能直接照搬 H5 假设,进入真机前要单独验收:
|
||||||
|
|
||||||
|
- `@supabase/supabase-js` 的 fetch、storage、URL、token refresh 兼容性。
|
||||||
|
- 如果兼容成本高,小程序登录只调用 `apps/api/auth/*`,由后端换取可信 session。
|
||||||
|
- 微信支付容器、订阅消息、分享参数、小程序码 tenant/referral 场景。
|
||||||
|
- KaTeX/公式渲染替代方案、题图资源字段化、PDF 预览能力和下载限制。
|
||||||
|
- 网络错误、弱网续练、本地缓存恢复、切后台/回前台 token 刷新。
|
||||||
|
|
||||||
|
## 旧前端参考边界
|
||||||
|
|
||||||
|
可以参考旧项目:
|
||||||
|
|
||||||
|
- 学生端刷题流程、答题卡、题型展示、背单词卡片、知识手册阅读、个人中心视觉。
|
||||||
|
- 微信小程序分享、支付、授权和登录交互经验。
|
||||||
|
- 租户后台/运营后台字段含义和常用工作流。
|
||||||
|
|
||||||
|
不能继承旧项目:
|
||||||
|
|
||||||
|
- PocketBase SDK 直连和旧 collection 命名。
|
||||||
|
- 前端保存或拼接用户 id、租户 id 来绕过后端鉴权。
|
||||||
|
- 前端直接写订单、权益、学习记录、错题、收藏、导入任务。
|
||||||
|
- 旧头像上传/第三方头像同步;学生头像只保留男女预设。
|
||||||
|
- 默认排行榜请求;排行榜只作为租户显式开启后的活动能力。
|
||||||
@@ -124,8 +124,8 @@ STORAGE_REQUIRE_TENANT_PREFIX=true
|
|||||||
|
|
||||||
1. `npm run perf:postgres:evidence` 采集默认 PostgreSQL 参数。
|
1. `npm run perf:postgres:evidence` 采集默认 PostgreSQL 参数。
|
||||||
2. 按 `docs/refactor/postgresql-4c16g-tuning.md` 应用 shared-host 起步值。
|
2. 按 `docs/refactor/postgresql-4c16g-tuning.md` 应用 shared-host 起步值。
|
||||||
3. 重启 PostgreSQL 后再次 `npm run perf:postgres:evidence`。
|
3. 重启 PostgreSQL 后执行 `PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json`,确认 profile 达标、`pending_restart=0`、`pg_stat_statements` 可用。
|
||||||
4. 跑 `npm run perf:api:local` 的 6/30/50/100 阶梯,只读和少量写入各一组。
|
4. 跑 `npm run perf:api:local` 的 6/30/50/100 阶梯,只读和少量写入各一组。
|
||||||
5. 把摘要写入本地 `production-launch-evidence.json`,执行 `npm run launch:gate`。
|
5. 把 `postgres.tuning-evidence` 和 API 压测摘要写入本地 `production-launch-evidence.json`,执行 `npm run launch:gate`。
|
||||||
|
|
||||||
容量折算不要直接把压测 worker 当在线人数。真实学生有读题和思考时间,应结合 H5 埋点估算单人平均 RPS,再按成功 RPS 折算在线容量。
|
容量折算不要直接把压测 worker 当在线人数。真实学生有读题和思考时间,应结合 H5 埋点估算单人平均 RPS,再按成功 RPS 折算在线容量。
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"perf:api:local": "npm run build:api && node scripts/api-performance-benchmark.js",
|
"perf:api:local": "npm run build:api && node scripts/api-performance-benchmark.js",
|
||||||
"perf:api:docker-4c16g": "node scripts/run-docker-4c16g-benchmark.js",
|
"perf:api:docker-4c16g": "node scripts/run-docker-4c16g-benchmark.js",
|
||||||
"perf:postgres:evidence": "node scripts/postgres-tuning-evidence.js",
|
"perf:postgres:evidence": "node scripts/postgres-tuning-evidence.js",
|
||||||
|
"perf:postgres:sql": "node scripts/postgres-tuning-evidence.js --print-sql",
|
||||||
"test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js",
|
"test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js",
|
||||||
"test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js",
|
"test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js",
|
||||||
"test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js",
|
"test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js",
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
||||||
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
|
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
|
||||||
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
||||||
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
|
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
|
||||||
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
|
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
|
||||||
"test:launch-gate": "node scripts/production-launch-gate-test.js",
|
"test:launch-gate": "node scripts/production-launch-gate-test.js",
|
||||||
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
|
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
|
||||||
|
|||||||
175
scripts/lib/postgres-tuning-profile.js
Normal file
175
scripts/lib/postgres-tuning-profile.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
export const postgresTuningProfiles = {
|
||||||
|
'shared-host': {
|
||||||
|
name: 'shared-host',
|
||||||
|
label: '4c16g shared-host',
|
||||||
|
description: '4 vCPU / 16GB RAM, PostgreSQL shares the host with API, worker, Nginx and monitoring agents.',
|
||||||
|
settings: {
|
||||||
|
max_connections: {
|
||||||
|
recommended: '80',
|
||||||
|
min: 40,
|
||||||
|
max: 120,
|
||||||
|
type: 'number',
|
||||||
|
rationale: '4 vCPU should use application/pooler limits instead of many direct active connections.',
|
||||||
|
},
|
||||||
|
shared_buffers: {
|
||||||
|
recommended: '3GB',
|
||||||
|
min: '2GB',
|
||||||
|
max: '4GB',
|
||||||
|
type: 'bytes',
|
||||||
|
restart: true,
|
||||||
|
rationale: 'Keep PostgreSQL buffers large enough while leaving RAM for OS cache and sibling services.',
|
||||||
|
},
|
||||||
|
effective_cache_size: {
|
||||||
|
recommended: '10GB',
|
||||||
|
min: '8GB',
|
||||||
|
max: '12GB',
|
||||||
|
type: 'bytes',
|
||||||
|
rationale: 'Planner estimate only; reflects shared buffers plus OS cache on a 16GB shared host.',
|
||||||
|
},
|
||||||
|
work_mem: {
|
||||||
|
recommended: '16MB',
|
||||||
|
min: '8MB',
|
||||||
|
max: '32MB',
|
||||||
|
type: 'bytes',
|
||||||
|
rationale: 'This is per sort/hash operation, not a global cap.',
|
||||||
|
},
|
||||||
|
maintenance_work_mem: {
|
||||||
|
recommended: '512MB',
|
||||||
|
min: '256MB',
|
||||||
|
max: '1GB',
|
||||||
|
type: 'bytes',
|
||||||
|
rationale: 'Helps index builds, vacuum and imports without overcommitting memory.',
|
||||||
|
},
|
||||||
|
autovacuum_work_mem: {
|
||||||
|
recommended: '256MB',
|
||||||
|
min: '128MB',
|
||||||
|
max: '512MB',
|
||||||
|
type: 'bytes',
|
||||||
|
rationale: 'Avoid letting multiple autovacuum workers inherit an overly high maintenance_work_mem.',
|
||||||
|
},
|
||||||
|
wal_buffers: {
|
||||||
|
recommended: '16MB',
|
||||||
|
min: '8MB',
|
||||||
|
max: '64MB',
|
||||||
|
type: 'bytes',
|
||||||
|
restart: true,
|
||||||
|
rationale: 'Enough WAL buffering for steady learning writes and import bursts.',
|
||||||
|
},
|
||||||
|
min_wal_size: {
|
||||||
|
recommended: '1GB',
|
||||||
|
min: '1GB',
|
||||||
|
max: '4GB',
|
||||||
|
type: 'bytes',
|
||||||
|
rationale: 'Reserve WAL for short write spikes and batch jobs.',
|
||||||
|
},
|
||||||
|
max_wal_size: {
|
||||||
|
recommended: '6GB',
|
||||||
|
min: '4GB',
|
||||||
|
max: '12GB',
|
||||||
|
type: 'bytes',
|
||||||
|
rationale: 'Reduce checkpoint pressure while keeping crash recovery bounded.',
|
||||||
|
},
|
||||||
|
checkpoint_timeout: {
|
||||||
|
recommended: '10min',
|
||||||
|
min: '5min',
|
||||||
|
max: '15min',
|
||||||
|
type: 'duration',
|
||||||
|
rationale: 'Avoid overly frequent checkpoints on write bursts.',
|
||||||
|
},
|
||||||
|
checkpoint_completion_target: {
|
||||||
|
recommended: '0.9',
|
||||||
|
min: 0.8,
|
||||||
|
max: 0.95,
|
||||||
|
type: 'number',
|
||||||
|
rationale: 'Spread checkpoint I/O without pushing completion too close to the next checkpoint.',
|
||||||
|
},
|
||||||
|
effective_io_concurrency: {
|
||||||
|
recommended: '100',
|
||||||
|
min: 50,
|
||||||
|
max: 300,
|
||||||
|
type: 'number',
|
||||||
|
rationale: 'SSD cloud disk starting point; validate against the actual disk class.',
|
||||||
|
},
|
||||||
|
random_page_cost: {
|
||||||
|
recommended: '1.1',
|
||||||
|
min: 1,
|
||||||
|
max: 1.5,
|
||||||
|
type: 'number',
|
||||||
|
rationale: 'Reflect SSD random I/O and encourage reasonable index scans.',
|
||||||
|
},
|
||||||
|
jit: {
|
||||||
|
recommended: 'off',
|
||||||
|
exact: 'off',
|
||||||
|
type: 'text',
|
||||||
|
rationale: 'Question-bank API queries are mostly short OLTP requests where JIT planning cost is usually not worth it.',
|
||||||
|
},
|
||||||
|
log_min_duration_statement: {
|
||||||
|
recommended: '500ms',
|
||||||
|
min: '100ms',
|
||||||
|
max: '1000ms',
|
||||||
|
type: 'duration',
|
||||||
|
rationale: 'Capture slow SQL during launch without logging every normal request.',
|
||||||
|
},
|
||||||
|
idle_in_transaction_session_timeout: {
|
||||||
|
recommended: '60s',
|
||||||
|
min: '30s',
|
||||||
|
max: '120s',
|
||||||
|
type: 'duration',
|
||||||
|
rationale: 'Kill idle transactions before they block migrations and writes.',
|
||||||
|
},
|
||||||
|
statement_timeout: {
|
||||||
|
recommended: '30s',
|
||||||
|
min: '5s',
|
||||||
|
max: '60s',
|
||||||
|
type: 'duration',
|
||||||
|
rationale: 'API requests should fail boundedly; import jobs can override per session.',
|
||||||
|
},
|
||||||
|
lock_timeout: {
|
||||||
|
recommended: '5s',
|
||||||
|
min: '1s',
|
||||||
|
max: '10s',
|
||||||
|
type: 'duration',
|
||||||
|
rationale: 'Ordinary API requests should not wait a long time behind locks.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'dedicated-db': {
|
||||||
|
name: 'dedicated-db',
|
||||||
|
label: '4c16g dedicated PostgreSQL',
|
||||||
|
description: '4 vCPU / 16GB RAM where PostgreSQL is the only heavy service on the host.',
|
||||||
|
settings: {
|
||||||
|
max_connections: { recommended: '120', min: 60, max: 150, type: 'number' },
|
||||||
|
shared_buffers: { recommended: '4GB', min: '3GB', max: '5GB', type: 'bytes', restart: true },
|
||||||
|
effective_cache_size: { recommended: '12GB', min: '10GB', max: '14GB', type: 'bytes' },
|
||||||
|
work_mem: { recommended: '16MB', min: '8MB', max: '32MB', type: 'bytes' },
|
||||||
|
maintenance_work_mem: { recommended: '768MB', min: '512MB', max: '1536MB', type: 'bytes' },
|
||||||
|
autovacuum_work_mem: { recommended: '256MB', min: '128MB', max: '512MB', type: 'bytes' },
|
||||||
|
wal_buffers: { recommended: '16MB', min: '8MB', max: '64MB', type: 'bytes', restart: true },
|
||||||
|
min_wal_size: { recommended: '2GB', min: '1GB', max: '4GB', type: 'bytes' },
|
||||||
|
max_wal_size: { recommended: '8GB', min: '4GB', max: '16GB', type: 'bytes' },
|
||||||
|
checkpoint_timeout: { recommended: '15min', min: '5min', max: '20min', type: 'duration' },
|
||||||
|
checkpoint_completion_target: { recommended: '0.9', min: 0.8, max: 0.95, type: 'number' },
|
||||||
|
effective_io_concurrency: { recommended: '100', min: 50, max: 300, type: 'number' },
|
||||||
|
random_page_cost: { recommended: '1.1', min: 1, max: 1.5, type: 'number' },
|
||||||
|
jit: { recommended: 'off', exact: 'off', type: 'text' },
|
||||||
|
log_min_duration_statement: { recommended: '500ms', min: '100ms', max: '1000ms', type: 'duration' },
|
||||||
|
idle_in_transaction_session_timeout: { recommended: '60s', min: '30s', max: '120s', type: 'duration' },
|
||||||
|
statement_timeout: { recommended: '30s', min: '5s', max: '60s', type: 'duration' },
|
||||||
|
lock_timeout: { recommended: '5s', min: '1s', max: '10s', type: 'duration' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultPostgresTuningProfile = 'shared-host';
|
||||||
|
|
||||||
|
export function getPostgresTuningProfile(name = defaultPostgresTuningProfile) {
|
||||||
|
const profile = postgresTuningProfiles[name];
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(`Unknown PostgreSQL tuning profile: ${name}`);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostgresTuningSettingNames(profileName = defaultPostgresTuningProfile) {
|
||||||
|
return Object.keys(getPostgresTuningProfile(profileName).settings);
|
||||||
|
}
|
||||||
38
scripts/postgres-tuning-evidence-test.js
Normal file
38
scripts/postgres-tuning-evidence-test.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const scriptPath = path.join(repoRoot, 'scripts', 'postgres-tuning-evidence.js');
|
||||||
|
|
||||||
|
function run(args) {
|
||||||
|
return spawnSync(process.execPath, [scriptPath, ...args], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH || '',
|
||||||
|
Path: process.env.Path || '',
|
||||||
|
SystemRoot: process.env.SystemRoot || '',
|
||||||
|
ComSpec: process.env.ComSpec || '',
|
||||||
|
TEMP: process.env.TEMP || '',
|
||||||
|
TMP: process.env.TMP || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedSql = run(['--print-sql', '--profile=shared-host']);
|
||||||
|
assert.equal(sharedSql.status, 0, sharedSql.stderr);
|
||||||
|
assert.match(sharedSql.stdout, /alter system set shared_buffers = '3GB';/);
|
||||||
|
assert.match(sharedSql.stdout, /alter system set statement_timeout = '30s';/);
|
||||||
|
assert.match(sharedSql.stdout, /alter system set jit = 'off';/);
|
||||||
|
|
||||||
|
const dedicatedSql = run(['--print-sql', '--profile=dedicated-db']);
|
||||||
|
assert.equal(dedicatedSql.status, 0, dedicatedSql.stderr);
|
||||||
|
assert.match(dedicatedSql.stdout, /alter system set shared_buffers = '4GB';/);
|
||||||
|
assert.match(dedicatedSql.stdout, /alter system set max_wal_size = '8GB';/);
|
||||||
|
|
||||||
|
const invalidProfile = run(['--print-sql', '--profile=unknown']);
|
||||||
|
assert.notEqual(invalidProfile.status, 0, 'unknown profile should fail');
|
||||||
|
assert.match(`${invalidProfile.stderr}${invalidProfile.stdout}`, /Unknown PostgreSQL tuning profile/);
|
||||||
|
|
||||||
|
console.log('[PASS] PostgreSQL tuning evidence helpers');
|
||||||
@@ -1,30 +1,38 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
|
import {
|
||||||
|
defaultPostgresTuningProfile,
|
||||||
|
getPostgresTuningProfile,
|
||||||
|
getPostgresTuningSettingNames,
|
||||||
|
} from './lib/postgres-tuning-profile.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||||
const outputDir = process.env.PG_TUNING_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
|
const outputDir = process.env.PG_TUNING_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
|
||||||
|
|
||||||
const settingNames = [
|
function parseArgs(argv) {
|
||||||
'max_connections',
|
const options = {
|
||||||
'shared_buffers',
|
profile: process.env.PG_TUNING_PROFILE || defaultPostgresTuningProfile,
|
||||||
'effective_cache_size',
|
strict: process.env.PG_TUNING_STRICT === 'true',
|
||||||
'work_mem',
|
printSql: false,
|
||||||
'maintenance_work_mem',
|
json: false,
|
||||||
'autovacuum_work_mem',
|
};
|
||||||
'wal_buffers',
|
for (let index = 2; index < argv.length; index += 1) {
|
||||||
'min_wal_size',
|
const arg = argv[index];
|
||||||
'max_wal_size',
|
if (arg === '--strict') options.strict = true;
|
||||||
'checkpoint_timeout',
|
else if (arg === '--json') options.json = true;
|
||||||
'checkpoint_completion_target',
|
else if (arg === '--print-sql') options.printSql = true;
|
||||||
'effective_io_concurrency',
|
else if (arg === '--profile') {
|
||||||
'random_page_cost',
|
options.profile = argv[index + 1] || options.profile;
|
||||||
'jit',
|
index += 1;
|
||||||
'log_min_duration_statement',
|
} else if (arg.startsWith('--profile=')) {
|
||||||
'idle_in_transaction_session_timeout',
|
options.profile = arg.slice('--profile='.length);
|
||||||
'statement_timeout',
|
}
|
||||||
'lock_timeout',
|
}
|
||||||
];
|
options.profileDefinition = getPostgresTuningProfile(options.profile);
|
||||||
|
options.settingNames = getPostgresTuningSettingNames(options.profile);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
function shanghaiStamp(date = new Date()) {
|
function shanghaiStamp(date = new Date()) {
|
||||||
const parts = Object.fromEntries(
|
const parts = Object.fromEntries(
|
||||||
@@ -59,19 +67,184 @@ function redactUrl(value) {
|
|||||||
return value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@');
|
return value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pgUnitMultiplier(unit) {
|
||||||
|
const text = String(unit || '').trim().toLowerCase();
|
||||||
|
if (!text) return 1;
|
||||||
|
if (text === 'ms') return 1;
|
||||||
|
if (text === 's') return 1000;
|
||||||
|
if (text === 'min') return 60_000;
|
||||||
|
const memoryMatch = text.match(/^(\d+)?\s*(b|kb|mb|gb|tb)$/);
|
||||||
|
if (!memoryMatch) return 1;
|
||||||
|
const blockCount = Number(memoryMatch[1] || 1);
|
||||||
|
const unitName = memoryMatch[2];
|
||||||
|
const factors = {
|
||||||
|
b: 1,
|
||||||
|
kb: 1024,
|
||||||
|
mb: 1024 ** 2,
|
||||||
|
gb: 1024 ** 3,
|
||||||
|
tb: 1024 ** 4,
|
||||||
|
};
|
||||||
|
return blockCount * factors[unitName];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!Number.isFinite(bytes)) return '';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let value = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
const rounded = value >= 10 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
|
||||||
|
return `${rounded}${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (!Number.isFinite(ms)) return '';
|
||||||
|
if (ms === 0) return '0ms';
|
||||||
|
if (ms % 60_000 === 0) return `${ms / 60_000}min`;
|
||||||
|
if (ms % 1000 === 0) return `${ms / 1000}s`;
|
||||||
|
return `${ms}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSettingValue(setting) {
|
||||||
|
const number = Number(setting.setting);
|
||||||
|
if (!Number.isFinite(number)) return `${setting.setting}${setting.unit || ''}`;
|
||||||
|
const unit = String(setting.unit || '').trim();
|
||||||
|
if (!unit) return String(setting.setting);
|
||||||
|
if (/^(?:\d+)?\s*(?:b|kb|mb|gb|tb)$/i.test(unit)) {
|
||||||
|
return formatBytes(number * pgUnitMultiplier(unit));
|
||||||
|
}
|
||||||
|
if (/^(?:ms|s|min)$/i.test(unit)) {
|
||||||
|
return formatDuration(number * pgUnitMultiplier(unit));
|
||||||
|
}
|
||||||
|
return `${setting.setting}${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
function explainSetting(setting) {
|
function explainSetting(setting) {
|
||||||
const value = `${setting.setting}${setting.unit || ''}`;
|
|
||||||
return {
|
return {
|
||||||
name: setting.name,
|
name: setting.name,
|
||||||
value,
|
value: normalizedSettingValue(setting),
|
||||||
|
rawSetting: setting.setting,
|
||||||
|
unit: setting.unit || null,
|
||||||
source: setting.source,
|
source: setting.source,
|
||||||
pendingRestart: setting.pending_restart,
|
pendingRestart: setting.pending_restart,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluate(settingsRows) {
|
function parseSettingNumber(setting) {
|
||||||
|
if (!setting) return Number.NaN;
|
||||||
|
const number = Number(setting.setting);
|
||||||
|
return Number.isFinite(number) ? number : Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBytes(value) {
|
||||||
|
if (typeof value === 'number') return value;
|
||||||
|
const text = String(value || '').trim().toLowerCase();
|
||||||
|
if (!text) return Number.NaN;
|
||||||
|
const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/);
|
||||||
|
if (!match) return Number.NaN;
|
||||||
|
const amount = Number(match[1]);
|
||||||
|
const unit = match[2] || 'b';
|
||||||
|
const factors = {
|
||||||
|
b: 1,
|
||||||
|
byte: 1,
|
||||||
|
bytes: 1,
|
||||||
|
kb: 1024,
|
||||||
|
mb: 1024 ** 2,
|
||||||
|
gb: 1024 ** 3,
|
||||||
|
tb: 1024 ** 4,
|
||||||
|
};
|
||||||
|
return amount * (factors[unit] || Number.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDurationMs(value) {
|
||||||
|
if (typeof value === 'number') return value;
|
||||||
|
const text = String(value || '').trim().toLowerCase();
|
||||||
|
if (!text) return Number.NaN;
|
||||||
|
const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/);
|
||||||
|
if (!match) return Number.NaN;
|
||||||
|
const amount = Number(match[1]);
|
||||||
|
const unit = match[2] || 'ms';
|
||||||
|
const factors = {
|
||||||
|
ms: 1,
|
||||||
|
s: 1000,
|
||||||
|
sec: 1000,
|
||||||
|
secs: 1000,
|
||||||
|
second: 1000,
|
||||||
|
seconds: 1000,
|
||||||
|
min: 60_000,
|
||||||
|
mins: 60_000,
|
||||||
|
minute: 60_000,
|
||||||
|
minutes: 60_000,
|
||||||
|
h: 3_600_000,
|
||||||
|
hour: 3_600_000,
|
||||||
|
hours: 3_600_000,
|
||||||
|
};
|
||||||
|
return amount * (factors[unit] || Number.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparableValue(setting, rule) {
|
||||||
|
if (!setting) return Number.NaN;
|
||||||
|
if (rule.type === 'bytes') return Number(setting.setting) * pgUnitMultiplier(setting.unit);
|
||||||
|
if (rule.type === 'duration') return Number(setting.setting) * pgUnitMultiplier(setting.unit);
|
||||||
|
return parseSettingNumber(setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recommendedComparator(rule, key) {
|
||||||
|
if (rule.type === 'bytes') return parseBytes(rule[key]);
|
||||||
|
if (rule.type === 'duration') return parseDurationMs(rule[key]);
|
||||||
|
return Number(rule[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateProfile(settingsRows, profile) {
|
||||||
|
const byName = new Map(settingsRows.map(row => [row.name, row]));
|
||||||
|
const failures = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
for (const [name, rule] of Object.entries(profile.settings)) {
|
||||||
|
const setting = byName.get(name);
|
||||||
|
if (!setting) {
|
||||||
|
failures.push(`${name} is missing from pg_settings evidence.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (setting.pending_restart) {
|
||||||
|
failures.push(`${name} has pending_restart=true; restart PostgreSQL before capacity testing.`);
|
||||||
|
}
|
||||||
|
if (rule.exact !== undefined) {
|
||||||
|
const actual = String(setting.setting || '').toLowerCase();
|
||||||
|
if (actual !== String(rule.exact).toLowerCase()) {
|
||||||
|
failures.push(`${name} should be ${rule.exact} for ${profile.label}, got ${setting.setting}.`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.min !== undefined) {
|
||||||
|
const actual = comparableValue(setting, rule);
|
||||||
|
const expected = recommendedComparator(rule, 'min');
|
||||||
|
if (!Number.isFinite(actual) || actual < expected) {
|
||||||
|
failures.push(`${name} is below ${rule.min} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rule.max !== undefined) {
|
||||||
|
const actual = comparableValue(setting, rule);
|
||||||
|
const expected = recommendedComparator(rule, 'max');
|
||||||
|
if (!Number.isFinite(actual) || actual > expected) {
|
||||||
|
failures.push(`${name} is above ${rule.max} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rule.recommended !== undefined && String(setting.source || '').toLowerCase() === 'default') {
|
||||||
|
warnings.push(`${name} still comes from default; recommended ${profile.name} value is ${rule.recommended}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { failures, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluate(settingsRows, profile, options, pgStatStatements) {
|
||||||
const byName = new Map(settingsRows.map(row => [row.name, row]));
|
const byName = new Map(settingsRows.map(row => [row.name, row]));
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
|
const failures = [];
|
||||||
const maxConnections = Number(byName.get('max_connections')?.setting || 0);
|
const maxConnections = Number(byName.get('max_connections')?.setting || 0);
|
||||||
const jit = String(byName.get('jit')?.setting || '').toLowerCase();
|
const jit = String(byName.get('jit')?.setting || '').toLowerCase();
|
||||||
const statementTimeout = String(byName.get('statement_timeout')?.setting || '');
|
const statementTimeout = String(byName.get('statement_timeout')?.setting || '');
|
||||||
@@ -84,9 +257,19 @@ function evaluate(settingsRows) {
|
|||||||
if (idleTimeout === '0') warnings.push('idle_in_transaction_session_timeout is disabled; long idle transactions can block migrations and writes.');
|
if (idleTimeout === '0') warnings.push('idle_in_transaction_session_timeout is disabled; long idle transactions can block migrations and writes.');
|
||||||
if (lockTimeout === '0') warnings.push('lock_timeout is disabled; ordinary API requests may wait too long behind locks.');
|
if (lockTimeout === '0') warnings.push('lock_timeout is disabled; ordinary API requests may wait too long behind locks.');
|
||||||
if (settingsRows.some(row => row.pending_restart)) warnings.push('Some PostgreSQL settings have pending_restart=true; restart is required before capacity testing.');
|
if (settingsRows.some(row => row.pending_restart)) warnings.push('Some PostgreSQL settings have pending_restart=true; restart is required before capacity testing.');
|
||||||
|
if (pgStatStatements?.unavailable) warnings.push('pg_stat_statements is unavailable; enable it before production capacity acceptance.');
|
||||||
|
|
||||||
|
const profileEvaluation = evaluateProfile(settingsRows, profile);
|
||||||
|
warnings.push(...profileEvaluation.warnings);
|
||||||
|
if (options.strict) {
|
||||||
|
failures.push(...profileEvaluation.failures);
|
||||||
|
if (pgStatStatements?.unavailable) failures.push('pg_stat_statements is unavailable in strict mode.');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: warnings.length ? 'warn' : 'pass',
|
status: failures.length ? 'fail' : options.strict ? 'pass' : warnings.length ? 'warn' : 'pass',
|
||||||
|
profile: profile.name,
|
||||||
|
failures,
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -99,9 +282,17 @@ function markdown(report) {
|
|||||||
'',
|
'',
|
||||||
`数据库:${report.databaseUrl}`,
|
`数据库:${report.databaseUrl}`,
|
||||||
'',
|
'',
|
||||||
|
`调参 profile:${report.profile.label} (${report.profile.name})`,
|
||||||
|
'',
|
||||||
`评估:${report.evaluation.status}`,
|
`评估:${report.evaluation.status}`,
|
||||||
'',
|
'',
|
||||||
];
|
];
|
||||||
|
if (report.evaluation.failures.length) {
|
||||||
|
lines.push('## 阻断项');
|
||||||
|
lines.push('');
|
||||||
|
for (const failure of report.evaluation.failures) lines.push(`- ${failure}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
if (report.evaluation.warnings.length) {
|
if (report.evaluation.warnings.length) {
|
||||||
lines.push('## 警告');
|
lines.push('## 警告');
|
||||||
lines.push('');
|
lines.push('');
|
||||||
@@ -158,7 +349,30 @@ function markdown(report) {
|
|||||||
return `${lines.join('\n')}\n`;
|
return `${lines.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sqlQuote(value) {
|
||||||
|
return String(value).replaceAll("'", "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
function alterSystemSql(profile) {
|
||||||
|
const lines = [
|
||||||
|
`-- PostgreSQL tuning profile: ${profile.label}`,
|
||||||
|
'-- Review current values and take a snapshot before applying this file.',
|
||||||
|
'-- Run npm run perf:postgres:evidence -- --strict after reload/restart.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
for (const [name, rule] of Object.entries(profile.settings)) {
|
||||||
|
lines.push(`alter system set ${name} = '${sqlQuote(rule.recommended)}';`);
|
||||||
|
}
|
||||||
|
lines.push('select pg_reload_conf();');
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv);
|
||||||
|
if (options.printSql) {
|
||||||
|
console.log(alterSystemSql(options.profileDefinition));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 });
|
const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 });
|
||||||
try {
|
try {
|
||||||
const settingsRaw = await query(
|
const settingsRaw = await query(
|
||||||
@@ -169,7 +383,7 @@ async function main() {
|
|||||||
where name = any($1::text[])
|
where name = any($1::text[])
|
||||||
order by array_position($1::text[], name)
|
order by array_position($1::text[], name)
|
||||||
`,
|
`,
|
||||||
[settingNames],
|
[options.settingNames],
|
||||||
);
|
);
|
||||||
const activityByWait = await query(
|
const activityByWait = await query(
|
||||||
pool,
|
pool,
|
||||||
@@ -231,7 +445,12 @@ async function main() {
|
|||||||
const report = {
|
const report = {
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
databaseUrl: redactUrl(databaseUrl),
|
databaseUrl: redactUrl(databaseUrl),
|
||||||
evaluation: evaluate(settingsRaw),
|
profile: {
|
||||||
|
name: options.profileDefinition.name,
|
||||||
|
label: options.profileDefinition.label,
|
||||||
|
description: options.profileDefinition.description,
|
||||||
|
},
|
||||||
|
evaluation: evaluate(settingsRaw, options.profileDefinition, options, pgStatStatements),
|
||||||
settings: settingsRaw.map(explainSetting),
|
settings: settingsRaw.map(explainSetting),
|
||||||
activityByWait,
|
activityByWait,
|
||||||
databaseStats,
|
databaseStats,
|
||||||
@@ -239,15 +458,32 @@ async function main() {
|
|||||||
largeRelations,
|
largeRelations,
|
||||||
pgStatStatements,
|
pgStatStatements,
|
||||||
};
|
};
|
||||||
|
const pendingRestart = settingsRaw.filter(row => row.pending_restart).length;
|
||||||
await fs.mkdir(outputDir, { recursive: true });
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
const jsonPath = path.join(outputDir, `postgres-tuning-evidence-${shanghaiStamp()}.json`);
|
const jsonPath = path.join(outputDir, `postgres-tuning-evidence-${shanghaiStamp()}.json`);
|
||||||
const mdPath = jsonPath.replace(/\.json$/, '.md');
|
const mdPath = jsonPath.replace(/\.json$/, '.md');
|
||||||
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||||
await fs.writeFile(mdPath, markdown(report), 'utf8');
|
await fs.writeFile(mdPath, markdown(report), 'utf8');
|
||||||
|
const payload = {
|
||||||
|
status: report.evaluation.status,
|
||||||
|
profile: report.profile.name,
|
||||||
|
warnings: report.evaluation.warnings.length,
|
||||||
|
failures: report.evaluation.failures.length,
|
||||||
|
pgStatStatementsAvailable: !pgStatStatements?.unavailable,
|
||||||
|
pendingRestart,
|
||||||
|
jsonPath,
|
||||||
|
mdPath,
|
||||||
|
};
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
} else {
|
||||||
console.log(`[pg-evidence] status=${report.evaluation.status}`);
|
console.log(`[pg-evidence] status=${report.evaluation.status}`);
|
||||||
|
for (const failure of report.evaluation.failures) console.log(`[pg-evidence] failure: ${failure}`);
|
||||||
for (const warning of report.evaluation.warnings) console.log(`[pg-evidence] warning: ${warning}`);
|
for (const warning of report.evaluation.warnings) console.log(`[pg-evidence] warning: ${warning}`);
|
||||||
console.log(`[pg-evidence] wrote ${jsonPath}`);
|
console.log(`[pg-evidence] wrote ${jsonPath}`);
|
||||||
console.log(`[pg-evidence] wrote ${mdPath}`);
|
console.log(`[pg-evidence] wrote ${mdPath}`);
|
||||||
|
}
|
||||||
|
if (options.strict && report.evaluation.failures.length > 0) process.exitCode = 1;
|
||||||
} finally {
|
} finally {
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ assert.ok(
|
|||||||
'migration profile mismatch should be reported as a blocker',
|
'migration profile mismatch should be reported as a blocker',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const missingPostgresTuning = runGate(tempDir => {
|
||||||
|
const evidence = createEvidence(tempDir);
|
||||||
|
evidence.checks = evidence.checks.filter(item => item.id !== 'postgres.tuning-evidence');
|
||||||
|
return evidence;
|
||||||
|
});
|
||||||
|
assert.notEqual(missingPostgresTuning.status, 0, 'missing PostgreSQL tuning evidence should fail launch gate');
|
||||||
|
assert.ok(
|
||||||
|
missingPostgresTuning.payload.checks?.some(item => item.id === 'check.postgres.tuning-evidence' && item.status === 'blocker'),
|
||||||
|
'missing PostgreSQL tuning evidence should be reported as a blocker',
|
||||||
|
);
|
||||||
|
|
||||||
const slowBenchmark = runGate(tempDir => {
|
const slowBenchmark = runGate(tempDir => {
|
||||||
const evidence = createEvidence(tempDir);
|
const evidence = createEvidence(tempDir);
|
||||||
const item = evidence.checks.find(check => check.id === 'performance.api-real-data-read');
|
const item = evidence.checks.find(check => check.id === 'performance.api-real-data-read');
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ const gateChecks = [
|
|||||||
commandIncludes: 'readiness:production:db',
|
commandIncludes: 'readiness:production:db',
|
||||||
summary: { blocker: 0 },
|
summary: { blocker: 0 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'postgres.tuning-evidence',
|
||||||
|
label: 'PostgreSQL 4c16g tuning evidence',
|
||||||
|
commandIncludes: 'perf:postgres:evidence',
|
||||||
|
summary: {
|
||||||
|
status: 'pass',
|
||||||
|
profile: { oneOf: ['shared-host', 'dedicated-db'] },
|
||||||
|
failures: 0,
|
||||||
|
pgStatStatementsAvailable: true,
|
||||||
|
pendingRestart: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'auth.remote-smoke',
|
id: 'auth.remote-smoke',
|
||||||
label: 'Remote Supabase Auth/JWKS smoke',
|
label: 'Remote Supabase Auth/JWKS smoke',
|
||||||
|
|||||||
Reference in New Issue
Block a user