update_ancient_daily_2026_fall.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. #!/usr/bin/env node
  2. import assert from "node:assert/strict";
  3. import fs from "node:fs";
  4. import path from "node:path";
  5. import { fileURLToPath } from "node:url";
  6. import mysql from "mysql2/promise";
  7. import config from "../../src/config/dev.js";
  8. const taskDir = path.dirname(fileURLToPath(import.meta.url));
  9. const sourcePath = path.join(taskDir, "source_ancient_daily_2026_fall.json");
  10. const source = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
  11. const flags = new Set(process.argv.slice(2));
  12. const knownFlags = new Set(["--dry-run", "--apply", "--audit"]);
  13. for (const flag of flags) assert(knownFlags.has(flag), `不支持的参数:${flag}`);
  14. assert(
  15. ["--dry-run", "--apply", "--audit"].filter((flag) => flags.has(flag)).length <= 1,
  16. "--dry-run、--apply、--audit 只能选择一个",
  17. );
  18. const mode = flags.has("--apply") ? "apply" : flags.has("--audit") ? "audit" : "dry-run";
  19. function nowForFile() {
  20. return new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
  21. }
  22. function writeJson(filePath, data) {
  23. fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
  24. }
  25. function stringifyContent(content) {
  26. return JSON.stringify(content);
  27. }
  28. function normalized(value) {
  29. return value == null ? null : value;
  30. }
  31. function tagFor(book, entry) {
  32. return book.type === "daily"
  33. ? `${book.label},日积月累,${entry.kind}`
  34. : `${book.label},${entry.kind}`;
  35. }
  36. function categoryFor(book, entry) {
  37. if (book.type === "ancient") return 0;
  38. return entry.kind === "诗词" ? 0 : 1;
  39. }
  40. function validateSource() {
  41. assert.equal(source.version, "2026-fall");
  42. assert.equal(source.sortBase, 0);
  43. assert.equal(source.books.length, 12, "应包含六册古诗文和六册日积月累");
  44. const bookIds = source.books.map((book) => book.miaoguoBookId);
  45. const bookIdsOld = source.books.map((book) => book.bookIdOld);
  46. assert.equal(new Set(bookIds).size, bookIds.length, "MiaoguoBook.ID 重复");
  47. assert.equal(new Set(bookIdsOld).size, bookIdsOld.length, "目标 BookIDOld 重复");
  48. const existingIds = [];
  49. const newTitles = [];
  50. for (const book of source.books) {
  51. assert(["ancient", "daily"].includes(book.type), `${book.label} type 错误`);
  52. assert(book.entries.length > 0, `${book.label}/${book.type} 没有条目`);
  53. book.entries.forEach((entry, index) => {
  54. assert(entry.kind, `${book.label}/${book.type}/Sort=${index} 缺少 kind`);
  55. const tag = tagFor(book, entry);
  56. assert(tag.length <= 45, `${tag} 超过 Tag 长度`);
  57. if (entry.id != null) {
  58. existingIds.push(entry.id);
  59. } else {
  60. assert(entry.title, `${book.label}/${book.type}/Sort=${index} 新增项缺少标题`);
  61. assert(entry.content, `${book.label}/${book.type}/${entry.title} 新增项缺少正文`);
  62. newTitles.push(entry.title);
  63. }
  64. if (entry.title != null) assert(entry.title.length <= 200, `${entry.title} 标题过长`);
  65. if (entry.author != null) assert(entry.author.length <= 45, `${entry.title} Author 过长`);
  66. if (entry.dynasty != null) assert(entry.dynasty.length <= 45, `${entry.title} Dynasty 过长`);
  67. if (entry.content != null) {
  68. const content = stringifyContent(entry.content);
  69. assert(content.length <= 2000, `${entry.title ?? entry.id} PeomContent 超过 2000 字符`);
  70. assert.deepEqual(JSON.parse(content), entry.content);
  71. }
  72. });
  73. }
  74. assert.equal(new Set(existingIds).size, existingIds.length, "来源中的 AncientPoetry.ID 重复");
  75. assert.equal(new Set(newTitles).size, newTitles.length, "新增标题重复");
  76. const detachIds = source.detach.map((entry) => entry.id);
  77. assert.equal(new Set(detachIds).size, detachIds.length, "detach ID 重复");
  78. assert(
  79. detachIds.every((id) => !existingIds.includes(id)),
  80. "同一 ID 不能既出现在最终清单又出现在 detach",
  81. );
  82. const metadataIds = source.metadata.map((entry) => entry.miaoguoBookId);
  83. assert.equal(new Set(metadataIds).size, metadataIds.length, "metadata MiaoguoBook.ID 重复");
  84. for (const book of source.books) {
  85. const metadata = source.metadata.find((entry) => entry.miaoguoBookId === book.miaoguoBookId);
  86. assert(metadata, `${book.label}/${book.type} 缺少 metadata`);
  87. assert.equal(metadata.bookIdOld, book.bookIdOld, `${book.label}/${book.type} metadata 映射错误`);
  88. assert.equal(metadata.wordNum, book.entries.length, `${book.label}/${book.type} WordNum 不等于最终行数`);
  89. }
  90. }
  91. async function readState(connection, lock = false) {
  92. const existingIds = source.books.flatMap((book) => book.entries)
  93. .filter((entry) => entry.id != null)
  94. .map((entry) => entry.id);
  95. const affectedIds = [...new Set([...existingIds, ...source.detach.map((entry) => entry.id)])];
  96. const metadataIds = source.metadata.map((entry) => entry.miaoguoBookId);
  97. const targetBookIdsOld = source.books.map((book) => book.bookIdOld);
  98. const lockSuffix = lock ? " FOR UPDATE" : "";
  99. const [rows] = await connection.query(
  100. `SELECT ID, HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
  101. PeomContent, Translation, Flag
  102. FROM AncientPoetry
  103. WHERE ID IN (?)${lockSuffix}`,
  104. [affectedIds],
  105. );
  106. const [allRows] = await connection.query(
  107. `SELECT ID, HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
  108. PeomContent, Translation, Flag
  109. FROM AncientPoetry${lockSuffix}`,
  110. );
  111. const [books] = await connection.query(
  112. `SELECT ID, BookIDOld, BookName, UnitNum, WordNum
  113. FROM MiaoguoBook
  114. WHERE ID IN (?)${lockSuffix}`,
  115. [metadataIds],
  116. );
  117. const [targetRows] = await connection.query(
  118. `SELECT ID, HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
  119. PeomContent, Translation, Flag
  120. FROM AncientPoetry
  121. WHERE HanziBookID IN (?)${lockSuffix}`,
  122. [targetBookIdsOld],
  123. );
  124. return { rows, allRows, books, targetRows };
  125. }
  126. function materialize(state) {
  127. const allById = new Map(state.allRows.map((row) => [row.ID, row]));
  128. const allByTitle = new Map();
  129. for (const row of state.allRows) {
  130. const matches = allByTitle.get(row.Title) ?? [];
  131. matches.push(row);
  132. allByTitle.set(row.Title, matches);
  133. }
  134. const referencedIds = source.books.flatMap((book) => book.entries)
  135. .filter((entry) => entry.id != null)
  136. .map((entry) => entry.id);
  137. for (const id of [...referencedIds, ...source.detach.map((entry) => entry.id)]) {
  138. assert(allById.has(id), `AncientPoetry.ID=${id} 不存在`);
  139. }
  140. const desiredBooks = source.books.map((book) => ({
  141. ...book,
  142. entries: book.entries.map((entry, index) => {
  143. let current = null;
  144. if (entry.id != null) {
  145. current = allById.get(entry.id);
  146. } else {
  147. const titleMatches = allByTitle.get(entry.title) ?? [];
  148. assert(titleMatches.length <= 1, `新增标题“${entry.title}”在数据库中存在多条记录`);
  149. if (titleMatches.length === 1) {
  150. current = titleMatches[0];
  151. assert.equal(
  152. current.HanziBookID,
  153. book.bookIdOld,
  154. `新增标题“${entry.title}”已存在,但位于 BookIDOld=${current.HanziBookID}`,
  155. );
  156. }
  157. }
  158. return {
  159. source: entry,
  160. current,
  161. desired: {
  162. ID: current?.ID ?? null,
  163. HanziBookID: book.bookIdOld,
  164. Sort: source.sortBase + index,
  165. Category: categoryFor(book, entry),
  166. Tag: tagFor(book, entry),
  167. Title: entry.title ?? current?.Title,
  168. ImgUrl: current?.ImgUrl ?? null,
  169. Author: Object.hasOwn(entry, "author") ? entry.author : normalized(current?.Author),
  170. Dynasty: Object.hasOwn(entry, "dynasty") ? entry.dynasty : normalized(current?.Dynasty),
  171. PeomContent: entry.content != null
  172. ? stringifyContent(entry.content)
  173. : current?.PeomContent,
  174. Translation: current?.Translation ?? null,
  175. Flag: current?.Flag ?? 0,
  176. },
  177. };
  178. }),
  179. }));
  180. for (const book of desiredBooks) {
  181. for (const entry of book.entries) {
  182. assert(entry.desired.Title, `${book.label}/${book.type} 存在空标题`);
  183. assert(entry.desired.PeomContent, `${book.label}/${book.type}/${entry.desired.Title} 存在空正文`);
  184. JSON.parse(entry.desired.PeomContent);
  185. }
  186. }
  187. const currentTargetIds = new Set(state.targetRows.map((row) => row.ID));
  188. const desiredExistingIds = new Set(
  189. desiredBooks.flatMap((book) => book.entries)
  190. .filter((entry) => entry.current)
  191. .map((entry) => entry.current.ID),
  192. );
  193. const detachIds = new Set(source.detach.map((entry) => entry.id));
  194. const unexplainedTargetIds = [...currentTargetIds]
  195. .filter((id) => !desiredExistingIds.has(id) && !detachIds.has(id));
  196. assert.deepEqual(unexplainedTargetIds, [], `目标书存在未处理旧记录:${unexplainedTargetIds.join(",")}`);
  197. const finalBookByExistingId = new Map(state.allRows.map((row) => [row.ID, row.HanziBookID]));
  198. for (const book of desiredBooks) {
  199. for (const entry of book.entries) {
  200. if (entry.current) finalBookByExistingId.set(entry.current.ID, book.bookIdOld);
  201. }
  202. }
  203. for (const entry of source.detach) finalBookByExistingId.set(entry.id, 0);
  204. const expectedCounts = new Map();
  205. for (const bookIdOld of finalBookByExistingId.values()) {
  206. expectedCounts.set(bookIdOld, (expectedCounts.get(bookIdOld) ?? 0) + 1);
  207. }
  208. for (const book of desiredBooks) {
  209. const inserts = book.entries.filter((entry) => !entry.current).length;
  210. expectedCounts.set(book.bookIdOld, (expectedCounts.get(book.bookIdOld) ?? 0) + inserts);
  211. }
  212. for (const metadata of source.metadata) {
  213. assert.equal(
  214. expectedCounts.get(metadata.bookIdOld) ?? 0,
  215. metadata.wordNum,
  216. `BookIDOld=${metadata.bookIdOld} 模拟行数与 metadata.WordNum 不一致`,
  217. );
  218. }
  219. return desiredBooks;
  220. }
  221. const compareFields = [
  222. "HanziBookID",
  223. "Sort",
  224. "Category",
  225. "Tag",
  226. "Title",
  227. "Author",
  228. "Dynasty",
  229. "PeomContent",
  230. "Translation",
  231. "Flag",
  232. ];
  233. function buildPlan(state, desiredBooks) {
  234. const changes = [];
  235. for (const book of desiredBooks) {
  236. for (const entry of book.entries) {
  237. if (!entry.current) {
  238. changes.push({
  239. action: "insert",
  240. bookIdOld: book.bookIdOld,
  241. sort: entry.desired.Sort,
  242. title: entry.desired.Title,
  243. });
  244. continue;
  245. }
  246. const changedFields = compareFields.filter(
  247. (field) => normalized(entry.current[field]) !== normalized(entry.desired[field]),
  248. );
  249. if (changedFields.length > 0) {
  250. changes.push({
  251. action: entry.current.HanziBookID === entry.desired.HanziBookID ? "update" : "move",
  252. id: entry.current.ID,
  253. fromBookIdOld: entry.current.HanziBookID,
  254. toBookIdOld: entry.desired.HanziBookID,
  255. sort: entry.desired.Sort,
  256. fromTitle: entry.current.Title,
  257. toTitle: entry.desired.Title,
  258. changedFields,
  259. });
  260. }
  261. }
  262. }
  263. for (const entry of source.detach) {
  264. const current = state.allRows.find((row) => row.ID === entry.id);
  265. if (current.HanziBookID !== 0) {
  266. changes.push({
  267. action: "detach",
  268. id: entry.id,
  269. fromBookIdOld: current.HanziBookID,
  270. toBookIdOld: 0,
  271. title: current.Title,
  272. changedFields: ["HanziBookID"],
  273. });
  274. }
  275. }
  276. const booksById = new Map(state.books.map((book) => [book.ID, book]));
  277. const metadataChanges = source.metadata.map((desired) => {
  278. const current = booksById.get(desired.miaoguoBookId);
  279. assert(current, `MiaoguoBook.ID=${desired.miaoguoBookId} 不存在`);
  280. assert.equal(current.BookIDOld, desired.bookIdOld, `MiaoguoBook.ID=${desired.miaoguoBookId} 映射不一致`);
  281. return {
  282. miaoguoBookId: desired.miaoguoBookId,
  283. bookIdOld: desired.bookIdOld,
  284. from: { unitNum: current.UnitNum, wordNum: current.WordNum },
  285. to: { unitNum: desired.unitNum, wordNum: desired.wordNum },
  286. changed: current.UnitNum !== desired.unitNum || current.WordNum !== desired.wordNum,
  287. };
  288. });
  289. return {
  290. generatedAt: new Date().toISOString(),
  291. mode,
  292. sourceVersion: source.version,
  293. summary: {
  294. inserts: changes.filter((entry) => entry.action === "insert").length,
  295. updates: changes.filter((entry) => entry.action === "update").length,
  296. moves: changes.filter((entry) => entry.action === "move").length,
  297. detaches: changes.filter((entry) => entry.action === "detach").length,
  298. metadataUpdates: metadataChanges.filter((entry) => entry.changed).length,
  299. databaseDeletes: 0,
  300. },
  301. books: desiredBooks.map((book) => ({
  302. miaoguoBookId: book.miaoguoBookId,
  303. bookIdOld: book.bookIdOld,
  304. label: book.label,
  305. type: book.type,
  306. finalRows: book.entries.length,
  307. titles: book.entries.map((entry) => entry.desired.Title),
  308. })),
  309. changes,
  310. metadataChanges,
  311. };
  312. }
  313. async function applyPlan(connection, desiredBooks) {
  314. const inserted = [];
  315. const updated = [];
  316. const detached = [];
  317. for (const entry of source.detach) {
  318. const [result] = await connection.query(
  319. "UPDATE AncientPoetry SET HanziBookID=0 WHERE ID=? AND HanziBookID<>0",
  320. [entry.id],
  321. );
  322. if (result.affectedRows > 0) detached.push(entry.id);
  323. }
  324. for (const book of desiredBooks) {
  325. for (const entry of book.entries) {
  326. const row = entry.desired;
  327. if (entry.current) {
  328. const [result] = await connection.query(
  329. `UPDATE AncientPoetry
  330. SET HanziBookID=?, Sort=?, Category=?, Tag=?, Title=?, Author=?, Dynasty=?,
  331. PeomContent=?, Translation=?, Flag=?
  332. WHERE ID=?`,
  333. [
  334. row.HanziBookID,
  335. row.Sort,
  336. row.Category,
  337. row.Tag,
  338. row.Title,
  339. row.Author,
  340. row.Dynasty,
  341. row.PeomContent,
  342. row.Translation,
  343. row.Flag,
  344. entry.current.ID,
  345. ],
  346. );
  347. assert.equal(result.affectedRows, 1, `更新 AncientPoetry.ID=${entry.current.ID} 失败`);
  348. updated.push(entry.current.ID);
  349. } else {
  350. const [result] = await connection.query(
  351. `INSERT INTO AncientPoetry
  352. (HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
  353. PeomContent, Translation, Flag)
  354. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  355. [
  356. row.HanziBookID,
  357. row.Sort,
  358. row.Category,
  359. row.Tag,
  360. row.Title,
  361. row.ImgUrl,
  362. row.Author,
  363. row.Dynasty,
  364. row.PeomContent,
  365. row.Translation,
  366. row.Flag,
  367. ],
  368. );
  369. inserted.push({ id: result.insertId, bookIdOld: row.HanziBookID, title: row.Title });
  370. }
  371. }
  372. }
  373. for (const metadata of source.metadata) {
  374. const [result] = await connection.query(
  375. "UPDATE MiaoguoBook SET UnitNum=?, WordNum=? WHERE ID=? AND BookIDOld=?",
  376. [metadata.unitNum, metadata.wordNum, metadata.miaoguoBookId, metadata.bookIdOld],
  377. );
  378. assert.equal(result.affectedRows, 1, `更新 MiaoguoBook.ID=${metadata.miaoguoBookId} 失败`);
  379. }
  380. return { inserted, updated, detached };
  381. }
  382. async function audit(connection) {
  383. const failures = [];
  384. const bookResults = [];
  385. const entryByExistingId = new Map(
  386. source.books.flatMap((book) => book.entries)
  387. .filter((entry) => entry.id != null)
  388. .map((entry) => [entry.id, entry]),
  389. );
  390. for (const book of source.books) {
  391. const [rows] = await connection.query(
  392. `SELECT ID, HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
  393. PeomContent, Translation, Flag
  394. FROM AncientPoetry
  395. WHERE HanziBookID=?
  396. ORDER BY Sort, ID`,
  397. [book.bookIdOld],
  398. );
  399. const expectedSorts = book.entries.map((_, index) => source.sortBase + index);
  400. const actualSorts = rows.map((row) => row.Sort);
  401. if (rows.length !== book.entries.length) {
  402. failures.push(`${book.label}/${book.type} 行数 ${rows.length} != ${book.entries.length}`);
  403. }
  404. if (JSON.stringify(actualSorts) !== JSON.stringify(expectedSorts)) {
  405. failures.push(`${book.label}/${book.type} Sort 不连续:${actualSorts.join(",")}`);
  406. }
  407. const rowById = new Map(rows.map((row) => [row.ID, row]));
  408. const rowByTitle = new Map(rows.map((row) => [row.Title, row]));
  409. const checked = [];
  410. book.entries.forEach((entry, index) => {
  411. const row = entry.id != null ? rowById.get(entry.id) : rowByTitle.get(entry.title);
  412. if (!row) {
  413. failures.push(`${book.label}/${book.type} 缺少 ${entry.title ?? `ID=${entry.id}`}`);
  414. return;
  415. }
  416. const expectedTitle = entry.title ?? row.Title;
  417. const expectedCategory = categoryFor(book, entry);
  418. const expectedTag = tagFor(book, entry);
  419. if (row.HanziBookID !== book.bookIdOld) failures.push(`ID=${row.ID} HanziBookID 错误`);
  420. if (row.Sort !== source.sortBase + index) failures.push(`ID=${row.ID} Sort 错误`);
  421. if (row.Category !== expectedCategory) failures.push(`ID=${row.ID} Category 错误`);
  422. if (row.Tag !== expectedTag) failures.push(`ID=${row.ID} Tag 错误`);
  423. if (row.Title !== expectedTitle) failures.push(`ID=${row.ID} Title 错误`);
  424. try {
  425. JSON.parse(row.PeomContent);
  426. } catch {
  427. failures.push(`ID=${row.ID} PeomContent 不是合法 JSON`);
  428. }
  429. if (entry.content != null && row.PeomContent !== stringifyContent(entry.content)) {
  430. failures.push(`ID=${row.ID} PeomContent 与来源不一致`);
  431. }
  432. if (Object.hasOwn(entry, "author") && normalized(row.Author) !== normalized(entry.author)) {
  433. failures.push(`ID=${row.ID} Author 与来源不一致`);
  434. }
  435. if (Object.hasOwn(entry, "dynasty") && normalized(row.Dynasty) !== normalized(entry.dynasty)) {
  436. failures.push(`ID=${row.ID} Dynasty 与来源不一致`);
  437. }
  438. if (entry.id == null && row.Translation != null) failures.push(`新增 ID=${row.ID} Translation 应为空`);
  439. checked.push({ id: row.ID, sort: row.Sort, title: row.Title });
  440. });
  441. if (new Set(rows.map((row) => row.Title)).size !== rows.length) {
  442. failures.push(`${book.label}/${book.type} 存在重复标题`);
  443. }
  444. bookResults.push({
  445. miaoguoBookId: book.miaoguoBookId,
  446. bookIdOld: book.bookIdOld,
  447. label: book.label,
  448. type: book.type,
  449. rows: rows.length,
  450. checked,
  451. });
  452. }
  453. const [detachedRows] = await connection.query(
  454. "SELECT ID, HanziBookID, Title FROM AncientPoetry WHERE ID IN (?) ORDER BY ID",
  455. [source.detach.map((entry) => entry.id)],
  456. );
  457. for (const entry of source.detach) {
  458. const row = detachedRows.find((candidate) => candidate.ID === entry.id);
  459. if (!row) failures.push(`detach ID=${entry.id} 不存在`);
  460. else if (row.HanziBookID !== 0) failures.push(`detach ID=${entry.id} HanziBookID=${row.HanziBookID}`);
  461. }
  462. const [books] = await connection.query(
  463. "SELECT ID, BookIDOld, UnitNum, WordNum FROM MiaoguoBook WHERE ID IN (?) ORDER BY ID",
  464. [source.metadata.map((entry) => entry.miaoguoBookId)],
  465. );
  466. for (const expected of source.metadata) {
  467. const book = books.find((candidate) => candidate.ID === expected.miaoguoBookId);
  468. if (!book) failures.push(`MiaoguoBook.ID=${expected.miaoguoBookId} 不存在`);
  469. else {
  470. if (book.BookIDOld !== expected.bookIdOld) failures.push(`MiaoguoBook.ID=${book.ID} BookIDOld 错误`);
  471. if (book.UnitNum !== expected.unitNum) failures.push(`MiaoguoBook.ID=${book.ID} UnitNum 错误`);
  472. if (book.WordNum !== expected.wordNum) failures.push(`MiaoguoBook.ID=${book.ID} WordNum 错误`);
  473. const [[countRow]] = await connection.query(
  474. "SELECT COUNT(*) AS count FROM AncientPoetry WHERE HanziBookID=?",
  475. [expected.bookIdOld],
  476. );
  477. if (countRow.count !== expected.wordNum) {
  478. failures.push(`BookIDOld=${expected.bookIdOld} 实际行数 ${countRow.count} != WordNum ${expected.wordNum}`);
  479. }
  480. }
  481. }
  482. return {
  483. generatedAt: new Date().toISOString(),
  484. sourceVersion: source.version,
  485. ok: failures.length === 0,
  486. failures,
  487. books: bookResults,
  488. detached: detachedRows,
  489. metadata: books,
  490. sourceExistingIds: [...entryByExistingId.keys()].length,
  491. };
  492. }
  493. validateSource();
  494. const connection = await mysql.createConnection({ ...config.database, charset: "utf8mb4" });
  495. try {
  496. if (mode === "audit") {
  497. const auditResult = await audit(connection);
  498. const auditPath = path.join(taskDir, "audit_ancient_daily_2026_fall.json");
  499. writeJson(auditPath, auditResult);
  500. assert(auditResult.ok, auditResult.failures.join("\n"));
  501. console.log(JSON.stringify({ mode, auditPath, ok: true, books: auditResult.books.length }));
  502. process.exitCode = 0;
  503. } else if (mode === "dry-run") {
  504. const state = await readState(connection);
  505. const desiredBooks = materialize(state);
  506. const plan = buildPlan(state, desiredBooks);
  507. const reportPath = path.join(taskDir, "dry_run_ancient_daily_2026_fall.json");
  508. writeJson(reportPath, plan);
  509. console.log(JSON.stringify({ mode, reportPath, summary: plan.summary }));
  510. } else {
  511. const backupState = await readState(connection);
  512. const backupPath = path.join(taskDir, "backups", `before_ancient_daily_2026_fall_${nowForFile()}.json`);
  513. fs.mkdirSync(path.dirname(backupPath), { recursive: true });
  514. writeJson(backupPath, { capturedAt: new Date().toISOString(), sourceVersion: source.version, ...backupState });
  515. await connection.beginTransaction();
  516. try {
  517. const lockedState = await readState(connection, true);
  518. const desiredBooks = materialize(lockedState);
  519. const plan = buildPlan(lockedState, desiredBooks);
  520. const applied = await applyPlan(connection, desiredBooks);
  521. const auditResult = await audit(connection);
  522. assert(auditResult.ok, auditResult.failures.join("\n"));
  523. await connection.commit();
  524. const reportPath = path.join(taskDir, "apply_ancient_daily_2026_fall.json");
  525. writeJson(reportPath, {
  526. appliedAt: new Date().toISOString(),
  527. sourceVersion: source.version,
  528. backupPath,
  529. plan,
  530. applied,
  531. audit: auditResult,
  532. });
  533. console.log(JSON.stringify({
  534. mode,
  535. reportPath,
  536. backupPath,
  537. summary: plan.summary,
  538. insertedIds: applied.inserted.map((entry) => entry.id),
  539. auditOk: auditResult.ok,
  540. }));
  541. } catch (error) {
  542. await connection.rollback();
  543. throw error;
  544. }
  545. }
  546. } finally {
  547. await connection.end();
  548. }