feat: add pocketbase sqlite exporter

This commit is contained in:
Codex
2026-06-30 09:30:00 +08:00
parent 959e612211
commit 2510d736a4
10 changed files with 841 additions and 11 deletions

8
.gitignore vendored
View File

@@ -39,6 +39,14 @@ scripts/satellite/sync-config.json
stash_comparison.txt
stash_diff.txt
.local-storage/
pb_export/
pb_export_*/
migration-dry-run-report*.json
sqlite-export-manifest*.json
storage-manifest*.json
# ✅ 真实数据迁移报告(可能含业务敏感信息)
docs/refactor/migration-reports/
# ✅ 生产上线验收证据(可能包含内部域名、抽样说明或敏感运维信息)
docs/refactor/production-launch-evidence.json

View File

@@ -404,7 +404,26 @@ docs/refactor/production-launch-evidence.template.json
## PocketBase 迁移 Dry-Run
把旧 PocketBase 导出的集合 JSON 放到仓库根目录 `pb_export/` 后,先执行不写数据库的静态 dry-run
如果旧数据源是 SQLite先从旧 PocketBase 数据目录只读导出业务 collection。默认读取 `F:\project\参考\旧题库数据库文件`,输出到已被 `.gitignore` 覆盖的 `pb_export/`
```powershell
$env:PB_SQLITE_DIR="F:\project\参考\旧题库数据库文件"
$env:PB_EXPORT_DIR="F:\project\pb_export"
npm run pb:export:sqlite
```
导出会生成:
```text
pb_export/*.json 各 PocketBase collection 的普通业务 JSON
pb_export/sqlite-export-manifest.json
pb_export/storage-manifest.json
pb_export/pb_schema.sqlite.json
```
默认导出会移除 `password/token/secret/sessionKey/openid/unionid` 等敏感身份或密钥字段,只在 manifest 中记录脱敏字段数量;手机号等迁移必需字段会保留。不要把 `pb_export/`、manifest 或真实迁移报告提交到 Git。
把旧 PocketBase 导出的集合 JSON 放到仓库根目录 `pb_export/` 后,执行不写数据库的静态 dry-run
```bash
npm run pb:import:dry-run
@@ -420,6 +439,13 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失
默认 dry-run 使用 `development` profile正式迁移、预生产验收和 CI 应使用 `production` profile。生产 profile 会额外输出 `migrationReadiness`,检查用户、题目、科目、分类、订单、套餐、激活码、单词和知识手册等必需集合,以及用户手机号、题目归属、订单套餐、激活码、单词和手册归属等关键字段覆盖率。`--profile` 只接受 `development``production`,拼写错误会按 blocker 失败。
当前真实 SQLite 基线已经跑通只读导出58 个业务 collection、248555 条记录、9 个 storage 原始资源文件。production dry-run 目前仍有 2 个真实数据 blocker30 个订单缺用户、22 个知识手册章节缺所属手册;还有 `user_answer_records``mock_exam_configs``referral_qrcodes``commission_settings` 等 mapper gap详见
```text
docs/refactor/pocketbase-real-data-migration-runbook.md
docs/refactor/next-development-todo.md
```
真实生产数据迁移不要只看命令是否能跑完,需要按迁移验收 runbook 执行 dry-run、正式导入演练、导入后校验、业务抽样、Taro 联调、冻结切换和回滚准备:
```text

View File

@@ -74,7 +74,11 @@
3. 真实导入 dry-run
- 从 PocketBase 导出现有用户、题库、单词、知识手册、分数线、订单、权益数据。
- 已补 `npm run pb:import:dry-run` 静态迁移报告工具、`--profile=production` 生产迁移门禁、关键集合/关键字段覆盖率检查、strict warning 门禁测试和真实数据迁移验收 runbook拿到真实导出后先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,再跑 `pb:import:json``pb:import:validate` 和业务抽样
- 已补 `npm run pb:export:sqlite` 只读 SQLite 导出工具,默认从 `F:\project\参考\旧题库数据库文件\data.db` 导出到 `.gitignore` 覆盖的 `pb_export/`,并生成 `sqlite-export-manifest.json``storage-manifest.json` 和脱敏统计
- 已补 `npm run pb:import:dry-run` 静态迁移报告工具、`--profile=production` 生产迁移门禁、关键集合/关键字段覆盖率检查、strict warning 门禁测试和真实数据迁移验收 runbook真实 SQLite 已导出 58 个业务 collection、248555 条记录,并跑过 production dry-run。
- 当前真实 dry-run 剩余 blocker30 个订单缺 `userId`,其中 7 个 paid22 个知识手册章节缺 `subjectId`。正式导入前必须补自动修复/隔离 mapper 和人工复核报告。
- 当前真实 dry-run 剩余 mapper gap`user_answer_records` 85442 条、`mock_exam_configs` 48 条、`referral_qrcodes` 79 条、`commission_settings` 1 条。它们分别对应学习历史/错题归因、全真模拟蓝图、推广码/小程序码、分佣设置,后续要进入标准化导入。
- 处理完 blocker 后再跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,再跑 `pb:import:json``pb:import:validate` 和业务抽样。
- 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。
4. 部署配置

