1
0

feat: 新增 DB checker — 支持 PostgreSQL/MySQL/SQLite 连接测试与 SQL 查询断言

- 实现 db 类型 checker,使用 Bun 内置 SQL 类
- 支持 db.url 连接字符串和可选 db.query 查询语句
- expect 支持 maxDurationMs、rowCount、rows 逐列校验
- 凭据屏蔽序列化输出
- SQLite 内存数据库测试覆盖
This commit is contained in:
2026-05-16 09:00:15 +08:00
parent c36df94e59
commit 146cef982e
16 changed files with 1344 additions and 7 deletions

View File

@@ -0,0 +1,206 @@
import { SQL } from "bun";
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { DbExpectConfig, DbTargetConfig, ResolvedDbTarget } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { checkRowCount, checkRows } from "./expect";
import { dbCheckerSchemas } from "./schema";
import { validateDbConfig } from "./validate";
const PROBE_QUERY = "SELECT 1";
export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
readonly configKey = "db";
readonly schemas = dbCheckerSchemas;
readonly type = "db";
async execute(t: ResolvedDbTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
let db: SQL | undefined;
try {
// 创建连接SQLite 不需要 max 选项)
db = new SQL(t.db.url);
// 监听 abort signal
ctx.signal.addEventListener(
"abort",
() => {
void db?.close({ timeout: 0 }).catch(() => {
/* best-effort close */
});
},
{ once: true },
);
// 连接测试Bun SQL 是 lazy 的,首次查询才真正连接)
try {
await db.unsafe(PROBE_QUERY);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetName: t.name,
timestamp,
};
}
// 无 query 时仅测试连接
if (!t.db.query) {
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
if (!durationResult.matched) {
return {
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: "connected",
targetName: t.name,
timestamp,
};
}
return {
durationMs,
failure: null,
matched: true,
statusDetail: "connected",
targetName: t.name,
timestamp,
};
}
// 执行用户 SQL
let rows: unknown[];
try {
rows = await db.unsafe(t.db.query);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetName: t.name,
timestamp,
};
}
const durationMs = Math.round(performance.now() - start);
// 检查是否超时
if (ctx.signal.aborted) {
return {
durationMs,
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
targetName: t.name,
timestamp,
};
}
// duration 断言
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
if (!durationResult.matched) {
return {
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
targetName: t.name,
timestamp,
};
}
// rowCount 断言
if (t.expect?.rowCount) {
const rowCountResult = checkRowCount(rows, t.expect.rowCount);
if (!rowCountResult.matched) {
return {
durationMs,
failure: rowCountResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
targetName: t.name,
timestamp,
};
}
}
// rows 断言
if (t.expect?.rows && t.expect.rows.length > 0) {
const rowsResult = checkRows(rows, t.expect.rows);
if (!rowsResult.matched) {
return {
durationMs,
failure: rowsResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
targetName: t.name,
timestamp,
};
}
}
return {
durationMs,
failure: null,
matched: true,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
targetName: t.name,
timestamp,
};
} finally {
if (db) {
try {
await db.close({ timeout: 0 });
} catch {
/* best-effort close */
}
}
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };
return {
db: {
query: t.db.query,
url: t.db.url,
},
expect: target.expect as DbExpectConfig | undefined,
group: target.group ?? "default",
intervalMs: context.defaultIntervalMs,
name: t.name,
timeoutMs: context.defaultTimeoutMs,
type: "db",
} satisfies ResolvedDbTarget;
}
serialize(t: ResolvedDbTarget): { config: string; target: string } {
// 屏蔽凭据postgres://user:pass@host → postgres://***:***@host
const masked = t.db.url.replace(/:\/\/([^@]+)@/, "://***:***@");
return {
config: JSON.stringify({
query: t.db.query ?? null,
url: masked,
}),
target: masked,
};
}
validate(input: CheckerValidationInput) {
return validateDbConfig(input);
}
}

View File

@@ -0,0 +1,57 @@
import type { ExpectResult } from "../../expect/types";
import type { ExpectOperator, ExpectValue } from "../../types";
import { mismatchFailure } from "../../expect/failure";
import { checkExpectValue } from "../../expect/operator";
export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult {
const actual = Array.isArray(rows) ? rows.length : 0;
const matched = checkExpectValue(actual, op);
if (!matched) {
return {
failure: mismatchFailure("rowCount", "rowCount", op, actual, `rowCount ${actual} 不满足条件`),
matched: false,
};
}
return { failure: null, matched: true };
}
export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue>>): ExpectResult {
if (!Array.isArray(rows)) {
return {
failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"),
matched: false,
};
}
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]!;
if (i >= rows.length) {
return {
failure: mismatchFailure("row", `rows[${i}]`, "expected row", undefined, `结果行数不足,需要第 ${i + 1}`),
matched: false,
};
}
const row = rows[i]! as null | Record<string, unknown> | undefined;
if (!row || typeof row !== "object" || Array.isArray(row)) {
return {
failure: mismatchFailure("row", `rows[${i}]`, "object", row, `${i + 1} 行不是对象`),
matched: false,
};
}
for (const [col, expected] of Object.entries(rule)) {
const actual = row[col];
const matched = checkExpectValue(actual, expected);
if (!matched) {
return {
failure: mismatchFailure("row", `rows[${i}].${col}`, expected, actual, `rows[${i}].${col} mismatch`),
matched: false,
};
}
}
}
return { failure: null, matched: true };
}

