diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 34c79ff..9af3512 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -2,27 +2,27 @@ name: Node.js Build on: push: - branches: [ "main", "master" ] + branches: ["main", "master"] pull_request: - branches: [ "main", "master" ] + branches: ["main", "master"] jobs: build: - runs-on: docker # 匹配你 runner 的标签 + runs-on: docker # 匹配你 runner 的标签 steps: - name: Checkout code uses: actions/checkout@v4 + - name: Setup npm for Verdaccio + run: | + npm config set registry http://verdaccio:4873/ + npm config set //verdaccio:4873/:_authToken ${{ secrets.NPM_TOKEN }} + - name: Install dependencies - run: npm i # 或 npm install + run: npm i --registry http://verdaccio:4873/ # 或 npm install - name: Run build run: npm run build - - - name: Setup npm for Verdaccio - run: | - npm config set registry http://verdaccio:4873/ - npm config set //verdaccio:4873/:_authToken ${{ secrets.NPM_TOKEN }} - name: Publish package - run: npm publish \ No newline at end of file + run: npm publish diff --git a/.gitignore b/.gitignore index 2d8e09c..a4deb1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,15 @@ # Dependencies node_modules/ +package-lock.json # Build output dist/ +# SQLite database files +*.db +*.db-wal +*.db-shm + # OS files .DS_Store Thumbs.db diff --git a/.memory-bank/2026-06-06-session.md b/.memory-bank/2026-06-06-session.md new file mode 100644 index 0000000..946654a --- /dev/null +++ b/.memory-bank/2026-06-06-session.md @@ -0,0 +1,123 @@ +# Windychen Utils 项目开发记录 + +**日期**: 2026-06-06 +**项目**: windychen-untils (v1.0.1) +**类型**: Storage 模块跨环境兼容改造 + +--- + +## 一、本次需求 + +将 `storage.js` 从**仅浏览器环境**扩展为**浏览器 + Node.js 双环境**可用,并在 Node.js 端使用 SQLite 持久化。 + +--- + +## 二、新增存储类 + +### 1. MemoryStorage(通用兜底) + +| 方法 | 功能 | 后端 | +| ---------------------- | ------------ | ----- | +| `get(key, initValue?)` | 从内存获取值 | `Map` | +| `set(key, value)` | 写入内存 | `Map` | + +- 纯内存,进程退出即丢失 +- 所有环境通用,作为最低优先级兜底 + +### 2. SqliteStorage(Node.js 专用) + +| 方法 | 功能 | 后端 | +| ---------------------- | -------------- | ---------------- | +| `get(key, initValue?)` | 从 SQLite 查询 | `better-sqlite3` | +| `set(key, value)` | 写入 SQLite | `better-sqlite3` | + +**实现细节:** + +```javascript +// 表结构 +CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT) + +// set: INSERT OR REPLACE,value 经 JSON.stringify +// get: SELECT value WHERE key = ?,结果经 JSON.parse +``` + +**优化点:** + +- WAL 模式(`PRAGMA journal_mode = WAL`)提升并发读写性能 +- 预编译语句(`db.prepare().get/run`)避免重复解析 SQL +- 延迟 require:`_SqliteStorageClass` 工厂函数 + try-catch 包装,使 better-sqlite3 在浏览器/webpack 环境中静默降级为 null + +--- + +## 三、环境自动选择策略(更新后) + +``` +浏览器:IndexedDB → localStorage → MemoryStorage +Node.js:SqliteStorage → MemoryStorage +``` + +**改动文件:** + +| 文件 | 变更 | +| -------------- | ------------------------------------------------------------------ | +| `storage.js` | +MemoryStorage 类,+SqliteStorage 类,更新导出逻辑 | +| `index.js` | 移除 `try { require('./storage') } catch (e) {}`,改为直接 require | +| `test.js` | 新增 5 条 Storage 异步测试(对象/字符串/数字/数组/默认值) | +| `.gitignore` | 新增 `*.db` `*.db-wal` `*.db-shm` | +| `package.json` | 新增 `better-sqlite3` 依赖 | + +--- + +## 四、演进过程 + +1. **第一版**:`FileStorage`(每 key 一个 JSON 文件,`fs.promises` 读写) +2. **TS 报错**:async 方法在 try-catch 块内部定义类导致解析异常 → 提取为 `_FileStorageClass` 工厂函数 +3. **用户反馈**:改用 SQLite 而非 JSON 文件存储 +4. **最终方案**:`SqliteStorage`,better-sqlite3 同步 API + WAL 模式 + +--- + +## 五、数据文件 + +``` +.windychen-storage/ +├── storage.db # 主数据库 +├── storage.db-shm # WAL 共享内存 +└── storage.db-wal # WAL 日志 +``` + +--- + +## 六、测试结果 + +``` +共 21 项,21 通过,0 失败 + +─── Object.getPro ─── (4 项) +─── Object.setPro ─── (2 项) +─── Array.findPro ─── (3 项) +─── Array.filterPro ─── (2 项) +─── Array.findIndexPro ─── (1 项) +─── Array.findLastPro ─── (1 项) +─── Array.somePro / everyPro ─── (3 项) +─── Storage ─── (5 项) +``` + +--- + +## 七、关键决策记录 + +1. **SQLite 选型**: 选择 better-sqlite3(原生编译、同步 API)而非 sql.js(纯 JS),优先性能 +2. **降级策略**: Node.js → SqliteStorage → MemoryStorage,与浏览器端 IndexedDB → localStorage → MemoryStorage 保持一致的链式降级模式 +3. **JSON 序列化**: 所有值通过 `JSON.stringify/parse` 统一存取,保证类型可靠性 +4. **WAL 模式**: 启用 Write-Ahead Logging,避免写阻塞读 + +--- + +## 八、待改进方向 + +- [ ] Storage 模块添加 delete/clear 方法 +- [ ] 添加单元测试框架(如 Jest/Vitest) +- [ ] 支持 TypeScript 类型声明 (.d.ts) +- [ ] 考虑添加更多工具方法(日期、字符串、数字等) +- [ ] CI/CD 自动化构建与发布 diff --git a/index.js b/index.js index 0e6909b..66515dc 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,3 @@ -require('./object'); -require('./array'); - -try { require('./storage'); } catch (e) {} // 仅浏览器环境生效 +require("./object"); +require("./array"); +require("./storage"); diff --git a/package.json b/package.json index 7225817..e9cb50a 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,8 @@ "devDependencies": { "webpack": "^5.90.0", "webpack-cli": "^5.1.4" + }, + "dependencies": { + "better-sqlite3": "^12.10.0" } -} \ No newline at end of file +} diff --git a/storage.js b/storage.js index b247bb1..333c315 100644 --- a/storage.js +++ b/storage.js @@ -7,7 +7,9 @@ var log = console.warn; * @returns {boolean} * @private */ -var isString = function (str) { return Object.getTypeString(str) === 'String'; }; +var isString = function (str) { + return Object.getTypeString(str) === "String"; +}; /** * IndexedDB 存储实现(大容量存储优先选择) @@ -29,24 +31,37 @@ class IDBStorage { /** @type {number} 数据库版本号 */ this.version = version || 1; /** @type {string} 数据库名称 */ - this.dbName = dbName || 'dbName'; + this.dbName = dbName || "dbName"; /** @type {string} 对象存储名称 */ - this.storeName = storeName || 'storeName'; + this.storeName = storeName || "storeName"; var request = indexedDB.open(this.dbName, this.version); /** @type {Promise} 数据库连接 Promise */ - this.requestPromise = new Promise(function (resolve, reject) { - request.onupgradeneeded = function (e) { - if (e && e.target && !request.result.objectStoreNames.contains(this.storeName)) { - request.result.createObjectStore(this.storeName, { keyPath: 'key' }) - .createIndex('key', 'key', { unique: false }); - } - log('[Index][open]-upgrade'); - resolve(request.result); - }.bind(this); - request.onsuccess = function () { log('[IndexDB][open]-success'); resolve(request.result); }; - request.onerror = function (e) { log('[IndexDB][open]-error', 2); reject(e); }; - }.bind(this)); + this.requestPromise = new Promise( + function (resolve, reject) { + request.onupgradeneeded = function (e) { + if ( + e && + e.target && + !request.result.objectStoreNames.contains(this.storeName) + ) { + request.result + .createObjectStore(this.storeName, { keyPath: "key" }) + .createIndex("key", "key", { unique: false }); + } + log("[Index][open]-upgrade"); + resolve(request.result); + }.bind(this); + request.onsuccess = function () { + log("[IndexDB][open]-success"); + resolve(request.result); + }; + request.onerror = function (e) { + log("[IndexDB][open]-error", 2); + reject(e); + }; + }.bind(this), + ); } /** @@ -60,11 +75,16 @@ class IDBStorage { var _this = this; return new Promise(async function (resolve) { var db = await _this.requestPromise; - var transaction = db.transaction(_this.storeName, 'readonly'); + var transaction = db.transaction(_this.storeName, "readonly"); var objectStore = transaction.objectStore(_this.storeName); var req = objectStore.get(key); - req.onsuccess = function () { resolve(req.result ? req.result.value : (initValue || null)); }; - req.onerror = function (e) { log('[indexDB][get]-error', 2, e); resolve(initValue || null); }; + req.onsuccess = function () { + resolve(req.result ? req.result.value : initValue || null); + }; + req.onerror = function (e) { + log("[indexDB][get]-error", 2, e); + resolve(initValue || null); + }; }); } @@ -79,11 +99,16 @@ class IDBStorage { var _this = this; return new Promise(async function (resolve) { var db = await _this.requestPromise; - var transaction = db.transaction(_this.storeName, 'readwrite'); + var transaction = db.transaction(_this.storeName, "readwrite"); var objectStore = transaction.objectStore(_this.storeName); var req = objectStore.put({ key: key, value: value }); - req.onsuccess = function () { resolve(value); }; - req.onerror = function (e) { log('[indexDB][set]-error', 2, e); resolve(value); }; + req.onsuccess = function () { + resolve(value); + }; + req.onerror = function (e) { + log("[indexDB][set]-error", 2, e); + resolve(value); + }; }); } } @@ -108,11 +133,12 @@ class ILocalStorage { load() { var _this = this; Object.entries(localStorage).forEach(function (entry) { - var key = entry[0], value = entry[1]; + var key = entry[0], + value = entry[1]; try { _this.cache.set(key, JSON.parse(value)); } catch (error) { - log('[localStorage][load]-error', 2, error, key, value); + log("[localStorage][load]-error", 2, error, key, value); } }); } @@ -125,9 +151,13 @@ class ILocalStorage { * @returns {Promise} 缓存的值或默认值 */ get(key, initValue) { - return new Promise(function (resolve) { - resolve(this.cache.get(key) !== undefined ? this.cache.get(key) : initValue); - }.bind(this)); + return new Promise( + function (resolve) { + resolve( + this.cache.get(key) !== undefined ? this.cache.get(key) : initValue, + ); + }.bind(this), + ); } /** @@ -138,31 +168,159 @@ class ILocalStorage { * @returns {Promise} 返回写入的值 */ set(key, value) { - return new Promise(function (resolve) { - var _value = isString(value) ? value : ''; - try { - _value = JSON.stringify(value); - } catch (e) { - log('[localStorage][set]-error', 2, e); - } - localStorage.setItem(key, _value); - this.cache.set(key, _value); - resolve(value); - }.bind(this)); + return new Promise( + function (resolve) { + var _value = isString(value) ? value : ""; + try { + _value = JSON.stringify(value); + } catch (e) { + log("[localStorage][set]-error", 2, e); + } + localStorage.setItem(key, _value); + this.cache.set(key, _value); + resolve(value); + }.bind(this), + ); } } -// 浏览器环境自动选择存储方式 -if (typeof window !== 'undefined') { +/** + * 纯内存存储实现(无持久化,适用于所有环境兜底) + * + * @class MemoryStorage + * @example + * const storage = new MemoryStorage(); + * await storage.set('key', { data: 789 }); + * await storage.get('key', null); + */ +class MemoryStorage { + constructor() { + /** @type {Map} 内存缓存 */ + this.cache = new Map(); + } + /** - * 当前环境可用的存储类(IDBStorage 或 ILocalStorage) - * @type {Function|null} - * @global + * 从内存获取值 + * @template T + * @param {string} key - 键名 + * @param {T} initValue - 默认值 + * @returns {Promise} 缓存的值或默认值 */ - window.IStorage = window.indexedDB ? IDBStorage : (window.localStorage ? ILocalStorage : null); - if (typeof module !== 'undefined') { + get(key, initValue) { + return Promise.resolve( + this.cache.has(key) + ? this.cache.get(key) + : initValue !== undefined + ? initValue + : null, + ); + } + + /** + * 写入内存 + * @template T + * @param {string} key - 键名 + * @param {T} value - 要存储的值 + * @returns {Promise} 返回写入的值 + */ + set(key, value) { + this.cache.set(key, value); + return Promise.resolve(value); + } +} + +/** + * Node.js SQLite 持久化存储实现 + * 仅在 Node.js 环境下可用,浏览器/webpack 打包中为 null + * + * @class SqliteStorage + * @example + * const storage = new SqliteStorage('./data/storage.db'); + * await storage.set('key', { data: 123 }); + * await storage.get('key', null); + * @private + */ +var _SqliteStorageClass = function () { + var Database = require("better-sqlite3"); + var pathModule = require("path"); + var fs = require("fs"); + + return class SqliteStorage { + /** + * 创建 SQLite 存储实例 + * @param {string} [dbPath='.windychen-storage/storage.db'] - 数据库文件路径 + */ + constructor(dbPath) { + this.dbPath = + dbPath || pathModule.resolve(".windychen-storage", "storage.db"); + var dir = pathModule.dirname(this.dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + this.db = new Database(this.dbPath); + this.db.pragma("journal_mode = WAL"); + this.db.exec( + "CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT)", + ); + } + + /** + * 获取存储值 + * @template T + * @param {string} key - 键名 + * @param {T} initValue - 默认值(未找到时返回) + * @returns {Promise} 存储的值或默认值 + */ + async get(key, initValue) { + var row = this.db + .prepare("SELECT value FROM kv_store WHERE key = ?") + .get(key); + if (row) { + try { + return JSON.parse(row.value); + } catch (e) { + log("[SqliteStorage][get]-parse-error", 2, e); + } + } + return initValue !== undefined ? initValue : null; + } + + /** + * 设置存储值 + * @template T + * @param {string} key - 键名 + * @param {T} value - 要存储的值 + * @returns {Promise} 返回写入的值 + */ + async set(key, value) { + var json = JSON.stringify(value); + this.db + .prepare("INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)") + .run(key, json); + return value; + } + }; +}; + +var SqliteStorage = null; +try { + SqliteStorage = _SqliteStorageClass(); +} catch (e) { + // better-sqlite3 不可用(浏览器 / webpack 打包环境),SqliteStorage 保持为 null +} + +// ===== 环境检测与自动导出 ===== +if (typeof window !== "undefined") { + // 浏览器环境:优先 IndexedDB → localStorage → 内存 + window.IStorage = window.indexedDB + ? IDBStorage + : window.localStorage + ? ILocalStorage + : MemoryStorage; + if (typeof module !== "undefined") { module.exports = new window.IStorage(); } -} else if (typeof module !== 'undefined') { - module.exports = null; +} else if (typeof module !== "undefined") { + // Node.js 环境:SQLite 存储 → 内存 + module.exports = SqliteStorage ? new SqliteStorage() : new MemoryStorage(); } diff --git a/test.js b/test.js index ddadf89..c402020 100644 --- a/test.js +++ b/test.js @@ -1,4 +1,4 @@ -require('./index'); +require("./index"); let passed = 0; let failed = 0; @@ -18,50 +18,89 @@ function group(name) { } // ===== Object.getPro ===== -group('Object.getPro'); -const obj = { a: { b: { c: 1 } }, 'x.y': { z: 2 } }; -assert("getPro('a.b.c') === 1", obj.getPro('a.b.c') === 1); -assert("getPro('a.b.d', 'default') === 'default'", obj.getPro('a.b.d', 'default') === 'default'); +group("Object.getPro"); +const obj = { a: { b: { c: 1 } }, "x.y": { z: 2 } }; +assert("getPro('a.b.c') === 1", obj.getPro("a.b.c") === 1); +assert( + "getPro('a.b.d', 'default') === 'default'", + obj.getPro("a.b.d", "default") === "default", +); assert("getPro(\"['x.y'].z\") === 2", obj.getPro("['x.y'].z") === 2); -assert("getPro('missing', 42) === 42", obj.getPro('missing', 42) === 42); +assert("getPro('missing', 42) === 42", obj.getPro("missing", 42) === 42); // ===== Object.setPro ===== -group('Object.setPro'); +group("Object.setPro"); const obj2 = {}; -obj2.setPro('foo.bar.baz', 'hello'); -assert("setPro 嵌套赋值", obj2.foo.bar.baz === 'hello'); +obj2.setPro("foo.bar.baz", "hello"); +assert("setPro 嵌套赋值", obj2.foo.bar.baz === "hello"); obj2.setPro("['a.b'].c", 99); -assert("setPro 特殊键名", obj2['a.b'].c === 99); +assert("setPro 特殊键名", obj2["a.b"].c === 99); // ===== Array Pro 方法 ===== const arr = [ - { user: { name: 'Alice' } }, - { user: { name: 'Bob' } }, + { user: { name: "Alice" } }, + { user: { name: "Bob" } }, null, - { user: { name: 'Charlie' } }, + { user: { name: "Charlie" } }, ]; -group('Array.findPro'); +group("Array.findPro"); assert("回调 findPro(t=>t>1)", [1, 2, 3].findPro((t) => t > 1) === 2); -assert("key-value findPro('user.name','Bob')", arr.findPro('user.name', 'Bob').user.name === 'Bob'); -assert("单值 findPro('cc')", ['aa', 'bb', 'cc'].findPro('cc') === 'cc'); +assert( + "key-value findPro('user.name','Bob')", + arr.findPro("user.name", "Bob").user.name === "Bob", +); +assert("单值 findPro('cc')", ["aa", "bb", "cc"].findPro("cc") === "cc"); -group('Array.filterPro'); -assert("key-value filterPro", arr.filterPro('user.name', 'Alice').length === 1); +group("Array.filterPro"); +assert("key-value filterPro", arr.filterPro("user.name", "Alice").length === 1); assert("单值 filterPro(2)", [1, 2, 2, 3].filterPro(2).length === 2); -group('Array.findIndexPro'); -assert("findIndexPro", arr.findIndexPro('user.name', 'Charlie') === 3); +group("Array.findIndexPro"); +assert("findIndexPro", arr.findIndexPro("user.name", "Charlie") === 3); -group('Array.findLastPro'); -assert("findLastPro", arr.findLastPro('user.name', 'Bob').user.name === 'Bob'); +group("Array.findLastPro"); +assert("findLastPro", arr.findLastPro("user.name", "Bob").user.name === "Bob"); -group('Array.somePro / everyPro'); -assert("somePro", arr.somePro('user.name', 'Alice') === true); -assert("everyPro false", arr.everyPro('user.name', 'Alice') === false); -assert("everyPro true", ['a', 'a'].everyPro('a') === true); +group("Array.somePro / everyPro"); +assert("somePro", arr.somePro("user.name", "Alice") === true); +assert("everyPro false", arr.everyPro("user.name", "Alice") === false); +assert("everyPro true", ["a", "a"].everyPro("a") === true); -// 汇总 -console.log(`\n\x1b[33m共 ${passed + failed} 项,${passed} 通过,${failed} 失败\x1b[0m`); +// ===== Storage(Node.js 环境:FileStorage) ===== +group("Storage"); -process.exit(failed > 0 ? 1 : 0); +var storage = require("./storage"); + +async function testStorage() { + await storage.set("testKey", { msg: "hello node" }); + var val = await storage.get("testKey", null); + assert("storage.set/get (对象)", val && val.msg === "hello node"); + + await storage.set("testKey2", "plain string"); + var val2 = await storage.get("testKey2", null); + assert("storage.set/get (字符串)", val2 === "plain string"); + + var val3 = await storage.get("nonExistent", "defaultVal"); + assert("storage.get 默认值", val3 === "defaultVal"); + + await storage.set("testKeyNum", 42); + var val4 = await storage.get("testKeyNum", 0); + assert("storage.set/get (数字)", val4 === 42); + + await storage.set("testKeyArr", [1, 2, 3]); + var val5 = await storage.get("testKeyArr", []); + assert("storage.set/get (数组)", Array.isArray(val5) && val5.length === 3); + + // 汇总 + console.log( + `\n\x1b[33m共 ${passed + failed} 项,${passed} 通过,${failed} 失败\x1b[0m`, + ); + + process.exit(failed > 0 ? 1 : 0); +} + +testStorage().catch(function (e) { + console.log("\x1b[31mstorage test error:\x1b[0m", e); + process.exit(1); +});