View File

@@ -56,7 +56,7 @@ F:\project\参考\旧题库数据库文件
storage/
```
`data.db``auxiliary.db` 必须按只读源处理,不能直接在旧库上执行修复 SQL。后续应由本地转换脚本把 PocketBase SQLite 集合导出为规范 JSON再进入下面的 dry-run/import 管线;`storage/` 中的附件、题图、PDF、视频封面等资源同步生成资源迁移清单,最终进入 `content_assets` 和对象存储,不允许把旧本地路径或长效 URL 直接写给前端。
`data.db``auxiliary.db` 必须按只读源处理,不能直接在旧库上执行修复 SQL。当前已补 `npm run pb:export:sqlite`,会用只读 SQLite 连接把 PocketBase collection 导出为规范 JSON再进入下面的 dry-run/import 管线;`storage/` 中的附件、题图、PDF、视频封面等资源同步生成资源迁移清单,最终进入 `content_assets` 和对象存储,不允许把旧本地路径或长效 URL 直接写给前端。
把 PocketBase 导出的集合 JSON 放到仓库根目录:
@@ -93,20 +93,61 @@ $env:PB_EXPORT_DIR="F:\migration\pb_export_20260630"
npm run pb:import:dry-run
```
如果输入源仍是 SQLite而不是 JSON 目录,下一阶段需要先补 `scripts/import-pocketbase` 的 SQLite 只读导出命令,建议输出到仓库外或 `.gitignore` 覆盖的 `pb_export/`
如果输入源仍是 SQLite而不是 JSON 目录,先执行 `scripts/import-pocketbase` 的 SQLite 只读导出命令,建议输出到仓库外或 `.gitignore` 覆盖的 `pb_export/`
```powershell
$env:PB_SQLITE_DIR="F:\project\参考\旧题库数据库文件"
$env:PB_EXPORT_DIR="F:\project\pb_export"
# 规划命令npm run pb:export:sqlite
npm run pb:export:sqlite
```
导出脚本要求
导出脚本行为
- 使用只读 SQLite 连接,禁止修改 `data.db``auxiliary.db``storage/`
- 每个 PocketBase collection 输出一个 JSON 文件,并保留旧 `id``created``updated`、relation 字段和文件字段。
- `users`、订单、支付、openid/unionid、手机号等敏感字段默认脱敏写 dry-run 报告;正式导入只在后端 mapper 中进入受控身份/订单/审计表
- 资源文件字段只输出相对路径、hash、size、mime 和旧 collection/record/file 三元组,后续由对象存储迁移步骤转为 `content_assets`
- `password``tokenKey``secret``wechatSessionKey`、短信验证码等密钥类字段默认不会写入普通 JSON`openid/unionid` 默认也会移除,只在 `sqlite-export-manifest.json` 里记录字段和数量。后续如果确实要迁移第三方登录身份,必须走单独的加密身份迁移链路,不混入普通 `pb_export`
- 手机号、邮箱、业务旧 ID、订单、题目、学习记录等迁移必需字段会保留用于自营 ToC 租户的数据归属和售后追溯
- 资源清单输出到 `storage-manifest.json`包含相对路径、hash、size、mime 和旧 collection/record/file 三元组,后续由对象存储迁移步骤转为 `content_assets`
- `auxiliary.db` 默认只写 `_logs` 的数量和 level 摘要,不导出完整日志正文,避免把 UA、请求参数或历史 token 放进迁移数据包。
可选环境变量:
```powershell
$env:PB_SQLITE_DATA_DB="F:\project\参考\旧题库数据库文件\data.db"
$env:PB_SQLITE_AUX_DB="F:\project\参考\旧题库数据库文件\auxiliary.db"
$env:PB_SQLITE_STORAGE_DIR="F:\project\参考\旧题库数据库文件\storage"
$env:PB_SQLITE_COLLECTIONS="users,regions,subjects,categories,questions"
$env:PB_SQLITE_BATCH_SIZE="2000"
```
正常迁移不要设置 `PB_SQLITE_INCLUDE_SENSITIVE_IDENTITIES=true`。这个开关只允许在加密迁移工作区临时使用,并且导出的身份文件不得提交 Git。
当前真实库只读导出基线:
```text
data.db: 58 个业务 collection248555 条记录
questions: 74102
users: 3670
orders: 636
user_answer_records: 85442
vocabulary: 3500
handbook_entries: 2676
storage-manifest: 9 个原始资源文件
auxiliary.db: _logs 121715 条,仅摘要
```
最近一次 production dry-run 仍有 2 个 blocker需要在正式导入前处理
- `orders.userId` 覆盖率 95.3%30 个订单缺用户,其中 7 个为已支付订单。处理策略应优先从支付通知、手机号、订单号或人工售后记录找回用户;找不回的已支付订单必须进入人工异常台账,不允许静默开权益。
- `handbook_chapters.subjectId` 覆盖率 94.4%22 个章节缺所属手册。处理策略应按章节标题和条目归属归并到正确 `handbook_subjects`,无法归并的放入迁移隔离手册并标记待人工复核。
production dry-run 还有这些 warning属于后续 mapper 或运营处理项:
- `user_answer_records` 85442 条尚未规范化到新练习历史/答题记录模型。
- `mock_exam_configs` 48 条尚未规范化到 `practice_blueprints`
- `referral_qrcodes` 79 条尚未规范化到新推广码/小程序码模型。
- `commission_settings` 1 条需要迁移到新分佣设置。
- `dashboard_cache`、空 `customer_messages/smscodes/tenant_config` 可不作为正式数据源,必要时只保留审计摘要。
## 阶段 1静态 Dry-Run
@@ -150,6 +191,7 @@ dry-run 会检查:
- 用户、题目、订单、SVIP、激活码、单词、手册、分数线、视频等业务数量。
- `production` profile 会额外检查生产迁移必需集合:`users``questions``subjects``categories``orders``svip_plans``codes``vocabulary_units``vocabulary``handbook_subjects``handbook_chapters``handbook_entries`
- `migrationReadiness.criticalFieldCoverage` 会统计关键字段覆盖率,例如 `users.phone``questions.subjectId/categoryId/content``orders.userId/planId/status``codes.code`、单词和手册的归属字段;生产模式下低于阈值会变成 blocker。
- 对 SQLite 导出,`questions.categoryId` 会把 `nodeId` 视为新架构有效归属,`vocabulary.unitId` 会兼容旧字段 `unit`
准入标准:

