sqlite-engine-probe.mjs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. /**
  2. * SQLite 引擎能力探针
  3. *
  4. * 工作台库用 node:sqlite 的 DatabaseSync,而 bun 编译产物里没有这个内置模块,只有 bun:sqlite。
  5. * 本脚本用同一组断言分别在两个引擎上跑,输出能力矩阵,用来判断适配层是否可行、差异在哪。
  6. *
  7. * 用法:
  8. * node scripts/sqlite-engine-probe.mjs
  9. * bun scripts/sqlite-engine-probe.mjs
  10. */
  11. import fs from 'node:fs';
  12. import os from 'node:os';
  13. import path from 'node:path';
  14. const isBun = typeof globalThis.Bun !== 'undefined';
  15. const engine = isBun ? 'bun:sqlite' : 'node:sqlite';
  16. async function openDatabase(file, options = {}) {
  17. if (isBun) {
  18. const { Database } = await import('bun:sqlite');
  19. // bun 用全小写 readonly,node 用驼峰 readOnly;且传空对象会因为没有任何 open flag 报 SQLITE_MISUSE
  20. return new Database(file, options.readOnly ? { readonly: true } : { create: true, readwrite: true });
  21. }
  22. const { DatabaseSync } = await import('node:sqlite');
  23. return new DatabaseSync(file, options.readOnly ? { readOnly: true } : {});
  24. }
  25. const results = [];
  26. function check(name, fn) {
  27. try {
  28. const detail = fn();
  29. results.push({ name, ok: true, detail: detail === undefined ? '' : String(detail) });
  30. } catch (error) {
  31. results.push({ name, ok: false, detail: error.message.slice(0, 120) });
  32. }
  33. }
  34. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sqlite-probe-'));
  35. const file = path.join(dir, 'probe.db');
  36. const db = await openDatabase(file);
  37. check('exec 多语句 PRAGMA', () => {
  38. db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;');
  39. });
  40. check('WAL 已生效', () => {
  41. const row = db.prepare('PRAGMA journal_mode').get();
  42. const mode = String(row.journal_mode || row['journal_mode'] || '').toLowerCase();
  43. if (mode !== 'wal') throw new Error(`journal_mode=${mode}`);
  44. return mode;
  45. });
  46. check('busy_timeout 已设置', () => {
  47. const row = db.prepare('PRAGMA busy_timeout').get();
  48. const value = Number(row.timeout ?? row.busy_timeout ?? 0);
  49. if (value !== 5000) throw new Error(`busy_timeout=${value}`);
  50. return value;
  51. });
  52. check('建表含 CHECK 与外键', () => {
  53. db.exec(`
  54. CREATE TABLE parent (
  55. id TEXT PRIMARY KEY,
  56. mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','human')),
  57. created_at TEXT NOT NULL
  58. );
  59. CREATE TABLE child (
  60. id TEXT PRIMARY KEY,
  61. parent_id TEXT NOT NULL REFERENCES parent(id) ON DELETE CASCADE,
  62. body TEXT NOT NULL
  63. );
  64. `);
  65. });
  66. check('run 位置参数并返回 changes', () => {
  67. const result = db.prepare('INSERT INTO parent(id,mode,created_at) VALUES(?,?,?)')
  68. .run('p1', 'review', new Date().toISOString());
  69. if (Number(result.changes) !== 1) throw new Error(`changes=${result.changes}`);
  70. return `changes=${result.changes} lastInsertRowid=${result.lastInsertRowid}`;
  71. });
  72. check('get 命中返回行对象', () => {
  73. const row = db.prepare('SELECT * FROM parent WHERE id=?').get('p1');
  74. if (!row || row.mode !== 'review') throw new Error(JSON.stringify(row));
  75. return `mode=${row.mode}`;
  76. });
  77. check('get 未命中的返回值', () => {
  78. const row = db.prepare('SELECT * FROM parent WHERE id=?').get('missing');
  79. // 生产代码写法是 .get(...)?.value ?? fallback,null 与 undefined 都能走通
  80. if (row !== undefined && row !== null) throw new Error(`返回了 ${JSON.stringify(row)}`);
  81. return row === null ? 'null' : 'undefined';
  82. });
  83. check('all 返回数组', () => {
  84. const rows = db.prepare('SELECT * FROM parent').all();
  85. if (!Array.isArray(rows) || rows.length !== 1) throw new Error(`len=${rows?.length}`);
  86. return `len=${rows.length}`;
  87. });
  88. check('PRAGMA table_info 走 prepare.all', () => {
  89. const rows = db.prepare('PRAGMA table_info(parent)').all();
  90. const names = rows.map(item => item.name);
  91. if (!names.includes('mode')) throw new Error(names.join(','));
  92. return names.join(',');
  93. });
  94. check('CHECK 约束生效', () => {
  95. try {
  96. db.prepare('INSERT INTO parent(id,mode,created_at) VALUES(?,?,?)').run('bad', 'illegal', 'now');
  97. } catch {
  98. return '违规插入已被拒绝';
  99. }
  100. throw new Error('CHECK 未拦截非法值');
  101. });
  102. check('外键约束生效', () => {
  103. try {
  104. db.prepare('INSERT INTO child(id,parent_id,body) VALUES(?,?,?)').run('c1', 'nonexistent', 'x');
  105. } catch {
  106. return '孤儿行已被拒绝';
  107. }
  108. throw new Error('外键未拦截孤儿行');
  109. });
  110. check('显式事务 COMMIT', () => {
  111. db.exec('BEGIN');
  112. db.prepare('INSERT INTO parent(id,mode,created_at) VALUES(?,?,?)').run('p2', 'auto', 'now');
  113. db.exec('COMMIT');
  114. const row = db.prepare('SELECT * FROM parent WHERE id=?').get('p2');
  115. if (!row) throw new Error('提交后查不到');
  116. });
  117. check('显式事务 ROLLBACK', () => {
  118. db.exec('BEGIN');
  119. db.prepare('INSERT INTO parent(id,mode,created_at) VALUES(?,?,?)').run('p3', 'auto', 'now');
  120. db.exec('ROLLBACK');
  121. const row = db.prepare('SELECT * FROM parent WHERE id=?').get('p3');
  122. if (row) throw new Error('回滚后仍查得到');
  123. });
  124. check('ON CONFLICT DO UPDATE', () => {
  125. db.exec('CREATE TABLE settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL)');
  126. const sql = `INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)
  127. ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`;
  128. db.prepare(sql).run('k', 'v1', 'now');
  129. db.prepare(sql).run('k', 'v2', 'later');
  130. const row = db.prepare('SELECT value FROM settings WHERE key=?').get('k');
  131. if (row.value !== 'v2') throw new Error(`value=${row.value}`);
  132. return 'upsert 生效';
  133. });
  134. check('WAL 附属文件已落盘', () => {
  135. const walFile = `${file}-wal`;
  136. if (!fs.existsSync(walFile)) throw new Error('未生成 -wal 文件');
  137. return path.basename(walFile);
  138. });
  139. let readOnlyDetail = '';
  140. try {
  141. const ro = await openDatabase(file, { readOnly: true });
  142. const row = ro.prepare('SELECT COUNT(*) AS n FROM parent').get();
  143. readOnlyDetail = `可读,count=${row.n}`;
  144. try {
  145. ro.prepare('INSERT INTO parent(id,mode,created_at) VALUES(?,?,?)').run('ro', 'auto', 'now');
  146. readOnlyDetail += ';但写入未被拒绝';
  147. results.push({ name: '只读模式', ok: false, detail: readOnlyDetail });
  148. } catch {
  149. results.push({ name: '只读模式', ok: true, detail: `${readOnlyDetail};写入已拒绝` });
  150. }
  151. ro.close();
  152. } catch (error) {
  153. results.push({ name: '只读模式', ok: false, detail: error.message.slice(0, 120) });
  154. }
  155. db.close();
  156. const failed = results.filter(item => !item.ok);
  157. process.stdout.write(`\n引擎:${engine}(${isBun ? `bun ${Bun.version}` : `node ${process.version}`})\n\n`);
  158. for (const item of results) {
  159. process.stdout.write(` ${item.ok ? '[ok] ' : '[FAIL]'} ${item.name}${item.detail ? ` — ${item.detail}` : ''}\n`);
  160. }
  161. process.stdout.write(`\n${results.length - failed.length}/${results.length} 通过\n`);
  162. // Windows 下句柄可能尚未完全释放,清理失败不影响探测结论
  163. try {
  164. fs.rmSync(dir, { recursive: true, force: true });
  165. } catch {
  166. process.stdout.write(`(临时目录未能清理,可手动删除:${dir})\n`);
  167. }
  168. process.exit(failed.length ? 1 : 0);