View File

@@ -0,0 +1 @@
export { DbChecker } from "./execute";

View File

@@ -0,0 +1,52 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createPureOperatorSchema, jsonValueSchema, operatorProperties } from "../../schema/fragments";
// Db expect 允许行对象中的列值为字面量或 operator
const dbRowValueSchema = Type.Union([jsonValueSchema, createPureOperatorSchema()]);
export const dbCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
query: Type.Optional(
Type.String({
minLength: 1,
}),
),
url: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
),
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object(
{
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
rowCount: Type.Optional(createPureOperatorSchema()),
rows: Type.Optional(
Type.Array(
Type.Record(Type.String(), dbRowValueSchema, {
additionalProperties: false,
minProperties: 1,
}),
),
),
},
{ additionalProperties: false },
),
};
// 导出用于 validate 的辅助类型
export const DbOperatorKeys = new Set<string>([
...Object.keys(operatorProperties()),
"contains",
"empty",
"equals",
"exists",
"gt",
"gte",
"lt",
"lte",
"match",
]);

View File

@@ -0,0 +1,27 @@
import type { ExpectOperator, ExpectValue, ResolvedTargetBase } from "../../types";
export interface DbExpectConfig {
maxDurationMs?: number;
rowCount?: ExpectOperator;
rows?: Array<Record<string, ExpectValue>>;
}
export interface DbTargetConfig {
query?: string;
url: string;
}
export interface ResolvedDbConfig {
query?: string;
url: string;
}
export interface ResolvedDbTarget extends ResolvedTargetBase {
db: ResolvedDbConfig;
expect?: DbExpectConfig;
group: string;
intervalMs: number;
name: string;
timeoutMs: number;
type: "db";
}

View File

@@ -0,0 +1,142 @@
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isUnsafeRegex } from "../../expect/redos";
import { validateOperatorObject } from "../../expect/validate-operator";
import { issue, joinPath } from "../../schema/issues";
export function validateDbConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (target["type"] !== "db") continue;
issues.push(...validateDbTarget(target, `targets[${i}]`));
}
return issues;
}
function collectRowOperators(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
if (!isRecord(row)) {
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
continue;
}
for (const [col, value] of Object.entries(row)) {
const colPath = `${path}[${i}].${col}`;
if (isRecord(value) && Object.keys(value).some((k) => k === "match")) {
// 检查 match 正则
const match = value["match"];
if (typeof match === "string") {
try {
new RegExp(match);
} catch {
issues.push(issue("invalid-regex", colPath, "正则不合法", targetName));
}
if (typeof match === "string" && isUnsafeRegex(match)) {
issues.push(issue("unsafe-regex", colPath, "正则存在 ReDoS 风险", targetName));
}
}
}
// 校验 operator 对象
if (isRecord(value)) {
issues.push(...validateOperatorObject(value, colPath, targetName, { requireAtLeastOne: false }));
}
}
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
if (expect["rowCount"] !== undefined) {
issues.push(...validateOperatorObject(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
}
if (expect["rows"] !== undefined) {
if (!Array.isArray(expect["rows"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName));
} else {
issues.push(...collectRowOperators(expect["rows"], joinPath(expectPath, "rows"), targetName));
}
}
// 检查未知字段
const allowedKeys = new Set(["maxDurationMs", "rowCount", "rows"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validateDbTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const db = target["db"];
if (!isRecord(db)) {
issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName));
issues.push(...validateDbExpect(target, path));
return issues;
}
// url 必填
if (typeof db["url"] !== "string" || db["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "db"), "url"), "缺少 db.url 字段", targetName));
}
// query 可选但不能为空字符串
if (db["query"] !== undefined) {
if (typeof db["query"] !== "string") {
issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName));
} else if (db["query"].trim() === "") {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "db"), "query"),
"不能为空字符串(如不需要查询则不配置该字段)",
targetName,
),
);
}
}
// 检查未知字段
const allowedDbKeys = new Set(["query", "url"]);
for (const key of Object.keys(db)) {
if (!allowedDbKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "db"), key), "是未知字段", targetName));
}
}
issues.push(...validateDbExpect(target, path));
return issues;
}

View File

@@ -1,8 +1,9 @@
import { CommandChecker } from "./cmd";
import { DbChecker } from "./db";
import { HttpChecker } from "./http";
import { CheckerRegistry } from "./registry";
const checkers = [new HttpChecker(), new CommandChecker()];
const checkers = [new HttpChecker(), new CommandChecker(), new DbChecker()];
export function createDefaultCheckerRegistry(): CheckerRegistry {
const registry = new CheckerRegistry();