feat: harden pocketbase migration readiness checks

This commit is contained in:
Codex
2026-06-30 00:51:20 +08:00
parent db86fa3705
commit 889a53c90f
7 changed files with 348 additions and 7 deletions

View File

@@ -279,6 +279,26 @@ npm run pb:import:dry-run -- --json
dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失旧 ID、敏感字段、旧 schema 关系断裂和未映射集合。存在 blocker 时命令返回非 0所有 blocker 处理完后,再执行 `npm run pb:import:json``npm run pb:import:validate` dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失旧 ID、敏感字段、旧 schema 关系断裂和未映射集合。存在 blocker 时命令返回非 0所有 blocker 处理完后,再执行 `npm run pb:import:json``npm run pb:import:validate`
真实生产数据迁移不要只看命令是否能跑完,需要按迁移验收 runbook 执行 dry-run、正式导入演练、导入后校验、业务抽样、Taro 联调、冻结切换和回滚准备:
```text
docs/refactor/pocketbase-real-data-migration-runbook.md
```
正式切换前建议使用严格模式:
```bash
npm run pb:import:dry-run -- --json --fail-on-warnings
```
导入后校验建议在预生产/生产切换前把 warning 也作为阻断:
```powershell
$env:FAIL_ON_WARNINGS="true"
npm run pb:import:validate
Remove-Item Env:\FAIL_ON_WARNINGS
```
## API 模块 ## API 模块
当前 API 目录: 当前 API 目录:

View File

