feat:上传到阿里云oss

feat:把以前的图片迁移到阿里云oss
This commit is contained in:
dzc committed 2026-11-05 15:22:55 +08:00
1 parent 2de8fa02b2
commit f55561c98d
47 files changed
+7672 -96

No files matched your search

@@ -0,0 +1,4 @@
node_modules/
config.local.json
*.local.json
reports/
+155
View File
@@ -0,0 +1,155 @@
# 数据库图片迁移到阿里云 OSS
纯 JavaScript / Node.js 工具。默认自动读取项目当前环境的主数据库连接,从数据库识别旧数据库存储和默认阿里云 OSS,直接读取原图上传并校验,然后更新文件表和业务字段中的旧地址。
**通常不需要填写配置,不需要设置数据库密码环境变量,也不需要启动本地后端。** 需要 Node.js 20.19+、MySQL 5.7/8.x 和项目已设置好的默认阿里云 OSS。
## 直接使用
在本目录执行:
```sh
npm ci --ignore-scripts
node migrate.mjs init
```
这会读取代码里的数据库连接,查询存储配置和已有迁移批次,自动生成 `config.local.json`。不上传图片、不更新数据库、不创建迁移表。数据库密码仍从项目 YAML 读取,OSS 密钥仍从存储配置表读取,不重复写入生成文件。
接着只读预检,或开始迁移:
```sh
node migrate.mjs
node migrate.mjs apply --limit 50
node migrate.mjs apply
```
第一条没有参数,默认只读预检;第二条实际迁移少量图片;第三条继续完成剩余迁移。可跳过 init,预检时也会自动生成配置。中断或失败后重跑相同 apply 命令即可。
预检只读文件元数据,不读取图片二进制内容或计算 SHA-256。图片分组、文件记录和路径拼写由三次批量查询完成,不再逐张反复访问远程数据库。输出包含当前阶段、扫描表名和行数;等待较长查询时每 5 秒显示耗时。`estimatedBytes` 是文件表记录的容量估算,`sizePending` 是缺少容量信息的文件数,实际长度和哈希在 apply 时校验。
上传时的源文件复核也分两步:先只查最新记录编号,再按主键读取该图片的长度和哈希,避免在没有路径索引的排序查询中读取图片大字段。每张图片完成后会显示源读取/复核、OSS 上传/校验、最终地址校验及总耗时,便于区分数据库等待与网络传输。
“更新已校验文件记录”是数据库事务阶段,不是再次上传。脚本每批最多处理 50 张:按主键范围加锁,批量复核源文件最新版本和哈希,再批量更新文件记录、写入逐条回滚日志及迁移状态,一起提交。保留原值比较和事务保护,不需要新增配置或给业务表加索引。该阶段重新计时,并显示每批源校验、记录及日志、提交的耗时。已知源变化或原值冲突会回滚整批,再逐项定位;连接或提交异常则停止,避免重复提交。
按 Ctrl+C 中断后,保留 `config.local.json` 和同一个 runId,重新执行 `node migrate.mjs apply` 即可续跑。已提交记录保留,未提交事务由数据库回滚。处于 `verified` 的已上传文件采用最多 4 张并发,重新读取 OSS 内容和最终访问地址核对哈希,不重新下载数据库图片,也不重新上传;切换时仍在事务里复核源内容。已切换文件也会批量核对文件记录,并并发检查访问地址。校验失败的图片不会用于更新业务引用。更新脚本后,需要停止旧进程再执行,新代码才会生效。
文件总大小不等于网络总传输量:脚本在运行电脑上从数据库读取、上传 OSS,并读取 OSS 及最终访问地址进行校验。macOS 系统代理开关也不代表 Node 默认会使用代理;需要结合进程代理变量、实际连接及系统路由判断。此脚本不修改系统代理设置。
如需回滚:
```sh
node migrate.mjs rollback
```
也可以使用 `npm run init`、`npm run plan`、`npm run apply`、`npm run rollback`。生成的配置和报告已忽略,不进入 Git。
## 自动识别的内容
- 环境:显式 `--profile` 优先,其次 `SPRING_PROFILES_ACTIVE`,否则按项目 `application.yaml`。当前源码默认是 dev;生成文件会固定已选环境,避免下一次切换环境后误续跑。
- 数据库:合并基础 YAML 和对应环境 YAML,读取 dynamic.primary 指定的主数据源,包括地址、端口、数据库、用户名、密码。按需要解析环境变量占位符。
- 源配置:从仍有未删除 BLOB 的数据库存储配置中识别,不依赖迁移过程中会变化的文件表 config_id。
- 目标:唯一的默认存储配置,且必须是阿里云 OSS。不能确定时停止,不自动猜选。
- 历史域名:从旧文件地址提取;OSS Bucket、访问域名和密钥直接使用已有配置。
- 批次:已有批次优先续跑;首次生成稳定批次名称。保留服务器身份、存储编号和扫描范围,避免换库或换目标后误续跑。
- 下载接口:根据项目端口和 API 前缀推导。当前源码端口是 48083;默认直接读数据库,不依赖这个接口。
项目 dev/local 的数据库可能是远程地址,不能把环境名 local 当成本机数据库。每次执行会显示实际连接地址和库名。工具读取仓库配置及支持的环境变量,无法知道部署服务器额外的 Docker/命令行覆盖。
多个未完成批次、默认 OSS 改变、数据库服务器改变等情况会明确提示。若固定批次建立后又出现新 BLOB,可显式新建批次:
```sh
node migrate.mjs apply --run-id db-images-next
```
新批次名称会保存到自动配置。完成回滚的批次不会自动重新迁移。
## 仅在需要时覆盖
正常不需要复制示例或逐项编辑。`config.example.json` 为 `{}`,表示全部自动识别;`config.manual.example.json` 保留完全手工配置方式,兼容旧用法。
例如切换项目环境:
```sh
node migrate.mjs init --profile local
```
同一生成配置绑定原数据库;如果切换后指向不同数据库,会停止,不能把原批次用于另一台服务器。
其他可选设置仍可写入配置文件:`referenceSchemas` 添加同一 MySQL 实例的 IM 库,`imagesOnly: false` 包含非图片,`sourceMode: "http"` 使用原下载接口。手动连接另一数据库时可用 `--config config.manual.local.json`,其中 mysql.passwordEnv 指定的环境变量需要自行提供。
迁移前备份数据库,保持原图片和目标配置稳定。回滚只恢复本批次写入且没有被后来修改的值,不删除原 BLOB 或 OSS 对象。最终清理仍是独立步骤。
## 实际处理流程
1. 按数据库自身的路径比较规则,选取每个 `config_id + path` 最大的未删除内容 ID,与 `DBFileClient.getContent` 一致。大整数 ID 全程使用字符串。
2. 默认处理 MIME 为 `image/*` 或常见图片扩展名的内容;没有文件表记录但可以识别为图片的 BLOB 也会处理。其他文件计入跳过数量;如确需迁移全部数据库文件,设置 `imagesOnly: false`。无法识别的无扩展名内容需要单独核查。
3. 建立固定迁移清单,保存原文件记录和旧地址。默认直接读取数据库原始字节;HTTP 模式通过旧下载接口读取。两种方式均与数据库计算的长度、SHA-256 比较。
4. 使用独立目录 `objectPrefix/旧配置编号/路径哈希/内容哈希.扩展名` 上传;保留原始文件字节,不压缩图片。上传前后均检查对象,不覆盖不同内容。
5. 从 OSS 重新读取校验,并匿名访问最终将写入数据库的 URL 再次校验。SDK 上传成功但浏览器地址不可访问时,不切换数据库记录。
6. 本次选中文件全部通过上传校验后,每批最多 50 张用一个事务更新 `infra_file.config_id/path/url/type/size`,保留文件记录 ID 和自动更新时间。逐条回滚日志与文件记录、迁移状态同时提交。
7. 扫描指定数据库的字符、文本、JSON 字段,按精确地址映射替换单 URL、逗号列表、JSON 值及可识别的富文本地址。每行更新都检查原值,避免覆盖用户刚修改的数据。原生 JSON 的日志保存数据库实际持久化结果,便于正确回滚。
上传图片是逐个进行的;正式迁移按主键分批计算内容哈希,不会一次加载整个 BLOB 表。文件元数据和 URL 映射保存在内存;引用读取每页至少 1000 行,更新仍逐行执行。MySQL 当前会话的单条 SELECT 默认最多执行 60 秒,沿用 `timeoutMs` 配置,不修改服务器全局设置。
## OSS 访问方式
- 默认 `targetUrlMode: "direct"`,业务字段保存 OSS/CDN 稳定地址。地址优先使用 `targetPublicDomain`,其次使用存储配置的 `domain`,最后根据 Bucket 和 OSS 节点生成公网域名。Bucket/CDN 应允许这些图片按该地址读取;脚本不会修改 Bucket 权限。
- 私有 Bucket 可使用 `targetUrlMode: "backend"`,业务字段保存现有后端下载入口加目标配置编号的地址,后端通过自己的 OSS 凭据读取。`downloadBaseUrl` 此时必须是用户设备能访问的稳定地址,不能填仅运行脚本机器可访问的 localhost。现有接口是 `@PermitAll`,此模式沿用项目已有访问规则,不新增鉴权。
- 默认 `sourceMode: "database"`,无需启动旧后端。也可设置 `sourceMode: "http"` 使用原下载接口。使用 backend 目标模式时,后端仍须在线。
## 旧地址与迁移范围
修改文件表不会自动改变旧接口的行为:它仍按 URL 中的配置编号直接读取存储。因此脚本始终保留 `infra_file_content` 和原数据库配置,让旧客户端缓存及未替换引用仍有机会继续读取。
过渡期通过文件管理删除 OSS 文件,不会自动删除旧数据库副本。历史副本的删除和最终清理需要关联处理;本脚本不承担清理工作。续跑会重新检查已迁移对象和文件记录,目标已被删除或改动时停止复用对应映射。
旧接口会解码路径一次,并将裸 `+` 当空格。脚本下载时逐段编码;旧 URL 只有在其实际解码结果对应当前文件时才加入映射。裸 `+`、已有 `%HH`、`?/#` 等存在歧义的旧地址不会被静默指向另一张图片,未解决的引用会进入复核统计。
未知查询参数、片段、相对地址和无法识别的文本组合不做猜测替换。JSON 属性名不改。历史域名别名通过 `oldDomains` 补充。默认扫描主数据库,额外数据库只扫描同一个 MySQL 实例且当前账号有权限的 `referenceSchemas`;不同实例的 IM 库需要单独处理。
IM 自己上传到 MinIO 的文件不属于本次 BLOB 搬迁,但 IM 中保存的主库旧图片 URL 可以随引用扫描替换。Redis、客户端本地缓存、其他数据库实例和外部系统不在扫描范围,旧 BLOB 不能在脚本结束后立即清理。
业务引用表需要单列主键;无主键、复合主键表会列入待复核。更新要求 InnoDB。视图、生成列、文件基础表和迁移日志表不参与业务引用替换。`excludeReferenceTables` 可以填写明确不需要扫描的表名或 `库名.表名`;这些主动排除项会在报告中列出。
接口访问日志、接口错误日志、定时任务日志、系统操作日志和登录日志也默认排除:它们不是页面图片引用,迁移不应改写历史记录。具体为 `infra_api_access_log`、`infra_api_error_log`、`infra_job_log`、`system_operate_log`、`system_login_log`,不会泛化排除其他名字包含 log 的业务表。
## 结果、恢复与权限
每次命令在配置文件旁的 `reports/` 写 JSON 报告:
| 状态 | 含义 |
| --- | --- |
| `planned` | 数据库预检通过,尚未校验 HTTP/OSS 连通性 |
| `complete` | 本批文件已切换,已扫描范围未检测到剩余旧路由引用 |
| `partial` | 限量试迁移、上传失败、文件记录冲突或业务更新冲突,需要续跑/处理 |
| `needs-review` | 预检/迁移扫描仍发现旧地址或不支持的表;可能包含有意跳过的非图片、未知 URL 变体,不能据此清理源数据 |
| `rolled_back` / `rollback_partial` | 回滚完成 / 仍有并发修改冲突 |
退出码:0 为本次命令成功;2 为部分完成或需要复核;1 为执行错误。
失败时只输出简短错误,不输出配置密钥;详细原值、映射及逐项状态保存在主库的三个表:
```text
infra_file_oss_migration_runs
infra_file_oss_migration_items
infra_file_oss_migration_changes
```
`plan` 不创建这些表、不上传、不更新业务数据;`apply` 首次执行会创建日志表。更新与日志在同一事务中提交,回滚不依赖本地报告。保留这些表及数据库备份直到验收结束。
同一主库的 apply/rollback 使用 MySQL 命名锁避免重复运行。同一 runId 绑定原数据库服务器、存储目标和扫描范围,不能换库或换 Bucket 后继续执行。需要 SELECT/UPDATE、主库创建和写入日志表的权限,以及 OSS 对指定对象的读取/写入权限;不需要 OSS 删除权限。
脚本会开启当前数据库连接的严格模式,并在 SQL 出现截断等警告时回滚,防止较短的业务字段保存损坏 URL。apply 将当前连接的隔离级别设为 REPEATABLE READ,要求源内容表、文件表和日志表为 InnoDB;批次源校验的主键范围锁覆盖可能新增或恢复的较新版本,防止校验期间源内容变化。不修改数据库全局隔离级别。配置文件、依赖目录和报告已通过此目录的 `.gitignore` 排除;回滚日志包含原业务值,应按业务数据库的权限保护。
## 验证
```sh
npm test
```
默认运行本地 HTTP、模拟 OSS 和事务接口的测试。真实 MySQL 集成测试默认跳过;仅可指向专用临时 MySQL 容器的端口:
```sh
MIGRATION_TEST_DB_PORT=临时容器端口 node --test test/integration.test.mjs
```
集成测试会创建并删除固定的 `codex_oss_migration_test` 测试库,不可指向业务数据库服务器。其 OSS 是内存模拟,HTTP 下载接口由本地测试服务模拟;真实阿里云连接和实际业务页面仍需通过少量试迁移验收。
@@ -0,0 +1 @@
{}
@@ -0,0 +1,25 @@
{
"runId": "db-images-20261105",
"mysql": {
"host": "127.0.0.1",
"port": 3306,
"user": "root",
"passwordEnv": "MIGRATION_DB_PASSWORD",
"database": "YOUR_DATABASE"
},
"sourceConfigIds": [],
"targetConfigId": null,
"sourceMode": "http",
"downloadBaseUrl": "http://127.0.0.1:48083/admin-api/infra/file",
"oldDomains": [],
"targetUrlMode": "direct",
"targetPublicDomain": "",
"objectPrefix": "migration/db-images",
"referenceSchemas": [],
"excludeReferenceTables": [],
"imagesOnly": true,
"batchSize": 100,
"timeoutMs": 60000,
"maxFileBytes": 67108864,
"retries": 3
}
@@ -0,0 +1,173 @@
import { baseUrl, bit, id, normalizeConfig, ossSettings, qualified, sha256 } from './core.mjs';
const RUNS_TABLE = 'infra_file_oss_migration_runs';
const RESTORED_FIELDS = [
'sourceConfigIds', 'targetConfigId', 'sourceMode', 'downloadBaseUrl', 'oldDomains',
'objectPrefix', 'imagesOnly', 'referenceSchemas', 'excludeReferenceTables', 'targetUrlMode'
];
const hasValue = (value, key) => Object.prototype.hasOwnProperty.call(value, key) && value[key] !== undefined;
const sortedIds = values => [...new Set(values.map(id))].sort((left, right) => BigInt(left) < BigInt(right) ? -1 : BigInt(left) > BigInt(right) ? 1 : 0);
function assertIdentity(expected, current) {
if (!expected || expected.server !== current.server || expected.schema !== current.schema) {
throw new Error('数据库身份与原迁移配置不一致,请检查数据库连接和批次编号');
}
}
function parseRunSettings(value) {
let settings;
try {
settings = typeof value === 'string' ? JSON.parse(value) : value;
} catch {
throw new Error('迁移批次的配置记录损坏,不能自动恢复');
}
if (!settings || typeof settings !== 'object' || !settings.binding || typeof settings.binding !== 'object') {
throw new Error('迁移批次缺少原始配置绑定,不能自动恢复');
}
return settings;
}
function selectRun(rows, requestedRunId) {
if (requestedRunId !== undefined) return rows.find(row => row.run_id === requestedRunId);
const unfinished = rows.filter(row => !['complete', 'rolled_back'].includes(row.status));
if (unfinished.length > 1) throw new Error('存在多个未完成迁移批次,请使用 --run-id 指定要继续的批次');
if (unfinished.length === 1) return unfinished[0];
if (rows.length > 1) throw new Error('存在多个历史迁移批次,请使用 --run-id 指定批次,或指定新的批次编号');
return rows[0];
}
function contentId(value) {
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^\d+$/.test(String(value))) {
throw new Error('迁移批次的内容上界无效,不能安全判断增量数据');
}
return BigInt(value);
}
/**
* Read-only discovery of storage configuration and a stable migration run.
* This function never emits logs, persists configuration, or returns the OSS
* credentials read from infra_file_config. Explicit input fields take priority
* over a restored binding; loadSettings/runApply validate the final fingerprint.
*/
export async function discoverStorageConfig(db, input, { command = 'plan' } = {}) {
if (!input?.mysql?.database) throw new Error('缺少本地数据库名称,无法自动识别文件存储');
const schema = input.mysql.database;
const [[serverRow]] = await db.execute('SELECT @@server_uuid AS server_uuid');
if (!serverRow?.server_uuid) throw new Error('无法读取数据库服务器身份');
const identity = { server: serverRow.server_uuid, schema };
if (hasValue(input, '_databaseIdentity')) assertIdentity(input._databaseIdentity, identity);
const [runTable] = await db.execute(
"SELECT 1 AS present FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND TABLE_TYPE='BASE TABLE' LIMIT 1",
[schema, RUNS_TABLE]
);
let history = [];
if (runTable.length) {
[history] = await db.execute(`SELECT run_id,settings,status FROM ${qualified(schema, RUNS_TABLE)}`);
}
const selected = selectRun(history, input.runId);
const settings = selected ? parseRunSettings(selected.settings) : undefined;
const config = { ...input, mysql: { ...input.mysql } };
if (selected) {
assertIdentity(settings.binding, identity);
for (const key of RESTORED_FIELDS) {
if (!hasValue(input, key) && hasValue(settings.binding, key)) {
config[key] = structuredClone(settings.binding[key]);
}
}
if (!hasValue(input, 'targetPublicDomain') && hasValue(settings.binding, 'publicDomain')) {
config.targetPublicDomain = settings.binding.publicDomain;
}
config.runId = selected.run_id;
if (command === 'apply' && (selected.status.startsWith('rollback') || selected.status === 'rolled_back')) {
throw new Error('该批次已开始或完成回滚,不能继续迁移;请继续 rollback,或使用 --run-id 指定新批次');
}
} else if (command === 'rollback') {
throw new Error('未找到可回滚的历史迁移批次,请使用 --run-id 指定已有批次');
}
config._databaseIdentity = identity;
config._autoDiscovery = true;
delete config._projectDownloadBaseUrl;
// Rollback only restores journalled database values. Current storage rows,
// default targets and OSS credentials are deliberately irrelevant here.
if (command === 'rollback') return normalizeConfig(config);
if (!hasValue(config, 'sourceMode')) config.sourceMode = 'database';
if (!hasValue(config, 'downloadBaseUrl') && input._projectDownloadBaseUrl) {
config.downloadBaseUrl = input._projectDownloadBaseUrl;
}
const [storageRows] = await db.execute(`SELECT id,storage,master,config FROM ${qualified(schema, 'infra_file_config')} WHERE deleted=0`);
const byId = new Map(storageRows.map(row => [id(row.id), row]));
const masters = storageRows.filter(row => bit(row.master));
const requireDefault = !hasValue(config, 'targetConfigId') || input._autoDiscovery === true || Boolean(selected);
if (requireDefault && masters.length !== 1) {
throw new Error('默认文件存储配置必须唯一,请先在文件配置中设置唯一的阿里云 OSS 默认存储');
}
if (!hasValue(config, 'targetConfigId')) config.targetConfigId = id(masters[0].id);
config.targetConfigId = id(config.targetConfigId);
if (requireDefault && (id(masters[0].id) !== config.targetConfigId
|| (selected && id(masters[0].id) !== id(settings.binding.targetConfigId)))) {
throw new Error('当前默认 OSS 与原迁移目标不同,不能自动续跑;请恢复原默认配置或使用 --run-id 建立新批次');
}
const target = byId.get(config.targetConfigId);
if (!target) throw new Error('目标文件存储配置不存在或已删除');
try {
ossSettings(target, config);
} catch {
// JSON/URL parsing errors must not include raw configuration or secrets.
throw new Error('目标默认存储不是有效的阿里云 OSS 配置,请检查类型、endpoint、Bucket 和访问凭据');
}
if (!hasValue(config, 'sourceConfigIds')) {
const databaseIds = sortedIds(storageRows.filter(row => Number(row.storage) === 1).map(row => row.id));
let contents = [];
if (databaseIds.length) {
[contents] = await db.execute(
`SELECT DISTINCT config_id FROM ${qualified(schema, 'infra_file_content')} WHERE deleted=0 AND config_id IN (${databaseIds.map(() => '?').join(',')})`,
databaseIds
);
}
config.sourceConfigIds = sortedIds(contents.map(row => row.config_id));
}
if (!Array.isArray(config.sourceConfigIds) || !config.sourceConfigIds.length) {
throw new Error('未发现包含未删除内容的数据库存储配置,无需迁移');
}
config.sourceConfigIds = [...new Set(config.sourceConfigIds.map(id))];
for (const sourceId of config.sourceConfigIds) {
const source = byId.get(sourceId);
if (!source || Number(source.storage) !== 1) throw new Error('原数据库存储配置不存在、已删除或类型发生变化,请检查原迁移绑定');
if (bit(source.master)) throw new Error('源配置仍是默认上传配置,请先切换默认配置到阿里云 OSS');
}
if (selected && selected.status !== 'inventory' && command === 'apply') {
const sourceIds = settings.binding.sourceConfigIds.map(id);
if (!sourceIds.length) throw new Error('历史迁移批次的源配置列表为空,不能安全续跑');
const [[maximum]] = await db.execute(
`SELECT COALESCE(MAX(id),0) AS max_id FROM ${qualified(schema, 'infra_file_content')} WHERE deleted=0 AND config_id IN (${sourceIds.map(() => '?').join(',')})`,
sourceIds
);
if (contentId(maximum.max_id) > contentId(settings.maxId)) {
throw new Error('原批次建立后出现新的数据库文件,请使用 --run-id 指定新批次,避免遗漏新增内容');
}
}
if (!hasValue(config, 'oldDomains')) {
const [domains] = await db.execute(
`SELECT DISTINCT SUBSTRING_INDEX(url,'/admin-api/infra/file/',1) AS domain FROM ${qualified(schema, 'infra_file')} WHERE deleted=0 AND config_id IN (${config.sourceConfigIds.map(() => '?').join(',')}) AND url LIKE '%/admin-api/infra/file/%'`,
config.sourceConfigIds
);
const validDomains = new Set();
for (const row of domains) {
try { validDomains.add(baseUrl(row.domain)); } catch { /* Ignore malformed and credential-bearing historical URLs. */ }
}
config.oldDomains = [...validDomains].sort();
}
if (!hasValue(config, 'runId')) {
const fingerprint = { ...identity, sourceConfigIds: sortedIds(config.sourceConfigIds), targetConfigId: config.targetConfigId };
config.runId = `db-images-${sha256(JSON.stringify(fingerprint)).slice(0, 16)}`;
}
return normalizeConfig(config);
}
@@ -0,0 +1,30 @@
/**
* Map in input order with bounded parallelism. After the first failure, stop
* scheduling work and drain every in-flight worker before rejecting.
*/
export async function mapBounded(items, limit, worker) {
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('并发数量必须为正整数');
const source = Array.from(items);
const results = new Array(source.length);
let next = 0;
let failed = false;
let firstError;
async function run() {
while (!failed && next < source.length) {
const index = next++;
try {
results[index] = await worker(source[index], index);
} catch (error) {
if (!failed) {
failed = true;
firstError = error;
}
}
}
}
await Promise.all(Array.from({ length: Math.min(limit, source.length) }, () => run()));
if (failed) throw firstError;
return results;
}
+174
View File
@@ -0,0 +1,174 @@
import { createHash } from 'node:crypto';
export const sha256 = value => createHash('sha256').update(value).digest('hex');
export const quote = name => '`' + String(name).replaceAll('`', '``') + '`';
export const qualified = (schema, table) => `${quote(schema)}.${quote(table)}`;
export const bit = value => Buffer.isBuffer(value) ? value.some(byte => byte !== 0) : value === true || value === 1 || value === '1';
export const errorText = error => String(error.code || error.message || error.name || '未知错误').replace(/https?:\/\/\S+/g, '[URL]').slice(0, 300);
export function id(value) {
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('配置编号超出安全整数范围,请使用字符串');
if (!/^[1-9]\d*$/.test(String(value))) throw new Error('配置编号必须为正整数,不能使用存储类型代替配置编号');
return String(value);
}
export function baseUrl(value) {
const parsed = new URL(value);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error('访问基础地址必须是无账号、查询参数和片段的 HTTP(S) URL');
}
return parsed.href.replace(/\/+$/, '');
}
export function encodePath(path) {
if (!path || path.startsWith('/') || path.includes('\\') || path.split('/').some(part => ['.', '..', ''].includes(part))) {
throw new Error('文件路径为空或含不支持的路径段,需单独处理');
}
return path.split('/').map(encodeURIComponent).join('/');
}
export const downloadUrl = (base, configId, path) => `${baseUrl(base)}/${id(configId)}/get/${encodePath(path)}`;
export function normalizeConfig(input) {
const config = {
sourceMode: 'http', targetUrlMode: 'direct', objectPrefix: 'migration/db-images',
imagesOnly: true, batchSize: 100, timeoutMs: 60000, maxFileBytes: 64 * 1024 * 1024,
retries: 3, oldDomains: [], excludeReferenceTables: [], ...input
};
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(config.runId || '')) throw new Error('runId 必须是 1~64 位字母、数字、横线或下划线');
if (!config.mysql?.database || !config.mysql?.user) throw new Error('请填写 mysql.database 和 mysql.user');
if (!Array.isArray(config.sourceConfigIds) || !config.sourceConfigIds.length) throw new Error('请填写 sourceConfigIds(原数据库存储配置编号)');
config.sourceConfigIds = [...new Set(config.sourceConfigIds.map(id))];
config.targetConfigId = id(config.targetConfigId);
if (config.sourceConfigIds.includes(config.targetConfigId)) throw new Error('源配置和 OSS 目标配置不能相同');
if (!['http', 'database'].includes(config.sourceMode)) throw new Error('sourceMode 只能为 http 或 database');
if (!['direct', 'backend'].includes(config.targetUrlMode)) throw new Error('targetUrlMode 只能为 direct 或 backend');
if (config.sourceMode === 'http' || config.targetUrlMode === 'backend') config.downloadBaseUrl = baseUrl(config.downloadBaseUrl);
if (!/^[a-zA-Z0-9][a-zA-Z0-9/_-]{0,119}$/.test(config.objectPrefix) || config.objectPrefix.endsWith('/')) throw new Error('objectPrefix 只能包含字母、数字、横线、下划线和目录分隔符');
for (const key of ['batchSize', 'timeoutMs', 'maxFileBytes', 'retries']) {
if (!Number.isSafeInteger(config[key]) || config[key] < 1) throw new Error(`${key} 必须为正整数`);
}
if (config.batchSize > 1000 || config.retries > 10) throw new Error('batchSize 上限为 1000,retries 上限为 10');
config.oldDomains = config.oldDomains.map(baseUrl);
config.referenceSchemas = [...new Set([config.mysql.database, ...(config.referenceSchemas || [])])];
return config;
}
export function parseStorage(row) {
return typeof row.config === 'string' ? JSON.parse(row.config) : row.config;
}
export function ossSettings(row, config) {
if (Number(row.storage) !== 20) throw new Error('目标配置不是 S3/OSS 存储(storage=20)');
const value = parseStorage(row);
const endpoint = new URL(/^https?:\/\//.test(value.endpoint) ? value.endpoint : `https://${value.endpoint}`);
if (!/^oss-[a-z0-9-]+\.aliyuncs\.com$/.test(endpoint.hostname) || endpoint.pathname !== '/' || endpoint.search || endpoint.hash || endpoint.username || endpoint.password) {
throw new Error('目标 endpoint 必须为阿里云 OSS 区域/加速节点,不能是其他 S3 服务或 Bucket 域名');
}
if (!value.bucket || !value.accessKey || !value.accessSecret) throw new Error('OSS 配置缺少 bucket/accessKey/accessSecret');
const publicDomain = baseUrl(config.targetPublicDomain || value.domain || `https://${value.bucket}.${endpoint.hostname.replace('-internal.', '.')}`);
return { endpoint: endpoint.origin, bucket: value.bucket, accessKeyId: value.accessKey, accessKeySecret: value.accessSecret, publicDomain };
}
export function objectKey(config, item) {
const extension = /\.([a-zA-Z0-9]{1,10})$/.exec(item.path)?.[1]?.toLowerCase();
return `${config.objectPrefix}/${item.configId}/${sha256(item.path)}/${item.sha256}${extension ? '.' + extension : ''}`;
}
export function aliases(item, rows, sourceDomain, config) {
const result = new Set(rows.map(row => row.url).filter(Boolean));
const domains = [...new Set([sourceDomain, ...config.oldDomains].filter(Boolean).map(baseUrl))];
const paths = [...new Set([item.path, ...rows.map(row => row.path)])];
const sourcePaths = new Set(paths);
const addPathAliases = (prefix, path) => {
const encoded = encodePath(path);
result.add(prefix + path);
result.add(prefix + encoded);
// FileController uses form-style decoding: a literal '+' means a space.
result.add(prefix + encoded.replaceAll('%20', '+'));
};
for (const domain of domains) for (const path of paths) {
const prefix = `${domain}/admin-api/infra/file/${item.configId}/get/`;
addPathAliases(prefix, path);
}
if (config.downloadBaseUrl) {
const prefix = baseUrl(config.downloadBaseUrl) + '/' + id(item.configId) + '/get/';
for (const path of paths) addPathAliases(prefix, path);
}
return [...result].filter(value => {
try {
// Parse the actual request path: raw '?' / '#' end the path, and the
// browser encodes spaces/Unicode before FileController sees requestURI.
const parsed = new URL(value, 'https://migration-alias.invalid');
const route = parsed.pathname.match(/\/infra\/file\/([^/]+)\/get(?:\/|$)/);
if (!route) return true; // Keep pre-existing URLs from other file routes.
if (BigInt(decodeURIComponent(route[1])) !== BigInt(item.configId)) return false;
const marker = parsed.pathname.indexOf('/get/');
if (marker < 0) return false;
const path = decodeURIComponent(parsed.pathname.slice(marker + 5).replaceAll('+', ' '));
return sourcePaths.has(path);
} catch {
// Unverifiable aliases remain untouched and are reported by the caller.
return false;
}
});
}
export function mimeType(path, records) {
const stored = records.map(row => row.type).find(type => /^image\//i.test(type || ''));
if (stored) return stored;
const ext = path.split('.').pop().toLowerCase();
const types = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon', tif: 'image/tiff', tiff: 'image/tiff', avif: 'image/avif', heic: 'image/heic', heif: 'image/heif' };
return types[ext] || records.find(row => row.type)?.type || 'application/octet-stream';
}
export async function readHttp(url, { timeoutMs, maxFileBytes }, expectedSize) {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error' });
if (!response.ok) { await response.body?.cancel(); throw new Error(`文件访问返回 HTTP ${response.status}`); }
const declared = Number(response.headers.get('content-length'));
if (declared > maxFileBytes || (expectedSize !== undefined && declared > expectedSize)) {
await response.body?.cancel(); throw new Error('HTTP 文件长度超过限制');
}
let size = 0;
const chunks = [];
for await (const chunk of response.body) {
size += chunk.length;
if (size > maxFileBytes || (expectedSize !== undefined && size > expectedSize)) throw new Error('HTTP 文件长度超过限制');
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
export function verifyContent(bytes, expected) {
if (bytes.length !== Number(expected.size) || sha256(bytes) !== expected.sha256) throw new Error('文件长度或 SHA-256 不一致');
}
export async function ensureObject(oss, key, bytes, item) {
let exists = true;
try {
const result = await oss.head(key);
if (Number(result.res.headers['content-length']) !== Number(item.size)) throw new Error('OSS 同名对象长度不同,禁止覆盖');
} catch (error) {
if (error.code === 'NoSuchKey' || error.status === 404) exists = false;
else throw error;
}
if (!exists) {
try {
await oss.put(key, bytes, { headers: { 'Content-Type': item.type, 'x-oss-forbid-overwrite': 'true' } });
} catch (error) {
if (error.code !== 'FileAlreadyExists' && error.status !== 409) throw error;
}
}
const result = await oss.head(key);
if (Number(result.res.headers['content-length']) !== Number(item.size)) throw new Error('OSS 文件长度不一致');
verifyContent((await oss.get(key)).content, item);
}
export async function retry(action, attempts, wait = ms => new Promise(resolve => setTimeout(resolve, ms))) {
for (let i = 0; ; i++) {
try { return await action(); } catch (error) {
if (i + 1 >= attempts) throw error;
await wait(Math.min(1000 * 2 ** i, 5000));
}
}
}
@@ -0,0 +1,318 @@
import { qualified, quote } from './core.mjs';
const RUNS = 'infra_file_oss_migration_runs';
const ITEMS = 'infra_file_oss_migration_items';
const CHANGES = 'infra_file_oss_migration_changes';
const metadataCache = new WeakMap();
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
// Snapshots must preserve BIT/Buffer and 64-bit keys without Number conversion.
function pack(value) {
if (Buffer.isBuffer(value)) return { type: 'buffer', value: value.toString('base64') };
if (typeof value === 'bigint') return { type: 'bigint', value: value.toString() };
if (value instanceof Date) return { type: 'date', value: value.toISOString() };
if (value === undefined) throw new Error('变更快照不能包含 undefined');
return { type: 'json', value };
}
function unpack(value) {
if (value.type === 'buffer') return Buffer.from(value.value, 'base64');
if (value.type === 'bigint') return BigInt(value.value);
if (value.type === 'date') return new Date(value.value);
if (value.type === 'json') return value.value;
throw new Error('变更日志值类型不受支持');
}
function snapshot(record) {
return JSON.stringify(Object.fromEntries(Object.entries(record).map(([key, value]) => [key, pack(value)])));
}
function restoreSnapshot(text) {
return Object.fromEntries(Object.entries(JSON.parse(text)).map(([key, value]) => [key, unpack(value)]));
}
function parameter(value) {
if (typeof value === 'bigint') return value.toString();
if (value !== null && typeof value === 'object' && !Buffer.isBuffer(value) && !(value instanceof Date)) return JSON.stringify(value);
return value;
}
const equal = (left, right) => JSON.stringify(pack(left)) === JSON.stringify(pack(right));
function condition(values) {
const entries = Object.entries(values);
return {
sql: entries.map(([key]) => `CAST(${quote(key)} AS BINARY) <=> CAST(? AS BINARY)`).join(' AND '),
params: entries.map(([, value]) => parameter(value))
};
}
async function tableMetadata(connection, schema, table) {
let cache = metadataCache.get(connection);
if (!cache) { cache = new Map(); metadataCache.set(connection, cache); }
const key = JSON.stringify([schema, table]);
if (!cache.has(key)) {
const [rows] = await connection.execute(
`SELECT c.COLUMN_NAME AS name, c.EXTRA AS extra, t.ENGINE AS engine
FROM information_schema.COLUMNS c JOIN information_schema.TABLES t
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME
WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ?`, [schema, table]);
if (!rows.length || rows.some(row => String(row.engine).toLowerCase() !== 'innodb')) {
throw new Error(`表 ${schema}.${table} 必须为 InnoDB,才能保证更新和日志原子提交`);
}
cache.set(key, {
names: new Set(rows.map(row => row.name)),
automatic: rows.filter(row => /on update/i.test(row.extra || '')).map(row => row.name)
});
}
return cache.get(key);
}
function assertFields(metadata, primaryKey, before, after) {
if (own(after, primaryKey)) throw new Error('迁移不能修改主键');
for (const name of [primaryKey, ...Object.keys(before), ...Object.keys(after)]) {
if (!metadata.names.has(name)) throw new Error(`变更列 ${name} 不存在`);
}
for (const name of Object.keys(after)) {
if (!own(before, name)) throw new Error(`变更列 ${name} 缺少原值快照`);
}
}
function assignments(values, metadata) {
const entries = Object.entries(values);
return {
sql: [
...entries.map(([name]) => `${quote(name)} = ?`),
...metadata.automatic.filter(name => !own(values, name)).map(name => `${quote(name)} = ${quote(name)}`)
].join(', '),
params: entries.map(([, value]) => parameter(value))
};
}
export async function ensureJournal(connection, schema) {
await connection.execute(`CREATE TABLE IF NOT EXISTS ${qualified(schema, RUNS)} (
run_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
settings LONGTEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`);
await connection.execute(`CREATE TABLE IF NOT EXISTS ${qualified(schema, ITEMS)} (
run_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
item_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
data LONGTEXT NOT NULL,
status VARCHAR(32) NOT NULL,
error TEXT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (run_id, item_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`);
await connection.execute(`CREATE TABLE IF NOT EXISTS ${qualified(schema, CHANGES)} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
run_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
schema_name VARCHAR(64) NOT NULL,
table_name VARCHAR(64) NOT NULL,
pk_name VARCHAR(64) NOT NULL,
pk_value TEXT NOT NULL,
before_json LONGTEXT NOT NULL,
after_json LONGTEXT NOT NULL,
rolled_back TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY ix_run_id_id (run_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`);
for (const table of [RUNS, ITEMS, CHANGES]) await tableMetadata(connection, schema, table);
}
/** The caller owns BEGIN/COMMIT/ROLLBACK, including the change-journal INSERT. */
export async function applyChange(connection, journalSchema, runId, change) {
const { schema, table, primaryKey, pkValue, before, after } = change;
const metadata = await tableMetadata(connection, schema, table);
assertFields(metadata, primaryKey, before, after);
const changed = Object.fromEntries(Object.entries(after).filter(([name, value]) => !equal(before[name], value)));
if (!Object.keys(changed).length) return { changed: false };
const update = assignments(changed, metadata);
const guard = condition(before);
const [result] = await connection.execute(
`UPDATE ${qualified(schema, table)} SET ${update.sql}
WHERE ${quote(primaryKey)} = ?${guard.sql ? ` AND ${guard.sql}` : ''}`,
[...update.params, parameter(pkValue), ...guard.params]);
if (result.affectedRows !== 1) {
const error = new Error(`数据已变化或不存在:${schema}.${table},本次更新已停止`);
error.code = 'MIGRATION_CAS_CONFLICT';
throw error;
}
if (result.warningStatus > 0) {
throw new Error('数据库更新产生警告,可能发生字段截断或类型转换;事务必须回滚');
}
// Native JSON columns normalize whitespace/key order, and MySQL may convert
// numeric request values. Rollback must compare against the bytes/type that
// the database actually persisted, not the JavaScript UPDATE parameters.
const [storedRows] = await connection.execute(
`SELECT ${Object.keys(changed).map(quote).join(', ')} FROM ${qualified(schema, table)}
WHERE ${quote(primaryKey)} = ? FOR UPDATE`, [parameter(pkValue)]);
if (storedRows.length !== 1 || Object.keys(changed).some(name => !own(storedRows[0], name))) {
throw new Error('无法读取更新后的记录,事务必须回滚');
}
const persisted = Object.fromEntries(Object.keys(changed).map(name => [name, storedRows[0][name]]));
const [journal] = await connection.execute(
`INSERT INTO ${qualified(journalSchema, CHANGES)}
(run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[runId, schema, table, primaryKey, JSON.stringify(pack(pkValue)), snapshot(before), snapshot(persisted)]);
return { changed: true, journalId: String(journal.insertId) };
}
function primaryKeyIdentity(value) {
if (Buffer.isBuffer(value)) return `buffer:${value.toString('base64')}`;
if (!['string', 'number', 'bigint'].includes(typeof value)
|| (typeof value === 'number' && !Number.isSafeInteger(value))) {
throw new Error('批量变更主键必须是字符串、安全整数、BigInt 或 Buffer');
}
// mysql2 may return integer primary keys as either strings or numbers.
return `scalar:${String(value)}`;
}
/**
* Apply one homogeneous batch with one CASE UPDATE, one persisted-value read,
* and one journal INSERT. The caller must roll back the entire transaction on
* any error: the UPDATE can have matched only part of a concurrently edited
* batch before affectedRows reports the conflict.
*/
export async function applyChanges(connection, journalSchema, runId, changes) {
if (!Array.isArray(changes)) throw new Error('批量变更必须为数组');
if (!changes.length) return { changed: 0, skipped: 0 };
const { schema, table, primaryKey } = changes[0];
const afterFields = Object.keys(changes[0].after).sort();
const identities = new Set();
for (const change of changes) {
if (change.schema !== schema || change.table !== table || change.primaryKey !== primaryKey) {
throw new Error('批量变更必须使用相同的数据库、表和主键列');
}
if (JSON.stringify(Object.keys(change.after).sort()) !== JSON.stringify(afterFields)) {
throw new Error('批量变更的目标字段集合必须一致');
}
const identity = primaryKeyIdentity(change.pkValue);
if (identities.has(identity)) throw new Error('批量变更包含重复主键');
identities.add(identity);
}
const metadata = await tableMetadata(connection, schema, table);
const active = [];
for (const change of changes) {
assertFields(metadata, primaryKey, change.before, change.after);
const changed = Object.fromEntries(Object.entries(change.after).filter(([name, value]) => !equal(change.before[name], value)));
if (Object.keys(changed).length) active.push({ ...change, changed });
}
const skipped = changes.length - active.length;
if (!active.length) return { changed: 0, skipped };
const fields = afterFields.filter(name => active.some(change => own(change.changed, name)));
const updateParams = [];
const set = fields.map(name => {
const branches = active.filter(change => own(change.changed, name)).map(change => {
updateParams.push(parameter(change.pkValue), parameter(change.changed[name]));
return 'WHEN ? THEN ?';
});
return `${quote(name)} = CASE ${quote(primaryKey)} ${branches.join(' ')} ELSE ${quote(name)} END`;
});
set.push(...metadata.automatic.filter(name => !fields.includes(name)).map(name => `${quote(name)} = ${quote(name)}`));
const where = active.map(change => {
const guard = condition(change.before);
updateParams.push(parameter(change.pkValue), ...guard.params);
return `(${quote(primaryKey)} = ?${guard.sql ? ` AND ${guard.sql}` : ''})`;
});
const [updated] = await connection.execute(
`UPDATE ${qualified(schema, table)} SET ${set.join(', ')} WHERE ${where.join(' OR ')}`, updateParams);
if (updated.affectedRows !== active.length) {
const error = new Error(`批量更新发现数据已变化或不存在:${schema}.${table},必须回滚整批事务`);
error.code = 'MIGRATION_CAS_CONFLICT';
throw error;
}
if (updated.warningStatus > 0) throw new Error('数据库批量更新产生警告,可能发生字段截断或类型转换;事务必须回滚');
const [storedRows] = await connection.execute(
`SELECT ${[primaryKey, ...fields].map(quote).join(', ')} FROM ${qualified(schema, table)}
WHERE ${quote(primaryKey)} IN (${active.map(() => '?').join(', ')}) FOR UPDATE`,
active.map(change => parameter(change.pkValue)));
if (storedRows.length !== active.length) throw new Error('无法读取批量更新后的全部记录,事务必须回滚');
const storedByKey = new Map();
for (const row of storedRows) {
if (![primaryKey, ...fields].every(name => own(row, name))) throw new Error('批量更新读回记录缺少字段,事务必须回滚');
const identity = primaryKeyIdentity(row[primaryKey]);
if (storedByKey.has(identity)) throw new Error('批量更新读回记录包含重复主键,事务必须回滚');
storedByKey.set(identity, row);
}
const journalParams = [];
for (const change of active) {
const row = storedByKey.get(primaryKeyIdentity(change.pkValue));
if (!row) throw new Error('批量更新读回主键与请求不一致,事务必须回滚');
const persisted = Object.fromEntries(Object.keys(change.changed).map(name => [name, row[name]]));
journalParams.push(runId, schema, table, primaryKey, JSON.stringify(pack(change.pkValue)), snapshot(change.before), snapshot(persisted));
}
const [journal] = await connection.execute(
`INSERT INTO ${qualified(journalSchema, CHANGES)}
(run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json)
VALUES ${active.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')}`, journalParams);
if (journal.affectedRows !== active.length || journal.warningStatus > 0) {
throw new Error('批量回滚日志未完整保存,事务必须回滚');
}
return { changed: active.length, skipped };
}
export async function rollbackChanges(connection, journalSchema, runId) {
const result = { restored: 0, alreadyRestored: 0, conflicts: [] };
let cursor = null;
const journal = qualified(journalSchema, CHANGES);
while (true) {
const [batch] = await connection.execute(
`SELECT CAST(id AS CHAR) AS id FROM ${journal}
WHERE run_id = ? AND rolled_back = 0${cursor === null ? '' : ' AND id < ?'} ORDER BY id DESC LIMIT 100`,
cursor === null ? [runId] : [runId, cursor]);
if (!batch.length) break;
for (const entry of batch) {
cursor = String(entry.id);
await connection.beginTransaction();
try {
const [[row]] = await connection.execute(`SELECT * FROM ${journal} WHERE id = ? AND run_id = ? FOR UPDATE`, [cursor, runId]);
if (!row || Number(row.rolled_back) === 1) { await connection.commit(); continue; }
const before = restoreSnapshot(row.before_json);
const after = restoreSnapshot(row.after_json);
const pkValue = unpack(JSON.parse(row.pk_value));
const metadata = await tableMetadata(connection, row.schema_name, row.table_name);
assertFields(metadata, row.pk_name, before, after);
const target = qualified(row.schema_name, row.table_name);
const expectedAfter = condition({ ...before, ...after });
const [matchesAfter] = await connection.execute(
`SELECT 1 AS matched FROM ${target} WHERE ${quote(row.pk_name)} = ? AND ${expectedAfter.sql} FOR UPDATE`,
[parameter(pkValue), ...expectedAfter.params]);
let alreadyRestored = false;
if (!matchesAfter.length) {
const expectedBefore = condition(before);
const [matchesBefore] = await connection.execute(
`SELECT 1 AS matched FROM ${target} WHERE ${quote(row.pk_name)} = ? AND ${expectedBefore.sql} FOR UPDATE`,
[parameter(pkValue), ...expectedBefore.params]);
if (!matchesBefore.length) {
await connection.rollback();
result.conflicts.push({ id: cursor, schema: row.schema_name, table: row.table_name, pkValue: typeof pkValue === 'bigint' ? pkValue.toString() : pkValue });
continue;
}
alreadyRestored = true;
} else {
const values = Object.fromEntries(Object.keys(after).map(name => [name, before[name]]));
const update = assignments(values, metadata);
const [updated] = await connection.execute(
`UPDATE ${target} SET ${update.sql} WHERE ${quote(row.pk_name)} = ? AND ${expectedAfter.sql}`,
[...update.params, parameter(pkValue), ...expectedAfter.params]);
if (updated.affectedRows !== 1) throw new Error('已锁定的回滚记录发生变化,事务已终止');
if (updated.warningStatus > 0) throw new Error('数据库回滚产生警告,事务已终止');
}
await connection.execute(`UPDATE ${journal} SET rolled_back = 1 WHERE id = ? AND run_id = ?`, [cursor, runId]);
await connection.commit();
if (alreadyRestored) result.alreadyRestored++;
else result.restored++;
} catch (error) {
await connection.rollback();
throw error;
}
}
}
return result;
}
@@ -0,0 +1,43 @@
/**
* Keep database waits and long scans visible without logging every record.
* update() also checks elapsed time when a synchronous loop delays timers.
*/
export function createProgress({ log = console.log, intervalMs = 5000, now = Date.now } = {}) {
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) throw new Error('进度输出间隔必须为正整数毫秒');
let message = '';
let startedAt;
let lastOutput;
let closed = false;
function output(force = false) {
if (closed || !message) return;
const current = now();
if (!force && lastOutput !== undefined && current - lastOutput < intervalMs) return;
lastOutput = current;
const seconds = Math.max(0, Math.floor((current - startedAt) / 1000));
log(message + '(已用时 ' + seconds + ' 秒)');
}
const timer = setInterval(() => output(), intervalMs);
timer.unref?.();
return {
stage(value) {
if (closed) return;
message = String(value ?? '').trim();
startedAt = now();
output(true);
},
update(value) {
if (closed) return;
message = String(value ?? '').trim();
if (startedAt === undefined) startedAt = now();
output();
},
close() {
if (closed) return;
closed = true;
clearInterval(timer);
},
};
}
@@ -0,0 +1,223 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { parseAllDocuments } from 'yaml';
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const canonical = key => key.replace(/[-_]/g, '').toLowerCase();
const environmentName = path => path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[.-]/g, '_').toUpperCase();
function merge(target, source) {
const result = Object.assign(Object.create(null), target);
for (const [key, value] of Object.entries(source)) {
result[key] = record(value) && record(result[key]) ? merge(result[key], value) : value;
}
return result;
}
function property(root, path) {
const parts = path.split('.');
let current = root;
for (let index = 0; index < parts.length; index++) {
if (!record(current)) return undefined;
const remainder = parts.slice(index).join('.');
if (own(current, remainder)) return current[remainder];
const part = parts[index];
let key = part;
if (!own(current, key)) {
const matches = Object.keys(current).filter(candidate => canonical(candidate) === canonical(part));
if (matches.length > 1) throw new Error('配置存在多个等价属性名,无法自动选择;请使用明确的迁移配置');
if (!matches.length) return undefined;
key = matches[0];
}
current = current[key];
}
return current;
}
function resolver(configuration, env) {
const raw = name => {
if (own(env, name) && env[name] !== undefined) return env[name];
const key = environmentName(name);
if (own(env, key) && env[key] !== undefined) return env[key];
return property(configuration, name);
};
const scalar = value => {
if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
throw new Error('自动配置所需字段必须为单个值,请检查环境配置');
}
return String(value);
};
function expand(value, trail = []) {
const text = scalar(value);
let output = '', cursor = 0;
while (cursor < text.length) {
const start = text.indexOf('${', cursor);
if (start < 0) return output + text.slice(cursor);
output += text.slice(cursor, start);
let depth = 1, end = start + 2;
for (; end < text.length && depth; end++) {
if (text.startsWith('${', end)) { depth++; end++; }
else if (text[end] === '}') depth--;
}
if (depth) throw new Error('自动配置所需字段包含未闭合的占位符,请检查环境配置');
const expression = text.slice(start + 2, end - 1);
let separator = -1, nested = 0;
for (let index = 0; index < expression.length; index++) {
if (expression.startsWith('${', index)) { nested++; index++; }
else if (expression[index] === '}') nested--;
else if (expression[index] === ':' && nested === 0) { separator = index; break; }
}
const name = (separator < 0 ? expression : expression.slice(0, separator)).trim();
if (!name || name.includes('${')) throw new Error('自动配置不支持动态占位符名称,请使用明确的迁移配置');
if (trail.includes(name) || trail.length >= 32) throw new Error('自动配置所需字段存在循环占位符引用,请检查环境配置');
let replacement = raw(name);
if (replacement === undefined || replacement === null) {
if (separator < 0) throw new Error('自动配置所需占位符未提供,请设置对应环境变量或使用明确的迁移配置');
replacement = expression.slice(separator + 1);
}
output += expand(replacement, [...trail, name]);
cursor = end;
}
return output;
}
return {
raw,
field(name, fallback) {
const value = raw(name);
if (value === undefined || value === null) {
if (fallback !== undefined) return expand(fallback);
throw new Error('项目配置缺少迁移所需字段,请检查主数据源配置或使用明确的迁移配置');
}
return expand(value, [name]);
},
expand,
};
}
async function yamlFile(path) {
let text;
try { text = await readFile(path, 'utf8'); }
catch { throw new Error('无法读取基础或所选环境 YAML,请检查项目路径和 --profile'); }
try {
const documents = parseAllDocuments(text, { uniqueKeys: true, intAsBigInt: true });
let result = Object.create(null);
for (const document of documents) {
if (document.errors.length || document.warnings.length) throw new Error();
const value = document.toJS({ maxAliasCount: 100 });
if (value === null) continue;
if (!record(value)) throw new Error();
if (property(value, 'spring.config.activate.on-profile') !== undefined
|| typeof property(value, 'spring.profiles') === 'string'
|| Array.isArray(property(value, 'spring.profiles'))) {
throw Object.assign(new Error('自动配置不支持带 profile 条件的 YAML 文档,请使用明确的迁移配置'), { unsupported: true });
}
result = merge(result, value);
}
return result;
} catch (error) {
if (error.unsupported) throw error;
throw new Error('YAML 无法安全解析,请检查缩进、重复键和文档结构');
}
}
function singleProfile(value) {
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value.trim())) {
throw new Error('自动配置仅支持单个 profile,请使用 --profile dev、local 或 prod 明确选择');
}
return value.trim();
}
function assertSelectionSupported(configuration, profile, env) {
const resolved = resolver(configuration, env);
const imports = resolved.raw('spring.config.import');
const includes = resolved.raw('spring.profiles.include');
if (imports !== undefined && imports !== null && imports !== '' && (!Array.isArray(imports) || imports.length)) {
throw new Error('自动配置不支持 spring.config.import,请使用明确的迁移配置');
}
if (includes !== undefined && includes !== null && includes !== '' && (!Array.isArray(includes) || includes.length)) {
throw new Error('自动配置不支持附加 profiles.include,请使用明确的迁移配置');
}
const groups = property(configuration, 'spring.profiles.group');
if ((record(groups) && own(groups, profile))
|| resolved.raw('spring.profiles.group.' + profile) !== undefined) {
throw new Error('所选 profile 是配置组,无法唯一确定数据源;请使用具体环境或明确的迁移配置');
}
}
function port(value, fallbackMessage) {
if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new Error(fallbackMessage);
return Number(value);
}
function mysqlAddress(value) {
if (!value.startsWith('jdbc:mysql://')) throw new Error('自动配置仅支持单主机 jdbc:mysql:// URL,请使用明确的迁移配置');
try {
const url = new URL(value.slice(5));
const host = url.hostname.replace(/^\[|\]$/g, '');
if (!host || (!isIP(host) && !/^[a-zA-Z0-9.-]+$/.test(host)) || url.username || url.password || url.hash) throw new Error();
const parts = url.pathname.split('/');
if (parts.length !== 2 || !parts[1]) throw new Error();
const database = decodeURIComponent(parts[1]);
if (!database || /[/\\\u0000]/.test(database)) throw new Error();
return { host, port: url.port ? port(url.port, '') : 3306, database };
} catch {
throw new Error('MySQL URL 必须指定单个主机、有效端口和数据库,不能含账号或多主机地址');
}
}
function route(value, allowEmpty) {
if (value === '' && allowEmpty) return '';
if (!value.startsWith('/') || /[?#\\]/.test(value)
|| value.split('/').some(part => part === '.' || part === '..')) {
throw new Error('HTTP context-path 或 API 前缀无效,请使用明确的迁移下载地址');
}
return value.replace(/\/+$/, '');
}
/**
* Read only the selected project's YAML. This deliberately supports a narrow
* Spring configuration subset and never logs credentials or parser source text.
*/
export async function loadProjectConfig({ projectRoot, profile, env = process.env }) {
if (!projectRoot || typeof projectRoot !== 'string') throw new Error('请指定后端项目根目录');
const resourceRoot = resolve(projectRoot, 'yudao-server/src/main/resources');
const baseFile = resolve(resourceRoot, 'application.yaml');
const base = await yamlFile(baseFile);
const baseResolver = resolver(base, env);
let selected;
if (profile !== undefined) selected = profile;
else if (own(env, 'SPRING_PROFILES_ACTIVE') && env.SPRING_PROFILES_ACTIVE !== undefined) selected = env.SPRING_PROFILES_ACTIVE;
else selected = baseResolver.raw('spring.profiles.active');
if (typeof selected === 'string') selected = baseResolver.expand(selected);
const selectedProfile = singleProfile(selected);
assertSelectionSupported(base, selectedProfile, env);
const selectedFile = resolve(resourceRoot, 'application-' + selectedProfile + '.yaml');
const selectedConfiguration = await yamlFile(selectedFile);
if (property(selectedConfiguration, 'spring.profiles.active') !== undefined) {
throw new Error('所选环境文件再次声明 active profile,自动选择存在歧义;请使用明确的迁移配置');
}
const configuration = merge(base, selectedConfiguration);
assertSelectionSupported(configuration, selectedProfile, env);
const selectedResolver = resolver(configuration, env);
const primary = selectedResolver.field('spring.datasource.dynamic.primary', 'master');
if (!/^[a-zA-Z0-9_-]+$/.test(primary)) throw new Error('主数据源名称无效,请检查 dynamic.primary');
const datasource = 'spring.datasource.dynamic.datasource.' + primary + '.';
const address = mysqlAddress(selectedResolver.field(datasource + 'url'));
const user = selectedResolver.field(datasource + 'username');
const password = selectedResolver.field(datasource + 'password');
if (!user.trim() || !password.trim()) throw new Error('主数据库用户名或密码缺失,请配置凭据后重试');
if (/^\s*ENC\s*\(/i.test(password)) throw new Error('数据库密码仍为 ENC 加密内容,请通过环境变量提供已解密密码');
const serverPort = port(selectedResolver.field('server.port', '8080'), 'HTTP 端口无效,无法推导下载地址');
const context = route(selectedResolver.field('server.servlet.context-path', ''), true);
const prefix = route(selectedResolver.field('yudao.web.admin-api.prefix', '/admin-api'), false);
const ssl = selectedResolver.field('server.ssl.enabled', 'false');
if (!['true', 'false'].includes(ssl.toLowerCase())) throw new Error('HTTP SSL 配置无效,无法推导下载地址');
return {
profile: selectedProfile,
mysql: { ...address, user, password },
downloadBaseUrl: (ssl.toLowerCase() === 'true' ? 'https' : 'http') + '://127.0.0.1:' + serverPort + context + prefix + '/infra/file',
sourceFiles: [baseFile, selectedFile],
};
}
@@ -0,0 +1,207 @@
const DEFAULT_EXCLUDED_TABLES = new Set([
'infra_file', 'infra_file_content', 'infra_file_config',
'infra_api_access_log', 'infra_api_error_log', 'infra_job_log',
'system_operate_log', 'system_login_log',
]);
const MIGRATION_TABLE_PREFIX = /^(?:_+)?(?:migration(?:_|$)|migrate(?:_|$)|(?:infra_file_oss|infra_file|file|oss)_migration(?:_|$))/i;
const URL_RUN = /[a-z][a-z0-9+.-]*:\/\/[^\s"'`<>]+/gi;
const NEXT_URL = /,(?=[a-z][a-z0-9+.-]*:\/\/)/gi;
const TRAILING_DELIMITERS = /[,\])}]+$/;
function lookup(value, replacements) {
const replacement = replacements.get(value);
return typeof replacement === 'string' && replacement !== value
? { value: replacement, changed: true, replacements: 1 }
: { value, changed: false, replacements: 0 };
}
function rewriteUrlPart(part, replacements) {
const exact = lookup(part, replacements);
if (exact.changed || replacements.has(part)) return exact;
// Closing prose/Markdown punctuation is separate from a URL. A dictionary
// entry that includes that punctuation always takes precedence.
const delimiters = part.match(TRAILING_DELIMITERS)?.[0] ?? '';
for (let count = 1; count <= delimiters.length; count += 1) {
const prefix = part.slice(0, -count);
const result = lookup(prefix, replacements);
if (result.changed) return { ...result, value: result.value + part.slice(-count) };
}
return exact;
}
function rewriteUrlRun(run, replacements) {
const exact = rewriteUrlPart(run, replacements);
if (exact.changed || replacements.has(run)) return exact;
const parts = run.split(NEXT_URL);
if (parts.length === 1) return exact;
let count = 0;
const rewritten = [];
for (let index = 0; index < parts.length; index += 1) {
const result = rewriteUrlPart(parts[index], replacements);
rewritten.push(result.value);
count += result.replacements;
// An unknown query/fragment can contain a literal URL. Do not treat its
// comma-delimited contents as independent business references.
if (!result.changed && !replacements.has(parts[index]) && /[?#]/.test(parts[index])) {
rewritten.push(...parts.slice(index + 1));
break;
}
}
return { value: rewritten.join(','), changed: count > 0, replacements: count };
}
function rewriteText(value, replacements) {
const exact = lookup(value, replacements);
if (exact.changed || replacements.has(value)) return exact;
// Validate JSON first, then edit only string-value tokens. This preserves
// whitespace, key spelling and untouched escapes in serialized JSON.
if (/^[\s]*[\[{\"]/.test(value)) {
let isJson = false;
try {
const parsed = JSON.parse(value);
isJson = parsed !== null && (typeof parsed === 'object' || typeof parsed === 'string');
} catch {
// A regular text or HTML field may also begin with a JSON-like character.
}
if (isJson) {
let count = 0;
const rewritten = value.replace(/"(?:\\[\s\S]|[^"\\])*"/g, (token, offset) => {
if (/^\s*:/.test(value.slice(offset + token.length))) return token;
const result = rewriteText(JSON.parse(token), replacements);
count += result.replacements;
return result.changed ? JSON.stringify(result.value) : token;
});
return { value: rewritten, changed: count > 0, replacements: count };
}
}
let count = 0;
const rewritten = value.replace(URL_RUN, (run) => {
const result = rewriteUrlRun(run, replacements);
count += result.replacements;
return result.value;
});
return { value: rewritten, changed: count > 0, replacements: count };
}
/**
* Replace complete absolute URL references, without URL decoding, prefix
* substitution, or query/fragment normalization. JSON values returned as
* objects by mysql2 are supported; object keys are never rewritten.
* The input is not mutated and replacement values are not processed again.
*/
export function rewriteReferences(value, replacements) {
if (!(replacements instanceof Map)) throw new TypeError('replacements must be a Map');
const active = new WeakSet();
function visit(input) {
if (typeof input === 'string') return rewriteText(input, replacements);
if (input === null || typeof input !== 'object' || input instanceof Date || ArrayBuffer.isView(input)) {
return { value: input, changed: false, replacements: 0 };
}
if (active.has(input)) throw new TypeError('Reference values must not contain cycles');
active.add(input);
const output = Array.isArray(input) ? [] : {};
let count = 0;
for (const [key, item] of Object.entries(input)) {
const result = visit(item);
// defineProperty also handles JSON keys such as "__proto__" safely.
Object.defineProperty(output, key, { value: result.value, enumerable: true, writable: true, configurable: true });
count += result.replacements;
}
active.delete(input);
return { value: count > 0 ? output : input, changed: count > 0, replacements: count };
}
return visit(value);
}
/** Detect remaining legacy URLs in text/JSON values, including nested JSON. */
export function containsLegacyReference(value, sourceConfigIds) {
const markers = [...sourceConfigIds].map((id) => `/infra/file/${String(id)}/get/`);
if (!markers.length) return false;
const visited = new WeakSet();
function visit(input) {
if (typeof input === 'string') {
if (/^[\s]*[\[{\"]/.test(input)) {
try {
const parsed = JSON.parse(input);
// Parse JSON before examining its text so keys do not count as
// references, and \\u002f / nested JSON escapes are decoded properly.
return visit(parsed);
} catch {
// Rich text or malformed JSON still receives the literal text check.
}
}
const normalized = input.replaceAll('\\/', '/');
return markers.some((marker) => normalized.includes(marker));
}
if (input === null || typeof input !== 'object' || input instanceof Date || ArrayBuffer.isView(input)) return false;
if (visited.has(input)) return false;
visited.add(input);
return Object.values(input).some(visit);
}
return visit(value);
}
/**
* Discover writable text/JSON columns with one primary-key column. All schema
* names are bound values, never SQL identifiers. excludedTables accepts table
* names or schema.table names in an Array/Set (case-insensitive exclusion).
*/
export async function discoverReferenceColumns(connection, schemas, excludedTables = []) {
const schemaNames = [...new Set(schemas)];
if (schemaNames.some((name) => typeof name !== 'string' || !name.trim())) {
throw new TypeError('schemas must contain non-empty schema names');
}
if (schemaNames.length === 0) return { columns: [], skipped: [] };
const excluded = new Set([...excludedTables].map((name) => String(name).toLowerCase()));
const placeholders = schemaNames.map(() => '?').join(', ');
const [rows] = await connection.execute(`
SELECT c.TABLE_SCHEMA AS schemaName, c.TABLE_NAME AS tableName,
c.COLUMN_NAME AS columnName, c.DATA_TYPE AS dataType,
c.EXTRA AS extra, c.GENERATION_EXPRESSION AS generationExpression,
pk.primaryKey, pk.primaryKeyCount
FROM information_schema.COLUMNS c
JOIN information_schema.TABLES t
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME
LEFT JOIN (
SELECT TABLE_SCHEMA, TABLE_NAME, MIN(COLUMN_NAME) AS primaryKey,
COUNT(*) AS primaryKeyCount
FROM information_schema.KEY_COLUMN_USAGE
WHERE CONSTRAINT_NAME = 'PRIMARY' AND TABLE_SCHEMA IN (${placeholders})
GROUP BY TABLE_SCHEMA, TABLE_NAME
) pk ON pk.TABLE_SCHEMA = c.TABLE_SCHEMA AND pk.TABLE_NAME = c.TABLE_NAME
WHERE c.TABLE_SCHEMA IN (${placeholders}) AND t.TABLE_TYPE = 'BASE TABLE'
AND c.DATA_TYPE IN ('char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'json')
ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION
`, [...schemaNames, ...schemaNames]);
const columns = [];
const skipped = [];
const skippedTables = new Set();
for (const row of rows) {
const schema = row.schemaName;
const table = row.tableName;
const lowerTable = table.toLowerCase();
const fullName = `${schema}.${table}`;
let reason;
if (DEFAULT_EXCLUDED_TABLES.has(lowerTable) || MIGRATION_TABLE_PREFIX.test(table)
|| excluded.has(lowerTable) || excluded.has(fullName.toLowerCase())) {
reason = 'excluded table';
} else if (Number(row.primaryKeyCount) !== 1) {
reason = Number(row.primaryKeyCount) > 1 ? 'composite primary key' : 'no primary key';
}
if (reason) {
if (!skippedTables.has(fullName)) {
skipped.push({ schema, table, reason });
skippedTables.add(fullName);
}
continue;
}
const extra = row.extra ?? '';
if (row.columnName === row.primaryKey || /\bauto_increment\b/i.test(extra)
|| row.generationExpression || /\b(?:VIRTUAL|STORED)\s+GENERATED\b/i.test(extra)) continue;
columns.push({ schema, table, column: row.columnName, primaryKey: row.primaryKey, dataType: row.dataType.toLowerCase() });
}
return { columns, skipped };
}
@@ -0,0 +1,87 @@
import { id, qualified } from './core.mjs';
function sourceChanged() {
return Object.assign(new Error('源图片在任务建立后发生变化或已删除,请检查该批文件'), {
code: 'MIGRATION_SOURCE_CHANGED'
});
}
/**
* Validate a bounded batch immediately after the caller begins an InnoDB
* REPEATABLE READ transaction, BEFORE any consistent read or explicitly created
* consistent snapshot. The plain metadata read must establish its snapshot
* only after the primary-key range has been locked.
*
* The first locking read scans from the minimum expected ID through the upper
* key range, including deleted rows and potential newer versions. This retains
* range/gap protection until commit/rollback, including against undeleting or
* changing another row into a newer matching version. Do not restrict this
* range lock to only expected IDs, active rows, or matching paths.
*
* The caller owns transaction boundaries, rollback on failure and batch size.
*/
export async function verifySourceBatch(db, config, items) {
if (!Array.isArray(items) || items.length === 0) throw new TypeError('源文件校验批次不能为空');
if (!config?.mysql?.database) throw new TypeError('源文件校验需要数据库名称');
const expected = new Map();
for (const item of items) {
const contentId = id(item?.contentId);
const configId = id(item?.configId);
if (expected.has(contentId)) throw new TypeError('源文件校验批次不能包含重复的内容编号');
if (typeof item.path !== 'string' || !item.path.length) throw new TypeError('源文件路径必须为非空字符串');
if (!Number.isSafeInteger(item.size) || item.size < 0 || typeof item.sha256 !== 'string' || !item.sha256.length) {
throw new TypeError('源文件校验需要有效的预期大小和 SHA-256');
}
expected.set(contentId, { ...item, contentId, configId });
}
const entries = [...expected.values()];
const minimumId = entries.reduce((minimum, item) => BigInt(item.contentId) < BigInt(minimum) ? item.contentId : minimum, entries[0].contentId);
const sourceTable = qualified(config.mysql.database, 'infra_file_content');
const cases = entries.map(() => 'WHEN config_id=? AND path=? THEN ?').join(' ');
const predicates = entries.map(() => '(config_id=? AND path=? AND id>=?)').join(' OR ');
const caseParams = entries.flatMap(item => [item.configId, item.path, item.contentId]);
const predicateParams = entries.flatMap(item => [item.configId, item.path, item.contentId]);
// A primary-key-only locking scan avoids the large-row access path triggered
// by locking reads that evaluate config/path predicates. Do not filter out
// deleted rows: their undelete could otherwise create a new active version.
await db.execute(
`SELECT id FROM ${sourceTable} WHERE id>=? ORDER BY id FOR UPDATE`,
[minimumId]
);
// CASE is evaluated by MySQL using the path column's collation. The returned
// expected ID is the only grouping key used in JavaScript. This consistent
// read establishes a fresh snapshot while the whole relevant range is held.
const [latestRows] = await db.execute(
`SELECT id,CASE ${cases} END AS expected_content_id FROM ${sourceTable}
WHERE deleted=0 AND id>=? AND (${predicates}) ORDER BY id DESC`,
[...caseParams, minimumId, ...predicateParams]
);
const latest = new Map();
for (const row of latestRows) {
const expectedId = String(row.expected_content_id);
if (!expected.has(expectedId)) throw sourceChanged();
const actualId = id(row.id);
if (!latest.has(expectedId) || BigInt(actualId) > BigInt(latest.get(expectedId))) latest.set(expectedId, actualId);
}
for (const contentId of expected.keys()) {
if (latest.get(contentId) !== contentId) throw sourceChanged();
}
// The range is already locked and each config/path version is validated.
// Digest work now touches only the selected primary keys, not a sorted scan.
const [contentRows] = await db.execute(
`SELECT id,config_id,path,OCTET_LENGTH(content) AS size,LOWER(SHA2(content,256)) AS sha256
FROM ${sourceTable} WHERE deleted=0 AND id IN (${entries.map(() => '?').join(',')}) FOR UPDATE`,
entries.map(item => item.contentId)
);
const verified = new Set();
for (const row of contentRows) {
const contentId = id(row.id);
const item = expected.get(contentId);
if (!item || verified.has(contentId) || Number(row.size) !== item.size || row.sha256 !== item.sha256) throw sourceChanged();
verified.add(contentId);
}
if (verified.size !== expected.size) throw sourceChanged();
}
@@ -0,0 +1,107 @@
import { id, qualified } from './core.mjs';
const compareIds = (left, right) => BigInt(left) < BigInt(right) ? -1 : BigInt(left) > BigInt(right) ? 1 : 0;
function boundary(value) {
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^\d+$/.test(String(value))) {
throw new Error('文件清单上界必须是非负整数;大整数请使用字符串');
}
return BigInt(value).toString();
}
function pathText(value) {
if (Buffer.isBuffer(value)) return value.toString('utf8');
if (typeof value !== 'string') throw new Error('文件路径不是有效的文本值');
return value;
}
function fileMetadata(row) {
return {
id: id(row.id), config_id: id(row.config_id), path: pathText(row.path),
url: row.url, type: row.type, size: row.size
};
}
/**
* Build a metadata-only index in three SELECTs. MySQL performs all path
* grouping/comparisons with the columns' collation; JavaScript only associates
* records by their latest numeric ID. No file bytes or digests are selected.
*/
export async function loadSourceIndex(db, config, { maxId, progress } = {}) {
if (!config?.mysql?.database || !Array.isArray(config.sourceConfigIds) || !config.sourceConfigIds.length) {
throw new Error('文件清单需要数据库名称和至少一个源配置编号');
}
const sourceIds = [...new Set(config.sourceConfigIds.map(id))].sort(compareIds);
const upperBound = maxId === undefined ? undefined : boundary(maxId);
const contentTable = qualified(config.mysql.database, 'infra_file_content');
const fileTable = qualified(config.mysql.database, 'infra_file');
const markers = sourceIds.map(() => '?').join(',');
// Keep the bound outside this grouping: a newer active row above the bound
// must suppress the whole group, not make an older duplicate current again.
const latest = `SELECT config_id,path,MAX(id) AS id FROM ${contentTable}
WHERE deleted=0 AND config_id IN (${markers}) GROUP BY config_id,path`;
const boundClause = upperBound === undefined ? '' : ' WHERE g.id<=?';
const boundParams = upperBound === undefined ? [] : [upperBound];
progress?.stage?.('读取数据库文件分组(只读取元数据,不计算图片哈希)');
const [latestRows] = await db.execute(
`SELECT c.id AS content_id,c.config_id,c.path FROM ${contentTable} c
JOIN (${latest}) g ON g.id=c.id${boundClause} ORDER BY c.id`,
[...sourceIds, ...boundParams]
);
const byContentId = new Map();
let observedMaximum = '0';
for (const row of latestRows) {
const contentId = id(row.content_id);
if (upperBound !== undefined && compareIds(contentId, upperBound) > 0) continue;
const group = {
contentId, configId: id(row.config_id), path: pathText(row.path), files: [], spellings: []
};
byContentId.set(contentId, group);
if (compareIds(contentId, observedMaximum) > 0) observedMaximum = contentId;
}
progress?.stage?.('批量匹配文件记录并检查缺失内容');
const [fileRows] = await db.execute(
`SELECT f.id,f.config_id,f.path,f.url,f.type,f.size,g.id AS content_id
FROM ${fileTable} f LEFT JOIN (${latest}) g
ON g.config_id=f.config_id AND g.path=f.path
WHERE f.deleted=0 AND f.config_id IN (${markers}) ORDER BY f.id`,
[...sourceIds, ...sourceIds]
);
const missingFiles = [];
for (const row of fileRows) {
const file = fileMetadata(row);
if (row.content_id === null || row.content_id === undefined) {
missingFiles.push(file);
continue;
}
// The join deliberately has no upper bound: a file whose latest content
// lies beyond this batch is not a missing-content file.
byContentId.get(id(row.content_id))?.files.push(file);
}
progress?.stage?.('批量收集原路径拼写,保留历史图片地址兼容性');
const [spellingRows] = await db.execute(
`SELECT DISTINCT g.id AS content_id,BINARY c.path AS path
FROM ${contentTable} c JOIN (${latest}) g
ON g.config_id=c.config_id AND g.path=c.path
WHERE c.deleted=0${upperBound === undefined ? '' : ' AND g.id<=?'} ORDER BY content_id`,
[...sourceIds, ...boundParams]
);
const spellingSets = new Map();
for (const row of spellingRows) {
const contentId = id(row.content_id);
if (!byContentId.has(contentId)) continue;
if (!spellingSets.has(contentId)) spellingSets.set(contentId, new Set());
spellingSets.get(contentId).add(pathText(row.path));
}
const groups = [...byContentId.values()].sort((left, right) => compareIds(left.contentId, right.contentId));
for (const group of groups) {
group.files.sort((left, right) => compareIds(left.id, right.id));
group.spellings = [...(spellingSets.get(group.contentId) || [])]
.sort((left, right) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')));
}
missingFiles.sort((left, right) => compareIds(left.id, right.id));
return { maxId: upperBound ?? observedMaximum, groups, missingFiles };
}
@@ -0,0 +1,88 @@
import { performance } from 'node:perf_hooks';
import { qualified, sha256 } from './core.mjs';
import { applyChanges } from './journal.mjs';
import { verifySourceBatch } from './source-batch.mjs';
/**
* Switch a verified batch in one caller-session REPEATABLE READ transaction.
* Source validation must be the first operation after BEGIN: it establishes
* the source range lock before the transaction's consistent-read snapshot.
*/
export async function switchFiles(db, config, items) {
if (!Array.isArray(items)) throw new TypeError('文件切换批次必须为数组');
const timings = { sourceMs: 0, writeMs: 0, statusMs: 0, commitMs: 0 };
if (!items.length) return timings;
const changes = items.flatMap(item => {
if (!Array.isArray(item?.files)) throw new TypeError('文件切换项必须包含 files 数组(无文件记录时使用空数组)');
return item.files.map(file => ({
schema: config.mysql.database, table: 'infra_file', primaryKey: 'id', pkValue: file.id,
before: {
config_id: file.config_id, path: file.path, url: file.url,
type: file.type, size: file.size, deleted: Buffer.from([0])
},
after: {
config_id: config.targetConfigId, path: item.key, url: item.url,
type: item.type, size: item.size
}
}));
});
const keys = items.map(item => sha256(`${item.configId}\0${item.path}`));
if (new Set(keys).size !== keys.length) throw new TypeError('文件切换批次不能包含重复源路径');
if (new Set(changes.map(change => String(change.pkValue))).size !== changes.length) {
throw new TypeError('文件切换批次不能包含重复文件记录');
}
const measure = async (name, action) => {
const started = performance.now();
try { return await action(); }
finally { timings[name] += performance.now() - started; }
};
await db.beginTransaction();
let committing = false;
try {
// Do not insert metadata lookups or other consistent reads before this.
await measure('sourceMs', () => verifySourceBatch(db, config, items));
await measure('writeMs', async () => {
// One source path may have many infra_file rows. Bound prepared-statement
// size while retaining the same source locks and transaction for all rows.
for (let offset = 0; offset < changes.length; offset += 100) {
await applyChanges(db, config.mysql.database, config.runId, changes.slice(offset, offset + 100));
}
});
await measure('statusMs', async () => {
const [result] = await db.execute(
`UPDATE ${qualified(config.mysql.database, 'infra_file_oss_migration_items')}
SET status='switched',error=NULL,updated_at=CURRENT_TIMESTAMP
WHERE run_id=? AND status='verified' AND item_key IN (${keys.map(() => '?').join(',')})`,
[config.runId, ...keys]);
if (result.affectedRows !== items.length) {
throw Object.assign(new Error('部分迁移项已变化或不再处于 verified 状态,必须回滚整批事务'), {
code: 'MIGRATION_CAS_CONFLICT'
});
}
if (result.warningStatus > 0) {
throw Object.assign(new Error('批量迁移状态更新产生数据库警告,必须回滚整批事务'), {
code: 'MIGRATION_SQL_WARNING'
});
}
});
committing = true;
await measure('commitMs', () => db.commit());
return timings;
} catch (error) {
try {
await db.rollback();
} catch (rollbackError) {
throw Object.assign(new Error('事务回滚失败,连接状态不确定,禁止降级重试', { cause: error }), {
code: 'MIGRATION_ROLLBACK_FAILED', fatal: true, noFallback: true, rollbackError
});
}
if (committing) {
// A failed COMMIT acknowledgement does not prove the server rolled back.
throw Object.assign(new Error('事务提交结果不确定,禁止降级重试,请重新核对迁移状态', { cause: error }), {
code: error.code || 'MIGRATION_COMMIT_FAILED', fatal: true, noFallback: true
});
}
throw error;
}
}
+582
View File
@@ -0,0 +1,582 @@
#!/usr/bin/env node
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
import { aliases, bit, downloadUrl, encodePath, ensureObject, errorText, mimeType, normalizeConfig, objectKey, ossSettings, parseStorage, qualified, quote, readHttp, retry, sha256, verifyContent } from './lib/core.mjs';
import { containsLegacyReference, discoverReferenceColumns, rewriteReferences } from './lib/references.mjs';
import { ensureJournal, applyChange, rollbackChanges } from './lib/journal.mjs';
import { loadProjectConfig } from './lib/project-config.mjs';
import { discoverStorageConfig } from './lib/auto-storage.mjs';
import { loadSourceIndex } from './lib/source-index.mjs';
import { createProgress } from './lib/progress.mjs';
import { switchFiles } from './lib/transition.mjs';
import { mapBounded } from './lib/concurrency.mjs';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(SCRIPT_DIR, '../..');
const TRANSITION_BATCH_SIZE = 50;
const VALIDATION_CONCURRENCY = 4;
const seconds = milliseconds => (milliseconds / 1000).toFixed(2);
const HELP = `数据库图片迁移到阿里云 OSS(Node.js 20.19+)
node migrate.mjs 自动读取项目配置,只读预检
node migrate.mjs init 自动生成本机配置,只查询存储配置及已有批次
node migrate.mjs apply 执行迁移;可加 --limit 50 先迁移少量
node migrate.mjs rollback 回滚当前批次的数据库修改
可选:--profile dev | --config 文件路径 | --run-id 新批次名称
默认复用项目当前环境的主数据库和默认 OSS,不需要填写数据库密码或存储编号。
保留原 BLOB、旧配置和 OSS 对象;rollback 只撤销本批次数据库修改。`;
export function parseArgs(argv) {
if (argv.includes('--help') || argv.includes('-h')) return { help: true };
const hasCommand = argv.length && !argv[0].startsWith('--');
const command = hasCommand ? argv[0] : 'plan';
const rest = hasCommand ? argv.slice(1) : argv;
if (!['init', 'plan', 'apply', 'rollback'].includes(command)) throw new Error('命令只能为 init、plan、apply 或 rollback');
const result = { command, limit: Infinity, configPath: resolve(SCRIPT_DIR, 'config.local.json') };
for (let i = 0; i < rest.length; i += 2) {
if (!['--config', '--limit', '--profile', '--run-id'].includes(rest[i]) || !rest[i + 1]) throw new Error('未知参数或缺少参数值');
if (rest[i] === '--config') { result.configPath = resolve(rest[i + 1]); result.explicitConfig = true; }
else if (rest[i] === '--profile') result.profile = rest[i + 1];
else if (rest[i] === '--run-id') result.runId = rest[i + 1];
else result.limit = Number(rest[i + 1]);
}
if (result.limit !== Infinity && (!Number.isSafeInteger(result.limit) || result.limit < 1 || command !== 'apply')) throw new Error('--limit 仅用于 apply,且必须为正整数');
return result;
}
export async function prepareInput(args, projectRoot = PROJECT_ROOT) {
let raw = {}, exists = false;
try { raw = JSON.parse(await readFile(args.configPath, 'utf8')); exists = true; }
catch (error) { if (error.code !== 'ENOENT' || args.explicitConfig) throw error; }
const placeholder = raw.mysql?.database === 'YOUR_DATABASE';
const projectManaged = !raw.mysql || placeholder || raw._autoDiscovery === true;
let input;
if (projectManaged) {
const project = await loadProjectConfig({ projectRoot, profile: args.profile || raw.projectProfile });
input = { ...raw, projectProfile: project.profile, mysql: project.mysql,
_projectDownloadBaseUrl: project.downloadBaseUrl, _autoDiscovery: true };
} else {
if (args.profile) throw new Error('手动 mysql 配置不能同时使用 --profile;删除 mysql 项即可复用项目配置');
input = { ...raw, mysql: { ...raw.mysql } };
}
if (args.runId) input.runId = args.runId;
if (input.targetConfigId === null) delete input.targetConfigId;
if (Array.isArray(input.sourceConfigIds) && input.sourceConfigIds.length === 0) delete input.sourceConfigIds;
const password = input.mysql.passwordEnv ? process.env[input.mysql.passwordEnv] : input.mysql.password;
if (password === undefined) throw new Error('数据库密码未提供;自动模式无需填写 mysql,手动模式请设置指定环境变量');
input.mysql.password = password;
return { input, exists, projectManaged, raw };
}
export async function persistGeneratedConfig(config, args, prepared) {
if (!prepared.projectManaged || (prepared.exists && prepared.raw._autoDiscovery !== true)) return;
const saved = {
projectProfile: config.projectProfile, runId: config.runId,
sourceConfigIds: config.sourceConfigIds, targetConfigId: config.targetConfigId,
sourceMode: config.sourceMode, oldDomains: config.oldDomains,
_autoDiscovery: true, _databaseIdentity: config._databaseIdentity
};
// Persist discovery/batch choices, but read credentials from the project's
// YAML on each invocation. No database or OSS secret is copied here.
const defaults = normalizeConfig({ runId: config.runId, mysql: config.mysql, sourceConfigIds: config.sourceConfigIds, targetConfigId: config.targetConfigId, sourceMode: 'database' });
for (const key of ['targetUrlMode', 'targetPublicDomain', 'objectPrefix', 'referenceSchemas', 'excludeReferenceTables', 'imagesOnly', 'batchSize', 'timeoutMs', 'maxFileBytes', 'retries']) {
if (config[key] !== undefined && JSON.stringify(config[key]) !== JSON.stringify(defaults[key])) saved[key] = config[key];
}
if (config.sourceMode === 'http' || config.targetUrlMode === 'backend') saved.downloadBaseUrl = config.downloadBaseUrl;
const serialized = JSON.stringify(saved, null, 2) + '\n';
if (prepared.exists && JSON.stringify(prepared.raw) === JSON.stringify(saved)) return;
// Never overwrite a pre-existing manual file; generated files may be
// refreshed to pin an explicitly requested new batch.
await writeFile(args.configPath, serialized, { mode: 0o600, flag: prepared.exists ? 'w' : 'wx' });
}
const table = (config, name) => qualified(config.mysql.database, name);
const runs = config => table(config, 'infra_file_oss_migration_runs');
const items = config => table(config, 'infra_file_oss_migration_items');
const parseJson = value => typeof value === 'string' ? JSON.parse(value) : value;
const itemKey = item => sha256(`${item.configId}\0${item.path}`);
const samePlannedItem = (left, right) => {
const canonical = item => ({ ...item, aliases: [...item.aliases].sort(), files: [...item.files].sort((a, b) => BigInt(a.id) < BigInt(b.id) ? -1 : BigInt(a.id) > BigInt(b.id) ? 1 : 0) });
return isDeepStrictEqual(canonical(left), canonical(right));
};
export async function loadSettings(db, config) {
const ids = [...config.sourceConfigIds, config.targetConfigId];
const [rows] = await db.execute(`SELECT id, storage, master, config FROM ${table(config, 'infra_file_config')} WHERE deleted=0 AND id IN (${ids.map(() => '?').join(',')})`, ids);
const byId = new Map(rows.map(row => [String(row.id), row]));
for (const sourceId of config.sourceConfigIds) {
const row = byId.get(sourceId);
if (!row || Number(row.storage) !== 1) throw new Error(`源配置 ${sourceId} 不存在或不是数据库存储`);
if (bit(row.master)) throw new Error(`源配置 ${sourceId} 仍是默认上传配置,请先切换默认配置到 OSS`);
}
const targetRow = byId.get(config.targetConfigId);
if (!targetRow) throw new Error('目标存储配置不存在');
const oss = ossSettings(targetRow, config);
const [identity] = await db.query('SELECT @@server_uuid AS server_uuid');
const safe = {
version: 1, server: identity[0].server_uuid, schema: config.mysql.database,
sourceConfigIds: config.sourceConfigIds, targetConfigId: config.targetConfigId,
sourceDomains: config.sourceConfigIds.map(sourceId => parseStorage(byId.get(sourceId)).domain || ''),
sourceMode: config.sourceMode, downloadBaseUrl: config.downloadBaseUrl || '',
oldDomains: config.oldDomains, targetUrlMode: config.targetUrlMode,
endpoint: oss.endpoint, bucket: oss.bucket, publicDomain: oss.publicDomain,
objectPrefix: config.objectPrefix, imagesOnly: config.imagesOnly,
referenceSchemas: config.referenceSchemas, excludeReferenceTables: config.excludeReferenceTables
};
return { byId, oss, safe };
}
const progressOf = config => config._progress || { stage: message => console.log(message), update() {} };
function sourceItem(group, config, settings) {
const type = mimeType(group.path, group.files);
if (config.imagesOnly && !type.toLowerCase().startsWith('image/')) return null;
encodePath(group.path);
const item = { contentId: String(group.contentId), configId: String(group.configId), path: group.path, type, files: group.files };
const aliasRows = [...group.files, ...group.spellings.map(path => ({ path }))];
item.aliases = aliases(item, aliasRows, parseStorage(settings.byId.get(item.configId)).domain, config);
return item;
}
const missingCount = (index, config) => index.missingFiles.filter(file => !config.imagesOnly || mimeType(file.path, [file]).toLowerCase().startsWith('image/')).length;
export async function sourceCurrent(db, config, item, lock = false) {
// Keep large content out of the unindexed path/order lookup. On the actual
// database the combined lookup + BLOB projection took seconds per image;
// a metadata lookup followed by a primary-key hash read takes milliseconds.
// With lock=true the first SELECT retains the original range-lock semantics.
const [[latest]] = await db.execute(`SELECT id FROM ${table(config, 'infra_file_content')} WHERE deleted=0 AND config_id=? AND path=? ORDER BY id DESC LIMIT 1${lock ? ' FOR UPDATE' : ''}`, [item.configId, item.path]);
if (!latest || String(latest.id) !== String(item.contentId)) throw new Error('源图片在任务建立后发生变化或已删除,请检查该文件');
const [[current]] = await db.execute(`SELECT id,OCTET_LENGTH(content) AS size,LOWER(SHA2(content,256)) AS sha256 FROM ${table(config, 'infra_file_content')} WHERE id=? AND config_id=? AND path=? AND deleted=0${lock ? ' FOR UPDATE' : ''}`, [item.contentId, item.configId, item.path]);
if (!current || String(current.id) !== String(item.contentId) || current.sha256 !== item.sha256 || Number(current.size) !== item.size) throw new Error('源图片在任务建立后发生变化或已删除,请检查该文件');
}
export async function* inventory(db, config, settings, maxId, summary, sourceIndex) {
const progress = progressOf(config);
const index = sourceIndex || await loadSourceIndex(db, config, { maxId, progress });
const candidates = [];
for (const group of index.groups) {
summary.sourceGroups++;
const item = sourceItem(group, config, settings);
if (item) candidates.push(item); else summary.skippedNonImages++;
}
progress.stage(`建立迁移清单:按主键读取并校验 ${candidates.length} 个文件的内容`);
for (let start = 0; start < candidates.length; start += config.batchSize) {
const batch = candidates.slice(start, start + config.batchSize);
progress.update(`建立迁移清单:已校验 ${start}/${candidates.length} 个文件,正在读取下一批`);
const [rows] = await db.execute(`SELECT id,config_id,path,OCTET_LENGTH(content) AS size,LOWER(SHA2(content,256)) AS sha256 FROM ${table(config, 'infra_file_content')} WHERE deleted=0 AND id IN (${batch.map(() => '?').join(',')})`, batch.map(item => item.contentId));
const byId = new Map(rows.map(row => [String(row.id), row]));
for (const item of batch) {
const row = byId.get(item.contentId);
if (!row || String(row.config_id) !== item.configId || row.path !== item.path) throw new Error(`内容记录 ${item.contentId} 在建立清单期间发生变化,请检查后重试`);
item.size = Number(row.size);
item.sha256 = row.sha256;
if (!item.size || item.size > config.maxFileBytes) throw new Error(`内容记录 ${item.contentId} 为空或超过 maxFileBytes`);
item.key = objectKey(config, item);
item.url = config.targetUrlMode === 'backend' ? downloadUrl(config.downloadBaseUrl, config.targetConfigId, item.key) : `${settings.oss.publicDomain}/${encodePath(item.key)}`;
if (item.key.length > 512 || item.url.length > 1024) throw new Error('目标路径或访问地址超出 infra_file 字段长度');
summary.candidates++;
summary.bytes += item.size;
if (!item.files.length) summary.withoutFileRecord++;
yield item;
}
}
progress.stage(`迁移清单已建立:${summary.candidates} 个文件,内容校验完成`);
}
function addMappings(map, item) {
for (const oldUrl of item.aliases) {
if (map.has(oldUrl) && map.get(oldUrl) !== item.url) throw new Error('同一旧地址对应多个目标,必须先解决数据冲突');
map.set(oldUrl, item.url);
}
}
const emptyInventory = () => ({ sourceGroups: 0, candidates: 0, bytes: 0, skippedNonImages: 0, withoutFileRecord: 0 });
async function scanReferences(db, config, map, { apply = false } = {}) {
const progress = progressOf(config);
progress.stage('检查需要扫描的业务表和图片地址字段');
const discovery = await discoverReferenceColumns(db, config.referenceSchemas, config.excludeReferenceTables);
const groups = new Map();
for (const column of discovery.columns) {
const key = JSON.stringify([column.schema, column.table]);
if (!groups.has(key)) groups.set(key, { ...column, columns: [] });
groups.get(key).columns.push(column.column);
}
const result = { scannedRows: 0, changedRows: 0, replacements: 0, unresolvedRows: 0, conflicts: 0, skippedTables: discovery.skipped.filter(entry => entry.reason !== 'excluded table'), excludedTables: discovery.skipped.filter(entry => entry.reason === 'excluded table'), tables: [] };
let tableNumber = 0;
const readBatchSize = Math.max(config.batchSize, 1000);
for (const group of groups.values()) {
tableNumber++;
let scanned = 0;
progress.stage(`引用扫描 ${tableNumber}/${groups.size}:${group.schema}.${group.table}`);
const stat = { schema: group.schema, table: group.table, changedRows: 0, unresolvedRows: 0 };
let cursor;
for (;;) {
const columns = [group.primaryKey, ...group.columns];
const [rows] = await db.query(`SELECT ${columns.map(quote).join(',')} FROM ${qualified(group.schema, group.table)} ${cursor === undefined ? '' : `WHERE ${quote(group.primaryKey)}>?`} ORDER BY ${quote(group.primaryKey)} LIMIT ${readBatchSize}`, cursor === undefined ? [] : [cursor]);
if (!rows.length) break;
for (const row of rows) {
cursor = row[group.primaryKey];
result.scannedRows++;
scanned++;
progress.update(`引用扫描 ${tableNumber}/${groups.size}:${group.schema}.${group.table},已检查 ${scanned} 行,匹配 ${stat.changedRows} 行`);
const before = {}, after = {};
let unresolved = false, replacements = 0;
for (const column of group.columns) {
const rewritten = rewriteReferences(row[column], map);
if (rewritten.changed) { before[column] = row[column]; after[column] = rewritten.value; replacements += rewritten.replacements; }
if (containsLegacyReference(rewritten.value, config.sourceConfigIds)) unresolved = true;
}
if (unresolved) { result.unresolvedRows++; stat.unresolvedRows++; }
if (!Object.keys(after).length) continue;
if (apply) {
await db.beginTransaction();
try {
await applyChange(db, config.mysql.database, config.runId, { schema: group.schema, table: group.table, primaryKey: group.primaryKey, pkValue: cursor, before, after });
await db.commit();
} catch (error) {
await db.rollback();
result.conflicts++;
console.error(`引用更新未完成 ${group.schema}.${group.table}:${errorText(error)}`);
continue;
}
}
result.changedRows++; stat.changedRows++; result.replacements += replacements;
}
}
if (stat.changedRows || stat.unresolvedRows) result.tables.push(stat);
console.log(`引用扫描 ${group.schema}.${group.table}:匹配 ${stat.changedRows} 行,仍含旧地址 ${stat.unresolvedRows} 行`);
}
return result;
}
async function setItem(db, config, item, status, error = null) {
await db.execute(`UPDATE ${items(config)} SET status=?,error=?,updated_at=CURRENT_TIMESTAMP WHERE run_id=? AND item_key=?`, [status, error, config.runId, itemKey(item)]);
}
async function* savedItems(db, config, statuses = null) {
let cursor = '';
for (;;) {
const [rows] = await db.execute(`SELECT item_key,data,status FROM ${items(config)} WHERE run_id=? AND item_key>? ${statuses ? `AND status IN (${statuses.map(() => '?').join(',')})` : ''} ORDER BY item_key LIMIT ${config.batchSize}`, [config.runId, cursor, ...(statuses || [])]);
if (!rows.length) return;
for (const row of rows) { cursor = row.item_key; yield { ...parseJson(row.data), status: row.status }; }
}
}
async function migrateItem(db, config, oss, item) {
const timings = { sourceMs: 0, ossMs: 0, publicMs: 0, recordMs: 0 };
const measure = async (key, label, action) => {
progressOf(config).update(`文件 ${item.contentId}:${label}`);
const started = performance.now();
try { return await action(); } finally { timings[key] += performance.now() - started; }
};
await measure('sourceMs', '复核源文件版本', () => sourceCurrent(db, config, item));
let bytes;
if (config.sourceMode === 'http') {
bytes = await measure('sourceMs', '读取原图片', () => readHttp(downloadUrl(config.downloadBaseUrl, item.configId, item.path), config, item.size));
} else {
const [[row]] = await measure('sourceMs', '读取数据库原图片', () => db.execute(`SELECT content FROM ${table(config, 'infra_file_content')} WHERE id=? AND deleted=0`, [item.contentId]));
if (!row) throw new Error('源内容已删除');
bytes = row.content;
}
verifyContent(bytes, item);
await measure('ossMs', '上传 OSS 并读取校验', () => ensureObject(oss, item.key, bytes, item));
// SDK 可读不代表页面可读:按最终将写入业务表的地址匿名访问并再次校验。
verifyContent(await measure('publicMs', '校验最终图片访问地址', () => readHttp(item.url, config, item.size)), item);
await measure('sourceMs', '复核源图片未发生变化', () => sourceCurrent(db, config, item));
await measure('recordMs', '保存迁移进度', () => setItem(db, config, item, 'verified'));
return timings;
}
export async function verifyCheckpoint(oss, config, item) {
// A verified checkpoint needs no source download or PUT. Still check the
// authenticated object and its final public URL before trusting it again.
const head = await oss.head(item.key);
if (Number(head.res.headers['content-length']) !== item.size || item.size > config.maxFileBytes) {
throw new Error('OSS 文件长度不一致或超过限制');
}
verifyContent((await oss.get(item.key)).content, item);
verifyContent(await readHttp(item.url, config, item.size), item);
}
export async function verifySwitchedBatch(db, config, batch) {
// A resumed run must not reuse a mapping whose object/metadata was deleted
// or changed after the earlier successful batch. Read metadata once; only
// HTTP validation is concurrent, never transactions on the shared connection.
const ids = [...new Set(batch.flatMap(item => item.files.map(file => String(file.id))))];
const byId = new Map();
for (let start = 0; start < ids.length; start += 1000) {
const chunk = ids.slice(start, start + 1000);
const [rows] = await db.execute(`SELECT id,config_id,path,url,deleted FROM ${table(config, 'infra_file')} WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk);
for (const row of rows) byId.set(String(row.id), row);
}
return mapBounded(batch, VALIDATION_CONCURRENCY, async item => {
try {
for (const original of item.files) {
const current = byId.get(String(original.id));
if (!current || bit(current.deleted) || String(current.config_id) !== config.targetConfigId || current.path !== item.key || current.url !== item.url) {
throw new Error('已迁移文件记录后来发生变化或已删除,停止复用该地址映射');
}
}
await retry(async () => verifyContent(await readHttp(item.url, config, item.size), item), config.retries);
return { item };
} catch (error) {
return { item, error };
}
});
}
async function* itemBatches(iterable, size) {
let batch = [];
for await (const item of iterable) {
batch.push(item);
if (batch.length === size) { yield batch; batch = []; }
}
if (batch.length) yield batch;
}
export async function runPlan(db, config, settings) {
const progress = progressOf(config);
progress.stage('开始只读预检:只读取文件信息,不读取图片内容或计算 SHA-256');
const index = await loadSourceIndex(db, config, { progress });
const summary = { command: 'plan', ...emptyInventory(), bytes: null, estimatedBytes: 0, sizePending: 0, contentVerified: false, missingContent: missingCount(index, config) };
const mappings = new Map();
for (const group of index.groups) {
summary.sourceGroups++;
const item = sourceItem(group, config, settings);
if (!item) { summary.skippedNonImages++; continue; }
summary.candidates++;
const sizes = item.files.map(file => Number(file.size)).filter(size => Number.isSafeInteger(size) && size >= 0);
if (sizes.length) summary.estimatedBytes += Math.max(...sizes); else summary.sizePending++;
if (!item.files.length) summary.withoutFileRecord++;
// Preview markers exist only in memory. Actual object keys require the
// verified content hash and are created exclusively during apply.
item.url = `migration-preview://${item.configId}/${item.contentId}`;
addMappings(mappings, item);
}
progress.stage(`已识别 ${summary.candidates} 个图片文件;开始检查业务引用(实际内容在迁移时校验)`);
summary.references = await scanReferences(db, config, mappings);
summary.status = summary.missingContent || summary.references.unresolvedRows || summary.references.skippedTables.length ? 'needs-review' : 'planned';
progress.stage('只读预检完成');
console.log('预检未读取图片二进制内容、未计算 SHA-256;容量仅为文件表估算。未验证 HTTP/OSS 连通性,也未创建迁移表、上传或修改数据。');
return summary;
}
export async function runApply(db, config, settings, limit = Infinity, createOss = null) {
const progress = progressOf(config);
progress.stage('读取迁移批次和断点记录');
// Batch source checks depend on InnoDB next-key locks and a snapshot first
// established AFTER their range lock, even if the server defaults to RC.
await db.query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
const [engines] = await db.execute(`SELECT TABLE_NAME AS name,ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN ('infra_file_content','infra_file')`, [config.mysql.database]);
if (engines.length !== 2 || engines.some(row => String(row.engine).toLowerCase() !== 'innodb')) {
throw new Error('infra_file_content 和 infra_file 必须为 InnoDB,才能保证源校验与记录更新的事务保护');
}
await ensureJournal(db, config.mysql.database);
const [[saved]] = await db.execute(`SELECT settings,status FROM ${runs(config)} WHERE run_id=?`, [config.runId]);
let runSettings, sourceIndex;
if (saved) {
runSettings = parseJson(saved.settings);
if (JSON.stringify(runSettings.binding) !== JSON.stringify(settings.safe)) throw new Error('同一 runId 的数据库、源/目标或引用范围发生变化,请恢复原配置或使用新 runId');
if (saved.status.startsWith('rollback') || saved.status === 'rolled_back') throw new Error('本批次已开始回滚,不能继续迁移,请继续回滚或使用新 runId');
} else {
sourceIndex = await loadSourceIndex(db, config, { progress });
const missing = missingCount(sourceIndex, config);
if (missing) throw new Error(`发现 ${missing} 条图片记录没有数据库内容,请先执行 plan 并处理`);
runSettings = { binding: settings.safe, maxId: sourceIndex.maxId };
await db.execute(`INSERT INTO ${runs(config)} (run_id,settings,status) VALUES (?,?,'inventory')`, [config.runId, JSON.stringify(runSettings)]);
}
const summary = { command: 'apply', ...emptyInventory(), ...runSettings.inventory, uploaded: 0, revalidated: 0, switched: 0, failed: 0, limited: false };
if (!saved || saved.status === 'inventory') {
for await (const item of inventory(db, config, settings, runSettings.maxId, summary, sourceIndex)) {
const [[existing]] = await db.execute(`SELECT data FROM ${items(config)} WHERE run_id=? AND item_key=?`, [config.runId, itemKey(item)]);
if (existing) {
if (!samePlannedItem(parseJson(existing.data), item)) throw new Error('建立清单期间源数据发生变化,请检查后建立新批次');
} else {
await db.execute(`INSERT INTO ${items(config)} (run_id,item_key,data,status) VALUES (?,?,?,'planned')`, [config.runId, itemKey(item), JSON.stringify(item)]);
}
}
runSettings.inventory = Object.fromEntries(Object.keys(emptyInventory()).map(key => [key, summary[key]]));
await db.execute(`UPDATE ${runs(config)} SET settings=?,status='active',updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [JSON.stringify(runSettings), config.runId]);
}
// 先验证所有映射没有歧义,再发生 OSS 写入。
const allMappings = new Map();
let initialSwitched = 0, totalItems = 0;
progress.stage('检查迁移地址映射是否存在冲突');
for await (const item of savedItems(db, config)) {
addMappings(allMappings, item);
totalItems++;
if (item.status === 'switched') initialSwitched++;
}
const { publicDomain, ...ossOptions } = settings.oss;
const oss = createOss ? createOss() : new (await import('ali-oss')).default({ ...ossOptions, timeout: config.timeoutMs });
let processed = 0;
const selected = [], checkpoints = [];
progress.stage('开始上传并校验 OSS 文件');
for await (const item of savedItems(db, config, ['planned', 'failed', 'verified'])) {
if (processed >= limit) { summary.limited = true; break; }
processed++;
if (item.status === 'verified') { checkpoints.push(item); continue; }
try {
const started = performance.now();
const timings = await retry(() => migrateItem(db, config, oss, item), config.retries);
summary.uploaded++;
selected.push(item);
console.log(`上传校验通过:内容记录 ${item.contentId}(本次 ${processed},总计 ${seconds(performance.now() - started)} 秒;源读取/复核 ${seconds(timings.sourceMs)} 秒,OSS 上传/校验 ${seconds(timings.ossMs)} 秒,访问校验 ${seconds(timings.publicMs)} 秒)`);
} catch (error) {
await setItem(db, config, item, 'failed', errorText(error));
summary.failed++;
console.error(`上传未完成:内容记录 ${item.contentId},${errorText(error)}`);
}
}
if (checkpoints.length) {
let checked = 0;
progress.stage(`复核已上传断点:0/${checkpoints.length},最多 ${VALIDATION_CONCURRENCY} 张同时校验`);
const results = await mapBounded(checkpoints, VALIDATION_CONCURRENCY, async item => {
try {
await retry(() => verifyCheckpoint(oss, config, item), config.retries);
return { item };
} catch (error) {
return { item, error };
} finally {
progress.update(`复核已上传断点:${++checked}/${checkpoints.length}`);
}
});
// All network workers have settled before using the database again.
for (const { item, error } of results) {
if (error) {
await setItem(db, config, item, 'failed', errorText(error));
summary.failed++;
console.error(`已上传断点校验失败:内容记录 ${item.contentId},${errorText(error)}`);
} else { selected.push(item); summary.revalidated++; }
}
}
if (summary.failed) {
summary.status = 'partial';
const [counts] = await db.execute(`SELECT status,COUNT(*) AS count FROM ${items(config)} WHERE run_id=? GROUP BY status`, [config.runId]);
summary.items = Object.fromEntries(counts.map(row => [row.status, Number(row.count)]));
await db.execute(`UPDATE ${runs(config)} SET status='partial',updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [config.runId]);
return summary;
}
const switchProgress = () => `更新已校验文件记录:本次完成 ${summary.switched}/${selected.length},累计 ${initialSwitched + summary.switched}/${totalItems}`;
progress.stage(switchProgress());
for (let start = 0; start < selected.length; start += TRANSITION_BATCH_SIZE) {
const batch = selected.slice(start, start + TRANSITION_BATCH_SIZE);
const started = performance.now();
try {
const timing = await switchFiles(db, config, batch);
summary.switched += batch.length;
console.log(`文件记录批次完成:${batch.length} 张,${seconds(performance.now() - started)} 秒(源校验 ${seconds(timing.sourceMs)} 秒,文件及回滚日志 ${seconds(timing.writeMs)} 秒,进度 ${seconds(timing.statusMs)} 秒,提交 ${seconds(timing.commitMs)} 秒);累计 ${initialSwitched + summary.switched}/${totalItems}`);
} catch (error) {
// Isolate known data conflicts after the whole batch rolled back. Never
// replay a transaction with an ambiguous commit or broken connection.
if (error.fatal || !['MIGRATION_CAS_CONFLICT', 'MIGRATION_SOURCE_CHANGED'].includes(error.code)) throw error;
console.error(`该批文件存在修改冲突,事务已回滚;逐项定位:${errorText(error)}`);
for (const item of batch) {
try { await switchFiles(db, config, [item]); summary.switched++; }
catch (itemError) {
if (itemError.fatal || !['MIGRATION_CAS_CONFLICT', 'MIGRATION_SOURCE_CHANGED'].includes(itemError.code)) throw itemError;
summary.failed++;
console.error(`文件记录未切换:内容记录 ${item.contentId},${errorText(itemError)}`);
}
}
}
progress.update(switchProgress());
}
const mappings = new Map();
let checkedSwitched = 0;
progress.stage(`复核已迁移文件的访问地址:0/${initialSwitched + summary.switched}`);
for await (const batch of itemBatches(savedItems(db, config, ['switched']), TRANSITION_BATCH_SIZE)) {
for (const { item, error } of await verifySwitchedBatch(db, config, batch)) {
if (error) {
summary.failed++;
console.error(`目标地址不可复用:内容记录 ${item.contentId},${errorText(error)}`);
} else addMappings(mappings, item);
}
checkedSwitched += batch.length;
progress.update(`复核已迁移文件的访问地址:${checkedSwitched}/${initialSwitched + summary.switched}`);
}
summary.references = await scanReferences(db, config, mappings, { apply: true });
const [counts] = await db.execute(`SELECT status,COUNT(*) AS count FROM ${items(config)} WHERE run_id=? GROUP BY status`, [config.runId]);
summary.items = Object.fromEntries(counts.map(row => [row.status, Number(row.count)]));
const unfinished = counts.some(row => row.status !== 'switched');
summary.status = unfinished || summary.failed || summary.references.conflicts ? 'partial'
: summary.references.unresolvedRows || summary.references.skippedTables.length ? 'needs-review' : 'complete';
await db.execute(`UPDATE ${runs(config)} SET status=?,updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [summary.status, config.runId]);
return summary;
}
export async function runRollback(db, config) {
const [[run]] = await db.execute(`SELECT settings,status FROM ${runs(config)} WHERE run_id=?`, [config.runId]);
if (!run) throw new Error('找不到此 runId 的迁移日志');
const [[identity]] = await db.query('SELECT @@server_uuid AS server_uuid');
const binding = parseJson(run.settings).binding;
if (binding.schema !== config.mysql.database || binding.server !== identity.server_uuid) throw new Error('回滚数据库与迁移时不一致');
await db.execute(`UPDATE ${runs(config)} SET status='rollback',updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [config.runId]);
const result = await rollbackChanges(db, config.mysql.database, config.runId);
const status = result.conflicts.length ? 'rollback_partial' : 'rolled_back';
await db.execute(`UPDATE ${runs(config)} SET status=?,updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [status, config.runId]);
return { command: 'rollback', status, ...result };
}
export async function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
if (args.help) { console.log(HELP); return; }
const prepared = await prepareInput(args);
const { default: mysql } = await import('mysql2/promise');
const { passwordEnv, ...mysqlOptions } = prepared.input.mysql;
console.log(`读取${prepared.input.projectProfile ? `项目 ${prepared.input.projectProfile} 环境` : '指定配置'}:MySQL ${mysqlOptions.host || 'localhost'}:${mysqlOptions.port || 3306}/${mysqlOptions.database}`);
const db = await mysql.createConnection({ connectTimeout: 10000, ...mysqlOptions, multipleStatements: false, supportBigNumbers: true, bigNumberStrings: true, jsonStrings: true, dateStrings: true });
const progress = createProgress();
let locked = false;
const lockName = `oss-file-migration:${sha256(mysqlOptions.database).slice(0, 40)}`;
try {
progress.stage('识别旧存储、默认 OSS 和迁移批次');
await db.query("SET SESSION sql_mode = CONCAT_WS(',', NULLIF(@@SESSION.sql_mode,''), 'STRICT_ALL_TABLES')");
if (['apply', 'rollback'].includes(args.command)) {
const [[lock]] = await db.execute('SELECT GET_LOCK(?,0) AS acquired', [lockName]);
if (Number(lock.acquired) !== 1) throw new Error('已有文件迁移或回滚任务运行,请等待其结束');
locked = true;
}
const auto = prepared.projectManaged || !prepared.input.runId || !prepared.input.sourceConfigIds?.length || !prepared.input.targetConfigId;
const config = normalizeConfig(auto ? await discoverStorageConfig(db, prepared.input, { command: args.command }) : prepared.input);
config._progress = progress;
await db.query(`SET SESSION max_execution_time=${config.timeoutMs}`);
await persistGeneratedConfig(config, args, prepared);
if (args.command === 'init') {
await loadSettings(db, config);
progress.close();
console.log(`配置已就绪:旧数据库存储 ${config.sourceConfigIds.join(',')} → 默认 OSS ${config.targetConfigId};批次 ${config.runId}`);
console.log(`配置文件:${args.configPath};数据库密码和 OSS 密钥未复制到此文件。`);
console.log('只读取了项目和数据库配置,未上传图片或修改数据库。');
return { status: 'configured', configPath: args.configPath };
}
let summary;
if (args.command === 'rollback') summary = await runRollback(db, config);
else {
const settings = await loadSettings(db, config);
console.log(`源配置 ${config.sourceConfigIds.join(',')} → OSS 配置 ${config.targetConfigId};批次 ${config.runId}`);
summary = args.command === 'plan' ? await runPlan(db, config, settings) : await runApply(db, config, settings, args.limit);
}
const reportDir = resolve(dirname(args.configPath), 'reports');
await mkdir(reportDir, { recursive: true, mode: 0o700 });
const reportPath = resolve(reportDir, `${config.runId}-${args.command}-${Date.now()}.json`);
await writeFile(reportPath, JSON.stringify(summary, null, 2), { mode: 0o600 });
progress.close();
console.log(JSON.stringify(summary, null, 2));
console.log(`报告:${reportPath}`);
if (['partial', 'needs-review', 'rollback_partial'].includes(summary.status)) process.exitCode = 2;
return summary;
} finally {
progress.close();
try { if (locked) await db.execute('SELECT RELEASE_LOCK(?)', [lockName]); }
finally { await db.end(); }
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(error => { console.error(`执行停止:${errorText(error)}`); process.exitCode = 1; });
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,20 @@
{
"name": "friendship-db-files-to-oss",
"private": true,
"type": "module",
"engines": {
"node": ">=20.19"
},
"scripts": {
"test": "node --test test/*.test.mjs",
"init": "node migrate.mjs init",
"plan": "node migrate.mjs",
"apply": "node migrate.mjs apply",
"rollback": "node migrate.mjs rollback"
},
"dependencies": {
"ali-oss": "6.23.0",
"mysql2": "3.15.3",
"yaml": "2.8.2"
}
}
@@ -0,0 +1,172 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { discoverStorageConfig } from '../lib/auto-storage.mjs';
const database = 'local_app';
const server = 'test-server-uuid';
const input = () => ({ mysql: { database, user: 'local-test', password: 'db-test-only' }, projectProfile: 'local', _projectDownloadBaseUrl: 'http://localhost:48080/admin-api/infra/file' });
const source = value => ({ id: value, storage: 1, master: Buffer.from([0]), config: JSON.stringify({ domain: 'https://old.example' }) });
const target = (value = '30', master = 1) => ({ id: value, storage: 20, master: Buffer.from([master]), config: JSON.stringify({ endpoint: 'oss-cn-hangzhou.aliyuncs.com', bucket: 'migration-test', accessKey: 'oss-test-access', accessSecret: 'oss-secret-must-not-escape', domain: 'https://media.example' }) });
function binding(overrides = {}) {
return { server, schema: database, sourceConfigIds: ['5'], targetConfigId: '30', sourceMode: 'database', downloadBaseUrl: 'https://old.example/admin-api/infra/file', oldDomains: ['https://historic.example'], objectPrefix: 'migration/original', imagesOnly: true, referenceSchemas: [database, 'chat'], excludeReferenceTables: ['audit'], targetUrlMode: 'direct', publicDomain: 'https://original-media.example', ...overrides };
}
const run = (runId = 'original-run', status = 'partial', overrides = {}) => ({ run_id: runId, status, settings: JSON.stringify({ binding: binding(overrides), maxId: '9007199254740993' }) });
class FakeDb {
constructor(options = {}) {
this.server = server;
this.configs = [source('5'), target()];
this.sourceIds = ['5'];
this.runs = [];
this.domains = ['https://old.example'];
this.maximum = '9007199254740993';
this.calls = [];
Object.assign(this, options);
}
async execute(sql, params = []) {
assert.match(sql.trim(), /^SELECT\b/i, 'discovery must only issue SELECT');
this.calls.push({ sql, params });
if (sql.includes('@@server_uuid')) return [[{ server_uuid: this.server }]];
if (sql.includes('information_schema.TABLES')) {
assert.deepEqual(params, [database, 'infra_file_oss_migration_runs']);
return [this.runs.length ? [{ present: 1 }] : []];
}
if (sql.includes('infra_file_oss_migration_runs')) return [this.runs];
if (sql.includes('infra_file_config')) return [this.configs];
if (sql.includes('MAX(id)')) return [[{ max_id: this.maximum }]];
if (sql.includes('infra_file_content')) {
assert.match(sql, /deleted=0/);
return [this.sourceIds.filter(value => params.includes(value)).map(config_id => ({ config_id }))];
}
if (sql.includes('SUBSTRING_INDEX')) return [this.domains.map(domain => ({ domain }))];
throw new Error(`Unexpected test SQL: ${sql}`);
}
}
test('discovers source BLOB configurations and the unique OSS default using SELECT only', async () => {
const db = new FakeDb({ configs: [source('9007199254740995'), target(), source('12'), source('2'), source('999')], sourceIds: ['12', '9007199254740995', '2', '2'] });
const result = await discoverStorageConfig(db, input());
assert.deepEqual(result.sourceConfigIds, ['2', '12', '9007199254740995']);
assert.equal(result.targetConfigId, '30');
assert.equal(result.sourceMode, 'database');
assert.equal(result.downloadBaseUrl, input()._projectDownloadBaseUrl);
assert.equal(result.projectProfile, 'local');
assert.equal(result._autoDiscovery, true);
assert.deepEqual(result._databaseIdentity, { server, schema: database });
assert.match(result.runId, /^db-images-[a-f0-9]{16}$/);
assert.equal(JSON.stringify(result).includes('oss-secret-must-not-escape'), false);
assert.ok(db.calls.some(call => call.sql.includes('SELECT DISTINCT config_id FROM')));
});
test('generated runId is independent of source ordering, credentials, domains and remaining file metadata', async () => {
const first = await discoverStorageConfig(new FakeDb({ configs: [source('7'), target(), source('5')], sourceIds: ['7', '5'] }), input());
const secondInput = input();
secondInput.mysql.password = 'rotated-password';
const second = await discoverStorageConfig(new FakeDb({ configs: [source('5'), source('7'), target()], sourceIds: ['5', '7'], domains: [] }), secondInput);
assert.equal(second.runId, first.runId);
assert.deepEqual(second.sourceConfigIds, first.sourceConfigIds);
});
test('only valid HTTP base domains without credentials are inferred and sorted', async () => {
const result = await discoverStorageConfig(new FakeDb({ domains: ['https://z.example/', 'https://a.example/prefix', 'https://z.example', 'https://user:secret@example.com', 'ftp://file.example', 'https://x.example?secret=1', 'not a URL'] }), input());
assert.deepEqual(result.oldDomains, ['https://a.example/prefix', 'https://z.example']);
});
test('requires a unique default and refuses non-Aliyun defaults instead of selecting a different OSS', async () => {
await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), target('30', 0)] }), input()), /默认.*唯一/);
await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), target(), target('31')] }), input()), /默认.*唯一/);
const invalidDefault = target();
invalidDefault.config = JSON.stringify({ endpoint: 'minio.example', accessSecret: 'dont-leak' });
await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), invalidDefault, target('31', 0)] }), input()), error => /阿里云/.test(error.message) && !error.message.includes('dont-leak'));
const malformedDefault = target();
malformedDefault.config = '{"secret":"dont-leak", broken';
await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), malformedDefault] }), input()), error => !error.message.includes('dont-leak'));
});
test('restores an unfinished run before current metadata can shrink its original scope', async () => {
const original = run('original-run', 'partial', { sourceConfigIds: ['7', '5'] });
const db = new FakeDb({ runs: [original], configs: [source('5'), source('7'), target()], sourceIds: [], domains: [] });
const result = await discoverStorageConfig(db, input(), { command: 'apply' });
assert.equal(result.runId, 'original-run');
assert.deepEqual(result.sourceConfigIds, ['7', '5'], 'historical order is part of the existing fingerprint');
assert.deepEqual(result.oldDomains, ['https://historic.example']);
assert.equal(result.objectPrefix, 'migration/original');
assert.equal(result.downloadBaseUrl, 'https://old.example/admin-api/infra/file');
assert.equal(result.targetPublicDomain, 'https://original-media.example');
assert.deepEqual(result.referenceSchemas, [database, 'chat']);
assert.ok(!db.calls.some(call => call.sql.includes('SELECT DISTINCT config_id')));
assert.ok(!db.calls.some(call => call.sql.includes('SUBSTRING_INDEX')));
});
test('explicit input fields override restored fields for downstream fingerprint validation', async () => {
const result = await discoverStorageConfig(new FakeDb({ runs: [run()] }), {
...input(), oldDomains: [], objectPrefix: 'migration/explicit', imagesOnly: false,
targetPublicDomain: 'https://explicit.example', sourceMode: 'http', downloadBaseUrl: 'https://explicit.example/admin-api/infra/file'
});
assert.deepEqual(result.oldDomains, []);
assert.equal(result.objectPrefix, 'migration/explicit');
assert.equal(result.imagesOnly, false);
assert.equal(result.targetPublicDomain, 'https://explicit.example');
assert.equal(result.sourceMode, 'http');
});
test('automatic resumed inputs and historical runs reject a changed default target', async () => {
const options = { configs: [source('5'), target('30', 0), target('31')] };
await assert.rejects(discoverStorageConfig(new FakeDb(options), { ...input(), targetConfigId: '30', _autoDiscovery: true }), /原迁移目标不同/);
await assert.rejects(discoverStorageConfig(new FakeDb({ ...options, runs: [run()] }), input()), /原迁移目标不同/);
});
test('rejects cross-server/schema inputs and restored history before accessing storage credentials', async () => {
for (const wrong of [{ server: 'other', schema: database }, { server, schema: 'other' }]) {
const db = new FakeDb();
await assert.rejects(discoverStorageConfig(db, { ...input(), _databaseIdentity: wrong }), /身份.*不一致/);
assert.equal(db.calls.length, 1);
}
const db = new FakeDb({ runs: [run('foreign', 'partial', { server: 'other' })] });
await assert.rejects(discoverStorageConfig(db, input()), /身份.*不一致/);
assert.ok(!db.calls.some(call => call.sql.includes('infra_file_config')));
});
test('historical selection refuses ambiguity but honors an explicit existing or new runId', async () => {
const unfinished = [run('one'), run('two')];
await assert.rejects(discoverStorageConfig(new FakeDb({ runs: unfinished }), input()), /多个未完成.*--run-id/);
const chosen = await discoverStorageConfig(new FakeDb({ runs: unfinished }), { ...input(), runId: 'two' });
assert.equal(chosen.runId, 'two');
const fresh = await discoverStorageConfig(new FakeDb({ runs: unfinished }), { ...input(), runId: 'new-run' });
assert.equal(fresh.runId, 'new-run');
assert.equal(fresh.objectPrefix, 'migration/db-images');
await assert.rejects(discoverStorageConfig(new FakeDb({ runs: [run('one', 'complete'), run('two', 'rolled_back')] }), input()), /多个历史.*--run-id/);
const single = await discoverStorageConfig(new FakeDb({ runs: [run('complete-run', 'complete')] }), input());
assert.equal(single.runId, 'complete-run');
const active = await discoverStorageConfig(new FakeDb({ runs: [run('old', 'complete'), run('active', 'needs-review')] }), input());
assert.equal(active.runId, 'active');
});
test('apply cannot resume a rollback while rollback does not require current storage configs or secrets', async () => {
for (const status of ['rollback', 'rollback_partial', 'rolled_back']) {
await assert.rejects(discoverStorageConfig(new FakeDb({ runs: [run('old', status)] }), input(), { command: 'apply' }), /回滚.*--run-id/);
}
const db = new FakeDb({ runs: [run('old', 'rollback_partial')], configs: [] });
const result = await discoverStorageConfig(db, input(), { command: 'rollback' });
assert.equal(result.runId, 'old');
assert.equal(result.targetConfigId, '30');
assert.ok(!db.calls.some(call => call.sql.includes('infra_file_config')));
await assert.rejects(discoverStorageConfig(new FakeDb(), input(), { command: 'rollback' }), /未找到可回滚/);
});
test('new content beyond a non-inventory run boundary blocks apply, including bigint boundaries', async () => {
const options = { runs: [run()], maximum: '9007199254740994' };
await assert.rejects(discoverStorageConfig(new FakeDb(options), input(), { command: 'apply' }), /新增内容/);
const planned = await discoverStorageConfig(new FakeDb(options), input(), { command: 'plan' });
assert.equal(planned.runId, 'original-run');
const inventoryDb = new FakeDb({ ...options, runs: [run('still-building', 'inventory')] });
const inventory = await discoverStorageConfig(inventoryDb, input(), { command: 'apply' });
assert.equal(inventory.runId, 'still-building');
assert.ok(!inventoryDb.calls.some(call => call.sql.includes('MAX(id)')));
});
test('missing source contents produce a clear no-migration result without creating a run', async () => {
const db = new FakeDb({ sourceIds: [] });
await assert.rejects(discoverStorageConfig(db, input()), /无需迁移/);
assert.ok(db.calls.every(call => /^SELECT\b/i.test(call.sql.trim())));
});
@@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setImmediate } from 'node:timers/promises';
import { mapBounded } from '../lib/concurrency.mjs';
function deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
test('bounded workers keep input order when tasks finish out of order', { timeout: 2000 }, async () => {
const gates = Array.from({ length: 5 }, deferred);
const started = [];
let active = 0, maximum = 0;
const pending = mapBounded(['a', 'b', 'c', 'd', 'e'], 2, async (value, index) => {
started.push(index);
maximum = Math.max(maximum, ++active);
try { await gates[index].promise; return value.toUpperCase(); }
finally { active--; }
});
assert.deepEqual(started, [0, 1]);
gates[1].resolve();
await setImmediate();
assert.deepEqual(started, [0, 1, 2]);
gates[2].resolve();
await setImmediate();
assert.deepEqual(started, [0, 1, 2, 3]);
gates[0].resolve();
await setImmediate();
assert.deepEqual(started, [0, 1, 2, 3, 4]);
gates[4].resolve();
gates[3].resolve();
assert.deepEqual(await pending, ['A', 'B', 'C', 'D', 'E']);
assert.equal(maximum, 2);
assert.equal(active, 0);
});
test('failure stops new work and waits for all running workers before rejecting', { timeout: 2000 }, async () => {
const gates = Array.from({ length: 5 }, deferred);
const started = [];
const finished = [];
const failure = new Error('first verification failed');
let settled = false;
const pending = mapBounded([0, 1, 2, 3, 4], 3, async index => {
started.push(index);
try { await gates[index].promise; return index; }
finally { finished.push(index); }
});
const outcome = pending.then(
value => { settled = true; return { value }; },
error => { settled = true; return { error }; },
);
assert.deepEqual(started, [0, 1, 2]);
gates[1].reject(failure);
await setImmediate();
assert.deepEqual(started, [0, 1, 2]);
assert.equal(settled, false);
gates[0].resolve();
await setImmediate();
assert.deepEqual(started, [0, 1, 2]);
assert.equal(settled, false);
gates[2].resolve();
assert.equal((await outcome).error, failure);
assert.equal(settled, true);
assert.deepEqual(finished.sort(), [0, 1, 2]);
});
test('additional in-flight rejections are drained while preserving the first failure', { timeout: 2000 }, async () => {
const gates = Array.from({ length: 3 }, deferred);
const first = new Error('first');
const second = new Error('second');
let calls = 0, settled = false;
const outcome = mapBounded([0, 1, 2, 3], 3, async index => {
calls++;
await gates[index].promise;
return index;
}).then(
() => { settled = true; assert.fail('must reject'); },
error => { settled = true; return error; },
);
gates[0].reject(first);
await setImmediate();
gates[1].reject(second);
await setImmediate();
assert.equal(settled, false);
assert.equal(calls, 3);
gates[2].resolve();
assert.equal(await outcome, first);
});
test('a synchronous failure stops scheduling immediately', async () => {
const failure = new Error('synchronous failure');
const started = [];
await assert.rejects(mapBounded([0, 1, 2], 3, value => {
started.push(value);
throw failure;
}), error => error === failure);
assert.deepEqual(started, [0]);
});
test('falsy rejection values still reject and never get treated as success', async () => {
for (const failure of [undefined, null, false, 0, '']) {
let rejected = false;
await mapBounded([1], 1, () => Promise.reject(failure)).then(
() => assert.fail('must reject'),
error => { rejected = true; assert.equal(error, failure); },
);
assert.equal(rejected, true);
}
});
test('limit one executes serially and accepts synchronous return values', async () => {
const order = [];
assert.deepEqual(await mapBounded([2, 4, 6], 1, (value, index) => {
order.push(index);
return value * 2;
}), [4, 8, 12]);
assert.deepEqual(order, [0, 1, 2]);
});
test('limits larger than the item count only start existing items', async () => {
let calls = 0;
const result = await mapBounded(['x', 'y'], 1000, async value => { calls++; return value; });
assert.deepEqual(result, ['x', 'y']);
assert.equal(calls, 2);
});
test('empty input returns an empty array without invoking the worker', async () => {
assert.deepEqual(await mapBounded([], 4, () => assert.fail('must not run')), []);
});
test('invalid limits reject even for an empty input', async () => {
for (const limit of [0, -1, 1.5, Infinity, NaN, '4', null, undefined, Number.MAX_SAFE_INTEGER + 1]) {
await assert.rejects(mapBounded([], limit, () => {}), /正整数/);
}
});
test('input values are snapshotted before workers mutate the original collection', async () => {
const input = ['first', 'second', 'third'];
const result = await mapBounded(input, 1, async (value, index) => {
if (index === 0) input.splice(1, 2, 'changed');
return value;
});
assert.deepEqual(result, ['first', 'second', 'third']);
});
@@ -0,0 +1,428 @@
import test, { mock } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import {
aliases, baseUrl, bit, downloadUrl, encodePath, ensureObject, errorText, id,
mimeType, normalizeConfig, objectKey, ossSettings, parseStorage, qualified,
quote, readHttp, retry, sha256, verifyContent,
} from '../lib/core.mjs';
const IMAGE = Buffer.from('89504e470d0a1a0a0001020304050607', 'hex');
const ITEM = {
configId: '9007199254740993', path: '头像/张 三+100%.PNG',
size: IMAGE.length, sha256: sha256(IMAGE), type: 'image/png',
};
const HTTP_OPTIONS = { timeoutMs: 1000, maxFileBytes: 1024 };
const sourceBase = 'http://127.0.0.1:48080/admin-api/infra/file';
const baseConfig = overrides => ({
runId: 'unit-test', mysql: { database: 'unit_test', user: 'test-only' },
sourceConfigIds: ['1'], targetConfigId: '20', downloadBaseUrl: sourceBase,
...overrides,
});
const storageRow = overrides => ({
storage: 20,
config: {
endpoint: 'oss-cn-hangzhou.aliyuncs.com', bucket: 'test-only-bucket',
accessKey: 'test-only-access-key', accessSecret: 'test-only-access-secret',
...overrides,
},
});
const noSuchKey = () => Object.assign(new Error('missing'), { code: 'NoSuchKey', status: 404 });
const head = bytes => ({ res: { headers: { 'content-length': String(bytes.length) } } });
const storedObject = bytes => ({
head: mock.fn(async () => head(bytes)),
get: mock.fn(async () => ({ content: bytes })),
put: mock.fn(async () => { throw new Error('unexpected write'); }),
});
async function localServer(t, handler) {
const server = createServer(handler);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(async () => {
const closed = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
server.closeAllConnections();
await closed;
});
return 'http://127.0.0.1:' + server.address().port;
}
test('download URL preserves Chinese, space, plus, percent, question mark and fragment characters', () => {
const path = '头像/张 三+100%?#.png';
const encoded = '%E5%A4%B4%E5%83%8F/%E5%BC%A0%20%E4%B8%89%2B100%25%3F%23.png';
assert.equal(encodePath(path), encoded);
const url = downloadUrl(sourceBase + '///', ITEM.configId, path);
assert.equal(url, sourceBase + '/' + ITEM.configId + '/get/' + encoded);
assert.equal(decodeURIComponent(new URL(url).pathname.split('/get/')[1]), path);
assert.equal(new URL(url).search, '');
assert.equal(new URL(url).hash, '');
});
test('encoded-looking path text is encoded exactly once without decoding the source path', () => {
assert.equal(encodePath('%2F/%20+%.png'), '%252F/%2520%2B%25.png');
assert.equal(decodeURIComponent(encodePath('%2F/%20+%.png')), '%2F/%20+%.png');
});
test('ambiguous or normalized path segments are rejected', () => {
for (const path of ['', '/image.png', 'a//b.png', 'a/', './a.png', 'a/../b.png', 'a/./b.png', 'a\\b.png']) {
assert.throws(() => encodePath(path), /路径/, path);
}
});
test('URL base validates scheme and rejects credentials, queries and fragments', () => {
assert.equal(baseUrl('https://images.example.test/prefix///'), 'https://images.example.test/prefix');
for (const url of ['ftp://example.test', 'file:///tmp/image', 'https://user:pass@example.test', 'https://example.test/?key=value', 'https://example.test/#part']) {
assert.throws(() => baseUrl(url), /HTTP/);
}
});
test('bigint IDs remain exact strings and unsafe numeric IDs are rejected', () => {
assert.equal(id('9007199254740993'), '9007199254740993');
assert.equal(id(9007199254740993n), '9007199254740993');
assert.equal(id(42), '42');
assert.throws(() => id(Number('9007199254740993')), /安全整数/);
for (const value of [0, -1, 1.5, '01', '1e3', ' 1', '', null, undefined, true]) {
assert.throws(() => id(value), /正整数|安全整数/, String(value));
}
});
test('MySQL BIT values distinguish zero buffers from true values', () => {
for (const value of [Buffer.from([0]), Buffer.alloc(0), false, 0, '0', null, undefined]) assert.equal(bit(value), false);
for (const value of [Buffer.from([1]), true, 1, '1']) assert.equal(bit(value), true);
});
test('configuration normalizes IDs and schemas without changing bigint precision', () => {
const result = normalizeConfig(baseConfig({
sourceConfigIds: [1, '1', '9007199254740993'],
referenceSchemas: ['chat', 'unit_test', 'chat'],
oldDomains: ['https://old.example.test///'],
}));
assert.deepEqual(result.sourceConfigIds, ['1', '9007199254740993']);
assert.deepEqual(result.referenceSchemas, ['unit_test', 'chat']);
assert.deepEqual(result.oldDomains, ['https://old.example.test']);
assert.equal(result.downloadBaseUrl, sourceBase);
});
test('configuration rejects same source and target, invalid modes and unsafe batch limits', () => {
for (const overrides of [
{ targetConfigId: '1' }, { sourceMode: 'unknown' }, { targetUrlMode: 'unknown' },
{ batchSize: 0 }, { batchSize: 1001 }, { retries: 0 }, { retries: 11 },
{ maxFileBytes: 1.5 }, { timeoutMs: 0 }, { objectPrefix: '../unsafe' },
]) assert.throws(() => normalizeConfig(baseConfig(overrides)));
});
test('database source and direct target do not require a download address', () => {
const result = normalizeConfig(baseConfig({ sourceMode: 'database', downloadBaseUrl: undefined }));
assert.equal(result.sourceMode, 'database');
});
test('OSS settings use the actual Java configuration field names and support JSON storage', () => {
const row = storageRow();
row.config = JSON.stringify(row.config);
assert.equal(parseStorage(row).accessKey, 'test-only-access-key');
assert.deepEqual(ossSettings(row, {}), {
endpoint: 'https://oss-cn-hangzhou.aliyuncs.com', bucket: 'test-only-bucket',
accessKeyId: 'test-only-access-key', accessKeySecret: 'test-only-access-secret',
publicDomain: 'https://test-only-bucket.oss-cn-hangzhou.aliyuncs.com',
});
});
test('OSS internal endpoint gets a public fallback domain and explicit domain takes precedence', () => {
const row = storageRow({ endpoint: 'https://oss-cn-hangzhou-internal.aliyuncs.com' });
const result = ossSettings(row, {});
assert.equal(result.endpoint, 'https://oss-cn-hangzhou-internal.aliyuncs.com');
assert.equal(result.publicDomain, 'https://test-only-bucket.oss-cn-hangzhou.aliyuncs.com');
assert.equal(ossSettings(storageRow({ domain: 'https://cdn.example.test/' }), {}).publicDomain, 'https://cdn.example.test');
assert.equal(ossSettings(row, { targetPublicDomain: 'https://override.example.test/assets/' }).publicDomain, 'https://override.example.test/assets');
});
test('OSS settings reject wrong storage type, non-OSS services, bucket endpoints and missing fields', () => {
assert.throws(() => ossSettings({ ...storageRow(), storage: 1 }, {}), /storage=20/);
for (const endpoint of [
'http://127.0.0.1:9000', 's3.amazonaws.com', 'bucket.oss-cn-hangzhou.aliyuncs.com',
'oss-cn-hangzhou.aliyuncs.com.evil.test', 'https://oss-cn-hangzhou.aliyuncs.com/path',
'https://user:pass@oss-cn-hangzhou.aliyuncs.com', 'https://oss-cn-hangzhou.aliyuncs.com/?x=1',
]) assert.throws(() => ossSettings(storageRow({ endpoint }), {}), /endpoint/);
for (const field of ['bucket', 'accessKey', 'accessSecret']) {
assert.throws(() => ossSettings(storageRow({ [field]: '' }), {}), /缺少/);
}
});
test('object keys separate config, source path and content while keeping a safe extension', () => {
const config = { objectPrefix: 'migration/test' };
const key = objectKey(config, ITEM);
assert.equal(key, 'migration/test/' + ITEM.configId + '/' + sha256(ITEM.path) + '/' + ITEM.sha256 + '.png');
assert.notEqual(key, objectKey(config, { ...ITEM, configId: '2' }));
assert.notEqual(key, objectKey(config, { ...ITEM, path: 'other.PNG' }));
assert.notEqual(key, objectKey(config, { ...ITEM, sha256: 'b'.repeat(64) }));
});
test('aliases retain unrelated stored URLs and only route variants resolving to the source path', () => {
const row = { path: ITEM.path, url: 'https://special.example.test/persisted-image' };
const result = aliases(ITEM, [row, row], 'https://old.example.test', {
oldDomains: ['https://older.example.test'], downloadBaseUrl: sourceBase,
});
assert.equal(result.filter(value => value === row.url).length, 1);
assert.ok(!result.includes('https://old.example.test/admin-api/infra/file/' + ITEM.configId + '/get/' + ITEM.path));
assert.ok(result.includes('https://older.example.test/admin-api/infra/file/' + ITEM.configId + '/get/' + encodePath(ITEM.path)));
assert.ok(result.includes(downloadUrl(sourceBase, ITEM.configId, ITEM.path)));
});
test('plus and space source files cannot claim each other through raw plus aliases', () => {
const domain = 'https://old.example.test';
const prefix = domain + '/admin-api/infra/file/1/get/';
const config = { oldDomains: [], downloadBaseUrl: sourceBase };
const plus = aliases({ configId: '1', path: 'a+b.png' }, [
{ path: 'a+b.png', url: prefix + 'a+b.png' },
{ path: 'a+b.png', url: prefix + 'a%2Bb.png' },
], domain, config);
const space = aliases({ configId: '1', path: 'a b.png' }, [
{ path: 'a b.png', url: prefix + 'a+b.png' },
], domain, config);
assert.ok(!plus.includes(prefix + 'a+b.png'));
assert.ok(plus.includes(prefix + 'a%2Bb.png'));
assert.ok(space.includes(prefix + 'a+b.png'));
assert.ok(space.includes(prefix + 'a b.png'));
assert.ok(space.includes(prefix + 'a%20b.png'));
assert.ok(!space.includes(prefix + 'a%2Bb.png'));
assert.ok(space.includes(sourceBase + '/1/get/a+b.png'));
assert.deepEqual(plus.filter(url => space.includes(url)), []);
});
test('percent-encoded-looking filenames keep their meaning after exactly one route decode', () => {
const domain = 'https://old.example.test';
const prefix = domain + '/admin-api/infra/file/1/get/';
const config = { oldDomains: [] };
const literal = aliases({ configId: '1', path: 'a%2Fb.png' }, [
{ path: 'a%2Fb.png', url: prefix + 'a%2Fb.png' },
], domain, config);
const directory = aliases({ configId: '1', path: 'a/b.png' }, [
{ path: 'a/b.png', url: prefix + 'a%2Fb.png' },
], domain, config);
assert.ok(!literal.includes(prefix + 'a%2Fb.png'));
assert.ok(literal.includes(prefix + 'a%252Fb.png'));
assert.ok(directory.includes(prefix + 'a%2Fb.png'));
assert.ok(directory.includes(prefix + 'a/b.png'));
assert.deepEqual(literal.filter(url => directory.includes(url)), []);
});
test('raw query, fragment and unverifiable percent paths are excluded from source aliases', () => {
const domain = 'https://old.example.test';
const prefix = domain + '/admin-api/infra/file/1/get/';
for (const path of ['question?.png', 'fragment#.png', 'literal%.png', 'a%20b.png']) {
const result = aliases({ configId: '1', path }, [{ path, url: prefix + path }], domain, { oldDomains: [] });
assert.ok(!result.includes(prefix + path), path);
assert.ok(result.includes(prefix + encodePath(path)), path);
}
});
test('route validation accepts SQL-equivalent spellings and ignores genuine query parameters', () => {
const domain = 'https://old.example.test';
const prefix = domain + '/admin-api/infra/file/1/get/';
const alternate = 'photo a.JPG';
const result = aliases({ configId: '1', path: 'Photo A.jpg' }, [
{ path: alternate, url: prefix + 'photo+a.JPG?download=1#preview' },
{ path: alternate, url: domain + '/admin-api/infra/file/2/get/photo+a.JPG' },
{ path: alternate, url: prefix + 'unrelated.png' },
], domain, { oldDomains: [] });
assert.ok(result.includes(prefix + 'photo+a.JPG?download=1#preview'));
assert.ok(result.includes(prefix + 'photo+a.JPG'));
assert.ok(result.includes(prefix + 'Photo+A.jpg'));
assert.ok(!result.includes(domain + '/admin-api/infra/file/2/get/photo+a.JPG'));
assert.ok(!result.includes(prefix + 'unrelated.png'));
});
test('MIME detection prefers stored image type, then extension, then other stored type', () => {
assert.equal(mimeType('renamed.jpg', [{ type: 'image/png' }]), 'image/png');
assert.equal(mimeType('image.WEBP', []), 'image/webp');
assert.equal(mimeType('no-extension', [{ type: 'application/pdf' }]), 'application/pdf');
assert.equal(mimeType('no-extension', []), 'application/octet-stream');
});
test('SQL identifiers are quoted and error URLs are redacted', () => {
assert.equal(quote('strange\u0060name'), '\u0060strange\u0060\u0060name\u0060');
assert.equal(qualified('schema', 'table'), '\u0060schema\u0060.\u0060table\u0060');
const redacted = errorText(new Error('failed https://example.test/?secret=private afterward'));
assert.equal(redacted, 'failed [URL] afterward');
assert.ok(errorText(new Error('x'.repeat(500))).length <= 300);
});
test('HTTP download sends the intended encoded route and preserves binary bytes', async t => {
let observedUrl;
const base = await localServer(t, (request, response) => {
observedUrl = request.url;
response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': IMAGE.length });
response.end(IMAGE);
});
const route = downloadUrl(base + '/admin-api/infra/file', ITEM.configId, ITEM.path);
const bytes = await readHttp(route, HTTP_OPTIONS, IMAGE.length);
assert.deepEqual(bytes, IMAGE);
assert.equal(observedUrl, '/admin-api/infra/file/' + ITEM.configId + '/get/' + encodePath(ITEM.path));
assert.doesNotThrow(() => verifyContent(bytes, ITEM));
});
test('HTTP errors and redirects are rejected rather than uploaded', async t => {
const base = await localServer(t, (request, response) => {
if (request.url === '/redirect') response.writeHead(302, { Location: '/image' });
else response.writeHead(404);
response.end();
});
await assert.rejects(readHttp(base + '/missing', HTTP_OPTIONS), /HTTP 404/);
await assert.rejects(readHttp(base + '/redirect', HTTP_OPTIONS));
});
test('HTTP 200 HTML or JSON with the same byte count fails hash verification', async t => {
const base = await localServer(t, (request, response) => {
const fake = Buffer.alloc(IMAGE.length, ' ');
fake.write(request.url === '/html' ? '<html>error' : '{"error":true}');
response.writeHead(200, { 'Content-Type': request.url === '/html' ? 'text/html' : 'application/json' });
response.end(fake);
});
for (const path of ['/html', '/json']) {
const bytes = await readHttp(base + path, HTTP_OPTIONS, IMAGE.length);
assert.equal(bytes.length, IMAGE.length);
assert.throws(() => verifyContent(bytes, ITEM), /SHA-256/);
}
});
test('short downloads fail expected length validation', async t => {
const base = await localServer(t, (_request, response) => response.end(IMAGE.subarray(0, 4)));
const bytes = await readHttp(base, HTTP_OPTIONS, IMAGE.length);
assert.throws(() => verifyContent(bytes, ITEM), /长度/);
});
test('HTTP declared length exceeding either cap is rejected', async t => {
const base = await localServer(t, (_request, response) => {
response.writeHead(200, { 'Content-Length': IMAGE.length });
response.end(IMAGE);
});
await assert.rejects(readHttp(base, { ...HTTP_OPTIONS, maxFileBytes: 4 }), /长度超过限制/);
await assert.rejects(readHttp(base, HTTP_OPTIONS, 4), /长度超过限制/);
});
test('chunked HTTP bodies cannot bypass actual byte limits', async t => {
const base = await localServer(t, (_request, response) => {
response.writeHead(200, { 'Transfer-Encoding': 'chunked' });
response.write(IMAGE.subarray(0, 8));
response.end(IMAGE.subarray(8));
});
await assert.rejects(readHttp(base, { ...HTTP_OPTIONS, maxFileBytes: 4 }), /长度超过限制/);
await assert.rejects(readHttp(base, HTTP_OPTIONS, 4), /长度超过限制/);
});
test('HTTP requests time out when the source never responds', async t => {
const base = await localServer(t, () => {});
await assert.rejects(readHttp(base, { ...HTTP_OPTIONS, timeoutMs: 30 }));
});
test('content verification checks both length and hash', () => {
assert.doesNotThrow(() => verifyContent(IMAGE, { ...ITEM, size: String(IMAGE.length) }));
assert.throws(() => verifyContent(IMAGE, { ...ITEM, size: IMAGE.length + 1 }), /长度/);
assert.throws(() => verifyContent(IMAGE, { ...ITEM, sha256: '0'.repeat(64) }), /SHA-256/);
});
test('existing identical OSS objects are read and verified without upload', async () => {
const oss = storedObject(IMAGE);
await ensureObject(oss, 'target/key.png', IMAGE, ITEM);
assert.equal(oss.put.mock.callCount(), 0);
assert.equal(oss.get.mock.callCount(), 1);
assert.equal(oss.get.mock.calls[0].arguments[0], 'target/key.png');
});
test('existing OSS objects with a different length are not overwritten', async () => {
const oss = storedObject(Buffer.from('wrong length'));
await assert.rejects(ensureObject(oss, 'target/key.png', IMAGE, ITEM), /禁止覆盖/);
assert.equal(oss.put.mock.callCount(), 0);
assert.equal(oss.get.mock.callCount(), 0);
});
test('existing same-length OSS objects with different bytes are not overwritten', async () => {
const oss = storedObject(Buffer.alloc(IMAGE.length, 1));
await assert.rejects(ensureObject(oss, 'target/key.png', IMAGE, ITEM), /SHA-256/);
assert.equal(oss.put.mock.callCount(), 0);
});
test('missing OSS objects upload exact bytes with MIME and overwrite prevention', async () => {
let saved;
const oss = {
head: mock.fn(async () => { if (!saved) throw noSuchKey(); return head(saved); }),
put: mock.fn(async (_key, bytes) => { saved = Buffer.from(bytes); }),
get: mock.fn(async () => ({ content: saved })),
};
await ensureObject(oss, 'target/key.png', IMAGE, ITEM);
assert.equal(oss.put.mock.callCount(), 1);
assert.deepEqual(oss.put.mock.calls[0].arguments, [
'target/key.png', IMAGE, { headers: { 'Content-Type': 'image/png', 'x-oss-forbid-overwrite': 'true' } },
]);
assert.deepEqual(saved, IMAGE);
});
test('OSS HEAD authorization failures do not cause a speculative upload', async () => {
const denied = Object.assign(new Error('denied'), { code: 'AccessDenied', status: 403 });
const oss = { head: mock.fn(async () => { throw denied; }), put: mock.fn(), get: mock.fn() };
await assert.rejects(ensureObject(oss, 'key', IMAGE, ITEM), error => error === denied);
assert.equal(oss.put.mock.callCount(), 0);
});
test('upload competition is accepted only after verifying the winner content', async () => {
for (const sameContent of [true, false]) {
const winner = sameContent ? IMAGE : Buffer.alloc(IMAGE.length, 2);
let appeared = false;
const oss = {
head: mock.fn(async () => { if (!appeared) throw noSuchKey(); return head(winner); }),
put: mock.fn(async () => {
appeared = true;
throw Object.assign(new Error('another upload won'), { code: 'FileAlreadyExists', status: 409 });
}),
get: mock.fn(async () => ({ content: winner })),
};
if (sameContent) await ensureObject(oss, 'key', IMAGE, ITEM);
else await assert.rejects(ensureObject(oss, 'key', IMAGE, ITEM), /SHA-256/);
assert.equal(oss.put.mock.callCount(), 1);
assert.equal(oss.get.mock.callCount(), 1);
}
});
test('failed or corrupted OSS uploads never report success', async () => {
const offline = Object.assign(new Error('offline'), { code: 'ConnectionTimeout' });
const failed = {
head: mock.fn(async () => { throw noSuchKey(); }),
put: mock.fn(async () => { throw offline; }), get: mock.fn(),
};
await assert.rejects(ensureObject(failed, 'key', IMAGE, ITEM), error => error === offline);
assert.equal(failed.get.mock.callCount(), 0);
let uploaded = false;
const corrupt = {
head: mock.fn(async () => { if (!uploaded) throw noSuchKey(); return head(IMAGE); }),
put: mock.fn(async () => { uploaded = true; }),
get: mock.fn(async () => ({ content: Buffer.alloc(IMAGE.length) })),
};
await assert.rejects(ensureObject(corrupt, 'key', IMAGE, ITEM), /SHA-256/);
});
test('retry stops on success and uses capped exponential waits', async () => {
let calls = 0;
const waits = [];
const value = await retry(async () => {
if (++calls < 7) throw new Error('transient');
return 'done';
}, 7, async ms => { waits.push(ms); });
assert.equal(value, 'done');
assert.equal(calls, 7);
assert.deepEqual(waits, [1000, 2000, 4000, 5000, 5000, 5000]);
});
test('retry preserves the last failure and never exceeds the configured attempt count', async () => {
let calls = 0;
const waits = [];
const failure = new Error('permanent');
await assert.rejects(retry(async () => { calls++; throw failure; }, 3, async ms => waits.push(ms)), error => error === failure);
assert.equal(calls, 3);
assert.deepEqual(waits, [1000, 2000]);
const first = mock.fn(async () => 'first');
const wait = mock.fn();
assert.equal(await retry(first, 3, wait), 'first');
assert.equal(first.mock.callCount(), 1);
assert.equal(wait.mock.callCount(), 0);
});
@@ -0,0 +1,241 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { encodePath, normalizeConfig } from '../lib/core.mjs';
import { applyChange } from '../lib/journal.mjs';
import { discoverStorageConfig } from '../lib/auto-storage.mjs';
import { loadSourceIndex } from '../lib/source-index.mjs';
import { loadSettings, runApply, runPlan, runRollback } from '../migrate.mjs';
// Only point this at the disposable test MySQL container: this suite recreates
// its dedicated codex_oss_migration_test schema, never the application database.
test('MySQL migration, resume, exact reference replacement and rollback', {
skip: !process.env.MIGRATION_TEST_DB_PORT, timeout: 60000
}, async t => {
const { default: mysql } = await import('mysql2/promise');
const db = await mysql.createConnection({ host: '127.0.0.1', port: Number(process.env.MIGRATION_TEST_DB_PORT), user: 'root', password: '', supportBigNumbers: true, bigNumberStrings: true, jsonStrings: true, dateStrings: true });
const objects = new Map();
let putCount = 0, failPut = false, sourceDownloads = 0;
const oss = {
async head(key) { if (!objects.has(key)) throw Object.assign(new Error('missing'), { code: 'NoSuchKey', status: 404 }); return { res: { headers: { 'content-length': String(objects.get(key).length) } } }; },
async get(key) { return { content: Buffer.from(objects.get(key)) }; },
async put(key, bytes, options) {
assert.equal(options.headers['x-oss-forbid-overwrite'], 'true');
if (failPut) throw Object.assign(new Error('test failure'), { code: 'TestOssUnavailable' });
assert.ok(!objects.has(key)); objects.set(key, Buffer.from(bytes)); putCount++;
}
};
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
if (url.pathname.startsWith('/objects/')) {
const bytes = objects.get(decodeURIComponent(url.pathname.slice('/objects/'.length)));
if (!bytes) { response.writeHead(404).end(); return; }
response.end(bytes); return;
}
const prefix = '/admin-api/infra/file/5/get/';
if (url.pathname.startsWith(prefix)) {
sourceDownloads++;
const path = decodeURIComponent(url.pathname.slice(prefix.length).replaceAll('+', ' '));
const [[row]] = await db.execute('SELECT content FROM infra_file_content WHERE config_id=5 AND path=? AND deleted=0 ORDER BY id DESC LIMIT 1', [path]);
if (row) response.end(row.content); else response.writeHead(404).end();
return;
}
response.writeHead(404).end();
} catch { response.writeHead(500).end(); }
});
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const origin = `http://127.0.0.1:${server.address().port}`;
const schema = 'codex_oss_migration_test';
let config;
const firstPath = '头像/张 三+100%.png';
const firstUrl = `${origin}/admin-api/infra/file/5/get/${encodePath(firstPath)}`;
const orphanUrl = `${origin}/admin-api/infra/file/5/get/orphan.png`;
const caseUrl = `${origin}/admin-api/infra/file/5/get/Case.png`;
const originals = [Buffer.from('first current image'), Buffer.from('second orphan image'), Buffer.from('case latest image')];
try {
await db.query(`DROP DATABASE IF EXISTS ${schema}`);
await db.query(`CREATE DATABASE ${schema} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
await db.changeUser({ database: schema });
await db.query('CREATE TABLE infra_file_config (id BIGINT PRIMARY KEY,storage INT,master BIT(1),config TEXT,deleted BIT(1) DEFAULT 0) ENGINE=InnoDB');
await db.query('CREATE TABLE infra_file_content (id BIGINT PRIMARY KEY,config_id BIGINT,path VARCHAR(512),content MEDIUMBLOB,deleted BIT(1) DEFAULT 0) ENGINE=InnoDB');
await db.query('CREATE TABLE infra_file (id BIGINT PRIMARY KEY,config_id BIGINT,path VARCHAR(512),url VARCHAR(1024),type VARCHAR(128),size INT,deleted BIT(1) DEFAULT 0,update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB');
await db.query('CREATE TABLE member_user (id BIGINT PRIMARY KEY,avatar VARCHAR(1024),wechat_qr_code VARCHAR(255),extra_json JSON,update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB');
await db.query('CREATE TABLE notes (id INT PRIMARY KEY,body TEXT) ENGINE=InnoDB');
await db.execute('INSERT INTO infra_file_config VALUES (5,1,0,?,0),(30,20,1,?,0)', [JSON.stringify({ domain: origin }), JSON.stringify({ endpoint: 'oss-cn-hangzhou.aliyuncs.com', bucket: 'test', accessKey: 'test-key', accessSecret: 'test-secret', domain: `${origin}/objects` })]);
const records = [
['9007199254740992', 'Case.png', Buffer.from('case old image'), 0],
['9007199254740993', firstPath, Buffer.from('first old image'), 0],
['9007199254740994', firstPath, originals[0], 0],
['9007199254740995', firstPath, Buffer.from('deleted newest'), 1],
['9007199254740996', 'orphan.png', originals[1], 0],
['9007199254740997', 'case.PNG', originals[2], 0]
];
for (const [contentId, path, content, deleted] of records) await db.execute('INSERT INTO infra_file_content VALUES (?,5,?,?,?)', [contentId, path, content, deleted]);
for (const [fileId, path, url] of [['9007199254741001', firstPath, firstUrl], ['9007199254741002', 'Case.png', caseUrl], ['9007199254741003', 'case.PNG', caseUrl.replace('Case.png', 'case.PNG')]]) {
await db.execute("INSERT INTO infra_file (id,config_id,path,url,type,size,update_time) VALUES (?,5,?,?,'image/png',1,'2020-01-01 00:00:00')", [fileId, path, url]);
}
const payload = JSON.stringify({ a: [firstUrl, orphanUrl], nested: { image: caseUrl }, outside: 'https://outside.example/image.png' });
await db.execute("INSERT INTO member_user VALUES (9007199254741101,?,?,?,'2020-01-01 00:00:00')", [firstUrl, orphanUrl, payload]);
await db.execute('INSERT INTO notes VALUES (1,?)', [`<img src="${caseUrl}">${orphanUrl},${caseUrl}`]);
config = normalizeConfig({ runId: 'integration', mysql: { database: schema, user: 'root' }, sourceConfigIds: ['5'], targetConfigId: '30', downloadBaseUrl: `${origin}/admin-api/infra/file`, batchSize: 1, retries: 1 });
const settings = await loadSettings(db, config);
await t.test('automatic discovery uses the existing database and unique OSS without configuration writes', async () => {
const automatic = await discoverStorageConfig(db, { mysql: config.mysql, _autoDiscovery: true, _projectDownloadBaseUrl: config.downloadBaseUrl });
assert.deepEqual(automatic.sourceConfigIds, ['5']); assert.equal(automatic.targetConfigId, '30');
assert.equal(automatic.sourceMode, 'database'); assert.ok(automatic.runId.startsWith('db-images-'));
assert.ok(automatic.oldDomains.includes(origin)); assert.equal(objects.size, 0);
const [tables] = await db.query("SHOW TABLES LIKE 'infra_file_oss_migration_%'"); assert.equal(tables.length, 0);
});
await t.test('plan is read-only and uses latest active content with bigint/collation semantics', async () => {
const statements = [];
const readOnlyDb = {
execute(sql, params) { statements.push(sql); return db.execute(sql, params); },
query(sql, params) { statements.push(sql); return db.query(sql, params); }
};
const plan = await runPlan(readOnlyDb, config, settings);
assert.equal(plan.status, 'planned'); assert.equal(plan.candidates, 3); assert.equal(plan.withoutFileRecord, 1);
assert.equal(plan.bytes, null); assert.equal(plan.estimatedBytes, 2); assert.equal(plan.sizePending, 1); assert.equal(plan.contentVerified, false);
assert.ok(statements.every(sql => /^\s*SELECT/i.test(sql)));
assert.ok(statements.every(sql => !/\b(?:SHA2|OCTET_LENGTH|content)\b|NOT EXISTS/i.test(sql)), 'plan must never scan image bytes or use per-file correlated subqueries');
assert.equal(plan.references.changedRows, 2); assert.equal(objects.size, 0);
const [rows] = await db.query("SHOW TABLES LIKE 'infra_file_oss_migration_%'"); assert.equal(rows.length, 0);
});
await t.test('metadata upper bound excludes newer versions without misclassifying their files as missing', async () => {
const index = await loadSourceIndex(db, config, { maxId: '9007199254740996' });
assert.equal(index.groups.length, 2); assert.equal(index.missingFiles.length, 0);
assert.equal(index.groups[0].path, firstPath); assert.equal(index.groups[1].path, 'orphan.png');
const full = await loadSourceIndex(db, config);
assert.deepEqual(full.groups[2].spellings.slice().sort(), ['Case.png', 'case.PNG'].sort());
assert.equal(full.groups[2].files.length, 2);
});
await t.test('limited trial, resume and repeated apply preserve originals and journals', async () => {
const trial = await runApply(db, config, settings, 1, () => oss);
assert.equal(trial.status, 'partial'); assert.equal(trial.items.switched, 1); assert.equal(objects.size, 1);
const resumedConfig = await discoverStorageConfig(db, { mysql: config.mysql, _autoDiscovery: true }, { command: 'apply' });
assert.equal(resumedConfig.runId, 'integration'); assert.deepEqual(resumedConfig.sourceConfigIds, ['5']);
assert.equal(resumedConfig.sourceMode, 'http'); assert.equal(resumedConfig.downloadBaseUrl, config.downloadBaseUrl);
assert.deepEqual((await loadSettings(db, resumedConfig)).safe, settings.safe);
// Simulate interruption after all remaining uploads reached verified but
// before their first file transaction. Resume must reuse the checkpoint.
const interruptedDb = {
execute: (...args) => db.execute(...args), query: (...args) => db.query(...args),
beginTransaction: async () => { throw new Error('simulated interruption before transition'); }
};
await assert.rejects(runApply(interruptedDb, config, settings, Infinity, () => oss), /simulated interruption/);
const [[checkpoints]] = await db.query("SELECT COUNT(*) AS n FROM infra_file_oss_migration_items WHERE run_id='integration' AND status='verified'");
assert.equal(Number(checkpoints.n), 2);
const downloadsBeforeResume = sourceDownloads;
const putsBeforeResume = putCount;
const done = await runApply(db, config, settings, Infinity, () => oss);
assert.equal(done.status, 'complete'); assert.equal(done.items.switched, 3); assert.equal(objects.size, 3); assert.equal(putCount, 3);
assert.equal(done.revalidated, 2); assert.equal(done.uploaded, 0);
assert.equal(sourceDownloads, downloadsBeforeResume); assert.equal(putCount, putsBeforeResume);
const [[member]] = await db.query('SELECT * FROM member_user');
assert.ok(member.avatar.startsWith(`${origin}/objects/`)); assert.ok(member.wechat_qr_code.startsWith(`${origin}/objects/`));
assert.deepEqual(Object.values(JSON.parse(member.extra_json).nested).map(url => url.startsWith(`${origin}/objects/`)), [true]);
assert.equal(JSON.parse(member.extra_json).outside, 'https://outside.example/image.png');
const [[counts]] = await db.query('SELECT COUNT(*) AS n FROM infra_file_content'); assert.equal(Number(counts.n), 6);
const [files] = await db.query('SELECT * FROM infra_file ORDER BY id');
assert.ok(files.every(file => file.config_id === '30'));
assert.equal(files[1].path, files[2].path); // case/accent-insensitive source lookup remains equivalent
assert.equal(files[0].size, originals[0].length); // stale old metadata is repaired
assert.ok(files.every(file => file.update_time === '2020-01-01 00:00:00'));
const [[before]] = await db.query('SELECT COUNT(*) AS n FROM infra_file_oss_migration_changes');
const repeated = await runApply(db, config, settings, Infinity, () => oss); assert.equal(repeated.status, 'complete');
const [[after]] = await db.query('SELECT COUNT(*) AS n FROM infra_file_oss_migration_changes'); assert.equal(before.n, after.n); assert.equal(putCount, 3);
const [[entry]] = await db.query("SELECT data FROM infra_file_oss_migration_items WHERE JSON_UNQUOTE(JSON_EXTRACT(data,'$.path')) LIKE '%100%.png'");
assert.ok(JSON.parse(entry.data).aliases.includes(firstUrl)); // logs must never rewrite themselves
});
await t.test('resume does not rewrite new references using a deleted OSS object', async () => {
const [[entry]] = await db.query("SELECT data FROM infra_file_oss_migration_items WHERE JSON_UNQUOTE(JSON_EXTRACT(data,'$.path')) LIKE '%100%.png'");
const item = JSON.parse(entry.data), bytes = objects.get(item.key);
objects.delete(item.key);
await db.execute('INSERT INTO notes VALUES (2,?)', [firstUrl]);
const result = await runApply(db, config, settings, Infinity, () => oss);
assert.equal(result.status, 'partial'); assert.equal(result.failed, 1);
const [[note]] = await db.query('SELECT body FROM notes WHERE id=2'); assert.equal(note.body, firstUrl);
objects.set(item.key, bytes); await db.query('DELETE FROM notes WHERE id=2');
});
await t.test('rollback preserves later user edits and can resume after conflict resolution', async () => {
const [[migrated]] = await db.query('SELECT avatar FROM member_user');
await db.execute('UPDATE member_user SET avatar=?', ['https://user-new.example/avatar.png']);
const rollback = await runRollback(db, config); assert.equal(rollback.status, 'rollback_partial'); assert.ok(rollback.conflicts.length > 0);
const [[current]] = await db.query('SELECT avatar FROM member_user'); assert.equal(current.avatar, 'https://user-new.example/avatar.png');
await db.execute('UPDATE member_user SET avatar=?', [migrated.avatar]);
const resumed = await runRollback(db, config); assert.equal(resumed.status, 'rolled_back');
const [[member]] = await db.query('SELECT * FROM member_user'); assert.equal(member.avatar, firstUrl); assert.equal(member.wechat_qr_code, orphanUrl); assert.deepEqual(JSON.parse(member.extra_json), JSON.parse(payload));
const [files] = await db.query('SELECT * FROM infra_file'); assert.ok(files.every(file => file.config_id === '5')); assert.equal(objects.size, 3);
await assert.rejects(runApply(db, config, settings, Infinity, () => oss), /回滚/);
});
await t.test('failed uploads persist a partial state and never switch database addresses', async () => {
const failureConfig = { ...config, runId: 'upload-failure', objectPrefix: 'migration/failure' };
const failureSettings = await loadSettings(db, failureConfig); failPut = true;
const result = await runApply(db, failureConfig, failureSettings, Infinity, () => oss); failPut = false;
assert.equal(result.status, 'partial'); assert.equal(result.items.failed, 3);
const [[run]] = await db.query("SELECT status FROM infra_file_oss_migration_runs WHERE run_id='upload-failure'"); assert.equal(run.status, 'partial');
const [[member]] = await db.query('SELECT avatar FROM member_user'); assert.equal(member.avatar, firstUrl);
});
await t.test('non-strict MySQL truncation warnings roll back both data and journal', async () => {
await db.query('CREATE TABLE short_url (id INT PRIMARY KEY,url VARCHAR(8)) ENGINE=InnoDB');
await db.query("INSERT INTO short_url VALUES (1,'old')");
const [[mode]] = await db.query('SELECT @@SESSION.sql_mode AS value');
await db.query("SET SESSION sql_mode=''"); await db.beginTransaction();
await assert.rejects(applyChange(db, schema, 'warning', { schema, table: 'short_url', primaryKey: 'id', pkValue: 1, before: { url: 'old' }, after: { url: 'this-would-be-truncated' } }), /警告/);
await db.rollback(); await db.execute('SET SESSION sql_mode=?', [mode.value]);
const [[row]] = await db.query('SELECT url FROM short_url'); assert.equal(row.url, 'old');
const [[count]] = await db.query("SELECT COUNT(*) AS n FROM infra_file_oss_migration_changes WHERE run_id='warning'"); assert.equal(Number(count.n), 0);
});
await t.test('batch CAS conflict isolates the edited file and commits unrelated verified items', async () => {
const conflictConfig = { ...config, runId: 'batch-conflict', objectPrefix: 'migration/conflict' };
const conflictSettings = await loadSettings(db, conflictConfig);
let changed = false, rollbacks = 0;
const concurrentEditDb = {
query: (...args) => db.query(...args), execute: (...args) => db.execute(...args),
async beginTransaction() {
if (!changed) {
changed = true;
await db.execute('UPDATE infra_file SET url=? WHERE id=?', ['https://user-change.example/new.png', '9007199254741001']);
}
return db.beginTransaction();
},
commit: () => db.commit(),
rollback() { rollbacks++; return db.rollback(); }
};
const result = await runApply(concurrentEditDb, conflictConfig, conflictSettings, Infinity, () => oss);
assert.equal(result.status, 'partial'); assert.equal(result.failed, 1);
assert.equal(result.items.switched, 2); assert.equal(result.items.verified, 1);
assert.ok(rollbacks >= 2, 'the batch and the conflicted single item are rolled back');
const [[edited]] = await db.query('SELECT config_id,url FROM infra_file WHERE id=9007199254741001');
assert.equal(edited.config_id, '5'); assert.equal(edited.url, 'https://user-change.example/new.png');
const [[member]] = await db.query('SELECT avatar FROM member_user'); assert.equal(member.avatar, firstUrl);
const rollback = await runRollback(db, conflictConfig); assert.equal(rollback.status, 'rolled_back');
await db.execute('UPDATE infra_file SET url=? WHERE id=9007199254741001', [firstUrl]);
});
await t.test('database-source mode works and ambiguous raw-plus links require review', async () => {
const rawUrl = `${origin}/admin-api/infra/file/5/get/${firstPath}`;
await db.execute('INSERT INTO notes VALUES (3,?)', [rawUrl]);
const directConfig = { ...config, runId: 'database-mode', sourceMode: 'database' };
const directSettings = await loadSettings(db, directConfig);
const result = await runApply(db, directConfig, directSettings, Infinity, () => oss);
assert.equal(result.status, 'needs-review'); assert.equal(result.items.switched, 3);
const [[note]] = await db.query('SELECT body FROM notes WHERE id=3'); assert.equal(note.body, rawUrl);
assert.ok(result.references.unresolvedRows > 0);
});
} finally {
server.close(); server.closeAllConnections();
await db.query(`DROP DATABASE IF EXISTS ${schema}`);
await db.end();
}
});
@@ -0,0 +1,311 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyChanges } from '../lib/journal.mjs';
const clone = value => Buffer.isBuffer(value) ? Buffer.from(value)
: Array.isArray(value) ? value.map(clone)
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)])) : value;
const key = value => Buffer.isBuffer(value) ? `buffer:${value.toString('hex')}` : String(value);
const same = (left, right) => Buffer.isBuffer(left) || Buffer.isBuffer(right)
? Buffer.isBuffer(left) && Buffer.isBuffer(right) && left.equals(right)
: left === null || right === null ? left === right : String(left) === String(right);
const makeRow = (id, suffix) => ({
id, config_id: '4', path: `${suffix}.png`, url: `https://old/${suffix}.png`, type: 'image/png', size: 5,
deleted: Buffer.from([0]), update_time: 'unchanged', touched: 'unchanged'
});
// This small SQL test double evaluates the CASE dispatch and every WHERE guard
// independently, so mixed per-row values and partially matched batches surface.
class BatchConnection {
constructor(rows = [makeRow('9007199254740993', 'a'), makeRow('9007199254740994', 'b')]) {
this.rows = Object.fromEntries(rows.map(row => [key(row.id), clone(row)]));
this.calls = [];
this.journals = [];
this.warningStatus = 0;
this.failJournal = false;
this.journalWarningStatus = 0;
this.journalAffectedRows = null;
this.engine = 'InnoDB';
this.normalize = null;
this.omitReadback = false;
this.reverseReadback = false;
this.nextId = 9007199254741993n;
}
async beginTransaction() {
this.calls.push({ transaction: 'begin' });
this.saved = { rows: clone(this.rows), journals: clone(this.journals) };
}
async commit() { this.calls.push({ transaction: 'commit' }); this.saved = undefined; }
async rollback() {
this.calls.push({ transaction: 'rollback' });
if (this.saved) { this.rows = this.saved.rows; this.journals = this.saved.journals; this.saved = undefined; }
}
async execute(sql, params = []) {
this.calls.push({ sql, params });
if (sql.includes('FROM information_schema.COLUMNS')) {
return [[...['id', 'config_id', 'path', 'url', 'type', 'size', 'deleted', 'json_data', 'counter', 'update_time', 'touched']
.map(name => ({ name, engine: this.engine, extra: ['update_time', 'touched'].includes(name) ? 'on update CURRENT_TIMESTAMP' : '' }))]];
}
if (sql.startsWith('UPDATE')) {
const [setSql, whereSql] = sql.split(/\bWHERE\b/);
const cases = [...setSql.matchAll(/`([^`]+)`\s*=\s*CASE\s+`id`([\s\S]*?)\bEND/g)];
assert.ok(cases.length > 0, 'the batch must use a CASE expression for changed columns');
let offset = 0;
const values = new Map();
for (const [, column, branches] of cases) {
const count = [...branches.matchAll(/\bWHEN\s+\?\s+THEN\s+\?/g)].length;
assert.ok(count > 0);
assert.match(branches, new RegExp('ELSE\\s+`' + column + '`'));
const entries = new Map();
for (let index = 0; index < count; index++) entries.set(key(params[offset++]), params[offset++]);
values.set(column, entries);
}
let affectedRows = 0;
const groups = whereSql.trim().replace(/^\(/, '').replace(/\)$/, '').split(/\)\s+OR\s+\(/);
for (const group of groups) {
assert.match(group, /`id`\s*=\s*\?/);
const pk = key(params[offset++]);
const guards = [...group.matchAll(/CAST\(`([^`]+)` AS BINARY\)\s*<=>\s*CAST\(\? AS BINARY\)/g)].map(match => match[1]);
const expected = guards.map(() => params[offset++]);
const row = this.rows[pk];
if (!row || !guards.every((name, index) => same(row[name], expected[index]))) continue;
for (const [column, entries] of values) if (entries.has(pk)) row[column] = entries.get(pk);
if (this.normalize) this.normalize(row, [...values.keys()]);
if (!setSql.includes('`update_time` = `update_time`')) row.update_time = 'changed';
if (!setSql.includes('`touched` = `touched`')) row.touched = 'changed';
affectedRows++;
}
assert.equal(offset, params.length, 'all prepared UPDATE values must have a matching SQL placeholder');
return [{ affectedRows, warningStatus: this.warningStatus }];
}
if (sql.startsWith('SELECT `')) {
assert.match(sql, /\bIN\s*\(/);
assert.match(sql, /FOR UPDATE/);
const selected = [...sql.split(/\bFROM\b/)[0].matchAll(/`([^`]+)`/g)].map(match => match[1]);
const rows = params.map(pk => this.rows[key(pk)]).filter(Boolean)
.map(row => Object.fromEntries(selected.map(column => [column, row[column]])));
if (this.reverseReadback) rows.reverse();
return [this.omitReadback ? rows.slice(1) : rows];
}
if (sql.startsWith('INSERT INTO')) {
if (this.failJournal) throw Object.assign(new Error('injected journal INSERT failure'), { code: 'TEST_JOURNAL_FAILURE' });
assert.equal(params.length % 7, 0);
const firstId = this.nextId.toString();
for (let offset = 0; offset < params.length; offset += 7) {
const [run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json] = params.slice(offset, offset + 7);
this.journals.push({ id: (this.nextId++).toString(), run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json, rolled_back: 0 });
}
return [{ affectedRows: this.journalAffectedRows ?? params.length / 7, warningStatus: this.journalWarningStatus, insertId: firstId }];
}
throw new Error(`Unexpected test SQL: ${sql}`);
}
}
function change(row, suffix = 'migrated') {
const { config_id, path, url, type, size, deleted } = row;
return {
schema: 'app', table: 'infra_file', primaryKey: 'id', pkValue: row.id,
before: { config_id, path, url, type, size, deleted },
after: { config_id: '20', path: `${suffix}/${path}`, url: `https://oss/${suffix}/${path}`, type, size }
};
}
const changesFor = connection => Object.values(connection.rows).map(row => change(row));
const dml = connection => connection.calls.filter(call => call.sql && !call.sql.includes('information_schema'));
async function commitBatch(connection, changes) {
await connection.beginTransaction();
try {
const result = await applyChanges(connection, 'app', 'run', changes);
await connection.commit();
return result;
} catch (error) { await connection.rollback(); throw error; }
}
test('batch uses one CASE update, one readback and one journal insert for distinct bigint rows', async () => {
const connection = new BatchConnection();
const changes = changesFor(connection);
assert.deepEqual(await commitBatch(connection, changes), { changed: 2, skipped: 0 });
assert.deepEqual(dml(connection).map(call => call.sql.split(/\s/)[0]), ['UPDATE', 'SELECT', 'INSERT']);
assert.equal(connection.rows['9007199254740993'].url, 'https://oss/migrated/a.png');
assert.equal(connection.rows['9007199254740994'].url, 'https://oss/migrated/b.png');
assert.equal(connection.rows['9007199254740993'].update_time, 'unchanged');
assert.equal(connection.rows['9007199254740994'].touched, 'unchanged');
assert.equal(connection.journals.length, 2);
assert.deepEqual(connection.journals.map(row => JSON.parse(row.pk_value).value), ['9007199254740993', '9007199254740994']);
const [update] = dml(connection);
assert.match(update.sql, /CAST\(`deleted` AS BINARY\) <=> CAST\(\? AS BINARY\)/);
assert.ok(update.params.some(value => Buffer.isBuffer(value) && value.equals(Buffer.from([0]))));
assert.ok(!update.sql.includes('https://oss/'), 'URLs must be bound, not interpolated');
assert.ok(!update.sql.includes('`deleted` = CASE'), 'before-only deletion guards must not become assignments');
});
test('table metadata is cached across batches on the same connection', async () => {
const connection = new BatchConnection();
await commitBatch(connection, changesFor(connection));
await commitBatch(connection, Object.values(connection.rows).map(row => change(row, 'second')));
assert.equal(connection.calls.filter(call => call.sql?.includes('information_schema')).length, 1);
assert.equal(dml(connection).length, 6);
});
test('empty batches and unchanged rows create no updates or journal entries', async () => {
const connection = new BatchConnection();
assert.deepEqual(await commitBatch(connection, []), { changed: 0, skipped: 0 });
const changes = changesFor(connection);
changes.forEach(item => { item.after = Object.fromEntries(Object.keys(item.after).map(name => [name, item.before[name]])); });
assert.deepEqual(await commitBatch(connection, changes), { changed: 0, skipped: 2 });
assert.equal(dml(connection).length, 0);
assert.equal(connection.journals.length, 0);
});
test('a no-op row is skipped while a different row receives its own values', async () => {
const connection = new BatchConnection();
const changes = changesFor(connection);
changes[1].after = Object.fromEntries(Object.keys(changes[1].after).map(name => [name, changes[1].before[name]]));
assert.deepEqual(await commitBatch(connection, changes), { changed: 1, skipped: 1 });
assert.equal(connection.rows['9007199254740993'].url, 'https://oss/migrated/a.png');
assert.equal(connection.rows['9007199254740994'].url, 'https://old/b.png');
assert.equal(connection.journals.length, 1);
});
test('after field order may differ and row-specific unchanged fields stay unchanged', async () => {
const connection = new BatchConnection();
const changes = changesFor(connection);
changes[0].after.type = 'image/webp';
changes[1].after = Object.fromEntries(Object.entries(changes[1].after).reverse());
await commitBatch(connection, changes);
assert.equal(connection.rows['9007199254740993'].type, 'image/webp');
assert.equal(connection.rows['9007199254740994'].type, 'image/png');
});
test('different schema, table, primary key, or after-field groups are rejected before updates', async () => {
for (const mutate of [
item => { item.schema = 'other'; },
item => { item.table = 'member_user'; },
item => { item.primaryKey = 'other_id'; },
item => { delete item.after.size; }
]) {
const connection = new BatchConnection();
const changes = changesFor(connection);
mutate(changes[1]);
await assert.rejects(commitBatch(connection, changes));
assert.equal(dml(connection).length, 0);
}
});
test('duplicate primary keys are rejected even across string and bigint representations', async () => {
const connection = new BatchConnection();
const changes = changesFor(connection);
changes[1].pkValue = BigInt(changes[0].pkValue);
await assert.rejects(commitBatch(connection, changes), /主键|重复/);
assert.equal(dml(connection).length, 0);
});
test('primary key updates and missing before snapshots are rejected', async () => {
for (const mutate of [
item => { item.after.id = '1'; },
item => { delete item.before.url; }
]) {
const connection = new BatchConnection();
const changes = changesFor(connection);
changes.forEach(mutate);
await assert.rejects(commitBatch(connection, changes));
assert.equal(dml(connection).length, 0);
}
});
test('a concurrent case-only edit leaves the entire batch unchanged after caller rollback', async () => {
const connection = new BatchConnection();
const changes = changesFor(connection);
connection.rows['9007199254740994'].url = 'https://old/B.png';
const before = clone(connection.rows);
await assert.rejects(commitBatch(connection, changes), { code: 'MIGRATION_CAS_CONFLICT' });
assert.deepEqual(connection.rows, before);
assert.equal(connection.journals.length, 0);
assert.equal(dml(connection).length, 1);
assert.equal(connection.calls.at(-1).transaction, 'rollback');
});
test('a deletion guard conflict rolls back otherwise matching rows', async () => {
const connection = new BatchConnection();
const changes = changesFor(connection);
connection.rows['9007199254740994'].deleted = Buffer.from([1]);
const before = clone(connection.rows);
await assert.rejects(commitBatch(connection, changes), { code: 'MIGRATION_CAS_CONFLICT' });
assert.deepEqual(connection.rows, before);
});
test('journal INSERT failure rolls back all successfully updated rows', async () => {
const connection = new BatchConnection();
const changes = changesFor(connection);
const before = clone(connection.rows);
connection.failJournal = true;
await assert.rejects(commitBatch(connection, changes), { code: 'TEST_JOURNAL_FAILURE' });
assert.deepEqual(connection.rows, before);
assert.equal(connection.journals.length, 0);
assert.equal(dml(connection).length, 3);
});
test('MySQL JSON canonicalization and numeric conversion are read back into each journal entry', async () => {
const connection = new BatchConnection();
connection.reverseReadback = true;
for (const row of Object.values(connection.rows)) {
row.json_data = JSON.stringify({ url: row.url }).replaceAll('\":', '\": ');
row.counter = 1;
}
const changes = Object.values(connection.rows).map(row => ({
schema: 'app', table: 'infra_file', primaryKey: 'id', pkValue: row.id,
before: { json_data: row.json_data, counter: 1 },
after: { json_data: JSON.stringify({ url: `https://oss/${row.path}` }), counter: '2' }
}));
connection.normalize = (row, fields) => {
if (fields.includes('json_data')) row.json_data = JSON.stringify(JSON.parse(row.json_data)).replaceAll('\":', '\": ');
if (fields.includes('counter')) row.counter = Number(row.counter);
};
await commitBatch(connection, changes);
const snapshots = connection.journals.map(row => JSON.parse(row.after_json));
assert.equal(snapshots[0].json_data.value, '{"url": "https://oss/a.png"}');
assert.equal(snapshots[1].json_data.value, '{"url": "https://oss/b.png"}');
assert.equal(snapshots[0].counter.value, 2);
assert.equal(snapshots[1].counter.value, 2);
});
test('UPDATE warnings and incomplete readback each abort the entire batch', async () => {
for (const prepare of [
connection => { connection.warningStatus = 1; },
connection => { connection.omitReadback = true; }
]) {
const connection = new BatchConnection();
const changes = changesFor(connection);
const before = clone(connection.rows);
prepare(connection);
await assert.rejects(commitBatch(connection, changes));
assert.deepEqual(connection.rows, before);
assert.equal(connection.journals.length, 0);
}
});
test('incomplete or warned batch journal inserts roll back both updated rows and inserted logs', async () => {
for (const prepare of [
connection => { connection.journalAffectedRows = 1; },
connection => { connection.journalWarningStatus = 1; }
]) {
const connection = new BatchConnection();
const changes = changesFor(connection);
const before = clone(connection.rows);
prepare(connection);
await assert.rejects(commitBatch(connection, changes), /回滚日志/);
assert.deepEqual(connection.rows, before);
assert.equal(connection.journals.length, 0);
}
});
test('non-InnoDB targets are rejected before mutation', async () => {
const connection = new BatchConnection();
connection.engine = 'MyISAM';
await assert.rejects(commitBatch(connection, changesFor(connection)), /InnoDB/);
assert.equal(dml(connection).length, 0);
});
@@ -0,0 +1,238 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ensureJournal, applyChange, rollbackChanges } from '../lib/journal.mjs';
const clone = value => Buffer.isBuffer(value) ? Buffer.from(value)
: Array.isArray(value) ? value.map(clone)
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)])) : value;
const same = (a, b) => Buffer.isBuffer(a) && Buffer.isBuffer(b) ? a.equals(b) : a === b || (a !== null && b !== null && String(a) === String(b));
const names = sql => [...sql.matchAll(/CAST\(`((?:``|[^`])+)` AS BINARY\)/g)].map(match => match[1].replaceAll('``', '`'));
class MemoryConnection {
constructor() {
this.calls = [];
this.rows = { '9007199254740993': { id: '9007199254740993', avatar: 'https://old/a', nullable: null, deleted: Buffer.from([0]), update_time: 'unchanged', touched: 'unchanged' } };
this.journals = [];
this.nextId = 9007199254740993n;
this.engine = 'InnoDB';
this.failJournal = false;
this.normalizeUpdate = null;
this.warningStatus = 0;
}
async beginTransaction() { this.calls.push({ transaction: 'begin' }); this.saved = { rows: clone(this.rows), journals: clone(this.journals) }; }
async commit() { this.calls.push({ transaction: 'commit' }); this.saved = undefined; }
async rollback() { this.calls.push({ transaction: 'rollback' }); if (this.saved) { this.rows = this.saved.rows; this.journals = this.saved.journals; this.saved = undefined; } }
async execute(sql, params = []) {
this.calls.push({ sql, params });
if (sql.startsWith('CREATE TABLE')) return [{ affectedRows: 0 }];
if (sql.includes('FROM information_schema.COLUMNS')) {
return [[...['id', 'avatar', 'nullable', 'deleted', 'update_time', 'touched', 'json_data', 'counter'].map(name => ({ name, engine: this.engine, extra: ['update_time', 'touched'].includes(name) ? 'on update CURRENT_TIMESTAMP' : '' }))]];
}
if (sql.startsWith('INSERT INTO')) {
if (this.failJournal) throw Object.assign(new Error('journal unavailable'), { code: 'TEST_JOURNAL_FAILURE' });
const [run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json] = params;
const id = (this.nextId++).toString();
this.journals.push({ id, run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json, rolled_back: 0 });
return [{ insertId: id }];
}
if (sql.startsWith('SELECT CAST(id AS CHAR)')) {
const [runId, cursor] = params;
return [this.journals.filter(row => row.run_id === runId && row.rolled_back === 0 && (cursor === undefined || BigInt(row.id) < BigInt(cursor)))
.sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1).slice(0, 100).map(row => ({ id: row.id }))];
}
if (sql.startsWith('SELECT *')) return [[this.journals.find(row => row.id === params[0] && row.run_id === params[1])].filter(Boolean)];
if (sql.startsWith('SELECT 1 AS matched')) {
const row = this.rows[String(params[0])];
return [row && names(sql).every((name, index) => same(row[name], params[index + 1])) ? [{ matched: 1 }] : []];
}
if (sql.startsWith('SELECT `')) {
const row = this.rows[String(params[0])];
const selected = [...sql.split(' FROM ')[0].matchAll(/`((?:``|[^`])+)`/g)].map(match => match[1]);
return [row ? [Object.fromEntries(selected.map(name => [name, row[name]]))] : []];
}
if (sql.startsWith('UPDATE') && sql.includes('SET rolled_back = 1')) {
const row = this.journals.find(row => row.id === params[0] && row.run_id === params[1]);
if (row) row.rolled_back = 1;
return [{ affectedRows: row ? 1 : 0 }];
}
if (sql.startsWith('UPDATE')) {
const set = sql.split(' SET ')[1].split('WHERE')[0];
const changed = [...set.matchAll(/`((?:``|[^`])+)` = \?/g)].map(match => match[1]);
const row = this.rows[String(params[changed.length])];
if (!row || !names(sql).every((name, index) => same(row[name], params[changed.length + 1 + index]))) return [{ affectedRows: 0 }];
changed.forEach((name, index) => { row[name] = params[index]; });
if (this.normalizeUpdate) this.normalizeUpdate(row, changed);
if (!set.includes('`update_time` = `update_time`')) row.update_time = 'changed';
if (!set.includes('`touched` = `touched`')) row.touched = 'changed';
return [{ affectedRows: 1, warningStatus: this.warningStatus }];
}
throw new Error(`Unexpected test query: ${sql}`);
}
}
const change = (before = 'https://old/a', after = 'https://oss/a') => ({
schema: 'app', table: 'member_user', primaryKey: 'id', pkValue: '9007199254740993',
before: { avatar: before, deleted: Buffer.from([0]), nullable: null }, after: { avatar: after }
});
async function commitChange(connection, payload = change(), runId = 'run') {
await connection.beginTransaction();
try {
const result = await applyChange(connection, 'app', runId, payload);
await connection.commit();
return result;
} catch (error) { await connection.rollback(); throw error; }
}
test('journal schema uses InnoDB and independent run/item/change records', async () => {
const connection = new MemoryConnection();
await ensureJournal(connection, 'app');
const creates = connection.calls.filter(call => call.sql?.startsWith('CREATE TABLE'));
assert.equal(creates.length, 3);
assert.ok(creates.every(call => call.sql.includes('ENGINE=InnoDB')));
assert.match(creates[1].sql, /PRIMARY KEY \(run_id, item_key\)/);
assert.match(creates[2].sql, /KEY ix_run_id_id \(run_id, id\)/);
});
test('apply logs original values with binary null-safe guards and lossless bigint IDs', async () => {
const connection = new MemoryConnection();
const result = await commitChange(connection);
assert.equal(result.journalId, '9007199254740993');
assert.equal(connection.rows['9007199254740993'].avatar, 'https://oss/a');
assert.equal(connection.rows['9007199254740993'].update_time, 'unchanged');
assert.equal(connection.rows['9007199254740993'].touched, 'unchanged');
const update = connection.calls.find(call => call.sql?.startsWith('UPDATE'));
assert.match(update.sql, /CAST\(`avatar` AS BINARY\) <=> CAST\(\? AS BINARY\)/);
assert.match(update.sql, /CAST\(`nullable` AS BINARY\) <=> CAST\(\? AS BINARY\)/);
assert.equal(update.params[1], '9007199254740993');
assert.deepEqual(update.params[3], Buffer.from([0]));
assert.equal(update.params[4], null);
assert.equal(connection.journals.length, 1);
});
test('no-op changes do not update rows or create logs', async () => {
const connection = new MemoryConnection();
assert.deepEqual(await commitChange(connection, change('https://old/a', 'https://old/a')), { changed: false });
assert.equal(connection.journals.length, 0);
assert.equal(connection.calls.filter(call => call.sql?.startsWith('UPDATE')).length, 0);
});
test('primary-key changes and missing snapshots are rejected before mutation', async () => {
const connection = new MemoryConnection();
await assert.rejects(commitChange(connection, { ...change(), after: { id: '1' } }), /不能修改主键/);
await assert.rejects(commitChange(connection, { ...change(), before: { deleted: Buffer.from([0]) } }), /缺少原值快照/);
assert.equal(connection.journals.length, 0);
});
test('concurrent edits including case-only changes fail CAS and preserve the row', async () => {
const connection = new MemoryConnection();
connection.rows['9007199254740993'].avatar = 'https://old/A';
await assert.rejects(commitChange(connection), { code: 'MIGRATION_CAS_CONFLICT' });
assert.equal(connection.rows['9007199254740993'].avatar, 'https://old/A');
assert.equal(connection.journals.length, 0);
});
test('a journal INSERT failure is rolled back with the row by the caller transaction', async () => {
const connection = new MemoryConnection();
connection.failJournal = true;
await assert.rejects(commitChange(connection), { code: 'TEST_JOURNAL_FAILURE' });
assert.equal(connection.rows['9007199254740993'].avatar, 'https://old/a');
assert.equal(connection.journals.length, 0);
assert.equal(connection.calls.at(-1).transaction, 'rollback');
});
test('journal reads back native JSON canonicalization and numeric conversions before recording rollback values', async () => {
const connection = new MemoryConnection();
connection.rows['9007199254740993'].json_data = '{"avatar": "https://old/a"}';
connection.rows['9007199254740993'].counter = 1;
connection.normalizeUpdate = (row, changed) => {
if (changed.includes('json_data')) row.json_data = JSON.stringify(JSON.parse(row.json_data)).replaceAll('\":', '\": ');
if (changed.includes('counter')) row.counter = Number(row.counter);
};
await commitChange(connection, {
...change(),
before: { json_data: '{"avatar": "https://old/a"}', counter: 1 },
after: { json_data: '{"avatar":"https://oss/a"}', counter: '2' }
});
const after = JSON.parse(connection.journals[0].after_json);
assert.equal(after.json_data.value, '{"avatar": "https://oss/a"}');
assert.equal(after.counter.value, 2);
const result = await rollbackChanges(connection, 'app', 'run');
assert.deepEqual(result, { restored: 1, alreadyRestored: 0, conflicts: [] });
assert.equal(connection.rows['9007199254740993'].json_data, '{"avatar": "https://old/a"}');
assert.equal(connection.rows['9007199254740993'].counter, 1);
});
test('silent SQL truncation warnings abort the transaction before recording success', async () => {
const connection = new MemoryConnection();
connection.warningStatus = 1;
await assert.rejects(commitChange(connection), /数据库更新产生警告/);
assert.equal(connection.rows['9007199254740993'].avatar, 'https://old/a');
assert.equal(connection.journals.length, 0);
});
test('rollback restores consecutive edits in reverse journal order without losing bigint precision', async () => {
const connection = new MemoryConnection();
await commitChange(connection);
await commitChange(connection, change('https://oss/a', 'https://oss/b'));
const result = await rollbackChanges(connection, 'app', 'run');
assert.deepEqual(result, { restored: 2, alreadyRestored: 0, conflicts: [] });
assert.equal(connection.rows['9007199254740993'].avatar, 'https://old/a');
assert.equal(connection.rows['9007199254740993'].update_time, 'unchanged');
const reads = connection.calls.filter(call => call.sql?.startsWith('SELECT *'));
assert.deepEqual(reads.map(call => call.params[0]), ['9007199254740994', '9007199254740993']);
assert.ok(connection.journals.every(row => row.rolled_back === 1));
assert.deepEqual(await rollbackChanges(connection, 'app', 'run'), { restored: 0, alreadyRestored: 0, conflicts: [] });
});
test('rollback detects an already-restored row and only marks the journal', async () => {
const connection = new MemoryConnection();
await commitChange(connection);
connection.rows['9007199254740993'].avatar = 'https://old/a';
const result = await rollbackChanges(connection, 'app', 'run');
assert.deepEqual(result, { restored: 0, alreadyRestored: 1, conflicts: [] });
assert.equal(connection.journals[0].rolled_back, 1);
});
test('rollback SQL warnings do not mark a truncated restoration as complete', async () => {
const connection = new MemoryConnection();
await commitChange(connection);
connection.warningStatus = 1;
await assert.rejects(rollbackChanges(connection, 'app', 'run'), /数据库回滚产生警告/);
assert.equal(connection.rows['9007199254740993'].avatar, 'https://oss/a');
assert.equal(connection.journals[0].rolled_back, 0);
});
test('rollback leaves later user edits and deletion guards unchanged', async () => {
for (const update of [{ avatar: 'https://user/new' }, { deleted: Buffer.from([1]) }]) {
const connection = new MemoryConnection();
await commitChange(connection);
Object.assign(connection.rows['9007199254740993'], update);
const original = clone(connection.rows['9007199254740993']);
const result = await rollbackChanges(connection, 'app', 'run');
assert.equal(result.restored, 0);
assert.equal(result.conflicts.length, 1);
assert.equal(result.conflicts[0].pkValue, '9007199254740993');
assert.deepEqual(connection.rows['9007199254740993'], original);
assert.equal(connection.journals[0].rolled_back, 0);
}
});
test('rollback does not resurrect missing rows or touch another run', async () => {
const connection = new MemoryConnection();
await commitChange(connection);
delete connection.rows['9007199254740993'];
assert.deepEqual(await rollbackChanges(connection, 'app', 'another-run'), { restored: 0, alreadyRestored: 0, conflicts: [] });
const result = await rollbackChanges(connection, 'app', 'run');
assert.equal(result.conflicts.length, 1);
assert.equal(Object.keys(connection.rows).length, 0);
});
test('non-transactional tables are rejected before any update', async () => {
const connection = new MemoryConnection();
connection.engine = 'MyISAM';
await assert.rejects(commitChange(connection), /必须为 InnoDB/);
assert.equal(connection.calls.filter(call => call.sql?.startsWith('UPDATE')).length, 0);
});
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createProgress } from '../lib/progress.mjs';
function fixture(t, intervalMs = 5000) {
t.mock.timers.enable({ apis: ['setInterval'] });
let time = 0;
const lines = [];
const progress = createProgress({ log: line => lines.push(line), intervalMs, now: () => time });
t.after(() => progress.close());
return {
progress, lines,
setTime(value) { time = value; },
tick(value) { time += value; t.mock.timers.tick(value); },
};
}
test('construction stays silent until a stage is available', t => {
const { progress, lines, tick } = fixture(t);
tick(15000);
assert.deepEqual(lines, []);
progress.stage('正在等待数据库连接');
assert.deepEqual(lines, ['正在等待数据库连接(已用时 0 秒)']);
});
test('the timer reports a waiting stage at the configured interval', t => {
const { progress, lines, tick } = fixture(t);
progress.stage('正在统计历史图片');
tick(5000);
tick(5000);
assert.deepEqual(lines, [
'正在统计历史图片(已用时 0 秒)',
'正在统计历史图片(已用时 5 秒)',
'正在统计历史图片(已用时 10 秒)',
]);
});
test('frequent updates preserve the newest message without printing every record', t => {
const { progress, lines, setTime } = fixture(t);
progress.stage('正在扫描引用');
for (let count = 1; count <= 1000; count++) {
setTime(count * 4);
progress.update('正在扫描引用,已检查 ' + count + ' 行');
}
assert.equal(lines.length, 1);
setTime(5000);
progress.update('正在扫描引用,已检查 1200 行');
assert.equal(lines.at(-1), '正在扫描引用,已检查 1200 行(已用时 5 秒)');
assert.equal(lines.length, 2);
});
test('timer output uses the last update message without resetting stage elapsed time', t => {
const { progress, lines, tick } = fixture(t);
progress.stage('正在扫描引用');
tick(2000);
progress.update('正在扫描引用,已检查 100 行');
tick(3000);
assert.deepEqual(lines, [
'正在扫描引用(已用时 0 秒)',
'正在扫描引用,已检查 100 行(已用时 5 秒)',
]);
});
test('synchronous updates emit progress even when interval callbacks cannot run', t => {
const { progress, lines, setTime } = fixture(t);
progress.stage('正在处理清单');
setTime(4999);
progress.update('正在处理清单 1');
assert.equal(lines.length, 1);
setTime(5000);
progress.update('正在处理清单 2');
setTime(10000);
progress.update('正在处理清单 3');
assert.deepEqual(lines, [
'正在处理清单(已用时 0 秒)',
'正在处理清单 2(已用时 5 秒)',
'正在处理清单 3(已用时 10 秒)',
]);
});
test('a timer callback immediately after an update does not duplicate output', t => {
const { progress, lines, setTime } = fixture(t);
progress.stage('正在上传');
setTime(5000);
progress.update('正在上传,已处理 50 张');
t.mock.timers.tick(5000);
assert.equal(lines.length, 2);
});
test('changing stage prints immediately and restarts its elapsed time', t => {
const { progress, lines, tick } = fixture(t);
progress.stage('正在统计');
tick(5000);
progress.stage('正在上传');
assert.equal(lines.at(-1), '正在上传(已用时 0 秒)');
tick(5000);
assert.equal(lines.at(-1), '正在上传(已用时 5 秒)');
});
test('close is idempotent and prevents timer, stage and update output', t => {
const { progress, lines, tick } = fixture(t);
progress.stage('正在扫描');
progress.close();
progress.close();
tick(15000);
progress.stage('已关闭');
progress.update('不应输出');
assert.deepEqual(lines, ['正在扫描(已用时 0 秒)']);
});
test('an update can initialize progress when no explicit stage was set', t => {
const { progress, lines, tick } = fixture(t);
progress.update('正在处理');
tick(5000);
assert.deepEqual(lines, ['正在处理(已用时 0 秒)', '正在处理(已用时 5 秒)']);
});
test('the interval is unrefed and its exact handle is cleared', t => {
let callback;
const handle = { unref: t.mock.fn() };
const interval = t.mock.method(globalThis, 'setInterval', (fn, ms) => {
callback = fn;
assert.equal(ms, 5000);
return handle;
});
const clear = t.mock.method(globalThis, 'clearInterval', () => {});
const log = t.mock.fn();
const progress = createProgress({ log, now: () => 0 });
assert.equal(interval.mock.callCount(), 1);
assert.equal(handle.unref.mock.callCount(), 1);
assert.equal(log.mock.callCount(), 0);
progress.close();
assert.deepEqual(clear.mock.calls[0].arguments, [handle]);
callback();
assert.equal(log.mock.callCount(), 0);
});
test('invalid intervals fail before starting a timer', () => {
for (const intervalMs of [0, -1, 1.5, NaN, Infinity]) {
assert.throws(() => createProgress({ intervalMs }), /正整数/);
}
});
@@ -0,0 +1,309 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { stringify } from 'yaml';
import { loadProjectConfig } from '../lib/project-config.mjs';
const defaultBase = () => ({ spring: { profiles: { active: 'dev' } } });
const datasource = overrides => ({
url: 'jdbc:mysql://db.example.test:3307/fixture_db?useSSL=false&serverTimezone=Asia%2FShanghai',
username: 'fixture-user', password: 'fixture-password', ...overrides,
});
const profileDocument = (overrides = {}) => ({
spring: { datasource: { dynamic: { datasource: { master: datasource(overrides) } } } },
server: { port: 48083 },
});
const documents = value => (Array.isArray(value) ? value : [value]).map(document => stringify(document)).join('---\n');
async function fixture(t, { base = defaultBase(), profiles = { dev: profileDocument() } } = {}) {
const root = await mkdtemp(join(tmpdir(), 'oss-project-config-test-'));
t.after(() => rm(root, { recursive: true, force: true }));
const resources = join(root, 'yudao-server/src/main/resources');
await mkdir(resources, { recursive: true });
await writeFile(join(resources, 'application.yaml'), typeof base === 'string' ? base : documents(base));
for (const [name, content] of Object.entries(profiles)) {
await writeFile(join(resources, 'application-' + name + '.yaml'), typeof content === 'string' ? content : documents(content));
}
return root;
}
test('default profile, master credentials and actual server port come from the project YAML', async t => {
const projectRoot = await fixture(t);
const result = await loadProjectConfig({ projectRoot, env: {} });
assert.equal(result.profile, 'dev');
assert.deepEqual(result.mysql, {
host: 'db.example.test', port: 3307, database: 'fixture_db',
user: 'fixture-user', password: 'fixture-password',
});
assert.equal(result.downloadBaseUrl, 'http://127.0.0.1:48083/admin-api/infra/file');
assert.deepEqual(result.sourceFiles, [
resolve(projectRoot, 'yudao-server/src/main/resources/application.yaml'),
resolve(projectRoot, 'yudao-server/src/main/resources/application-dev.yaml'),
]);
});
test('explicit profile overrides environment, which overrides the base active profile', async t => {
const projectRoot = await fixture(t, { profiles: {
dev: profileDocument({ url: 'jdbc:mysql://dev.example.test/dev_db' }),
local: profileDocument({ url: 'jdbc:mysql://local.example.test/local_db' }),
prod: profileDocument({ url: 'jdbc:mysql://prod.example.test/prod_db' }),
} });
const explicit = await loadProjectConfig({ projectRoot, profile: 'local', env: { SPRING_PROFILES_ACTIVE: 'prod' } });
const environment = await loadProjectConfig({ projectRoot, env: { SPRING_PROFILES_ACTIVE: 'prod' } });
const base = await loadProjectConfig({ projectRoot, env: {} });
assert.equal(explicit.mysql.database, 'local_db');
assert.equal(environment.mysql.database, 'prod_db');
assert.equal(base.mysql.database, 'dev_db');
});
test('single-profile selection resolves a base property or environment placeholder with a default', async t => {
const projectRoot = await fixture(t, {
base: { selection: { profile: '${APP_PROFILE:dev}' }, spring: { profiles: { active: '${selection.profile}' } } },
profiles: { dev: profileDocument(), local: profileDocument() },
});
assert.equal((await loadProjectConfig({ projectRoot, env: {} })).profile, 'dev');
assert.equal((await loadProjectConfig({ projectRoot, env: { APP_PROFILE: 'local' } })).profile, 'local');
});
test('explicit profile works even when the base active profile is absent or unusable', async t => {
const projectRoot = await fixture(t, { base: { spring: { profiles: { active: ['unused', 'group'] } } } });
const result = await loadProjectConfig({ projectRoot, profile: 'dev', env: {} });
assert.equal(result.profile, 'dev');
});
test('multiple, missing and path-traversal profiles fail with actionable selection errors', async t => {
for (const active of [['dev', 'prod'], 'dev,prod', 'dev prod', '', '../prod', null, undefined]) {
const projectRoot = await fixture(t, { base: { spring: { profiles: { active } } } });
await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), /单个 profile.*--profile/);
}
const projectRoot = await fixture(t);
await assert.rejects(loadProjectConfig({ projectRoot, env: { SPRING_PROFILES_ACTIVE: 'dev,prod' } }), /单个 profile/);
await assert.rejects(loadProjectConfig({ projectRoot, profile: '../dev', env: {} }), /单个 profile/);
});
test('multiple unconditional documents merge recursively across base and selected profile', async t => {
const projectRoot = await fixture(t, {
base: [
{ spring: { profiles: { active: 'dev' }, datasource: { dynamic: { primary: 'main', datasource: { main: { username: 'base-user', password: 'base-password' } } } } } },
{ spring: { datasource: { dynamic: { datasource: { main: { url: 'jdbc:mysql://base.example.test/base_db' } } } } }, server: { port: 8081 } },
],
profiles: { dev: [
{ spring: { datasource: { dynamic: { datasource: { main: { url: 'jdbc:mysql://selected.example.test:3308/selected_db' } } } } } },
{ spring: { datasource: { dynamic: { datasource: { main: { password: 'selected-password' } } } } }, server: { port: 48083 } },
] },
});
const result = await loadProjectConfig({ projectRoot, env: {} });
assert.deepEqual(result.mysql, { host: 'selected.example.test', port: 3308, database: 'selected_db', user: 'base-user', password: 'selected-password' });
assert.equal(result.downloadBaseUrl, 'http://127.0.0.1:48083/admin-api/infra/file');
});
test('environment directly overrides master URL, username and password', async t => {
const projectRoot = await fixture(t);
const result = await loadProjectConfig({ projectRoot, env: {
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_URL: 'jdbc:mysql://override.example.test:3310/env_db',
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_USERNAME: 'env-user',
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_PASSWORD: ' env:pass#word ',
} });
assert.deepEqual(result.mysql, { host: 'override.example.test', port: 3310, database: 'env_db', user: 'env-user', password: ' env:pass#word ' });
});
test('dynamic primary environment override selects the named source instead of master or slave', async t => {
const document = profileDocument();
document.spring.datasource.dynamic.datasource.reporting = datasource({ url: 'jdbc:mysql://reporting.example.test/reporting_db' });
document.spring.datasource.dynamic.datasource.slave = datasource({ url: 'jdbc:mysql://slave.example.test/slave_db' });
const projectRoot = await fixture(t, { profiles: { dev: document } });
const result = await loadProjectConfig({ projectRoot, env: {
SPRING_DATASOURCE_DYNAMIC_PRIMARY: 'reporting',
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_REPORTING_PASSWORD: 'reporting-env-password',
} });
assert.equal(result.mysql.database, 'reporting_db');
assert.equal(result.mysql.password, 'reporting-env-password');
});
test('required fields support property references and nested environment defaults', async t => {
const document = profileDocument({
url: 'jdbc:mysql://${DB_HOST:${database.host}}:${DB_PORT:${database.port:3306}}/${database.name}',
username: '${DB_USER:${database.user}}',
password: '${DB_PASSWORD:${database.password}}',
});
document.database = { host: 'property.example.test', name: 'property_db', user: 'property-user', password: 'property-password' };
const projectRoot = await fixture(t, { profiles: { dev: document } });
const base = await loadProjectConfig({ projectRoot, env: {} });
assert.deepEqual(base.mysql, { host: 'property.example.test', port: 3306, database: 'property_db', user: 'property-user', password: 'property-password' });
const overridden = await loadProjectConfig({ projectRoot, env: { DB_HOST: 'env.example.test', DB_PORT: '3309', DB_PASSWORD: 'env-password' } });
assert.equal(overridden.mysql.host, 'env.example.test');
assert.equal(overridden.mysql.port, 3309);
assert.equal(overridden.mysql.password, 'env-password');
});
test('unrelated unresolved placeholders, service credentials and cycles are never resolved', async t => {
const document = profileDocument();
document.unrelated = { secret: '${MISSING_SERVICE_SECRET}', a: '${unrelated.b}', b: '${unrelated.a}', encrypted: 'ENC(unrelated-service-secret)' };
const projectRoot = await fixture(t, { profiles: { dev: document } });
assert.equal((await loadProjectConfig({ projectRoot, env: {} })).mysql.database, 'fixture_db');
});
test('HTTP context path, admin prefix and SSL settings determine the download URL', async t => {
const document = profileDocument();
document.server = { port: 8443, servlet: { contextPath: '/backend/' }, ssl: { enabled: true } };
document.yudao = { web: { adminApi: { prefix: '/management/' } } };
const projectRoot = await fixture(t, { profiles: { dev: document } });
const result = await loadProjectConfig({ projectRoot, env: {} });
assert.equal(result.downloadBaseUrl, 'https://127.0.0.1:8443/backend/management/infra/file');
const overridden = await loadProjectConfig({ projectRoot, env: {
SERVER_PORT: '9080', SERVER_SERVLET_CONTEXT_PATH: '/overridden',
YUDAO_WEB_ADMIN_API_PREFIX: '/admin', SERVER_SSL_ENABLED: 'false',
} });
assert.equal(overridden.downloadBaseUrl, 'http://127.0.0.1:9080/overridden/admin/infra/file');
});
test('Spring defaults apply to missing primary, HTTP port and API prefix', async t => {
const document = profileDocument();
delete document.server;
const projectRoot = await fixture(t, { profiles: { dev: document } });
assert.equal((await loadProjectConfig({ projectRoot, env: {} })).downloadBaseUrl, 'http://127.0.0.1:8080/admin-api/infra/file');
});
test('an explicit root context path does not produce duplicate slashes', async t => {
const document = profileDocument();
document.server.servlet = { 'context-path': '/' };
document.yudao = { web: { 'admin-api': { prefix: '/' } } };
const projectRoot = await fixture(t, { profiles: { dev: document } });
assert.equal((await loadProjectConfig({ projectRoot, env: {} })).downloadBaseUrl, 'http://127.0.0.1:48083/infra/file');
});
test('single-host MySQL URL supports omitted ports and IPv6 without changing database names', async t => {
for (const [url, expected] of [
['jdbc:mysql://localhost/test_db', { host: 'localhost', port: 3306, database: 'test_db' }],
['jdbc:mysql://[::1]:3309/test_db', { host: '::1', port: 3309, database: 'test_db' }],
['jdbc:mysql://db.example.test/test%20db', { host: 'db.example.test', port: 3306, database: 'test db' }],
]) {
const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ url }) } });
const result = await loadProjectConfig({ projectRoot, env: {} });
assert.deepEqual({ host: result.mysql.host, port: result.mysql.port, database: result.mysql.database }, expected);
}
});
test('ambiguous, unsupported and incomplete JDBC URLs are rejected without echoing their values', async t => {
for (const url of [
'jdbc:mysql:loadbalance://first,second/db',
'jdbc:mysql://first,second/db',
'jdbc:mysql://first:3306,second:3306/db',
'jdbc:postgresql://secret-host/secret-database',
'jdbc:mysql://db.example.test',
'jdbc:mysql://db.example.test/a/b',
'jdbc:mysql://db.example.test:65536/db',
'jdbc:mysql://db.example.test:0/db',
'jdbc:mysql://user:sensitive-url-password@db.example.test/db',
'jdbc:mysql://db.example.test/a%2Fb',
]) {
const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ url }) } });
await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
assert.match(error.message, /MySQL|jdbc:mysql/);
assert.ok(!error.message.includes(url));
assert.ok(!error.message.includes('sensitive-url-password'));
return true;
});
}
});
test('missing or still-encrypted passwords are rejected, with direct environment override available', async t => {
for (const password of [null, undefined, '', 'ENC(sensitive-encrypted-password)']) {
const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ password }) } });
await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
assert.match(error.message, /缺少|缺失|ENC/);
assert.ok(!error.message.includes('sensitive-encrypted-password'));
return true;
});
assert.equal((await loadProjectConfig({ projectRoot, env: { SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_PASSWORD: 'provided-password' } })).mysql.password, 'provided-password');
}
});
test('required unresolved, malformed or cyclic placeholders stop safely without revealing secrets', async t => {
for (const password of [
'sensitive-prefix-${MISSING_PASSWORD}',
'sensitive-prefix-${MISSING_PASSWORD',
'${spring.datasource.dynamic.datasource.master.password}',
]) {
const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ password }) } });
await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
assert.match(error.message, /占位符/);
assert.ok(!error.message.includes('sensitive-prefix'));
return true;
});
}
});
test('numeric YAML scalar credentials retain bigint precision', async t => {
const document = profileDocument({ password: 900719925474099312345n });
const projectRoot = await fixture(t, { profiles: { dev: document } });
assert.equal((await loadProjectConfig({ projectRoot, env: {} })).mysql.password, '900719925474099312345');
});
test('config imports, included profiles and a selected profile group require explicit configuration', async t => {
for (const base of [
{ spring: { profiles: { active: 'dev' }, config: { import: 'optional:file:sensitive-location' } } },
{ spring: { profiles: { active: 'dev', include: ['common'] } } },
{ spring: { profiles: { active: 'dev', group: { dev: ['common', 'database'] } } } },
]) {
const projectRoot = await fixture(t, { base });
await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
assert.match(error.message, /import|include|配置组/);
assert.ok(!error.message.includes('sensitive-location'));
return true;
});
}
const projectRoot = await fixture(t);
await assert.rejects(loadProjectConfig({ projectRoot, env: { SPRING_CONFIG_IMPORT: 'optional:file:sensitive-location' } }), /import/);
await assert.rejects(loadProjectConfig({ projectRoot, env: { SPRING_PROFILES_GROUP_DEV: 'common,database' } }), /配置组/);
});
test('an unrelated profile group does not affect an explicitly selected concrete profile', async t => {
const projectRoot = await fixture(t, { base: { spring: { profiles: { active: 'dev', group: { production: ['prod', 'shared'] } } } } });
assert.equal((await loadProjectConfig({ projectRoot, env: {} })).profile, 'dev');
});
test('conditional YAML documents and active-profile redefinition are rejected', async t => {
for (const content of [
[profileDocument(), { spring: { config: { activate: { 'on-profile': 'dev' } } }, server: { port: 8080 } }],
[profileDocument(), { spring: { profiles: 'dev' }, server: { port: 8080 } }],
[profileDocument(), { spring: { profiles: { active: 'prod' } } }],
]) {
const projectRoot = await fixture(t, { profiles: { dev: content } });
await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), /条件|歧义/);
}
});
test('missing files and malformed YAML errors do not expose parser source or secrets', async t => {
const projectRoot = await fixture(t, { base: 'spring: [sensitive-password' });
await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
assert.match(error.message, /YAML/);
assert.ok(!error.message.includes('sensitive-password'));
return true;
});
const validRoot = await fixture(t);
await assert.rejects(loadProjectConfig({ projectRoot: validRoot, profile: 'missing', env: {} }), /--profile/);
await assert.rejects(loadProjectConfig({ projectRoot: '', env: {} }), /项目根目录/);
});
test('duplicate keys inside one YAML document cannot silently choose credentials', async t => {
const projectRoot = await fixture(t, { base: 'spring:\n profiles:\n active: dev\nspring:\n password: sensitive-password\n' });
await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
assert.match(error.message, /YAML/);
assert.ok(!error.message.includes('sensitive-password'));
return true;
});
});
test('invalid HTTP ports and route prefixes fail instead of generating unusable URLs', async t => {
const projectRoot = await fixture(t);
for (const env of [
{ SERVER_PORT: '0' }, { SERVER_PORT: '65536' }, { SERVER_PORT: 'not-a-port' },
{ SERVER_SERVLET_CONTEXT_PATH: 'missing-leading-slash' },
{ YUDAO_WEB_ADMIN_API_PREFIX: '/admin?query' },
{ SERVER_SERVLET_CONTEXT_PATH: '/a/../b' },
{ SERVER_SSL_ENABLED: 'unknown' },
]) await assert.rejects(loadProjectConfig({ projectRoot, env }), /HTTP/);
});
@@ -0,0 +1,171 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { containsLegacyReference, discoverReferenceColumns, rewriteReferences } from '../lib/references.mjs';
const oldUrl = 'https://old.example/admin-api/infra/file/1/get/a.png';
const newUrl = 'https://bucket.example/migrated/a.png';
const otherUrl = 'https://elsewhere.example/a.png';
const replacements = new Map([[oldUrl, newUrl]]);
test('replaces an exact URL once and never cascades a destination mapping', () => {
assert.deepEqual(rewriteReferences(oldUrl, new Map([[oldUrl, newUrl], [newUrl, otherUrl]])), {
value: newUrl, changed: true, replacements: 1
});
assert.deepEqual(rewriteReferences(null, replacements), { value: null, changed: false, replacements: 0 });
});
test('replaces comma lists, repeated values and rich text while preserving surrounding text', () => {
const input = `${oldUrl},${otherUrl}, ${oldUrl}\n<a href="${oldUrl}"><img src=${oldUrl}></a>`;
const expected = `${newUrl},${otherUrl}, ${newUrl}\n<a href="${newUrl}"><img src=${newUrl}></a>`;
assert.deepEqual(rewriteReferences(input, replacements), { value: expected, changed: true, replacements: 4 });
assert.equal(rewriteReferences(`[image](${oldUrl}),`, replacements).value, `[image](${newUrl}),`);
});
test('never matches URL prefixes, query extensions, fragments or external embedded URLs', () => {
const unchanged = [
`${oldUrl}.bak`, `${oldUrl}/thumbnail`, `${oldUrl}?width=200`, `${oldUrl}#preview`,
`${oldUrl}%20other`, `${oldUrl}&x=1`, `${oldUrl};v=1`, otherUrl,
`https://external.example/?redirect=${oldUrl}`,
`https://external.example/?redirect=x,${oldUrl}`,
`${oldUrl}?url=x,${oldUrl}`
];
for (const value of unchanged) {
assert.deepEqual(rewriteReferences(value, replacements), { value, changed: false, replacements: 0 }, value);
}
});
test('only rewrites queries and fragments explicitly present in the dictionary', () => {
const url = `${oldUrl}?v=2#original`;
assert.equal(rewriteReferences(`<img src="${url}">`, new Map([[url, newUrl]])).value, `<img src="${newUrl}">`);
assert.equal(rewriteReferences(url, replacements).value, url);
});
test('keeps URL encoding literal and supports Unicode paths', () => {
const encoded = oldUrl.replace('a.png', '%E5%A4%B4%E5%83%8F%20a.png');
const unicode = oldUrl.replace('a.png', '头像.png');
const mapping = new Map([[encoded, newUrl], [unicode, `${newUrl}?kind=unicode`]]);
assert.equal(rewriteReferences(`<img src="${encoded}"> ${unicode}`, mapping).replacements, 2);
assert.equal(rewriteReferences(encoded.replace('%20', ' '), mapping).changed, false);
assert.equal(rewriteReferences(encoded.toLowerCase(), mapping).changed, false);
});
test('JSON text retains formatting and untouched escapes; keys remain unchanged', () => {
const escapedUrl = oldUrl.replaceAll('/', '\\/');
const input = `{\n "${oldUrl}": "key stays", "avatar" : "${escapedUrl}",\n "nested": [{"pic":"${oldUrl}"}], "untouched":"\\u4e2d"\n}`;
const expected = `{\n "${oldUrl}": "key stays", "avatar" : "${newUrl}",\n "nested": [{"pic":"${newUrl}"}], "untouched":"\\u4e2d"\n}`;
assert.deepEqual(rewriteReferences(input, replacements), { value: expected, changed: true, replacements: 2 });
assert.equal(rewriteReferences(JSON.stringify(JSON.stringify({ avatar: oldUrl })), replacements).value,
JSON.stringify(JSON.stringify({ avatar: newUrl })));
});
test('parsed JSON is recursively rewritten without changing input or prototype', () => {
const input = JSON.parse(`{"__proto__":{"url":"${oldUrl}"},"photos":["${oldUrl}",null,4],"url":"${otherUrl}"}`);
const result = rewriteReferences(input, replacements);
assert.equal(result.replacements, 2);
assert.equal(input.photos[0], oldUrl);
assert.equal(result.value.photos[0], newUrl);
assert.equal(result.value.__proto__.url, newUrl);
assert.equal(Object.getPrototypeOf(result.value), Object.prototype);
assert.equal(rewriteReferences({ url: otherUrl }, replacements).changed, false);
});
test('malformed JSON safely uses literal URL replacement without changing escapes', () => {
const input = `{"avatar":"${oldUrl}",}`;
assert.equal(rewriteReferences(input, replacements).value, `{"avatar":"${newUrl}",}`);
});
test('remaining-reference detection finds Unicode-escaped JSON URLs with unknown queries', () => {
const withQuery = `${oldUrl}?unmapped=1#preview`;
const unicodeJson = JSON.stringify({ avatar: withQuery }).replaceAll('/', '\\u002f');
const rewritten = rewriteReferences(unicodeJson, replacements);
assert.equal(rewritten.changed, false);
assert.equal(containsLegacyReference(rewritten.value, ['1']), true);
assert.equal(containsLegacyReference(rewritten.value, ['2']), false);
});
test('remaining-reference detection recursively handles nested JSON strings and parsed values', () => {
const nested = JSON.stringify({ data: JSON.stringify({ photos: [`${oldUrl}?v=2`] }).replaceAll('/', '\\u002f') });
assert.equal(containsLegacyReference(nested, new Set([1])), true);
assert.equal(containsLegacyReference({ data: [null, { image: oldUrl }] }, [1]), true);
assert.equal(containsLegacyReference(JSON.stringify(JSON.stringify(`${oldUrl}#unknown`)), ['1']), true);
assert.equal(containsLegacyReference(rewriteReferences(JSON.stringify({ image: oldUrl }), replacements).value, [1]), false);
});
test('remaining-reference detection handles escaped plain text, exact config IDs, and excludes JSON keys', () => {
assert.equal(containsLegacyReference(`<img src="${oldUrl.replaceAll('/', '\\/')}?size=2">`, [1]), true);
assert.equal(containsLegacyReference(`${oldUrl}?query=2`, [11]), false);
assert.equal(containsLegacyReference(oldUrl.replace('/file/1/', '/file/11/'), [1]), false);
assert.equal(containsLegacyReference(JSON.stringify({ [oldUrl]: 'not a URL value' }), [1]), false);
assert.equal(containsLegacyReference({ [oldUrl]: 'not a URL value' }, [1]), false);
assert.equal(containsLegacyReference(null, [1]), false);
assert.equal(containsLegacyReference(oldUrl, []), false);
const cycle = {};
cycle.self = cycle;
assert.equal(containsLegacyReference(cycle, [1]), false);
});
test('discovery binds schema names and excludes unsafe or non-content columns', async () => {
const row = (tableName, columnName, extra = {}) => ({
schemaName: 'app', tableName, columnName, dataType: 'varchar', extra: '',
generationExpression: '', primaryKey: 'id', primaryKeyCount: 1, ...extra
});
const rows = [
row('member_user', 'avatar'), row('member_user', 'id'),
row('member_user', 'generated', { generationExpression: 'avatar', extra: 'VIRTUAL GENERATED' }),
row('member_user', 'stored', { extra: 'STORED GENERATED' }),
row('member_user', 'seq', { extra: 'auto_increment' }),
row('member_user', 'defaulted', { extra: 'DEFAULT_GENERATED' }),
row('member_com_auth', 'auth_extra_json', { dataType: 'json' }),
row('infra_file', 'url'), row('infra_file_content', 'path'), row('infra_file_config', 'config'),
row('migration_files', 'old_url'), row('infra_file_migration_journal', 'old_url'),
row('infra_file_oss_migration_runs', 'settings'), row('infra_file_oss_migration_items', 'data'),
row('infra_file_oss_migration_changes', 'before_json'), row('INFRA_FILE_OSS_MIGRATION_CHANGES', 'after_json'),
row('no_key', 'a', { primaryKeyCount: null, primaryKey: null }),
row('no_key', 'b', { primaryKeyCount: null, primaryKey: null }),
row('composite', 'a', { primaryKeyCount: '2' }),
row('skip_me', 'a'), row('skip_qualified', 'a'),
];
const schemas = ['app', 'odd` schema; DROP TABLE x'];
const connection = { async execute(sql, params) {
assert.deepEqual(params, [...schemas, ...schemas]);
assert.ok(!sql.includes(schemas[1]));
assert.match(sql, /t\.TABLE_TYPE = 'BASE TABLE'/);
assert.match(sql, /c\.DATA_TYPE IN \('char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'json'\)/);
return [rows];
} };
const result = await discoverReferenceColumns(connection, schemas, new Set(['skip_me', 'APP.skip_qualified']));
assert.deepEqual(result.columns, [
{ schema: 'app', table: 'member_user', column: 'avatar', primaryKey: 'id', dataType: 'varchar' },
{ schema: 'app', table: 'member_user', column: 'defaulted', primaryKey: 'id', dataType: 'varchar' },
{ schema: 'app', table: 'member_com_auth', column: 'auth_extra_json', primaryKey: 'id', dataType: 'json' }
]);
assert.equal(result.skipped.length, 13);
for (const table of ['infra_file_oss_migration_runs', 'infra_file_oss_migration_items', 'infra_file_oss_migration_changes', 'INFRA_FILE_OSS_MIGRATION_CHANGES']) {
assert.equal(result.skipped.find((item) => item.table === table).reason, 'excluded table');
assert.ok(!result.columns.some((item) => item.table === table));
}
assert.equal(result.skipped.find((item) => item.table === 'no_key').reason, 'no primary key');
assert.equal(result.skipped.find((item) => item.table === 'composite').reason, 'composite primary key');
});
test('discovery makes no connection for an empty schema list and validates names', async () => {
assert.deepEqual(await discoverReferenceColumns({}, []), { columns: [], skipped: [] });
await assert.rejects(discoverReferenceColumns({}, ['']), /schema names/);
});
test('discovery explicitly excludes known technical and audit logs while retaining business log tables', async () => {
const excluded = [
'infra_api_access_log', 'infra_api_error_log', 'infra_job_log',
'system_operate_log', 'system_login_log', 'SYSTEM_LOGIN_LOG',
];
const retained = ['member_activity_log', 'chat_message_log', 'custom_audit_log', 'infra_api_access_log_archive'];
const rows = [...excluded, ...retained].flatMap(tableName => ['content', 'details'].map(columnName => ({
schemaName: 'app', tableName, columnName, dataType: 'text', extra: '',
generationExpression: '', primaryKey: 'id', primaryKeyCount: 1,
})));
const connection = { async execute() { return [rows]; } };
const result = await discoverReferenceColumns(connection, ['app']);
assert.deepEqual(result.skipped, excluded.map(table => ({ schema: 'app', table, reason: 'excluded table' })));
assert.deepEqual([...new Set(result.columns.map(column => column.table))], retained);
assert.equal(result.columns.length, retained.length * 2);
});
@@ -0,0 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { normalizeConfig } from '../lib/core.mjs';
import { parseArgs, prepareInput, persistGeneratedConfig } from '../migrate.mjs';
test('zero arguments default to read-only plan, with optional profile or explicit actions', () => {
const args = parseArgs([]);
assert.equal(args.command, 'plan'); assert.ok(args.configPath.endsWith('config.local.json'));
assert.equal(parseArgs(['--profile', 'dev']).command, 'plan');
assert.equal(parseArgs(['init']).command, 'init');
assert.equal(parseArgs(['apply', '--limit', '50']).limit, 50);
assert.equal(parseArgs(['apply', '--run-id', 'new-batch']).runId, 'new-batch');
assert.equal(parseArgs(['--help']).help, true);
assert.throws(() => parseArgs(['plan', '--limit', '50']));
assert.throws(() => parseArgs(['delete']));
});
test('automatic configuration inherits project credentials without exporting a password variable', async () => {
const root = await mkdtemp(join(tmpdir(), 'oss-runtime-'));
try {
const resources = join(root, 'yudao-server/src/main/resources'); await mkdir(resources, { recursive: true });
await writeFile(join(resources, 'application.yaml'), 'spring:\n profiles:\n active: dev\n');
await writeFile(join(resources, 'application-dev.yaml'), `server:\n port: 48083\nspring:\n datasource:\n dynamic:\n primary: master\n datasource:\n master:\n url: jdbc:mysql://127.0.0.1:3306/fixture_db\n username: fixture_user\n password: fixture-secret\n`);
const args = { command: 'init', configPath: join(root, 'config.local.json') };
const prepared = await prepareInput(args, root);
assert.equal(prepared.projectManaged, true); assert.equal(prepared.exists, false);
assert.equal(prepared.input.mysql.database, 'fixture_db'); assert.equal(prepared.input.mysql.password, 'fixture-secret');
assert.equal(prepared.input.sourceMode, undefined); // defaults must not overwrite a resumed run
assert.equal(prepared.input._projectDownloadBaseUrl, 'http://127.0.0.1:48083/admin-api/infra/file');
const config = normalizeConfig({ ...prepared.input, sourceMode: 'database', sourceConfigIds: ['5'], targetConfigId: '30', runId: 'auto-test', _databaseIdentity: { server: 'test-uuid', schema: 'fixture_db' } });
await persistGeneratedConfig(config, args, prepared);
const text = await readFile(args.configPath, 'utf8');
assert.ok(!text.includes('fixture-secret')); assert.ok(!text.includes('"mysql"'));
const saved = JSON.parse(text); assert.equal(saved.runId, 'auto-test'); assert.equal(saved.projectProfile, 'dev'); assert.equal(saved.targetConfigId, '30');
const reloaded = await prepareInput(args, root);
assert.equal(reloaded.input.mysql.password, 'fixture-secret'); assert.equal(reloaded.input.runId, 'auto-test');
} finally { await rm(root, { recursive: true, force: true }); }
});
test('manual connection remains supported and is never overwritten with project settings', async () => {
const root = await mkdtemp(join(tmpdir(), 'oss-manual-'));
try {
const path = join(root, 'manual.json');
const raw = { mysql: { host: 'manual.example', database: 'manual_db', user: 'manual', password: 'manual-secret' }, runId: 'manual-run', sourceConfigIds: ['5'], targetConfigId: '30', sourceMode: 'database' };
await writeFile(path, JSON.stringify(raw));
const args = { command: 'init', configPath: path, explicitConfig: true };
const prepared = await prepareInput(args, root);
assert.equal(prepared.projectManaged, false); assert.equal(prepared.input.mysql.host, 'manual.example');
await persistGeneratedConfig(normalizeConfig(prepared.input), args, prepared);
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), raw);
await assert.rejects(prepareInput({ ...args, profile: 'prod' }, root), /不能同时使用/);
} finally { await rm(root, { recursive: true, force: true }); }
});
@@ -0,0 +1,139 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { verifySourceBatch } from '../lib/source-batch.mjs';
const config = { mysql: { database: 'source_batch_test' } };
const first = { contentId: '9007199254740993', configId: '5', path: '相册/Résumé.PNG', size: 1234, sha256: 'a'.repeat(64) };
const second = { contentId: '9007199254740995', configId: '9007199254740999', path: '头像.png', size: 4321, sha256: 'b'.repeat(64) };
const latest = item => ({ id: item.contentId, expected_content_id: item.contentId });
const locked = (...items) => items.map(item => ({ id: item.contentId }));
const content = (item, overrides = {}) => ({ id: item.contentId, config_id: item.configId, path: item.path, size: String(item.size), sha256: item.sha256, ...overrides });
const changed = error => error.code === 'MIGRATION_SOURCE_CHANGED';
class FakeDb {
constructor(responses) {
this.responses = responses;
this.calls = [];
}
async execute(sql, params) {
assert.match(sql.trim(), /^SELECT\b/);
if (this.calls.length === 1) assert.doesNotMatch(sql, /FOR UPDATE|LOCK IN SHARE MODE/i);
else assert.match(sql, /FOR UPDATE$/);
assert.doesNotMatch(sql, /\b(?:START TRANSACTION|BEGIN|COMMIT|ROLLBACK)\b/i);
this.calls.push({ sql, params });
if (this.calls.length > this.responses.length) throw new Error('Unexpected database query');
const result = this.responses[this.calls.length - 1];
if (result instanceof Error) throw result;
return [result];
}
}
test('validates in three SELECTs with a primary-key-only range lock and a separate digest read', async () => {
const db = new FakeDb([locked(first, second), [latest(second), latest(first)], [content(first), content(second)]]);
await verifySourceBatch(db, config, [second, first]);
assert.equal(db.calls.length, 3);
const [range, metadata, hashes] = db.calls;
assert.match(range.sql, /^SELECT id FROM /);
assert.match(range.sql, /WHERE id>=\? ORDER BY id FOR UPDATE$/);
assert.doesNotMatch(range.sql, /\b(?:content|deleted|config_id|path|CASE|LIMIT)\b|SHA2|OCTET_LENGTH|id\s+IN\s*\(/i,
'lock every row and gap above the minimum ID, including deleted and unrelated-path rows');
assert.deepEqual(range.params, [first.contentId]);
assert.match(metadata.sql, /^SELECT id,CASE WHEN config_id=\? AND path=\? THEN \?/);
assert.match(metadata.sql, /END AS expected_content_id/);
assert.match(metadata.sql, /WHERE deleted=0 AND id>=\? AND \(\(config_id=\? AND path=\? AND id>=\?\) OR/);
assert.match(metadata.sql, /ORDER BY id DESC$/);
assert.doesNotMatch(metadata.sql, /\bcontent\b|OCTET_LENGTH|SHA2|\bLIMIT\b|id\s+IN\s*\(/i,
'metadata lookup must examine newer IDs without projecting BLOB expressions');
assert.deepEqual(metadata.params, [
second.configId, second.path, second.contentId,
first.configId, first.path, first.contentId,
first.contentId,
second.configId, second.path, second.contentId,
first.configId, first.path, first.contentId
]);
assert.match(hashes.sql, /OCTET_LENGTH\(content\) AS size,LOWER\(SHA2\(content,256\)\) AS sha256/);
assert.match(hashes.sql, /WHERE deleted=0 AND id IN \(\?,\?\) FOR UPDATE$/);
assert.doesNotMatch(hashes.sql, /ORDER BY|\bCASE\b|\bJOIN\b|\bLIMIT\b/i);
assert.deepEqual(hashes.params, [second.contentId, first.contentId]);
});
test('uses server-provided grouping IDs and accepts collation-equivalent case/accent spellings', async () => {
const db = new FakeDb([locked(first), [latest(first)], [content(first, { path: '相册/RESUME.png' })]]);
await verifySourceBatch(db, config, [first]);
assert.deepEqual(db.calls[1].params, [first.configId, first.path, first.contentId, first.contentId, first.configId, first.path, first.contentId]);
assert.doesNotMatch(db.calls[1].sql, /BINARY\s+path|LOWER\s*\(path\)/i);
});
test('rejects a newer active version before performing the batch digest read', async () => {
const db = new FakeDb([locked(first, second), [
latest(second), latest(first), { id: '9007199254740994', expected_content_id: first.contentId }
]]);
await assert.rejects(verifySourceBatch(db, config, [first, second]), changed);
assert.equal(db.calls.length, 2);
});
test('rejects a deleted/missing group or an unexpected server grouping ID before reading hashes', async t => {
for (const [label, rows] of [
['missing group', [latest(first)]],
['all deleted', []],
['unexpected group', [latest(first), { id: second.contentId, expected_content_id: '88' }]],
['null CASE result', [latest(first), { id: second.contentId, expected_content_id: null }]]
]) {
await t.test(label, async () => {
const db = new FakeDb([locked(first, second), rows]);
await assert.rejects(verifySourceBatch(db, config, [first, second]), changed);
assert.equal(db.calls.length, 2);
});
}
});
test('rejects disappearance, size changes, hash changes and wrong IDs in the digest result', async t => {
for (const [label, rows] of [
['missing content', [content(first)]],
['wrong size', [content(first), content(second, { size: second.size + 1 })]],
['same size different hash', [content(first), content(second, { sha256: 'c'.repeat(64) })]],
['wrong ID', [content(first), content(second, { id: '9007199254740996' })]],
['duplicate ID', [content(first), content(first)]]
]) {
await t.test(label, async () => {
const db = new FakeDb([locked(first, second), [latest(first), latest(second)], rows]);
await assert.rejects(verifySourceBatch(db, config, [first, second]), changed);
assert.equal(db.calls.length, 3);
});
}
});
test('preserves unsigned bigint IDs and binds all paths instead of interpolating them', async () => {
const large = { ...first, contentId: '18446744073709551615', path: "x.png' OR '1'='1" };
const db = new FakeDb([locked(large), [latest(large)], [content(large)]]);
await verifySourceBatch(db, { mysql: { database: 'app`db' } }, [large]);
for (const call of db.calls) {
assert.match(call.sql, /`app``db`\.`infra_file_content`/);
assert.ok(!call.sql.includes(large.path));
assert.ok(!call.sql.includes(large.contentId));
}
assert.deepEqual(db.calls[2].params, [large.contentId]);
});
test('rejects empty batches, duplicate IDs and unsafe numeric IDs before any SQL', async t => {
for (const [label, batch, expectedError] of [
['empty batch', [], /不能为空/],
['duplicate content ID', [first, { ...first }], /重复/],
['unsafe content ID', [{ ...first, contentId: Number.MAX_SAFE_INTEGER + 1 }], /安全整数/],
['unsafe config ID', [{ ...first, configId: Number.MAX_SAFE_INTEGER + 1 }], /安全整数/],
['invalid expected size', [{ ...first, size: Number.MAX_SAFE_INTEGER + 1 }], /预期大小/]
]) {
await t.test(label, async () => {
const db = new FakeDb([]);
await assert.rejects(verifySourceBatch(db, config, batch), expectedError);
assert.equal(db.calls.length, 0);
});
}
});
test('propagates database lock/read errors and leaves rollback to the transaction owner', async () => {
const lockFailure = Object.assign(new Error('lock wait timed out'), { code: 'ER_LOCK_WAIT_TIMEOUT' });
const db = new FakeDb([lockFailure]);
await assert.rejects(verifySourceBatch(db, config, [first]), error => error === lockFailure);
assert.equal(db.calls.length, 1);
});
@@ -0,0 +1,131 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sourceCurrent } from '../migrate.mjs';
const config = { mysql: { database: 'source_current_test' } };
const item = {
contentId: '9007199254740993', configId: '9007199254740995',
path: '头像/Résumé + 100%.png', size: 1234, sha256: 'a'.repeat(64)
};
const current = overrides => ({ id: item.contentId, size: String(item.size), sha256: item.sha256, ...overrides });
class FakeDb {
constructor(responses) {
this.responses = responses;
this.calls = [];
}
async execute(sql, params) {
assert.match(sql, /^SELECT\b/, 'source validation must not write or manage the caller transaction');
this.calls.push({ sql, params });
if (this.calls.length > this.responses.length) throw new Error('Unexpected extra database query');
const response = this.responses[this.calls.length - 1];
if (response instanceof Error) throw response;
return [response];
}
}
function assertMetadataLookup(call, expectedItem, locked = false) {
assert.match(call.sql, /^SELECT id FROM /);
assert.doesNotMatch(call.sql, /\bcontent\b|SHA2\s*\(|OCTET_LENGTH\s*\(/i,
'the unindexed path/order lookup must not project file bytes or content expressions');
assert.match(call.sql, /WHERE deleted=0 AND config_id=\? AND path=\? ORDER BY id DESC LIMIT 1/);
assert.doesNotMatch(call.sql, /BINARY\s+path|LOWER\s*\(path\)/i, 'preserve native MySQL path collation');
assert.deepEqual(call.params, [expectedItem.configId, expectedItem.path]);
if (locked) assert.match(call.sql, / FOR UPDATE$/);
else assert.doesNotMatch(call.sql, /FOR UPDATE/i);
}
function assertPrimaryKeyHashRead(call, expectedItem, locked = false) {
assert.match(call.sql, /OCTET_LENGTH\(content\) AS size/);
assert.match(call.sql, /LOWER\(SHA2\(content,256\)\) AS sha256/);
assert.match(call.sql, /WHERE id=\? AND config_id=\? AND path=\? AND deleted=0/);
assert.doesNotMatch(call.sql, /ORDER BY|LIMIT|JOIN/i, 'read the digest through a single primary-key lookup');
assert.deepEqual(call.params, [expectedItem.contentId, expectedItem.configId, expectedItem.path]);
if (locked) assert.match(call.sql, / FOR UPDATE$/);
else assert.doesNotMatch(call.sql, /FOR UPDATE/i);
}
test('looks up the latest ID without BLOB expressions, then validates size and SHA by primary key', async () => {
const db = new FakeDb([[{ id: item.contentId }], [current()]]);
await sourceCurrent(db, config, item);
assert.equal(db.calls.length, 2);
assertMetadataLookup(db.calls[0], item);
assertPrimaryKeyHashRead(db.calls[1], item);
});
test('rejects a newer active version before any content/hash query, without rounding bigint IDs', async () => {
const db = new FakeDb([[{ id: '9007199254740994' }]]);
await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
assert.equal(db.calls.length, 1);
assertMetadataLookup(db.calls[0], item);
});
test('rejects a missing latest record without issuing a content/hash query', async () => {
const db = new FakeDb([[]]);
await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
assert.equal(db.calls.length, 1);
assertMetadataLookup(db.calls[0], item);
});
test('rejects content disappearing between the metadata and primary-key reads', async () => {
const db = new FakeDb([[{ id: item.contentId }], []]);
await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
assert.equal(db.calls.length, 2);
assertPrimaryKeyHashRead(db.calls[1], item);
});
test('rejects changed length, changed hash, or an unexpected primary-key row', async t => {
for (const [name, row] of [
['changed size', current({ size: item.size + 1 })],
['changed hash with the same size', current({ sha256: 'b'.repeat(64) })],
['wrong content ID', current({ id: '9007199254740994' })],
['missing hash', current({ sha256: null })]
]) {
await t.test(name, async () => {
const db = new FakeDb([[{ id: item.contentId }], [row]]);
await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
assert.equal(db.calls.length, 2);
assertPrimaryKeyHashRead(db.calls[1], item);
});
}
});
test('retains FOR UPDATE on both validation queries when the caller owns a transaction', async () => {
const db = new FakeDb([[{ id: item.contentId }], [current()]]);
await sourceCurrent(db, config, item, true);
assert.equal(db.calls.length, 2);
assertMetadataLookup(db.calls[0], item, true);
assertPrimaryKeyHashRead(db.calls[1], item, true);
});
test('locked validation still stops at the metadata lookup if the latest version changed', async () => {
const db = new FakeDb([[{ id: '9007199254740994' }]]);
await assert.rejects(sourceCurrent(db, config, item, true), /源图片.*变化或已删除/);
assert.equal(db.calls.length, 1);
assertMetadataLookup(db.calls[0], item, true);
});
test('supports unsigned-bigint-sized IDs and binds paths without interpolation or normalization', async () => {
const largeItem = { ...item, contentId: '18446744073709551615', path: "case/IMAGE.png' OR 'x'='x" };
const db = new FakeDb([[{ id: largeItem.contentId }], [current({ id: largeItem.contentId })]]);
await sourceCurrent(db, { mysql: { database: 'app`db' } }, largeItem, false);
assertMetadataLookup(db.calls[0], largeItem);
assertPrimaryKeyHashRead(db.calls[1], largeItem);
for (const call of db.calls) {
assert.match(call.sql, /`app``db`\.`infra_file_content`/);
assert.ok(!call.sql.includes(largeItem.contentId));
assert.ok(!call.sql.includes(largeItem.path));
}
});
test('propagates database failures instead of accepting an unverified source', async () => {
for (const responses of [
[Object.assign(new Error('metadata lookup timed out'), { code: 'TEST_TIMEOUT' })],
[[{ id: item.contentId }], Object.assign(new Error('hash read failed'), { code: 'TEST_READ_FAILURE' })]
]) {
const db = new FakeDb(responses);
const expected = responses[responses.length - 1];
await assert.rejects(sourceCurrent(db, config, item), error => error === expected);
assert.equal(db.calls.length, responses.length);
}
});
@@ -0,0 +1,127 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadSourceIndex } from '../lib/source-index.mjs';
const config = { mysql: { database: 'source_index_test' }, sourceConfigIds: ['9007199254740999', '5'] };
const makeFile = (value, contentId, overrides = {}) => ({
id: value, config_id: '5', path: 'image.png', url: 'https://old.example/image.png',
type: 'image/png', size: 3, content_id: contentId, ...overrides
});
class FakeDb {
constructor(batches, stageEvents) {
this.batches = batches;
this.stageEvents = stageEvents;
this.calls = [];
}
async execute(sql, params) {
assert.match(sql.trim(), /^SELECT\b/);
assert.doesNotMatch(sql, /\b(?:OCTET_LENGTH|SHA2)\s*\(/i);
assert.doesNotMatch(sql, /\bcontent\b|SELECT\s+\*/i, 'only metadata columns may be selected');
assert.doesNotMatch(sql, /\b(?:NOT EXISTS|CREATE|INSERT|UPDATE|DELETE|ALTER)\b/i);
assert.match(sql, /GROUP BY config_id,path/);
assert.match(sql, /MAX\(id\) AS id/);
assert.match(sql, /WHERE deleted=0 AND config_id IN \(\?,\?\)/);
if (this.stageEvents) assert.equal(this.stageEvents.length, this.calls.length + 1, 'announce each phase before executing it');
this.calls.push({ sql, params });
if (this.calls.length > 3) throw new Error('Unexpected per-file query');
return [this.batches[this.calls.length - 1]];
}
}
test('loads all metadata in exactly three SELECTs and preserves bigint IDs and raw spellings', async () => {
const stages = [];
const db = new FakeDb([
[
{ content_id: '9007199254740995', config_id: '9007199254740999', path: '头像.png' },
{ content_id: '9', config_id: '5', path: 'IMAGE.png' }
],
[makeFile('9007199254740996', '9'), makeFile('2', '9', { path: 'IMAGE.png' }), makeFile('10', null)],
[
{ content_id: '9', path: Buffer.from('image.png') },
{ content_id: '9', path: Buffer.from('IMAGE.png') },
{ content_id: '9', path: Buffer.from('image.png ') },
{ content_id: '9', path: Buffer.from('image.png') },
{ content_id: '9007199254740995', path: Buffer.from('头像.png') }
]
], stages);
const result = await loadSourceIndex(db, config, { progress: { stage: message => stages.push(message) } });
assert.equal(db.calls.length, 3);
assert.equal(stages.length, 3);
assert.equal(result.maxId, '9007199254740995');
assert.deepEqual(result.groups.map(group => group.contentId), ['9', '9007199254740995']);
assert.equal(result.groups[0].path, 'IMAGE.png', 'the latest row supplies the canonical raw path');
assert.deepEqual(result.groups[0].files.map(file => file.id), ['2', '9007199254740996']);
assert.deepEqual(result.groups[0].spellings, ['IMAGE.png', 'image.png', 'image.png ']);
assert.equal(result.groups[1].configId, '9007199254740999');
assert.deepEqual(result.groups[1].files, [], 'orphan BLOBs remain candidates');
assert.deepEqual(result.groups[1].spellings, ['头像.png']);
assert.deepEqual(result.missingFiles.map(file => file.id), ['10']);
assert.equal(Object.hasOwn(result.missingFiles[0], 'content_id'), false);
assert.deepEqual(db.calls[0].params, ['5', '9007199254740999']);
assert.deepEqual(db.calls[1].params, ['5', '9007199254740999', '5', '9007199254740999']);
});
test('upper bound applies after grouping and files with newer content are never marked missing', async () => {
const bound = '9007199254740993';
const db = new FakeDb([
[{ content_id: '8', config_id: '5', path: 'image.png' }],
[makeFile('1', '8'), makeFile('2', '9007199254740994'), makeFile('3', null)],
[{ content_id: '8', path: 'image.png' }, { content_id: '9007199254740994', path: 'newer.png' }]
]);
const result = await loadSourceIndex(db, config, { maxId: bound });
assert.equal(result.maxId, bound);
assert.deepEqual(result.groups.map(group => group.contentId), ['8']);
assert.deepEqual(result.groups[0].files.map(file => file.id), ['1']);
assert.deepEqual(result.missingFiles.map(file => file.id), ['3']);
assert.match(db.calls[0].sql, /GROUP BY config_id,path\) g ON g.id=c.id WHERE g.id<=\?/);
assert.doesNotMatch(db.calls[0].sql, /WHERE deleted=0[^)]*id\s*[<>=]/i);
assert.doesNotMatch(db.calls[1].sql, /g\.id<=|id<=/);
assert.match(db.calls[2].sql, /WHERE c.deleted=0 AND g.id<=\?/);
assert.deepEqual(db.calls[0].params, ['5', '9007199254740999', bound]);
assert.deepEqual(db.calls[2].params, ['5', '9007199254740999', bound]);
});
test('all path associations use server-generated content IDs, never JavaScript case folding', async () => {
const db = new FakeDb([
[{ content_id: '7', config_id: '5', path: 'Résumé.png' }, { content_id: '8', config_id: '5', path: 'resume.png' }],
[makeFile('1', '7', { path: 'RESUME.png' }), makeFile('2', '8', { path: 'resume.png' })],
[{ content_id: '7', path: Buffer.from('Résumé.png') }, { content_id: '7', path: Buffer.from('RESUME.png') }, { content_id: '8', path: Buffer.from('resume.png') }]
]);
const result = await loadSourceIndex(db, config);
assert.equal(result.groups.length, 2);
assert.deepEqual(result.groups[0].files.map(file => file.id), ['1']);
assert.deepEqual(result.groups[1].files.map(file => file.id), ['2']);
assert.match(db.calls[1].sql, /g\.config_id=f\.config_id AND g\.path=f\.path/);
assert.match(db.calls[2].sql, /SELECT DISTINCT g.id AS content_id,BINARY c.path AS path/);
assert.match(db.calls[2].sql, /g\.config_id=c\.config_id AND g\.path=c\.path/);
assert.match(db.calls[1].sql, /WHERE f.deleted=0/);
assert.match(db.calls[2].sql, /WHERE c.deleted=0/);
});
test('empty content results still report missing files and use a zero upper bound', async () => {
const db = new FakeDb([[], [makeFile('4', null)], []]);
const result = await loadSourceIndex(db, config);
assert.equal(db.calls.length, 3);
assert.equal(result.maxId, '0');
assert.deepEqual(result.groups, []);
assert.deepEqual(result.missingFiles.map(file => file.id), ['4']);
const bounded = await loadSourceIndex(new FakeDb([[], [], []]), config, { maxId: '0' });
assert.equal(bounded.maxId, '0');
});
test('source IDs are deduplicated and database identifiers are safely quoted', async () => {
const db = new FakeDb([[], [], []]);
const unusual = { mysql: { database: 'app`db' }, sourceConfigIds: ['9007199254740999', '5', '5'] };
await loadSourceIndex(db, unusual);
assert.match(db.calls[0].sql, /`app``db`\.`infra_file_content`/);
assert.deepEqual(db.calls[0].params, ['5', '9007199254740999']);
});
test('rejects unsafe numeric boundaries and invalid source configurations without SQL', async () => {
const db = new FakeDb([[], [], []]);
await assert.rejects(loadSourceIndex(db, config, { maxId: Number.MAX_SAFE_INTEGER + 1 }), /大整数/);
await assert.rejects(loadSourceIndex(db, config, { maxId: '-1' }), /非负整数/);
await assert.rejects(loadSourceIndex(db, { ...config, sourceConfigIds: [] }), /至少一个/);
assert.equal(db.calls.length, 0);
});
@@ -0,0 +1,245 @@
import test, { mock } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { setImmediate } from 'node:timers/promises';
import { sha256 } from '../lib/core.mjs';
import { verifyCheckpoint, verifySwitchedBatch } from '../migrate.mjs';
const CONTENT = Buffer.from('89504e470d0a1a0a0011223344556677', 'hex');
const CORRUPT = Buffer.alloc(CONTENT.length, 1);
const CONFIG = { mysql: { database: 'target_test' }, targetConfigId: '20', timeoutMs: 2000, maxFileBytes: 1024, retries: 1 };
const item = (url, files = [{ id: '9007199254740993' }]) => ({
contentId: '9007199254740995', configId: '1', path: 'source.png',
key: 'migration/object.png', url, size: CONTENT.length, sha256: sha256(CONTENT), files,
});
const stored = entry => ({
id: entry.files[0].id, config_id: '20', path: entry.key,
url: entry.url, deleted: Buffer.from([0]),
});
const oss = (bytes = CONTENT) => ({
head: mock.fn(async () => ({ res: { headers: { 'content-length': String(bytes.length) } } })),
get: mock.fn(async () => ({ content: bytes })),
put: mock.fn(async () => { throw new Error('validation must never upload'); }),
});
async function server(t, handler) {
const instance = createServer(handler);
instance.listen(0, '127.0.0.1');
await once(instance, 'listening');
t.after(async () => {
const closing = new Promise((resolve, reject) => instance.close(error => error ? reject(error) : resolve()));
instance.closeAllConnections();
await closing;
});
return 'http://127.0.0.1:' + instance.address().port;
}
test('verified checkpoint validates authenticated and public bytes without PUT or source access', async t => {
let publicReads = 0;
const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
const entry = item(url);
const client = oss();
await verifyCheckpoint(client, CONFIG, entry);
assert.equal(client.head.mock.callCount(), 1);
assert.equal(client.get.mock.callCount(), 1);
assert.equal(client.put.mock.callCount(), 0);
assert.deepEqual(client.head.mock.calls[0].arguments, [entry.key]);
assert.deepEqual(client.get.mock.calls[0].arguments, [entry.key]);
assert.equal(publicReads, 1);
});
test('missing checkpoint object is rejected without downloading or creating another object', async () => {
const client = oss();
const missing = Object.assign(new Error('missing object'), { code: 'NoSuchKey', status: 404 });
client.head = mock.fn(async () => { throw missing; });
await assert.rejects(verifyCheckpoint(client, CONFIG, item('http://127.0.0.1:1/unused')), error => error === missing);
assert.equal(client.get.mock.callCount(), 0);
assert.equal(client.put.mock.callCount(), 0);
});
test('checkpoint HEAD rejects mismatched sizes or configured size limits before GET', async () => {
for (const tooLarge of [false, true]) {
const client = oss();
if (!tooLarge) client.head = mock.fn(async () => ({ res: { headers: { 'content-length': String(CONTENT.length + 1) } } }));
await assert.rejects(verifyCheckpoint(client, { ...CONFIG, maxFileBytes: tooLarge ? 1 : CONFIG.maxFileBytes }, item('http://127.0.0.1:1/unused')), /长度/);
assert.equal(client.get.mock.callCount(), 0);
assert.equal(client.put.mock.callCount(), 0);
}
});
test('same-length authenticated corruption rejects before public validation', async t => {
let publicReads = 0;
const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
const client = oss(CORRUPT);
await assert.rejects(verifyCheckpoint(client, CONFIG, item(url)), /SHA-256/);
assert.equal(publicReads, 0);
assert.equal(client.put.mock.callCount(), 0);
});
test('public corruption or access denial prevents reusing an otherwise valid checkpoint', async t => {
const url = await server(t, (request, response) => {
if (request.url === '/denied') { response.writeHead(403); response.end(); }
else response.end(CORRUPT);
});
for (const [path, message] of [['/corrupt', /SHA-256/], ['/denied', /HTTP 403/]]) {
const client = oss();
await assert.rejects(verifyCheckpoint(client, CONFIG, item(url + path)), message);
assert.equal(client.put.mock.callCount(), 0);
}
});
test('switched batch reads all metadata once with exact bigint IDs and deduplicates repeated records', async t => {
const url = await server(t, (_request, response) => response.end(CONTENT));
const first = item(url + '/one');
const second = { ...item(url + '/two', [{ id: '9007199254740997' }, { id: '9007199254740997' }]), key: 'migration/two.png' };
const db = { execute: mock.fn(async (sql, params) => {
assert.match(sql, /SELECT id,config_id,path,url,deleted/);
assert.match(sql, /WHERE id IN \(\?,\?\)/);
assert.deepEqual(params, ['9007199254740993', '9007199254740997']);
return [[stored(first), stored(second)]];
}) };
const results = await verifySwitchedBatch(db, CONFIG, [first, second]);
assert.equal(db.execute.mock.callCount(), 1);
assert.deepEqual(results, [{ item: first }, { item: second }]);
});
test('orphaned source content with no file metadata still validates its public target', async t => {
let publicReads = 0;
const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
const entry = item(url, []);
const db = { execute: mock.fn(async () => { throw new Error('orphan must not query file metadata'); }) };
assert.deepEqual(await verifySwitchedBatch(db, CONFIG, [entry]), [{ item: entry }]);
assert.equal(db.execute.mock.callCount(), 0);
assert.equal(publicReads, 1);
});
test('mutated, deleted and missing file records are returned as errors before public access', async t => {
let publicReads = 0;
const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
const entry = item(url);
for (const changed of [
null,
{ ...stored(entry), config_id: '21' },
{ ...stored(entry), path: 'some/other/object.png' },
{ ...stored(entry), url: url + '/edited' },
{ ...stored(entry), deleted: Buffer.from([1]) },
]) {
const db = { execute: mock.fn(async () => [changed ? [changed] : []]) };
const results = await verifySwitchedBatch(db, CONFIG, [entry]);
assert.equal(results.length, 1);
assert.equal(results[0].item, entry);
assert.match(results[0].error.message, /发生变化|已删除/);
assert.deepEqual(results.filter(result => !result.error), []);
}
assert.equal(publicReads, 0);
});
test('a single missing metadata record rejects an item that has multiple original records', async t => {
let publicReads = 0;
const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
const entry = item(url, [{ id: '9007199254740993' }, { id: '9007199254740997' }]);
const db = { execute: mock.fn(async () => [[stored(entry)]]) };
const [result] = await verifySwitchedBatch(db, CONFIG, [entry]);
assert.ok(result.error);
assert.equal(publicReads, 0);
});
test('switched public corruption excludes only its own mapping and keeps input order', async t => {
const url = await server(t, (request, response) => response.end(request.url === '/bad' ? CORRUPT : CONTENT));
const bad = item(url + '/bad', []);
const good = item(url + '/good', []);
const results = await verifySwitchedBatch({}, CONFIG, [bad, good]);
assert.equal(results[0].item, bad);
assert.match(results[0].error.message, /SHA-256/);
assert.deepEqual(results[1], { item: good });
assert.deepEqual(results.filter(result => !result.error).map(result => result.item.url), [good.url]);
});
test('metadata query errors reject before network validation starts', async t => {
let publicReads = 0;
const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
const failure = new Error('metadata unavailable');
const db = { execute: mock.fn(async () => { throw failure; }) };
await assert.rejects(verifySwitchedBatch(db, CONFIG, [item(url)]), error => error === failure);
assert.equal(publicReads, 0);
});
test('final validation is capped at four active requests and settles only after every started request', { timeout: 4000 }, async t => {
const responses = new Map();
let active = 0, maximum = 0, settled = false;
const url = await server(t, (request, response) => {
active++;
maximum = Math.max(maximum, active);
response.once('finish', () => { active--; });
responses.set(Number(request.url.slice(1)), response);
});
const batch = Array.from({ length: 6 }, (_, index) => item(url + '/' + index, []));
const pending = verifySwitchedBatch({}, CONFIG, batch).then(results => { settled = true; return results; });
const until = async predicate => {
const deadline = Date.now() + 1500;
while (!predicate()) {
if (Date.now() > deadline) assert.fail('validation did not reach the expected request state');
await setImmediate();
}
};
await until(() => responses.size === 4);
assert.equal(active, 4);
assert.equal(settled, false);
// Per-item failures are collected, so remaining items must still be checked.
responses.get(0).end(CORRUPT);
responses.get(1).end(CONTENT);
await until(() => responses.size === 6);
assert.equal(active, 4);
assert.equal(settled, false);
responses.get(2).end(CONTENT);
responses.get(3).end(CONTENT);
responses.get(4).end(CONTENT);
await setImmediate();
assert.equal(settled, false);
responses.get(5).end(CONTENT);
const results = await pending;
assert.equal(maximum, 4);
assert.equal(active, 0);
assert.equal(settled, true);
assert.equal(results.length, 6);
assert.match(results[0].error.message, /SHA-256/);
assert.deepEqual(results.slice(1), batch.slice(1).map(entry => ({ item: entry })));
});
test('empty switched batches perform no database or network work', async () => {
const db = { execute: mock.fn(async () => { throw new Error('must not query'); }) };
assert.deepEqual(await verifySwitchedBatch(db, CONFIG, []), []);
assert.equal(db.execute.mock.callCount(), 0);
});
test('more than 1000 file records are loaded in bounded sequential queries before public validation', async t => {
let completedQueries = 0, activeQueries = 0, maximumQueries = 0, publicReads = 0;
const url = await server(t, (_request, response) => {
publicReads++;
assert.equal(completedQueries, 2);
assert.equal(activeQueries, 0);
response.end(CONTENT);
});
const files = Array.from({ length: 1001 }, (_, index) => ({ id: String(9007199254740993n + BigInt(index)) }));
const entry = item(url, files);
const seenIds = [];
const querySizes = [];
const db = { execute: mock.fn(async (_sql, params) => {
maximumQueries = Math.max(maximumQueries, ++activeQueries);
assert.ok(params.length <= 1000);
assert.equal(publicReads, 0);
querySizes.push(params.length);
seenIds.push(...params);
await setImmediate();
completedQueries++;
activeQueries--;
return [params.map(id => ({ ...stored(entry), id })).reverse()];
}) };
assert.deepEqual(await verifySwitchedBatch(db, CONFIG, [entry]), [{ item: entry }]);
assert.deepEqual(querySizes, [1000, 1]);
assert.deepEqual(seenIds, files.map(file => file.id));
assert.equal(maximumQueries, 1);
assert.equal(db.execute.mock.callCount(), 2);
assert.equal(publicReads, 1);
});
@@ -0,0 +1,213 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import { encodePath, sha256 } from '../lib/core.mjs';
import { ensureJournal, rollbackChanges } from '../lib/journal.mjs';
import { verifySourceBatch } from '../lib/source-batch.mjs';
import { switchFiles } from '../lib/transition.mjs';
// Opt in only against the disposable localhost test server. No host, database,
// credentials or application configuration is read from the project.
const SCHEMA = 'codex_oss_transition_test';
const PORT = process.env.MIGRATION_TEST_DB_PORT;
const IDS = {
older: '9007199254741001', first: '9007199254741003', second: '9007199254741005',
orphan: '9007199254741007', deletedNewer: '9007199254741009', newVersion: '9007199254741011'
};
const FIRST_PATH = '相册/Résumé.PNG';
const EQUIVALENT_PATH = '相册/RESUME.png';
const imageBytes = [Buffer.from([1, 2, 3, 4, 5]), Buffer.from([6, 7, 8, 9]), Buffer.from([10, 11, 12])];
const changedSource = error => error.code === 'MIGRATION_SOURCE_CHANGED';
const changedRow = error => error.code === 'MIGRATION_CAS_CONFLICT';
test('MySQL batch transitions preserve source locks, atomic journals and rollback', {
skip: !PORT, timeout: 45000
}, async t => {
const port = Number(PORT);
assert.ok(Number.isInteger(port) && port > 0 && port <= 65535, 'MIGRATION_TEST_DB_PORT must be a valid disposable localhost MySQL port');
const { default: mysql } = await import('mysql2/promise');
const connectionOptions = {
host: '127.0.0.1', port, user: 'root', password: '', connectTimeout: 3000,
supportBigNumbers: true, bigNumberStrings: true, jsonStrings: true, dateStrings: true
};
const db = await mysql.createConnection(connectionOptions);
let contender, observer;
try {
await db.query(`DROP DATABASE IF EXISTS ${SCHEMA}`);
await db.query(`CREATE DATABASE ${SCHEMA} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
await db.changeUser({ database: SCHEMA });
await db.query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
await db.query("SET SESSION sql_mode=CONCAT_WS(',',NULLIF(@@SESSION.sql_mode,''),'STRICT_ALL_TABLES')");
await db.query('SET SESSION innodb_lock_wait_timeout=5');
await db.query(`CREATE TABLE infra_file_content (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,config_id BIGINT UNSIGNED NOT NULL,
path VARCHAR(512) NOT NULL,content MEDIUMBLOB NOT NULL,deleted BIT(1) NOT NULL DEFAULT 0
) ENGINE=InnoDB`);
await db.query(`CREATE TABLE infra_file (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,config_id BIGINT UNSIGNED NOT NULL,
path VARCHAR(512) NOT NULL,url VARCHAR(1024) NOT NULL,type VARCHAR(128),size INT,
deleted BIT(1) NOT NULL DEFAULT 0,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB`);
await ensureJournal(db, SCHEMA);
contender = await mysql.createConnection({ ...connectionOptions, database: SCHEMA });
observer = await mysql.createConnection({ ...connectionOptions, database: SCHEMA });
await contender.query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
await contender.query('SET SESSION innodb_lock_wait_timeout=5');
const [[thread]] = await contender.query('SELECT CONNECTION_ID() AS id');
const readFiles = async () => {
const [rows] = await db.query('SELECT id,config_id,path,url,type,size,deleted,update_time FROM infra_file ORDER BY id');
return rows.map(row => ({ ...row }));
};
const readStatuses = async runId => {
const [rows] = await db.execute('SELECT status FROM infra_file_oss_migration_items WHERE run_id=? ORDER BY item_key', [runId]);
return rows.map(row => row.status);
};
const journalCount = async runId => {
const [[row]] = await db.execute('SELECT COUNT(*) AS n FROM infra_file_oss_migration_changes WHERE run_id=?', [runId]);
return Number(row.n);
};
async function seed(runId) {
await db.rollback();
for (const table of ['infra_file_oss_migration_changes', 'infra_file_oss_migration_items', 'infra_file_oss_migration_runs', 'infra_file', 'infra_file_content']) {
await db.query(`DELETE FROM ${table}`);
}
const sources = [
[IDS.older, EQUIVALENT_PATH, Buffer.from('older version'), 0],
[IDS.first, FIRST_PATH, imageBytes[0], 0],
[IDS.second, 'second.png', imageBytes[1], 0],
[IDS.orphan, 'orphan.png', imageBytes[2], 0],
[IDS.deletedNewer, EQUIVALENT_PATH, Buffer.from('deleted newer version'), 1]
];
await db.execute(`INSERT INTO infra_file_content(id,config_id,path,content,deleted) VALUES ${sources.map(() => '(?,5,?,?,?)').join(',')}`, sources.flat());
const files = [
['9007199254741101', FIRST_PATH], ['9007199254741102', EQUIVALENT_PATH], ['9007199254741103', 'second.png']
];
await db.execute(`INSERT INTO infra_file(id,config_id,path,url,type,size,update_time) VALUES ${files.map(() => "(?,5,?,?,'image/png',1,'2020-01-01 00:00:00')").join(',')}`,
files.flatMap(([fileId, path]) => [fileId, path, `https://legacy.test/admin-api/infra/file/5/get/${encodePath(path)}`]));
const items = [];
for (const [index, [contentId, path]] of [[IDS.first, FIRST_PATH], [IDS.second, 'second.png'], [IDS.orphan, 'orphan.png']].entries()) {
const [records] = await db.execute('SELECT id,config_id,path,url,type,size FROM infra_file WHERE config_id=5 AND path=? AND deleted=0 ORDER BY id', [path]);
const key = `migration/test/${contentId}.png`;
items.push({ contentId, configId: '5', path, size: imageBytes[index].length, sha256: sha256(imageBytes[index]), key, url: `https://oss.test/${key}`, type: 'image/png', files: records.map(row => ({ ...row })) });
}
await db.execute("INSERT INTO infra_file_oss_migration_runs(run_id,settings,status) VALUES (?,?,'active')", [runId, JSON.stringify({ test: true })]);
for (const item of items) {
await db.execute("INSERT INTO infra_file_oss_migration_items(run_id,item_key,data,status) VALUES (?,?,?,'verified')", [runId, sha256(`${item.configId}\0${item.path}`), JSON.stringify(item)]);
}
return { config: { mysql: { database: SCHEMA }, runId, targetConfigId: '30' }, items, originals: await readFiles() };
}
await t.test('batch success switches duplicate metadata and orphan progress; journals restore original values', async () => {
const fixture = await seed('batch-success');
assert.equal(fixture.items[0].files.length, 2, 'native collation includes the case/accent-equivalent metadata');
assert.equal(fixture.items[2].files.length, 0, 'orphan content remains a migration item');
await switchFiles(db, fixture.config, fixture.items);
const files = await readFiles();
for (const item of fixture.items) for (const original of item.files) {
const stored = files.find(file => file.id === original.id);
assert.equal(stored.config_id, '30');
assert.equal(stored.path, item.key);
assert.equal(stored.url, item.url);
assert.equal(stored.size, item.size);
assert.equal(stored.update_time, '2020-01-01 00:00:00');
}
assert.deepEqual(await readStatuses(fixture.config.runId), ['switched', 'switched', 'switched']);
assert.equal(await journalCount(fixture.config.runId), 3);
const result = await rollbackChanges(db, SCHEMA, fixture.config.runId);
assert.equal(result.restored, 3);
assert.deepEqual(result.conflicts, []);
assert.deepEqual(await readFiles(), fixture.originals);
const [[source]] = await db.execute('SELECT content FROM infra_file_content WHERE id=?', [IDS.first]);
assert.deepEqual(source.content, imageBytes[0]);
});
await t.test('a business-row CAS conflict rolls back the entire batch and preserves concurrent edits', async () => {
const fixture = await seed('cas-conflict');
await db.execute('UPDATE infra_file SET url=? WHERE id=?', ['https://user-edit.test/new.png', fixture.items[1].files[0].id]);
const beforeAttempt = await readFiles();
await assert.rejects(switchFiles(db, fixture.config, fixture.items), changedRow);
assert.deepEqual(await readFiles(), beforeAttempt);
assert.deepEqual(await readStatuses(fixture.config.runId), ['verified', 'verified', 'verified']);
assert.equal(await journalCount(fixture.config.runId), 0);
});
await t.test('a final progress-state CAS conflict rolls back already-written files and journals', async () => {
const fixture = await seed('status-conflict');
const orphan = fixture.items[2];
await db.execute("UPDATE infra_file_oss_migration_items SET status='failed' WHERE run_id=? AND item_key=?", [fixture.config.runId, sha256(`${orphan.configId}\0${orphan.path}`)]);
await assert.rejects(switchFiles(db, fixture.config, fixture.items), changedRow);
assert.deepEqual(await readFiles(), fixture.originals);
assert.deepEqual((await readStatuses(fixture.config.runId)).sort(), ['failed', 'verified', 'verified']);
assert.equal(await journalCount(fixture.config.runId), 0);
});
await t.test('source mutations and a newer collation-equivalent version prevent every database switch', async t2 => {
for (const kind of ['same-size-hash-change', 'deleted-source', 'newer-equivalent-path']) {
await t2.test(kind, async () => {
const fixture = await seed(kind);
if (kind === 'same-size-hash-change') await db.execute('UPDATE infra_file_content SET content=? WHERE id=?', [Buffer.from([9, 9, 9, 9, 9]), IDS.first]);
else if (kind === 'deleted-source') await db.execute('UPDATE infra_file_content SET deleted=1 WHERE id=?', [IDS.first]);
else await db.execute('INSERT INTO infra_file_content(id,config_id,path,content) VALUES (?,5,?,?)', [IDS.newVersion, EQUIVALENT_PATH, Buffer.from('new active version')]);
await assert.rejects(switchFiles(db, fixture.config, fixture.items), changedSource);
assert.deepEqual(await readFiles(), fixture.originals);
assert.deepEqual(await readStatuses(fixture.config.runId), ['verified', 'verified', 'verified']);
assert.equal(await journalCount(fixture.config.runId), 0);
});
}
});
async function proveRangeLock(kind, statement, parameters) {
const fixture = await seed(`range-${kind}`);
let outcome, pending;
await db.beginTransaction();
try {
// Must be the first consistent read after BEGIN; the locking read
// inside verification establishes protection before the RR snapshot.
await verifySourceBatch(db, fixture.config, [fixture.items[0]]);
pending = contender.execute(statement, parameters).then(
value => { outcome = { value }; }, error => { outcome = { error }; }
);
const deadline = Date.now() + 3000;
let observedWait = false;
while (Date.now() < deadline) {
assert.equal(outcome, undefined, 'concurrent source mutation must not complete before commit');
const [transactions] = await observer.execute('SELECT trx_state FROM information_schema.innodb_trx WHERE trx_mysql_thread_id=?', [thread.id]);
if (transactions.some(transaction => transaction.trx_state === 'LOCK WAIT')) { observedWait = true; break; }
await delay(20);
}
assert.ok(observedWait, 'observe an actual InnoDB lock wait, not merely a delayed promise');
assert.equal(outcome, undefined);
await db.commit();
await pending;
if (outcome.error) throw outcome.error;
assert.equal(outcome.value[0].affectedRows, 1, 'mutation completes after the source lock is released');
} finally {
await db.rollback();
if (pending) await pending;
}
// Once the waiting writer commits, the next batch must detect the new
// active version rather than continuing with its old snapshot.
await db.beginTransaction();
try {
await assert.rejects(verifySourceBatch(db, fixture.config, [fixture.items[0]]), changedSource);
} finally { await db.rollback(); }
}
await t.test('RR range locks block insertion of a new matching version until commit', async () => {
await proveRangeLock('insert', 'INSERT INTO infra_file_content(id,config_id,path,content) VALUES (?,5,?,?)', [IDS.newVersion, EQUIVALENT_PATH, Buffer.from('concurrent version')]);
});
await t.test('RR range locks include deleted rows and block undeleting a newer version until commit', async () => {
await proveRangeLock('undelete', 'UPDATE infra_file_content SET deleted=0 WHERE id=?', [IDS.deletedNewer]);
});
} finally {
await db.rollback().catch(() => {});
if (contender) await contender.end();
if (observer) await observer.end();
try { await db.query(`DROP DATABASE IF EXISTS ${SCHEMA}`); }
finally { await db.end(); }
}
});
@@ -0,0 +1,251 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { switchFiles } from '../lib/transition.mjs';
import { sha256 } from '../lib/core.mjs';
const config = { mysql: { database: 'transition_test' }, runId: 'run', targetConfigId: '20' };
const itemKey = item => sha256(`${item.configId}\0${item.path}`);
const clone = value => Buffer.isBuffer(value) ? Buffer.from(value)
: Array.isArray(value) ? value.map(clone)
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)])) : value;
function fixture({ orphan = false, count = 1 } = {}) {
const item = {
contentId: '9007199254740993', configId: '5', path: '头像/原图.png',
size: 12, sha256: 'a'.repeat(64), key: 'migration/new.png',
url: 'https://oss/new.png', type: 'image/png', files: []
};
if (!orphan) for (let index = 0; index < count; index++) item.files.push({
id: String(9007199254741093n + BigInt(index)), config_id: '5', path: item.path,
url: 'https://old/old.png', type: 'image/png', size: 12
});
return [item];
}
class TransitionDb {
constructor(items) {
this.items = items;
this.calls = [];
this.files = Object.fromEntries(items.flatMap(item => item.files.map(file => [String(file.id), clone(file)])));
this.states = Object.fromEntries(items.map(item => [itemKey(item), 'verified']));
this.logs = [];
this.statusWarning = 0;
this.statusAffected = null;
this.fileAffected = null;
}
async beginTransaction() {
this.calls.push({ transaction: 'begin' });
if (this.beginFailure) throw this.beginFailure;
this.saved = clone({ files: this.files, states: this.states, logs: this.logs });
}
async commit() {
this.calls.push({ transaction: 'commit' });
if (this.commitFailure) throw this.commitFailure;
this.saved = undefined;
}
async rollback() {
this.calls.push({ transaction: 'rollback' });
if (this.rollbackFailure) throw this.rollbackFailure;
if (this.saved) Object.assign(this, this.saved);
this.saved = undefined;
}
async execute(sql, params = []) {
this.calls.push({ sql, params });
if (sql.startsWith('SELECT id FROM')) {
if (this.sourceFailure) throw this.sourceFailure;
return [this.items.map(item => ({ id: item.contentId }))];
}
if (sql.startsWith('SELECT id,CASE')) {
return [this.items.map(item => ({ id: this.newerSource ? String(BigInt(item.contentId) + 1n) : item.contentId, expected_content_id: item.contentId }))];
}
if (sql.startsWith('SELECT id,config_id,path,OCTET_LENGTH')) {
return [this.items.map(item => ({ id: item.contentId, config_id: item.configId, path: item.path, size: item.size, sha256: item.sha256 }))];
}
if (sql.includes('information_schema.COLUMNS')) {
return [[...['id', 'config_id', 'path', 'url', 'type', 'size', 'deleted', 'update_time'].map(name => ({
name, extra: name === 'update_time' ? 'on update CURRENT_TIMESTAMP' : '', engine: 'InnoDB'
}))]];
}
if (sql.startsWith('UPDATE') && sql.includes('`infra_file`')) {
const [set, where] = sql.split(' WHERE ');
let cursor = (set.match(/\?/g) || []).length;
const groups = where.split(' OR ');
for (const group of groups) {
const pk = String(params[cursor]);
cursor += (group.match(/\?/g) || []).length;
const item = this.items.find(value => value.files.some(file => String(file.id) === pk));
assert.ok(item, 'file updates must target the selected batch');
this.files[pk] = { ...this.files[pk], config_id: config.targetConfigId, path: item.key, url: item.url, type: item.type, size: item.size };
}
return [{ affectedRows: this.fileAffected ?? groups.length, warningStatus: 0 }];
}
if (sql.startsWith('SELECT `')) {
return [params.map(pk => this.files[String(pk)])];
}
if (sql.startsWith('INSERT INTO')) {
if (this.journalFailure) throw this.journalFailure;
for (let index = 0; index < params.length; index += 7) this.logs.push(params.slice(index, index + 7));
return [{ affectedRows: params.length / 7, warningStatus: 0 }];
}
if (sql.startsWith('UPDATE') && sql.includes('`infra_file_oss_migration_items`')) {
assert.match(sql, /WHERE run_id=\? AND status='verified' AND item_key IN \(/);
assert.match(sql, /SET status='switched',error=NULL,updated_at=CURRENT_TIMESTAMP/);
assert.equal(params[0], config.runId);
let affected = 0;
for (const key of params.slice(1)) if (this.states[key] === 'verified') { this.states[key] = 'switched'; affected++; }
return [{ affectedRows: this.statusAffected ?? affected, warningStatus: this.statusWarning }];
}
throw new Error(`Unexpected query: ${sql}`);
}
}
const state = db => clone({ files: db.files, states: db.states, logs: db.logs });
test('source range verification is the first operation after BEGIN, then files and guarded item status commit together', async () => {
const items = fixture();
const db = new TransitionDb(items);
const result = await switchFiles(db, config, items);
assert.deepEqual(Object.keys(result), ['sourceMs', 'writeMs', 'statusMs', 'commitMs']);
assert.ok(Object.values(result).every(value => Number.isFinite(value) && value >= 0));
assert.equal(db.calls[0].transaction, 'begin');
assert.match(db.calls[1].sql, /^SELECT id FROM .* WHERE id>=\? ORDER BY id FOR UPDATE$/);
assert.match(db.calls[2].sql, /^SELECT id,CASE/);
assert.match(db.calls[3].sql, /^SELECT id,config_id,path,OCTET_LENGTH/);
assert.ok(db.calls[4].sql.includes('information_schema.COLUMNS'));
assert.equal(db.calls.at(-1).transaction, 'commit');
assert.equal(db.files[items[0].files[0].id].url, items[0].url);
assert.equal(db.states[itemKey(items[0])], 'switched');
assert.equal(db.logs.length, 1);
const statusCall = db.calls.find(call => call.sql?.includes("SET status='switched'"));
assert.deepEqual(statusCall.params, [config.runId, itemKey(items[0])]);
});
test('more than 100 file records use bounded write chunks in one source-locked transaction', async () => {
const items = fixture({ count: 101 });
const db = new TransitionDb(items);
await switchFiles(db, config, items);
assert.deepEqual(db.calls.filter(call => call.transaction).map(call => call.transaction), ['begin', 'commit']);
assert.equal(db.calls.filter(call => call.sql?.startsWith('SELECT id FROM')).length, 1);
const writes = db.calls.filter(call => call.sql?.startsWith('UPDATE') && call.sql.includes('`infra_file`'));
assert.equal(writes.length, 2);
assert.equal(writes[0].sql.split(' WHERE ')[1].split(' OR ').length, 100);
assert.equal(writes[1].sql.split(' WHERE ')[1].split(' OR ').length, 1);
assert.equal(db.logs.length, 101);
assert.equal(Object.values(db.files).filter(file => file.url === items[0].url).length, 101);
});
test('orphan content is source-verified and status-switched even without any infra_file rows', async () => {
const items = fixture({ orphan: true });
const db = new TransitionDb(items);
await switchFiles(db, config, items);
assert.equal(db.states[itemKey(items[0])], 'switched');
assert.equal(db.logs.length, 0);
assert.equal(db.calls.filter(call => call.sql).length, 4);
assert.equal(db.calls.some(call => call.sql?.includes('information_schema')), false);
});
test('mixed regular and orphan items both participate in one guarded status update', async () => {
const items = fixture();
items.push({ ...fixture({ orphan: true })[0], contentId: '9007199254740994', path: 'orphan.png' });
const db = new TransitionDb(items);
await switchFiles(db, config, items);
assert.equal(db.logs.length, 1);
assert.deepEqual(Object.values(db.states), ['switched', 'switched']);
});
test('source errors are propagated unchanged after a successful rollback', async () => {
const items = fixture();
const db = new TransitionDb(items);
const failure = Object.assign(new Error('source changed'), { code: 'MIGRATION_SOURCE_CHANGED' });
db.sourceFailure = failure;
await assert.rejects(switchFiles(db, config, items), error => error === failure);
assert.deepEqual(db.calls.filter(call => call.transaction).map(call => call.transaction), ['begin', 'rollback']);
assert.equal(db.calls.some(call => call.sql?.startsWith('UPDATE')), false);
});
test('a newer source version stops the transaction before any file updates', async () => {
const items = fixture();
const db = new TransitionDb(items);
db.newerSource = true;
await assert.rejects(switchFiles(db, config, items), { code: 'MIGRATION_SOURCE_CHANGED' });
assert.equal(db.calls.some(call => call.sql?.startsWith('UPDATE')), false);
assert.equal(db.calls.at(-1).transaction, 'rollback');
});
test('file CAS mismatches roll back all file edits and preserve the conflict code', async () => {
const items = fixture();
const db = new TransitionDb(items);
const before = state(db);
db.fileAffected = 0;
await assert.rejects(switchFiles(db, config, items), { code: 'MIGRATION_CAS_CONFLICT' });
assert.deepEqual(state(db), before);
assert.equal(db.calls.some(call => call.sql?.includes("SET status='switched'")), false);
});
test('non-verified item status rolls back previously updated files and journal inserts', async () => {
const items = fixture();
const db = new TransitionDb(items);
db.states[itemKey(items[0])] = 'planned';
const before = state(db);
await assert.rejects(switchFiles(db, config, items), { code: 'MIGRATION_CAS_CONFLICT' });
assert.deepEqual(state(db), before);
assert.ok(db.calls.some(call => call.sql?.startsWith('INSERT INTO')));
});
test('item status SQL warnings roll back files, logs and item statuses', async () => {
const items = fixture();
const db = new TransitionDb(items);
const before = state(db);
db.statusWarning = 1;
await assert.rejects(switchFiles(db, config, items), { code: 'MIGRATION_SQL_WARNING' });
assert.deepEqual(state(db), before);
});
test('rollback failure is fatal and prohibits fallback while preserving both causes', async () => {
const items = fixture();
const db = new TransitionDb(items);
db.sourceFailure = Object.assign(new Error('source failed'), { code: 'MIGRATION_SOURCE_CHANGED' });
db.rollbackFailure = new Error('connection lost');
await assert.rejects(switchFiles(db, config, items), error => {
assert.equal(error.code, 'MIGRATION_ROLLBACK_FAILED');
assert.equal(error.fatal, true);
assert.equal(error.noFallback, true);
assert.equal(error.cause, db.sourceFailure);
assert.equal(error.rollbackError, db.rollbackFailure);
return true;
});
});
test('commit acknowledgement failure is fatal even if a rollback call subsequently succeeds', async () => {
const items = fixture();
const db = new TransitionDb(items);
db.commitFailure = Object.assign(new Error('commit transport failure'), { code: 'PROTOCOL_CONNECTION_LOST' });
await assert.rejects(switchFiles(db, config, items), error => {
assert.equal(error.code, 'PROTOCOL_CONNECTION_LOST');
assert.equal(error.fatal, true);
assert.equal(error.noFallback, true);
assert.equal(error.cause, db.commitFailure);
return true;
});
assert.deepEqual(db.calls.filter(call => call.transaction).map(call => call.transaction), ['begin', 'commit', 'rollback']);
});
test('BEGIN failure is propagated without performing any reads, writes or rollback', async () => {
const items = fixture();
const db = new TransitionDb(items);
db.beginFailure = new Error('cannot begin');
await assert.rejects(switchFiles(db, config, items), error => error === db.beginFailure);
assert.deepEqual(db.calls, [{ transaction: 'begin' }]);
});
test('empty batches are no-ops and malformed or duplicate input is rejected before BEGIN', async () => {
const db = new TransitionDb([]);
assert.deepEqual(await switchFiles(db, config, []), { sourceMs: 0, writeMs: 0, statusMs: 0, commitMs: 0 });
await assert.rejects(switchFiles(db, config, null), /数组/);
await assert.rejects(switchFiles(db, config, [{ ...fixture()[0], files: null }]), /files/);
await assert.rejects(switchFiles(db, config, [fixture()[0], fixture()[0]]), /重复源路径/);
const duplicateFiles = fixture();
duplicateFiles[0].files.push({ ...duplicateFiles[0].files[0] });
await assert.rejects(switchFiles(db, config, duplicateFiles), /重复文件记录/);
assert.equal(db.calls.length, 0);
});