View File

@@ -54,6 +54,7 @@
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
"test:launch-gate": "node scripts/production-launch-gate-test.js",
"test:pb:dry-run": "node scripts/pb-dry-run-report-test.js",
"test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js",
"readiness:production": "node scripts/production-readiness-check.js --skip-db",
"readiness:production:db": "node scripts/production-readiness-check.js --check-db",
"launch:gate": "node scripts/production-launch-gate.js",
@@ -65,6 +66,7 @@
"build:taro:h5:platform": "npm --workspace @tiku-saas/taro run build:h5:platform",
"pb:schema:summary": "npm --workspace @tiku-saas/import-pocketbase run schema:summary",
"pb:schema:risk": "npm --workspace @tiku-saas/import-pocketbase run schema:risk",
"pb:export:sqlite": "npm --workspace @tiku-saas/import-pocketbase run export:sqlite --",
"pb:import:dry-run": "npm --workspace @tiku-saas/import-pocketbase run import:dry-run --",
"pb:import:json": "npm --workspace @tiku-saas/import-pocketbase run import:json",
"pb:import:validate": "npm --workspace @tiku-saas/import-pocketbase run import:validate"

View File

@@ -6,6 +6,7 @@
"scripts": {
"schema:summary": "tsx src/analyze-schema.ts",
"schema:risk": "tsx src/risk-report.ts",
"export:sqlite": "tsx src/export-sqlite.ts",
"import:dry-run": "tsx src/dry-run-report.ts",
"import:json": "tsx src/import-json.ts",
"import:validate": "tsx src/validate-import.ts",

View File

@@ -81,6 +81,11 @@ const migrationProfile: MigrationProfile = migrationProfileInput === 'production
const migrationProfileInvalid = !validMigrationProfiles.has(migrationProfileInput);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const exportDir = path.resolve(repoRoot, process.env.PB_EXPORT_DIR || 'pb_export');
const nonCollectionJsonFiles = new Set([
'pb_schema.sqlite.json',
'sqlite-export-manifest.json',
'storage-manifest.json',
]);
const supportedCollections = new Set([
'announcements',
@@ -191,13 +196,13 @@ const criticalFieldRules = [
{ collection: 'users', field: 'phone', requiredRatio: 0.8 },
{ collection: 'questions', field: 'id', requiredRatio: 1 },
{ collection: 'questions', field: 'subjectId', requiredRatio: 0.95 },
{ collection: 'questions', field: 'categoryId', requiredRatio: 0.8 },
{ collection: 'questions', field: 'categoryId', requiredRatio: 0.8, alternatives: ['nodeId'] },
{ collection: 'questions', field: 'content', requiredRatio: 0.95, alternatives: ['question', 'title', 'stem'] },
{ collection: 'orders', field: 'userId', requiredRatio: 0.98 },
{ collection: 'orders', field: 'planId', requiredRatio: 0.8 },
{ collection: 'orders', field: 'status', requiredRatio: 0.95 },
{ collection: 'codes', field: 'code', requiredRatio: 0.98 },
{ collection: 'vocabulary', field: 'unitId', requiredRatio: 0.95 },
{ collection: 'vocabulary', field: 'unitId', requiredRatio: 0.95, alternatives: ['unit'] },
{ collection: 'vocabulary', field: 'word', requiredRatio: 0.98 },
{ collection: 'handbook_chapters', field: 'subjectId', requiredRatio: 0.95 },
{ collection: 'handbook_entries', field: 'chapterId', requiredRatio: 0.95 },
@@ -331,6 +336,9 @@ function validateRelations(
for (const record of records) {
for (const value of fieldValues(record, field)) {
if (!targetIds.has(value)) {
if (collection.name === 'questions' && field.name === 'categoryId' && idIndex.get('module_nodes')?.has(value)) {
continue;
}
unresolved += 1;
if (!sample) sample = `${text(record.id) || '(missing id)'} -> ${value}`;
}
@@ -445,7 +453,12 @@ function buildReport(): DryRunReport {
}
const schema = loadSchema(issues);
const collectionByName = schema?.byName || new Map<string, PocketBaseCollection>();
const files = fs.existsSync(exportDir) ? fs.readdirSync(exportDir).filter(file => file.toLowerCase().endsWith('.json')) : [];
const files = fs.existsSync(exportDir)
? fs
.readdirSync(exportDir)
.filter(file => file.toLowerCase().endsWith('.json'))
.filter(file => !nonCollectionJsonFiles.has(file.toLowerCase()))
: [];
const collections: CollectionReport[] = [];
const recordsByCollection = new Map<string, JsonRecord[]>();
const idIndex = new Map<string, Set<string>>();

View File

@@ -0,0 +1,113 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { loadEnv } from './env.js';
loadEnv();
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(moduleDir, '../../..');
function argValue(name: string) {
const prefix = `${name}=`;
return process.argv.slice(2).find(arg => arg.startsWith(prefix))?.slice(prefix.length);
}
function hasArg(name: string) {
return process.argv.slice(2).includes(name);
}
function resolvePath(input: string, fallbackBase = process.cwd()) {
return path.resolve(fallbackBase, input);
}
function findPython() {
const candidates = [
process.env.PYTHON,
process.env.PYTHON_BIN,
'python',
'py',
'python3',
].filter(Boolean) as string[];
for (const candidate of candidates) {
const result = spawnSync(candidate, ['--version'], { encoding: 'utf8' });
if (result.status === 0) return candidate;
}
throw new Error('Python 3 is required for SQLite export. Set PYTHON or install Python 3.');
}
const sqliteDir = resolvePath(
argValue('--sqlite-dir') || process.env.PB_SQLITE_DIR || path.join(repoRoot, '参考', '旧题库数据库文件'),
);
const dataDb = resolvePath(argValue('--data-db') || process.env.PB_SQLITE_DATA_DB || path.join(sqliteDir, 'data.db'));
const auxDb = resolvePath(argValue('--aux-db') || process.env.PB_SQLITE_AUX_DB || path.join(sqliteDir, 'auxiliary.db'));
const storageDir = resolvePath(
argValue('--storage-dir') || process.env.PB_SQLITE_STORAGE_DIR || path.join(sqliteDir, 'storage'),
);
const exportDir = resolvePath(argValue('--export-dir') || process.env.PB_EXPORT_DIR || path.join(repoRoot, 'pb_export'));
const collections = argValue('--collections') || process.env.PB_SQLITE_COLLECTIONS || '';
const batchSize = argValue('--batch-size') || process.env.PB_SQLITE_BATCH_SIZE || '1000';
const includeSystem = hasArg('--include-system') || process.env.PB_SQLITE_INCLUDE_SYSTEM === 'true';
const includeSensitiveIdentities =
hasArg('--include-sensitive-identities') || process.env.PB_SQLITE_INCLUDE_SENSITIVE_IDENTITIES === 'true';
const includeAuxLogs = hasArg('--include-aux-logs') || process.env.PB_SQLITE_INCLUDE_AUX_LOGS === 'true';
if (!fs.existsSync(dataDb)) {
throw new Error(`PocketBase SQLite data.db not found: ${dataDb}`);
}
if (!includeSensitiveIdentities) {
console.warn('[pb:export:sqlite] openid/unionid/session-like fields are redacted in JSON export by default.');
}
console.warn(`[pb:export:sqlite] reading SQLite source in read-only mode: ${dataDb}`);
console.warn(`[pb:export:sqlite] writing collection JSON to: ${exportDir}`);
const python = findPython();
const script = path.join(moduleDir, 'sqlite-exporter.py');
const args = [
script,
'--data-db',
dataDb,
'--export-dir',
exportDir,
'--batch-size',
batchSize,
];
if (fs.existsSync(auxDb)) args.push('--aux-db', auxDb);
if (fs.existsSync(storageDir)) args.push('--storage-dir', storageDir);
if (collections) args.push('--collections', collections);
if (includeSystem) args.push('--include-system');
if (includeSensitiveIdentities) args.push('--include-sensitive-identities');
if (includeAuxLogs) args.push('--include-aux-logs');
const result = spawnSync(python, args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.stderr) process.stderr.write(result.stderr);
if (result.stdout) {
try {
const payload = JSON.parse(result.stdout);
console.log(
[
'[pb:export:sqlite] export completed',
`collections=${payload.collectionCount}`,
`records=${payload.recordCount}`,
`manifest=${path.join(exportDir, 'sqlite-export-manifest.json')}`,
payload.storage ? `storageFiles=${payload.storage.fileCount}` : 'storageFiles=0',
].join(' '),
);
} catch {
process.stdout.write(result.stdout);
}
}
if (result.error) throw result.error;
if (result.status !== 0) {
process.exit(result.status || 1);
}

View File

@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""Read-only PocketBase SQLite exporter.
This script is intentionally small and dependency-free so it works on Windows
developer machines without compiling a Node SQLite native module. The TypeScript
wrapper owns CLI ergonomics; this script owns the SQLite cursor work.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import mimetypes
import os
import pathlib
import sqlite3
import sys
from datetime import datetime, timezone
from typing import Any
SYSTEM_COLLECTIONS = {
"_authOrigins",
"_externalAuths",
"_mfas",
"_otps",
"_params",
"_superusers",
}
SYSTEM_TABLE_PREFIXES = ("sqlite_",)
SENSITIVE_KEY_PARTS = (
"password",
"token",
"secret",
"privatekey",
"sessionkey",
"accesskey",
"appkey",
"apikey",
"api_v3_key",
"notifytoken",
"aeskey",
"wxaccesstoken",
"wechatsessionkey",
"smscode",
"verifycode",
"verificationcode",
"captcha",
)
OPTIONAL_IDENTITY_KEY_PARTS = (
"openid",
"unionid",
)
JSONISH_STARTS = ("{", "[")
SKIP_FIELD = object()
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Export PocketBase SQLite collections to JSON.")
parser.add_argument("--data-db", required=True, help="Path to PocketBase data.db")
parser.add_argument("--aux-db", default="", help="Optional path to PocketBase auxiliary.db")
parser.add_argument("--storage-dir", default="", help="Optional path to PocketBase storage directory")
parser.add_argument("--export-dir", required=True, help="Output directory for collection JSON files")
parser.add_argument("--collections", default="", help="Comma-separated collection allowlist")
parser.add_argument("--include-system", action="store_true", help="Include PocketBase system collections")
parser.add_argument(
"--include-sensitive-identities",
action="store_true",
help="Keep openid/unionid fields in collection JSON. Use only in an encrypted migration workspace.",
)
parser.add_argument(
"--include-aux-logs",
action="store_true",
help="Export auxiliary _logs summary and optional samples. Logs are not exported as full records by default.",
)
parser.add_argument("--batch-size", type=int, default=1000, help="SQLite fetch batch size")
return parser.parse_args()
def connect_readonly(db_path: pathlib.Path) -> sqlite3.Connection:
if not db_path.exists():
raise FileNotFoundError(f"SQLite database does not exist: {db_path}")
uri = db_path.resolve().as_posix()
con = sqlite3.connect(f"file:{uri}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
return con
def json_loads(value: Any, fallback: Any) -> Any:
if value is None:
return fallback
if isinstance(value, (dict, list)):
return value
if isinstance(value, (bytes, bytearray)):
try:
value = value.decode("utf-8")
except UnicodeDecodeError:
return fallback
if not isinstance(value, str):
return fallback
text = value.strip()
if not text or not text.startswith(JSONISH_STARTS):
return fallback
try:
return json.loads(text)
except json.JSONDecodeError:
return fallback
def normalize_sqlite_value(value: Any) -> Any:
if isinstance(value, bytes):
return {"__sqliteBlobSha256": hashlib.sha256(value).hexdigest(), "__sqliteBlobSize": len(value)}
if isinstance(value, str):
stripped = value.strip()
if stripped.startswith(JSONISH_STARTS):
try:
return json.loads(stripped)
except json.JSONDecodeError:
return value
return value
def key_has_part(key: str, parts: tuple[str, ...]) -> bool:
lowered = key.lower().replace("_", "").replace("-", "")
return any(part in lowered for part in parts)
def sanitize_value(value: Any, path: list[str], include_sensitive_identities: bool, redactions: list[dict[str, str]]) -> Any:
key = path[-1] if path else ""
is_secret = key_has_part(key, SENSITIVE_KEY_PARTS)
is_optional_identity = key_has_part(key, OPTIONAL_IDENTITY_KEY_PARTS)
if (is_secret or (is_optional_identity and not include_sensitive_identities)) and value not in (None, ""):
redactions.append(
{
"fieldPath": ".".join(path),
"reason": "secret" if is_secret else "optional_identity",
}
)
return SKIP_FIELD
if isinstance(value, dict):
clean: dict[str, Any] = {}
for child_key, child_value in value.items():
sanitized = sanitize_value(child_value, [*path, child_key], include_sensitive_identities, redactions)
if sanitized is not SKIP_FIELD:
clean[child_key] = sanitized
return clean
if isinstance(value, list):
clean_list = []
for index, child_value in enumerate(value):
sanitized = sanitize_value(child_value, [*path, str(index)], include_sensitive_identities, redactions)
if sanitized is not SKIP_FIELD:
clean_list.append(sanitized)
return clean_list
return value
def read_collections(con: sqlite3.Connection) -> list[dict[str, Any]]:
row = con.execute("select name from sqlite_master where type='table' and name='_collections'").fetchone()
if not row:
return []
rows = con.execute('select * from "_collections" order by name').fetchall()
collections: list[dict[str, Any]] = []
for item in rows:
raw = dict(item)
raw["system"] = bool(raw.get("system"))
raw["fields"] = json_loads(raw.get("fields"), [])
raw["indexes"] = json_loads(raw.get("indexes"), [])
raw["options"] = json_loads(raw.get("options"), {})
collections.append(raw)
return collections
def sqlite_tables(con: sqlite3.Connection) -> set[str]:
return {
row["name"]
for row in con.execute("select name from sqlite_master where type='table'").fetchall()
if not any(str(row["name"]).startswith(prefix) for prefix in SYSTEM_TABLE_PREFIXES)
}
def collection_should_export(collection: dict[str, Any], allowlist: set[str] | None, include_system: bool, tables: set[str]) -> bool:
name = str(collection.get("name") or "")
if not name or name not in tables:
return False
if allowlist is not None and name not in allowlist:
return False
if not include_system and (name in SYSTEM_COLLECTIONS or bool(collection.get("system")) or name.startswith("_")):
return False
return True
def table_count(con: sqlite3.Connection, table: str) -> int:
return int(con.execute(f'select count(*) as c from "{table}"').fetchone()["c"])
def write_collection(
con: sqlite3.Connection,
collection: dict[str, Any],
out_path: pathlib.Path,
batch_size: int,
include_sensitive_identities: bool,
) -> dict[str, Any]:
name = str(collection["name"])
count = table_count(con, name)
field_names = [row["name"] for row in con.execute(f'pragma table_info("{name}")').fetchall()]
file_fields = [field.get("name") for field in collection.get("fields", []) if field.get("type") == "file"]
redaction_counts: dict[str, int] = {}
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8", newline="\n") as handle:
handle.write("[\n")
cursor = con.execute(f'select * from "{name}" order by id')
written = 0
while True:
rows = cursor.fetchmany(max(1, batch_size))
if not rows:
break
for row in rows:
record = {field: normalize_sqlite_value(row[field]) for field in field_names}
redactions: list[dict[str, str]] = []
safe_record = sanitize_value(record, [], include_sensitive_identities, redactions)
for redaction in redactions:
key = redaction["fieldPath"]
redaction_counts[key] = redaction_counts.get(key, 0) + 1
if written:
handle.write(",\n")
handle.write(json.dumps(safe_record, ensure_ascii=False, separators=(",", ":")))
written += 1
handle.write("\n]\n")
return {
"name": name,
"collectionId": collection.get("id"),
"type": collection.get("type"),
"system": bool(collection.get("system")),
"recordCount": count,
"fieldCount": len(field_names),
"fields": field_names,
"fileFields": [field for field in file_fields if field],
"outputFile": out_path.name,
"redactedFields": [{"fieldPath": key, "count": value} for key, value in sorted(redaction_counts.items())],
}
def build_storage_manifest(storage_dir: pathlib.Path, collections: list[dict[str, Any]]) -> dict[str, Any] | None:
if not storage_dir or not storage_dir.exists():
return None
collection_by_id = {str(item.get("id")): str(item.get("name")) for item in collections}
files: list[dict[str, Any]] = []
total_size = 0
extension_counts: dict[str, int] = {}
collection_counts: dict[str, int] = {}
for file_path in sorted(storage_dir.rglob("*")):
if not file_path.is_file():
continue
relative = file_path.relative_to(storage_dir).as_posix()
if relative == ".DS_Store" or relative.endswith(".attrs") or "/thumbs_" in relative or relative.startswith("thumbs_"):
continue
parts = pathlib.PurePosixPath(relative).parts
collection_id = parts[0] if len(parts) >= 3 else ""
record_id = parts[1] if len(parts) >= 3 else ""
file_name = parts[-1]
size = file_path.stat().st_size
mime, _ = mimetypes.guess_type(file_name)
ext = file_path.suffix.lower() or "<none>"
total_size += size
extension_counts[ext] = extension_counts.get(ext, 0) + 1
collection_name = collection_by_id.get(collection_id, collection_id or "<unknown>")
collection_counts[collection_name] = collection_counts.get(collection_name, 0) + 1
files.append(
{
"collectionId": collection_id,
"collectionName": collection_name,
"recordId": record_id,
"fileName": file_name,
"relativePath": relative,
"size": size,
"mimeType": mime or "application/octet-stream",
"sha256": sha256_file(file_path),
}
)
return {
"storageDir": str(storage_dir),
"fileCount": len(files),
"totalSize": total_size,
"extensionCounts": dict(sorted(extension_counts.items())),
"collectionCounts": dict(sorted(collection_counts.items())),
"files": files,
}
def sha256_file(file_path: pathlib.Path) -> str:
digest = hashlib.sha256()
with file_path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def auxiliary_summary(aux_db: pathlib.Path, include_logs: bool) -> dict[str, Any] | None:
if not aux_db or not aux_db.exists():
return None
con = connect_readonly(aux_db)
try:
tables = sqlite_tables(con)
summary: dict[str, Any] = {"path": str(aux_db), "tables": []}
for table in sorted(tables):
item: dict[str, Any] = {"name": table, "recordCount": table_count(con, table)}
if table == "_logs":
levels = con.execute('select level, count(*) as count from "_logs" group by level order by level').fetchall()
item["levelCounts"] = {str(row["level"]): int(row["count"]) for row in levels}
if include_logs:
samples = con.execute('select level, message, created from "_logs" order by created desc limit 25').fetchall()
item["recentSamples"] = [dict(sample) for sample in samples]
summary["tables"].append(item)
return summary
finally:
con.close()
def main() -> int:
args = parse_args()
data_db = pathlib.Path(args.data_db)
aux_db = pathlib.Path(args.aux_db) if args.aux_db else None
storage_dir = pathlib.Path(args.storage_dir) if args.storage_dir else None
export_dir = pathlib.Path(args.export_dir)
export_dir.mkdir(parents=True, exist_ok=True)
allowlist = {item.strip() for item in args.collections.split(",") if item.strip()} or None
if args.batch_size < 1:
raise ValueError("--batch-size must be greater than 0")
con = connect_readonly(data_db)
try:
collections = read_collections(con)
tables = sqlite_tables(con)
selected = [
collection
for collection in collections
if collection_should_export(collection, allowlist, args.include_system, tables)
]
collection_reports = []
for collection in selected:
collection_reports.append(
write_collection(
con,
collection,
export_dir / f"{collection['name']}.json",
args.batch_size,
args.include_sensitive_identities,
)
)
schema_payload = {
"source": "pocketbase-sqlite",
"generatedAt": utc_now_iso(),
"collections": collections,
}
(export_dir / "pb_schema.sqlite.json").write_text(
json.dumps(schema_payload, ensure_ascii=False, indent=2),
encoding="utf-8",
newline="\n",
)
storage_manifest = build_storage_manifest(storage_dir, collections) if storage_dir else None
if storage_manifest:
(export_dir / "storage-manifest.json").write_text(
json.dumps(storage_manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
newline="\n",
)
aux = auxiliary_summary(aux_db, args.include_aux_logs) if aux_db else None
manifest = {
"source": "pocketbase-sqlite",
"generatedAt": utc_now_iso(),
"dataDb": str(data_db),
"auxDb": str(aux_db) if aux_db else None,
"storageDir": str(storage_dir) if storage_dir else None,
"exportDir": str(export_dir),
"includeSystemCollections": bool(args.include_system),
"includeSensitiveIdentities": bool(args.include_sensitive_identities),
"collectionCount": len(collection_reports),
"recordCount": sum(int(item["recordCount"]) for item in collection_reports),
"collections": collection_reports,
"skippedCollections": [
{"name": collection.get("name"), "system": bool(collection.get("system")), "type": collection.get("type")}
for collection in collections
if collection not in selected
],
"storage": None
if storage_manifest is None
else {
"manifestFile": "storage-manifest.json",
"fileCount": storage_manifest["fileCount"],
"totalSize": storage_manifest["totalSize"],
"collectionCounts": storage_manifest["collectionCounts"],
"extensionCounts": storage_manifest["extensionCounts"],
},
"auxiliary": aux,
}
(export_dir / "sqlite-export-manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
newline="\n",
)
print(json.dumps(manifest, ensure_ascii=False))
return 0
finally:
con.close()
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # noqa: BLE001 - CLI failure should be explicit.
print(f"[sqlite-exporter] {exc}", file=sys.stderr)
raise SystemExit(1)

View File

@@ -0,0 +1,185 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const repoRoot = process.cwd();
const exportScript = path.join(repoRoot, 'scripts', 'import-pocketbase', 'src', 'export-sqlite.ts');
function findPython() {
for (const candidate of [process.env.PYTHON, process.env.PYTHON_BIN, 'python', 'py', 'python3'].filter(Boolean)) {
const result = spawnSync(candidate, ['--version'], { encoding: 'utf8' });
if (result.status === 0) return candidate;
}
throw new Error('Python 3 is required for pb-sqlite-export-test');
}
function writeFixture(sqliteDir, storageDir) {
const python = findPython();
const script = `
import json
import pathlib
import sqlite3
sqlite_dir = pathlib.Path(r'''${sqliteDir}''')
storage_dir = pathlib.Path(r'''${storageDir}''')
data_db = sqlite_dir / 'data.db'
aux_db = sqlite_dir / 'auxiliary.db'
db = sqlite3.connect(data_db)
db.executescript("""
create table "_collections" (
id text primary key,
system boolean,
type text,
name text,
fields text,
indexes text,
listRule text,
viewRule text,
createRule text,
updateRule text,
deleteRule text,
options text,
created text,
updated text
);
create table "users" (
id text primary key,
created text,
updated text,
phone text,
password text,
tokenKey text,
wechatOpenId text,
wechatUnionId text,
stats text
);
create table "questions" (
id text primary key,
created text,
updated text,
content text,
options text,
media text
);
create table "_superusers" (
id text primary key,
email text,
password text
);
""")
insert_collection = """
insert into "_collections" (
id, system, type, name, fields, indexes, listRule, viewRule, createRule, updateRule, deleteRule, options, created, updated
) values (?, ?, ?, ?, ?, '[]', '', '', '', '', '', '{}', '2026-01-01 00:00:00Z', '2026-01-01 00:00:00Z')
"""
db.execute(insert_collection, ('_pb_users_auth_', 0, 'auth', 'users', json.dumps([
{'name': 'id', 'type': 'text'},
{'name': 'password', 'type': 'password'},
{'name': 'tokenKey', 'type': 'text'},
{'name': 'phone', 'type': 'text'},
{'name': 'wechatOpenId', 'type': 'text'},
{'name': 'wechatUnionId', 'type': 'text'},
{'name': 'stats', 'type': 'json'},
])))
db.execute(insert_collection, ('pbc_questions', 0, 'base', 'questions', json.dumps([
{'name': 'id', 'type': 'text'},
{'name': 'content', 'type': 'text'},
{'name': 'options', 'type': 'json'},
{'name': 'media', 'type': 'file', 'maxSelect': 1},
])))
db.execute(insert_collection, ('pbc_superusers', 1, 'auth', '_superusers', json.dumps([
{'name': 'password', 'type': 'password'},
])))
db.execute(
'insert into users (id, created, updated, phone, password, tokenKey, wechatOpenId, wechatUnionId, stats) values (?, ?, ?, ?, ?, ?, ?, ?, ?)',
('u1', '2026-01-01 00:00:00Z', '2026-01-01 00:00:00Z', '13800000000', 'hash-secret', 'token-secret', 'wx-openid', 'wx-unionid', json.dumps({'answered': 3})),
)
db.execute(
'insert into questions (id, created, updated, content, options, media) values (?, ?, ?, ?, ?, ?)',
('q1', '2026-01-01 00:00:00Z', '2026-01-01 00:00:00Z', '题干', json.dumps(['A', 'B']), 'question.png'),
)
db.execute('insert into _superusers (id, email, password) values (?, ?, ?)', ('su1', 'root@example.com', 'secret'))
db.commit()
db.close()
aux = sqlite3.connect(aux_db)
aux.executescript("""
create table "_logs" (
id text primary key,
level integer not null,
message text not null,
data text not null,
created text not null
);
""")
aux.execute(
'insert into "_logs" (id, level, message, data, created) values (?, ?, ?, ?, ?)',
('l1', 1, 'GET /api/collections/users/records/u1', json.dumps({'token': 'must-not-export'}), '2026-01-01 00:00:00Z'),
)
aux.commit()
aux.close()
media_dir = storage_dir / 'pbc_questions' / 'q1'
media_dir.mkdir(parents=True, exist_ok=True)
(media_dir / 'question.png').write_bytes(b'fixture-image')
(media_dir / 'question.png.attrs').write_text('{}', encoding='utf-8')
`;
const result = spawnSync(python, ['-c', script], { encoding: 'utf8' });
if (result.error) throw result.error;
assert.equal(result.status, 0, `fixture setup should pass\nstdout=${result.stdout}\nstderr=${result.stderr}`);
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-pb-sqlite-export-'));
const sqliteDir = path.join(root, 'pb');
const exportDir = path.join(root, 'export');
const storageDir = path.join(sqliteDir, 'storage');
fs.mkdirSync(storageDir, { recursive: true });
writeFixture(sqliteDir, storageDir);
const result = spawnSync(process.execPath, ['--import', 'tsx', exportScript], {
cwd: repoRoot,
encoding: 'utf8',
env: {
...process.env,
PB_SQLITE_DIR: sqliteDir,
PB_EXPORT_DIR: exportDir,
},
});
assert.equal(result.status, 0, `export should pass\nstdout=${result.stdout}\nstderr=${result.stderr}`);
const users = JSON.parse(fs.readFileSync(path.join(exportDir, 'users.json'), 'utf8'));
assert.equal(users.length, 1, 'users collection should export records');
assert.equal(users[0].phone, '13800000000', 'phone should be kept for migration matching');
assert.equal('password' in users[0], false, 'password should be removed from normal JSON export');
assert.equal('tokenKey' in users[0], false, 'token should be removed from normal JSON export');
assert.equal('wechatOpenId' in users[0], false, 'openid should be removed by default');
assert.deepEqual(users[0].stats, { answered: 3 }, 'JSON fields should be parsed');
const questions = JSON.parse(fs.readFileSync(path.join(exportDir, 'questions.json'), 'utf8'));
assert.deepEqual(questions[0].options, ['A', 'B'], 'question JSON fields should be parsed');
assert.ok(!fs.existsSync(path.join(exportDir, '_superusers.json')), 'system collections should not export by default');
const manifest = JSON.parse(fs.readFileSync(path.join(exportDir, 'sqlite-export-manifest.json'), 'utf8'));
assert.equal(manifest.collectionCount, 2, 'manifest should include exported collection count');
assert.equal(manifest.recordCount, 2, 'manifest should include exported record count');
assert.ok(
manifest.collections.find(item => item.name === 'users')?.redactedFields.some(item => item.fieldPath === 'password'),
'manifest should report redacted user password field',
);
assert.equal(manifest.storage.fileCount, 1, 'manifest should summarize storage files');
assert.equal(manifest.auxiliary.tables[0].name, '_logs', 'manifest should summarize auxiliary logs');
const storageManifest = JSON.parse(fs.readFileSync(path.join(exportDir, 'storage-manifest.json'), 'utf8'));
assert.equal(storageManifest.files[0].collectionName, 'questions', 'storage manifest should map collection id to name');
assert.ok(storageManifest.files[0].sha256, 'storage manifest should include file hash');
fs.rmSync(root, { recursive: true, force: true });
console.log('[PASS] PocketBase SQLite export');