Commit 6eef75b09b2260ab4812673b17e46b2c4f9631da
1 parent
d61c07b1
lib: drop duplicated headers, JSDoc restatements, CLI-entry comments; reuse advanceLiteral helper
Showing
4 changed files
with
20 additions
and
78 deletions
lib/apply-ddl.mjs
| 1 | -// lib/apply-ddl.mjs | |
| 2 | -// | |
| 3 | -// Replaces the inline `set -a; . .env.local; mysql < V1.sql` bash from db-init. | |
| 4 | -// | |
| 5 | -// parseEnv(): dotenv-style line parser. Pure parsing, NO variable expansion and | |
| 6 | -// NO shell execution — `$VAR`, backticks, `$(...)` and other shell constructs are | |
| 7 | -// kept verbatim as literal characters, which eliminates the shell-injection vector | |
| 8 | -// of `source`-ing an untrusted .env file. | |
| 9 | -// | |
| 10 | -// applyDDL(): connects with mysql2/promise (multipleStatements) to run a DDL file. | |
| 11 | - | |
| 12 | 1 | /** |
| 13 | 2 | * Parse dotenv-style text into a plain object. |
| 14 | 3 | * |
| ... | ... | @@ -60,14 +49,6 @@ export function parseEnv(text) { |
| 60 | 49 | /** |
| 61 | 50 | * Apply a DDL file to a MySQL database using mysql2/promise. |
| 62 | 51 | * |
| 63 | - * Reads connection settings from the parsed env file. Recognised keys (with | |
| 64 | - * common aliases) — DB_HOST/MYSQL_HOST, DB_PORT/MYSQL_PORT, DB_USER/MYSQL_USER, | |
| 65 | - * DB_PASS/DB_PASSWORD/MYSQL_PASSWORD, DB_SCHEMA/DB_NAME/MYSQL_DATABASE. | |
| 66 | - * DB_SCHEMA is the plugin's canonical schema key (see .env.local template); the | |
| 67 | - * target database MUST resolve to a non-empty value or applyDDL fails closed — | |
| 68 | - * V1 DDL contains no `USE`/`CREATE DATABASE`, so a missing schema would only | |
| 69 | - * surface as an opaque `ER_NO_DB_ERROR` on the first CREATE TABLE. | |
| 70 | - * | |
| 71 | 52 | * @param {{envPath: string, ddlPath: string}} opts |
| 72 | 53 | * @returns {Promise<void>} |
| 73 | 54 | */ |
| ... | ... | @@ -104,9 +85,7 @@ export async function applyDDL({ envPath, ddlPath }) { |
| 104 | 85 | * Resolve mysql2 connection settings from a parsed env object. Pure (no I/O), |
| 105 | 86 | * so it is unit-testable without mysql2 installed. |
| 106 | 87 | * |
| 107 | - * fail-closed on missing schema: DB_SCHEMA is the plugin's canonical key; V1 DDL | |
| 108 | - * has no `USE`/`CREATE DATABASE`, so a missing database would otherwise surface | |
| 109 | - * only as an opaque ER_NO_DB_ERROR on the first CREATE TABLE. | |
| 88 | + * Throws if no schema resolves — V1 has no USE/CREATE DATABASE. | |
| 110 | 89 | * |
| 111 | 90 | * @param {Record<string,string>} env |
| 112 | 91 | * @param {string} [envPath] only used to make the error message actionable |
| ... | ... | @@ -132,9 +111,7 @@ export class MysqlUnavailableError extends Error { |
| 132 | 111 | } |
| 133 | 112 | } |
| 134 | 113 | |
| 135 | -// CLI entry: node lib/apply-ddl.mjs <envPath> <ddlPath> | |
| 136 | -// Use pathToFileURL so the guard matches even when the path contains spaces or | |
| 137 | -// other characters that get percent-encoded in import.meta.url. | |
| 114 | +// CLI entry guard (see render.mjs for pathToFileURL rationale) | |
| 138 | 115 | const { pathToFileURL } = await import('node:url') |
| 139 | 116 | if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| 140 | 117 | const [envPath, ddlPath] = process.argv.slice(2) | ... | ... |
lib/merge-gitignore.mjs
| 1 | 1 | // lib/merge-gitignore.mjs |
| 2 | 2 | // 合并两份 .gitignore,对**规则行**逐行判重并集合并;注释行透传(相邻去重),空行丢弃(节由注释头承担)。 |
| 3 | -// 之所以不对注释去重:两段分组各自的同名注释头(如多次出现的 `# generated`)是分节标题, | |
| 4 | -// 全局去重会把第二段的标题吞掉,让 add 文件的规则被并入第一段的注释下、破坏分节语义。 | |
| 5 | 3 | export function mergeGitignore(baseText, addText) { |
| 6 | 4 | const seenRules = new Set() |
| 7 | 5 | const out = [] |
| ... | ... | @@ -25,10 +23,7 @@ export function mergeGitignore(baseText, addText) { |
| 25 | 23 | return text |
| 26 | 24 | } |
| 27 | 25 | |
| 28 | -// CLI entry: node lib/merge-gitignore.mjs <basePath> <addPath> | |
| 29 | -// Use pathToFileURL so the guard matches even when the path contains spaces or | |
| 30 | -// non-ASCII chars (import.meta.url is percent-encoded; process.argv[1] is raw). | |
| 31 | -const { pathToFileURL } = await import('node:url') | |
| 26 | +const { pathToFileURL } = await import('node:url') // CLI entry guard (see render.mjs) | |
| 32 | 27 | if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| 33 | 28 | const [basePath, addPath] = process.argv.slice(2) |
| 34 | 29 | const { readFileSync, writeFileSync } = await import('node:fs') | ... | ... |
lib/render.mjs
| 1 | 1 | // lib/render.mjs — literal-safe template render (replaces scope-lock/render.sh) |
| 2 | 2 | // |
| 3 | -// 用法(CLI):node lib/render.mjs <tplPath> <varsJsonPath> <outPath> | |
| 4 | -// 程序内:import { render } from './render.mjs' | |
| 5 | -// | |
| 6 | 3 | // 核心要求:{{key}} 占位替换;值中含 $、{、}、}} 不被二次解释(字面插入); |
| 7 | 4 | // 先剥离 HTML 注释(模板引导文本);缺少变量则 throw(不静默留空)。 |
| 8 | 5 | export function render(template, vars) { | ... | ... |
lib/validate-ddl.mjs
| 1 | 1 | // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 |
| 2 | 2 | // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 |
| 3 | -// 语法基线偏向 MySQL 8(int/varchar/json 等 ANSI + MySQL 类型;KEY/UNIQUE KEY 索引语法)。 | |
| 4 | -// 厂商扩展(Postgres `bytea`、Oracle `nvarchar2` 等)未列入 SQL_TYPE_RE,下游解析可能退化为 | |
| 5 | -// 跳过整项(fix #2 起 KEY/INDEX 项遇未知类型保留字会跳过而非误判为列)。 | |
| 6 | 3 | // |
| 7 | 4 | // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath> |
| 8 | 5 | // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。 |
| 9 | 6 | // 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' |
| 10 | 7 | // |
| 11 | -// 5 维 diff: | |
| 12 | -// 1) 表集合(missingTables / extraTables) | |
| 13 | -// 2) 列名(columnMismatches,side: 'docs'|'ddl') | |
| 14 | -// 3) 列类型(typeMismatches) | |
| 15 | -// 4) 索引(indexMismatches,side: 'docs'|'ddl') | |
| 16 | -// 5) 外键(foreignKeyMismatches,side: 'docs'|'ddl') | |
| 17 | -// | |
| 18 | 8 | // 数据结构(解析结果):Map<tableName, { |
| 19 | 9 | // columns: Map<colName, type>, indexes: Set<string>, foreignKeys: Set<string> }> |
| 20 | 10 | |
| ... | ... | @@ -102,10 +92,6 @@ function parseIndexBullet(line, indexes) { |
| 102 | 92 | |
| 103 | 93 | // 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete) |
| 104 | 94 | // 归一化为 parseDDL 同形的 `${fromCols}->${toTable}(${toCols})`(注意 docs 用 unicode → / DDL 用 ->)。 |
| 105 | -// 复合外键的两种合法 docs 写法都支持,避免与 DDL 侧的 `(idA, idB)` 形态不对称: | |
| 106 | -// - `fk`: colA, colB → other.idA, idB ← 平铺,到列也是逗号分隔 | |
| 107 | -// - `fk`: colA, colB → other.(idA, idB) ← 目标列括起 | |
| 108 | -// - `fk`: colA, colB → other.`idA`,`idB` ← 各列各带反引号 | |
| 109 | 95 | function parseForeignKeyBullet(line, foreignKeys) { |
| 110 | 96 | // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 |
| 111 | 97 | // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 |
| ... | ... | @@ -209,8 +195,6 @@ function parseTableBody(body) { |
| 209 | 195 | indexes.add(`${nameMatch[1]}:${kind}:${cols}`) |
| 210 | 196 | continue |
| 211 | 197 | } |
| 212 | - // 命名是类型关键字 / 无法定位 → 回退到列定义解析; | |
| 213 | - // 列正则下游会拒绝以保留字开头的列名(fix #2)。 | |
| 214 | 198 | } |
| 215 | 199 | // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 |
| 216 | 200 | if (/^CONSTRAINT\b/i.test(upper)) { |
| ... | ... | @@ -249,7 +233,6 @@ function extractType(rest) { |
| 249 | 233 | return base + paren + mod |
| 250 | 234 | } |
| 251 | 235 | |
| 252 | -// ── 5 维 diff ──────────────────────────────────────────────────── | |
| 253 | 236 | export function diffSchema(docsTables, ddlTables) { |
| 254 | 237 | const diff = { |
| 255 | 238 | missingTables: [], // docs 有、DDL 无 |
| ... | ... | @@ -264,8 +247,7 @@ export function diffSchema(docsTables, ddlTables) { |
| 264 | 247 | const docNames = new Set(docsTables.keys()) |
| 265 | 248 | const ddlNames = new Set(ddlTables.keys()) |
| 266 | 249 | |
| 267 | - for (const t of docNames) if (!ddlNames.has(t)) diff.missingTables.push(t) | |
| 268 | - for (const t of ddlNames) if (!docNames.has(t)) diff.extraTables.push(t) | |
| 250 | + symDiff(docNames, ddlNames, t => diff.missingTables.push(t), t => diff.extraTables.push(t)) | |
| 269 | 251 | diff.missingTables.sort() |
| 270 | 252 | diff.extraTables.sort() |
| 271 | 253 | |
| ... | ... | @@ -293,14 +275,14 @@ export function diffSchema(docsTables, ddlTables) { |
| 293 | 275 | // 表内体现 PK、不在 ### 索引 重列 → 从两侧索引集剔除 PRIMARY,避免假阳性;命名二级索引仍比对。 |
| 294 | 276 | const dIdx = new Set([...(d.indexes || [])].filter(ix => ix !== 'PRIMARY')) |
| 295 | 277 | const sIdx = new Set([...(s.indexes || [])].filter(ix => ix !== 'PRIMARY')) |
| 296 | - for (const ix of dIdx) if (!sIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }) | |
| 297 | - for (const ix of sIdx) if (!dIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' }) | |
| 278 | + symDiff(dIdx, sIdx, | |
| 279 | + ix => diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }), | |
| 280 | + ix => diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' })) | |
| 298 | 281 | |
| 299 | 282 | // 维度 5:外键 |
| 300 | - const dFk = d.foreignKeys || new Set() | |
| 301 | - const sFk = s.foreignKeys || new Set() | |
| 302 | - for (const fk of dFk) if (!sFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' }) | |
| 303 | - for (const fk of sFk) if (!dFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' }) | |
| 283 | + symDiff(d.foreignKeys || new Set(), s.foreignKeys || new Set(), | |
| 284 | + fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' }), | |
| 285 | + fk => diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' })) | |
| 304 | 286 | } |
| 305 | 287 | |
| 306 | 288 | diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 || |
| ... | ... | @@ -309,7 +291,6 @@ export function diffSchema(docsTables, ddlTables) { |
| 309 | 291 | return diff |
| 310 | 292 | } |
| 311 | 293 | |
| 312 | -// ── 工具函数 ───────────────────────────────────────────────────── | |
| 313 | 294 | // 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。 |
| 314 | 295 | // **字符串字面量感知**:单引号 / 双引号 / 反引号字面量内部的注释符按原文保留(DEFAULT 'a--b' / |
| 315 | 296 | // DEFAULT '#tag' 之类不会被错剥成"列丢失")。转义引号支持 SQL 标准的 '' 与反斜杠 \\'。 |
| ... | ... | @@ -322,19 +303,9 @@ function stripSqlComments(sql) { |
| 322 | 303 | const next = s[i + 1] |
| 323 | 304 | // 进入字符串 / 反引号:原样吐出整个字面量 |
| 324 | 305 | if (ch === "'" || ch === '"' || ch === '`') { |
| 325 | - const q = ch | |
| 326 | - out += ch | |
| 327 | - i++ | |
| 328 | - while (i < s.length) { | |
| 329 | - const c = s[i] | |
| 330 | - // SQL 标准的双引号转义:'' 或 "" | |
| 331 | - if (c === q && s[i + 1] === q) { out += c + c; i += 2; continue } | |
| 332 | - // 反斜杠转义:\' / \" / \\ 等(MySQL 默认开启 NO_BACKSLASH_ESCAPES 才禁,保守按开启处理) | |
| 333 | - if (c === '\\' && i + 1 < s.length && q !== '`') { out += c + s[i + 1]; i += 2; continue } | |
| 334 | - out += c | |
| 335 | - i++ | |
| 336 | - if (c === q) break | |
| 337 | - } | |
| 306 | + const end = advanceLiteral(s, i) | |
| 307 | + out += s.slice(i, end) | |
| 308 | + i = end | |
| 338 | 309 | continue |
| 339 | 310 | } |
| 340 | 311 | // /* ... */ 块注释(吞到下一个 */) |
| ... | ... | @@ -453,7 +424,12 @@ function typesEqual(a, b) { |
| 453 | 424 | return norm(a) === norm(b) |
| 454 | 425 | } |
| 455 | 426 | |
| 456 | -// ── 报告(供 CLI 与外部复用)──────────────────────────────────── | |
| 427 | +// 集合对称差:对 left\right 调用 onlyLeft,对 right\left 调用 onlyRight。 | |
| 428 | +function symDiff(left, right, onlyLeft, onlyRight) { | |
| 429 | + for (const x of left) if (!right.has(x)) onlyLeft(x) | |
| 430 | + for (const x of right) if (!left.has(x)) onlyRight(x) | |
| 431 | +} | |
| 432 | + | |
| 457 | 433 | export function formatDiff(diff) { |
| 458 | 434 | const out = [] |
| 459 | 435 | if (diff.missingTables.length) { |
| ... | ... | @@ -491,10 +467,7 @@ export function formatDiff(diff) { |
| 491 | 467 | return out.join('\n') |
| 492 | 468 | } |
| 493 | 469 | |
| 494 | -// ── CLI 入口 ───────────────────────────────────────────────────── | |
| 495 | -// 用 pathToFileURL 做比较:路径含空格/非 ASCII 时 import.meta.url 是百分号编码, | |
| 496 | -// 而 process.argv[1] 是原始路径,直接 `file://${argv1}` 拼接永远不相等。 | |
| 497 | -const { pathToFileURL } = await import('node:url') | |
| 470 | +const { pathToFileURL } = await import('node:url') // CLI entry guard (see render.mjs) | |
| 498 | 471 | const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href |
| 499 | 472 | if (isCliEntry) { |
| 500 | 473 | const { readFileSync, existsSync } = await import('node:fs') | ... | ... |