@@ -39,7 +39,7 @@
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 | | 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 |
| 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run``pb:import:validate` | 基础工具可用,需拿真实完整数据执行多轮 dry-run导入回归 | | 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run``pb:import:validate``test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run导入回归和抽样验收 |
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | | 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
## 已实现 API 范围 ## 已实现 API 范围

View File

@@ -116,6 +116,26 @@ npm run pb:import:validate
`pb:import:dry-run` 不写数据库,会先检查 JSON 格式、核心集合、旧 ID、敏感字段、关系断裂和未映射集合。存在 blocker 时先处理问题,不要直接正式导入。 `pb:import:dry-run` 不写数据库,会先检查 JSON 格式、核心集合、旧 ID、敏感字段、关系断裂和未映射集合。存在 blocker 时先处理问题,不要直接正式导入。
真实生产数据迁移请按完整验收流程执行:
```text
docs/refactor/pocketbase-real-data-migration-runbook.md
```
正式切换前建议先跑严格 dry-run
```bash
npm run pb:import:dry-run -- --json --fail-on-warnings
```
导入后校验如需把 warning 也视为阻断:
```powershell
$env:FAIL_ON_WARNINGS="true"
npm run pb:import:validate
Remove-Item Env:\FAIL_ON_WARNINGS
```
导入真实密钥时必须明确打开开关,且只允许进入 `app_private.tenant_secrets` 导入真实密钥时必须明确打开开关,且只允许进入 `app_private.tenant_secrets`
```bash ```bash

View File

@@ -65,7 +65,7 @@
3. 真实导入 dry-run 3. 真实导入 dry-run
- 从 PocketBase 导出现有用户、题库、单词、知识手册、分数线、订单、权益数据。 - 从 PocketBase 导出现有用户、题库、单词、知识手册、分数线、订单、权益数据。
- 已补 `npm run pb:import:dry-run` 静态迁移报告工具;拿到真实导出后先跑 dry-run再跑 `pb:import:json``pb:import:validate` - 已补 `npm run pb:import:dry-run` 静态迁移报告工具、strict warning 门禁测试和真实数据迁移验收 runbook;拿到真实导出后先跑 strict dry-run再跑 `pb:import:json``pb:import:validate` 和业务抽样
- 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。 - 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。
4. 部署配置 4. 部署配置
@@ -233,6 +233,6 @@
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、个人中心任务系统、背单词更细统计、小程序支付容器、分享场景和状态管理。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、个人中心任务系统、背单词更细统计、小程序支付容器、分享场景和状态管理。
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。 4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移 5. 导出现有 PocketBase 数据,`docs/refactor/pocketbase-real-data-migration-runbook.md` 做完整 dry-run、导入演练、校验和抽样验收
6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -0,0 +1,250 @@
# PocketBase 真实数据迁移验收 Runbook
更新时间2026-06-30
这份文档用于把旧 PocketBase 生产数据迁移到新的 Supabase/PostgreSQL 多租户题库 SaaS。目标不是简单把 JSON 塞进新库,而是证明旧系统的用户、题库、订单、权益、学习数据和运营配置进入新模型后仍能支撑业务上线。
## 适用范围
本流程适用于正式切换前的迁移演练、预生产验收和最终切换。旧 PocketBase/React 项目只作为导出来源和功能参照,新系统以 `apps/api``apps/worker``supabase/migrations``apps/taro` 为准。
迁移覆盖:
- 用户、租户成员、学生资料、身份映射。
- 地区、入口、分类、科目、题目、题目版本、题库集合、练习蓝图。
- 错题、收藏、最近练习、背单词进度和收藏。
- 单词、知识手册、分数线、题目视频、资料资源台账。
- 订单、支付、权益、SVIP 套餐、激活码、优惠券。
- Banner、公告、FAQ、考试日期、勋章、CRM、推广关系和统计数据。
## 迁移原则
- 不直接复用 PocketBase 的不规范字段作为长期模型,全部经过导入器规范化。
-`id` 必须保留到 `legacy_id` 或相关 legacy 字段,方便追溯和二次修复。
- 所有租户内数据必须带 `tenant_id`,不得出现跨租户共享业务行。
- 密码、token、secret、openid、unionid、短信验证码、支付密钥等不得进入 public schema。
- 私有资料、PDF、题图、视频不能变成长效 URL必须进入 `content_assets` 台账并由后端签名。
- 导入前 dry-run 有 blocker 时禁止正式导入;上线切换前 `pb:import:validate` 不能有 FAIL。
## 环境准备
本地或预生产环境需要:
- Node.js 20+
- Docker Desktop
- Supabase CLI
- 已执行最新数据库迁移
建议使用独立迁移验收库,不要混用日常开发 smoke 数据库。迁移演练开始前:
```bash
npm install
npm run supabase:start
npm run supabase:reset
```
如果要验证 API 主链路,可以在真实数据导入后再补充最小管理账号或临时测试账号。不要在真实迁移验收库中先跑 `npm run db:smoke-seed`,否则统计和抽样验收会被模拟数据污染。
## 导出目录
把 PocketBase 导出的集合 JSON 放到仓库根目录:
```text
pb_export/
users.json
regions.json
region_modules.json
module_nodes.json
subjects.json
categories.json
questions.json
orders.json
svip_plans.json
codes.json
code_batches.json
vocabulary_units.json
vocabulary.json
handbook_subjects.json
handbook_chapters.json
handbook_entries.json
scoreline_schools.json
scoreline_majors.json
scoreline_fields.json
scoreline_records.json
video_explanations.json
question_videos.json
```
可以用 `PB_EXPORT_DIR` 指向其他目录:
```powershell
$env:PB_EXPORT_DIR="F:\migration\pb_export_20260630"
npm run pb:import:dry-run
```
## 阶段 1静态 Dry-Run
先运行不写数据库的静态报告:
```bash
npm run pb:import:dry-run
```
生成机器可读报告:
```bash
npm run pb:import:dry-run -- --json > migration-dry-run-report.json
```
严格模式会把 warning 也作为阻断条件,建议预生产验收和 CI 使用:
```bash
npm run pb:import:dry-run -- --json --fail-on-warnings
```
dry-run 会检查:
- 导出目录是否存在。
- JSON 是否可解析,集合是否是数组或 `{ items: [] }` / `{ records: [] }`
- 核心集合是否缺失。
- 旧记录 `id` 是否缺失或重复。
- `docs/pb_schema.json` 中的 relation 是否能解析到导出数据。
- 敏感字段是否出现在旧导出中。
- 未映射集合是否需要补 mapper。
- 用户、题目、订单、SVIP、激活码、单词、手册、分数线、视频等业务数量。
准入标准:
- `blockers = 0`
- 正式切换前建议 `warnings = 0`;如确有历史脏数据,需要记录处理结论、影响范围和接受人。
- `businessCounts` 与旧后台统计口径差异必须能解释。
## 阶段 2正式导入演练
确认目标租户:
```powershell
$env:TENANT_ID="00000000-0000-0000-0000-000000000001"
$env:TENANT_SLUG="master"
$env:TENANT_NAME="工学题库主租户"
```
执行导入:
```bash
npm run pb:import:json
```
默认不会把旧系统密钥值写入新库,只会记录脱敏和问题项。只有在迁移受控密钥到 `app_private.tenant_secrets` 时才允许临时打开:
```powershell
$env:IMPORT_SECRET_VALUES="true"
npm run pb:import:json
Remove-Item Env:\IMPORT_SECRET_VALUES
```
密钥导入后必须立即执行生产就绪检查和人工抽查,确保 public 表不含密钥明文。
## 阶段 3导入后校验
导入完成后运行:
```bash
npm run pb:import:validate
```
正式切换前建议使用 warning 阻断:
```powershell
$env:FAIL_ON_WARNINGS="true"
npm run pb:import:validate
Remove-Item Env:\FAIL_ON_WARNINGS
```
必须通过的关键检查:
- 租户存在。
- 最新一次导入的核心 raw records 已规范化。
- 每道题都有 current version。
- `question_versions.tenant_id` 与题目一致。
- 已支付订单有 payment 行。
- 权益关联到有效用户。
- `platform_users.raw_profile` 不含敏感身份字段。
- `tenant_settings.public_config` 不含密钥。
- `crm_config.secret_ref` 只引用 `app_private.tenant_secrets`
## 阶段 4抽样验收
每次演练都应至少抽样下面数据:
| 范围 | 抽样建议 | 验收点 |
| --- | --- | --- |
| 用户 | 随机 20 个学生、5 个管理员/销售/教师 | 手机号、昵称、角色、禁用状态、地区/院校目标、会员状态 |
| 题库 | 每个地区至少 2 个入口,每个入口抽 2 条路径 | 入口、分类层级、考试意向标记、题目数量 |
| 题目 | 每种题型至少 10 道,含阅读理解/案例分析 | 题干、选项、答案、解析、子题、图片/公式、难度、标签 |
| 练习 | 顺序、随机、全真模拟各 3 次 | 组卷、答题、判分、错题、收藏、报告、复盘 |
| 单词 | 每个地区/科目抽 2 个单元 | 单词、音标、释义、例句、收藏、进度 |
| 知识手册 | 每个手册抽 2 个章节 | Markdown、图片、公式、目录、权限 |
| 分数线 | 每个地区抽 2 所学校、2 个专业 | 年份、动态字段、趋势查询 |
| 视频 | 抽 20 道带视频题 | 权限、次数扣减、签名 URL、水印 traceId、播放日志 |
| 资料 | 抽 PDF/图片各 10 个 | `content_assets` 台账、扫描状态、预览/下载短签名、水印 |
| 订单权益 | 抽 20 个付费订单、20 个激活码 | 订单状态、payment、entitlement、地区/范围、过期时间 |
| 营销 | 抽优惠券、激活码批次、勋章 | 规则、核销记录、发放记录 |
| CRM/推广 | 抽销售、代理、自然流用户 | 首绑保护、推广来源、队列 payload、分佣归因 |
抽样结果建议保存到 `docs/refactor/migration-reports/`。真实数据报告可能包含业务敏感信息提交前必须脱敏如包含用户手机号、订单号、openid、支付流水号不要提交到 Git。
## 阶段 5API 与 Taro 联调验收
迁移数据通过校验后,再启动 API
```bash
npm run dev:api
```
前端联调至少跑通:
- 学生 H5登录、首页、地区选择、题库入口、刷题、错题、收藏、背单词、知识手册、分数线、资料、视频、会员、订单、激活码、个人中心。
- 租户后台 H5数据看板、学生管理、题库内容、导入、公共题库采纳/同步、营销、财务、主题、权限、成员。
- 平台后台 H5租户、套餐、订阅、账单、用量、公共题库授权。
前端只允许通过 `apps/api` 获取业务数据和签名资源。不要在前端直接访问 Supabase 表或拼接对象存储私有 URL。
## 阶段 6最终切换前冻结
正式切换窗口建议:
1. 公告维护窗口。
2. 旧 PocketBase 进入只读或暂停写入。
3. 导出最终 JSON。
4. 执行 strict dry-run。
5. 重置目标生产库或清理目标租户迁移数据。
6. 执行正式导入。
7. 执行 `pb:import:validate` strict 模式。
8. 抽样验收核心链路。
9. 切换域名/API 配置。
10. 保留旧系统只读快照,至少覆盖一个完整售后周期。
## 回滚策略
切换后如果发现阻断级问题:
- 立即停止新系统写入或进入维护模式。
- 保留新库快照和 API 日志,便于定位导入器或业务 API 问题。
- 将域名/API 流量切回旧 PocketBase 只读或旧生产服务。
- 修复 mapper 或数据清洗规则后重新跑 dry-run、导入和抽样验收。
不要在问题未定位时手工批量修改生产表。所有批量修复应沉淀为可重复脚本或 importer mapper 修复,并保留审计记录。
## 上线准出标准
满足以下条件后,才建议进入生产切换:
- `npm run pb:import:dry-run -- --json --fail-on-warnings` 通过,或全部 warning 有签字确认的处理结论。
- `npm run pb:import:validate` 无 FAIL生产切换前 strict 模式无 WARN或 WARN 已确认。
- 核心业务抽样通过,尤其是题目答案解析、会员权益、订单支付、错题收藏、资料视频权限。
- `npm run readiness:production``npm run readiness:production:db` 通过。
- API/Taro 三端核心链路在迁移数据上跑通。
- 对象存储、短信、OAuth、支付、CRM webhook 使用生产 provider 或明确标记为暂不开启。
- 已准备数据库备份、旧系统快照、回滚步骤和负责人。

View File

@@ -1,5 +1,6 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url';
export interface PocketBaseField { export interface PocketBaseField {
name: string; name: string;
@@ -24,8 +25,20 @@ export interface PocketBaseCollection {
deleteRule?: string; deleteRule?: string;
} }
export function readPocketBaseSchema(schemaPath = process.env.PB_SCHEMA_PATH || '../../docs/pb_schema.json') { function defaultSchemaPath() {
const absPath = path.resolve(process.cwd(), schemaPath); const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.resolve(process.cwd(), process.env.PB_SCHEMA_PATH || ''),
path.resolve(process.cwd(), '../../docs/pb_schema.json'),
path.resolve(process.cwd(), 'docs/pb_schema.json'),
path.resolve(moduleDir, '../../../docs/pb_schema.json'),
].filter(candidate => candidate && candidate !== process.cwd());
return candidates.find(candidate => fs.existsSync(candidate)) || candidates[candidates.length - 1];
}
export function readPocketBaseSchema(schemaPath = process.env.PB_SCHEMA_PATH || '') {
const absPath = schemaPath ? path.resolve(process.cwd(), schemaPath) : defaultSchemaPath();
const raw = JSON.parse(fs.readFileSync(absPath, 'utf8')); const raw = JSON.parse(fs.readFileSync(absPath, 'utf8'));
const collections = (Array.isArray(raw) ? raw : raw.collections || []) as PocketBaseCollection[]; const collections = (Array.isArray(raw) ? raw : raw.collections || []) as PocketBaseCollection[];
const byId = new Map(collections.map(c => [c.id, c])); const byId = new Map(collections.map(c => [c.id, c]));

View File

@@ -11,13 +11,14 @@ function writeJson(filePath, value) {
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
} }
function runDryRun(exportDir) { function runDryRun(exportDir, options = {}) {
const result = spawnSync(process.execPath, ['--import', 'tsx', dryRunScript, '--json'], { const result = spawnSync(process.execPath, ['--import', 'tsx', dryRunScript, '--json', ...(options.args || [])], {
cwd: repoRoot, cwd: repoRoot,
encoding: 'utf8', encoding: 'utf8',
env: { env: {
...process.env, ...process.env,
PB_EXPORT_DIR: exportDir, PB_EXPORT_DIR: exportDir,
...(options.env || {}),
}, },
}); });
@@ -88,6 +89,22 @@ function createUnsafeExport() {
return dir; return dir;
} }
function createWarningOnlyExport() {
const dir = createSafeExport();
writeJson(path.join(dir, 'unknown_business.json'), [
{ id: 'x1', name: '尚未映射集合' },
]);
return dir;
}
function createBrokenRelationExport() {
const dir = createSafeExport();
writeJson(path.join(dir, 'questions.json'), [
{ id: 'q1', subjectId: 'missing-subject', categoryId: 'c1', type: 'choice', content: '题干', answer: 'A' },
]);
return dir;
}
const safeDir = createSafeExport(); const safeDir = createSafeExport();
const safe = runDryRun(safeDir); const safe = runDryRun(safeDir);
assert.equal(safe.status, 0, `safe dry-run should pass: ${safe.stdout} ${safe.stderr}`); assert.equal(safe.status, 0, `safe dry-run should pass: ${safe.stdout} ${safe.stderr}`);
@@ -115,8 +132,29 @@ assert.ok(
unsafe.payload.issues?.some(item => item.code === 'unsupported_collection'), unsafe.payload.issues?.some(item => item.code === 'unsupported_collection'),
'unsafe dry-run should warn about unsupported collections', 'unsafe dry-run should warn about unsupported collections',
); );
assert.ok(
unsafe.payload.issues?.some(item => item.code === 'relation_target_export_missing' && item.collection === 'questions'),
'unsafe dry-run should block when question relation target export is missing',
);
const warningOnlyDir = createWarningOnlyExport();
const warningOnly = runDryRun(warningOnlyDir);
assert.equal(warningOnly.status, 0, 'warning-only dry-run should pass without fail-on-warnings');
assert.ok(warningOnly.payload.summary?.warnings > 0, 'warning-only dry-run should report warnings');
const warningAsFailure = runDryRun(warningOnlyDir, { args: ['--fail-on-warnings'] });
assert.notEqual(warningAsFailure.status, 0, 'warning-only dry-run should fail with --fail-on-warnings');
const brokenRelationDir = createBrokenRelationExport();
const brokenRelation = runDryRun(brokenRelationDir);
assert.notEqual(brokenRelation.status, 0, 'broken relation dry-run should fail');
assert.ok(
brokenRelation.payload.issues?.some(item => item.code === 'relation_unresolved' && item.collection === 'questions'),
'broken relation dry-run should detect unresolved question subject relation',
);
fs.rmSync(safeDir, { recursive: true, force: true }); fs.rmSync(safeDir, { recursive: true, force: true });
fs.rmSync(unsafeDir, { recursive: true, force: true }); fs.rmSync(unsafeDir, { recursive: true, force: true });
fs.rmSync(warningOnlyDir, { recursive: true, force: true });
fs.rmSync(brokenRelationDir, { recursive: true, force: true });
console.log('[PASS] PocketBase dry-run report'); console.log('[PASS] PocketBase dry-run report');