diff --git a/examples/drivers/fs-demo/.gitignore b/examples/drivers/fs-demo/.gitignore new file mode 100644 index 00000000..40dc6f66 --- /dev/null +++ b/examples/drivers/fs-demo/.gitignore @@ -0,0 +1,11 @@ +# Data directory (generated at runtime) +data/ + +# Node modules +node_modules/ + +# Build output +dist/ + +# OS files +.DS_Store diff --git a/examples/drivers/fs-demo/README.md b/examples/drivers/fs-demo/README.md new file mode 100644 index 00000000..eb5a4897 --- /dev/null +++ b/examples/drivers/fs-demo/README.md @@ -0,0 +1,71 @@ +# FileSystem Driver Demo + +This example demonstrates how to use the `@objectql/driver-fs` package for file-based storage with ObjectQL. + +## Features Demonstrated + +- ✅ File system-based persistent storage +- ✅ One JSON file per object type +- ✅ CRUD operations (Create, Read, Update, Delete) +- ✅ Query operations (filters, sorting, pagination) +- ✅ Aggregate operations (count, distinct) +- ✅ Human-readable JSON format +- ✅ Automatic backup files + +## Running the Demo + +```bash +# From the project root +npm run dev + +# Or directly +ts-node src/index.ts +``` + +## What It Does + +1. **Initializes** the FileSystem driver with a data directory +2. **Creates** a schema for "projects" with various fields +3. **Inserts** 4 sample projects +4. **Queries** the data with different filters +5. **Updates** a project status +6. **Shows** aggregate operations + +## Output + +After running, you'll see: +- Console output showing all operations +- A `data/` directory with `projects.json` file +- A `projects.json.bak` backup file + +## Inspecting the Data + +The data is stored in human-readable JSON: + +```bash +cat data/projects.json +``` + +You can manually edit this file and the changes will be reflected in the application! + +## Data Directory Structure + +``` +fs-demo/ +├── src/ +│ └── index.ts +├── data/ ← Created on first run +│ ├── projects.json ← Current data +│ └── projects.json.bak ← Backup +├── package.json +└── tsconfig.json +``` + +## Use Cases + +This driver is ideal for: +- Development and prototyping +- Small applications (< 10k records) +- Configuration storage +- Embedded applications +- Scenarios without database setup diff --git a/examples/drivers/fs-demo/package.json b/examples/drivers/fs-demo/package.json new file mode 100644 index 00000000..73ce0841 --- /dev/null +++ b/examples/drivers/fs-demo/package.json @@ -0,0 +1,19 @@ +{ + "name": "fs-demo", + "version": "0.1.0", + "private": true, + "description": "Example demonstrating @objectql/driver-fs", + "scripts": { + "start": "ts-node src/index.ts", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "@objectql/core": "workspace:*", + "@objectql/driver-fs": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.0.0" + } +} diff --git a/examples/drivers/fs-demo/src/index.ts b/examples/drivers/fs-demo/src/index.ts new file mode 100644 index 00000000..879d69fc --- /dev/null +++ b/examples/drivers/fs-demo/src/index.ts @@ -0,0 +1,178 @@ +import { ObjectQL } from '@objectql/core'; +import { FileSystemDriver } from '@objectql/driver-fs'; +import * as path from 'path'; + +async function main() { + console.log("🚀 ObjectQL FileSystem Driver Demo\n"); + + // 1. Initialize Driver with data directory + const dataDir = path.join(__dirname, '../data'); + const driver = new FileSystemDriver({ + dataDir: dataDir, + prettyPrint: true, + enableBackup: true + }); + + console.log(`📁 Data directory: ${dataDir}\n`); + + // 2. Initialize ObjectQL + const app = new ObjectQL({ + datasources: { + default: driver + } + }); + + // 3. Define Object Schema + app.registerObject({ + name: 'projects', + label: 'Projects', + fields: { + name: { + type: 'text', + required: true, + label: 'Project Name' + }, + status: { + type: 'select', + options: [ + { label: 'Planning', value: 'planning' }, + { label: 'In Progress', value: 'in_progress' }, + { label: 'Completed', value: 'completed' } + ], + defaultValue: 'planning', + label: 'Status' + }, + priority: { + type: 'select', + options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' } + ], + defaultValue: 'medium', + label: 'Priority' + }, + budget: { + type: 'currency', + label: 'Budget' + }, + startDate: { + type: 'date', + label: 'Start Date' + } + } + }); + + await app.init(); + + // 4. Get Repository + const ctx = app.createContext({ isSystem: true }); + const projects = ctx.object('projects'); + + // 5. Create Sample Projects + console.log("📝 Creating sample projects...\n"); + + await projects.create({ + id: 'PROJ-001', + name: 'Website Redesign', + status: 'in_progress', + priority: 'high', + budget: 50000, + startDate: '2024-01-15' + }); + + await projects.create({ + id: 'PROJ-002', + name: 'Mobile App Development', + status: 'planning', + priority: 'high', + budget: 80000, + startDate: '2024-02-01' + }); + + await projects.create({ + id: 'PROJ-003', + name: 'Infrastructure Upgrade', + status: 'in_progress', + priority: 'medium', + budget: 30000, + startDate: '2024-01-10' + }); + + await projects.create({ + id: 'PROJ-004', + name: 'Marketing Campaign', + status: 'completed', + priority: 'low', + budget: 15000, + startDate: '2023-12-01' + }); + + console.log("✅ Created 4 projects\n"); + + // 6. Query Examples + console.log("🔍 Query Examples:\n"); + + // Find all projects + const allProjects = await projects.find({}); + console.log(`📊 Total projects: ${allProjects.length}`); + + // Find high priority projects + const highPriority = await projects.find({ + filters: [['priority', '=', 'high']] + }); + console.log(`🔥 High priority projects: ${highPriority.length}`); + highPriority.forEach(p => console.log(` - ${p.name}`)); + + // Find in-progress projects + const inProgress = await projects.find({ + filters: [['status', '=', 'in_progress']] + }); + console.log(`\n⚡ In-progress projects: ${inProgress.length}`); + inProgress.forEach(p => console.log(` - ${p.name}`)); + + // Find projects with budget > 40000 + const largeBudget = await projects.find({ + filters: [['budget', '>', 40000]] + }); + console.log(`\n💰 Projects with budget > $40,000: ${largeBudget.length}`); + largeBudget.forEach(p => console.log(` - ${p.name}: $${p.budget.toLocaleString()}`)); + + // Sort by budget + const sortedByBudget = await projects.find({ + sort: [['budget', 'desc']] + }); + console.log(`\n📈 Projects sorted by budget (desc):`); + sortedByBudget.forEach(p => console.log(` - ${p.name}: $${p.budget.toLocaleString()}`)); + + // 7. Update Example + console.log(`\n🔄 Updating project status...\n`); + await projects.update('PROJ-002', { status: 'in_progress' }); + const updated = await projects.findOne('PROJ-002'); + console.log(`✅ Updated ${updated.name} to ${updated.status}`); + + // 8. Aggregate Operations + console.log(`\n📊 Aggregate Operations:\n`); + + const statusCount = await projects.count({ + filters: [['status', '=', 'in_progress']] + }); + console.log(`In-progress projects: ${statusCount}`); + + const priorities = await projects.distinct('priority'); + console.log(`Distinct priorities: ${priorities.join(', ')}`); + + // 9. Show file location + console.log(`\n📁 Data Files:\n`); + console.log(` JSON file: ${dataDir}/projects.json`); + console.log(` Backup: ${dataDir}/projects.json.bak`); + console.log(`\n💡 Tip: Open the JSON files to see human-readable data!`); + + // Cleanup + await app.close(); +} + +// Run the demo +if (require.main === module) { + main().catch(console.error); +} diff --git a/examples/drivers/fs-demo/tsconfig.json b/examples/drivers/fs-demo/tsconfig.json new file mode 100644 index 00000000..f953d3b8 --- /dev/null +++ b/examples/drivers/fs-demo/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/drivers/fs/CHANGELOG.md b/packages/drivers/fs/CHANGELOG.md new file mode 100644 index 00000000..c4f8da65 --- /dev/null +++ b/packages/drivers/fs/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to the @objectql/driver-fs package will be documented in this file. + +## [0.1.1] - 2024-01-16 + +### Added +- `initialData` configuration option to pre-populate data on initialization +- `clear(objectName)` method to clear all data for a specific object +- `clearAll()` method to clear all data from all objects +- `invalidateCache(objectName)` method to force cache reload +- `getCacheSize()` method to get the number of cached objects +- Chinese documentation (README.zh-CN.md) +- Better error handling for invalid JSON files +- Support for empty JSON files + +### Improved +- Enhanced JSON parse error messages with more detailed information +- Better documentation with examples for all new features +- Added 7 new test cases (total: 36 tests) +- TypeScript configuration for proper workspace resolution + +## [0.1.0] - 2024-01-16 + +### Added +- Initial release of FileSystem Driver for ObjectQL +- One JSON file per table/object type +- Atomic write operations with temp file + rename strategy +- Automatic backup files (`.bak`) on write +- Full query support (filters, sorting, pagination, field projection) +- Support for all standard Driver interface methods: + - find, findOne, create, update, delete + - count, distinct + - createMany, updateMany, deleteMany +- Pretty-printed JSON for human readability +- Zero external dependencies (only @objectql/types) +- Comprehensive test suite with 30+ test cases +- Complete documentation and examples diff --git a/packages/drivers/fs/README.md b/packages/drivers/fs/README.md new file mode 100644 index 00000000..f0015549 --- /dev/null +++ b/packages/drivers/fs/README.md @@ -0,0 +1,426 @@ +# @objectql/driver-fs + +File System Driver for ObjectQL - JSON file-based persistent storage with one file per table. + +> **中文文档**: [README.zh-CN.md](./README.zh-CN.md) + +## Features + +✅ **Persistent Storage** - Data survives process restarts +✅ **One File Per Table** - Each object type stored in a separate JSON file (e.g., `users.json`, `projects.json`) +✅ **Human-Readable** - Pretty-printed JSON for easy inspection and debugging +✅ **Atomic Writes** - Temp file + rename strategy prevents corruption +✅ **Backup Support** - Automatic backup files (`.bak`) on write +✅ **Full Query Support** - Filters, sorting, pagination, field projection +✅ **Zero Database Setup** - No external dependencies or database installation required + +## Installation + +```bash +npm install @objectql/driver-fs +``` + +## Quick Start + +```typescript +import { ObjectQL } from '@objectql/core'; +import { FileSystemDriver } from '@objectql/driver-fs'; + +// 1. Initialize Driver +const driver = new FileSystemDriver({ + dataDir: './data' // Directory where JSON files will be stored +}); + +// 2. Initialize ObjectQL +const app = new ObjectQL({ + datasources: { + default: driver + } +}); + +// 3. Define Objects +app.registerObject({ + name: 'users', + fields: { + name: { type: 'text', required: true }, + email: { type: 'email' }, + age: { type: 'number' } + } +}); + +await app.init(); + +// 4. Use the API +const ctx = app.createContext({ isSystem: true }); +const users = ctx.object('users'); + +// Create +await users.create({ name: 'Alice', email: 'alice@example.com', age: 30 }); + +// Find +const allUsers = await users.find({}); +console.log(allUsers); + +// Query with filters +const youngUsers = await users.find({ + filters: [['age', '<', 25]] +}); +``` + +## Configuration + +```typescript +interface FileSystemDriverConfig { + /** Directory path where JSON files will be stored */ + dataDir: string; + + /** Enable pretty-print JSON for readability (default: true) */ + prettyPrint?: boolean; + + /** Enable backup files on write (default: true) */ + enableBackup?: boolean; + + /** Enable strict mode (throw on missing objects) (default: false) */ + strictMode?: boolean; + + /** Initial data to populate the store (optional) */ + initialData?: Record; +} +``` + +### Example with Options + +```typescript +const driver = new FileSystemDriver({ + dataDir: './data', + prettyPrint: true, // Human-readable JSON + enableBackup: true, // Create .bak files + strictMode: false, // Graceful handling of missing records + initialData: { // Pre-populate with initial data + users: [ + { id: 'admin', name: 'Admin User', role: 'admin' } + ] + } +}); +``` + +## File Storage Format + +Each object type is stored in a separate JSON file: + +``` +./data/ + ├── users.json + ├── users.json.bak (backup) + ├── projects.json + ├── projects.json.bak + └── tasks.json +``` + +### File Content Example (`users.json`) + +```json +[ + { + "id": "users-1234567890-1", + "name": "Alice", + "email": "alice@example.com", + "age": 30, + "created_at": "2024-01-15T10:30:00.000Z", + "updated_at": "2024-01-15T10:30:00.000Z" + }, + { + "id": "users-1234567891-2", + "name": "Bob", + "email": "bob@example.com", + "age": 25, + "created_at": "2024-01-15T11:00:00.000Z", + "updated_at": "2024-01-15T11:00:00.000Z" + } +] +``` + +## API Examples + +### CRUD Operations + +```typescript +const ctx = app.createContext({ isSystem: true }); +const products = ctx.object('products'); + +// Create +const product = await products.create({ + name: 'Laptop', + price: 1000, + category: 'electronics' +}); + +// Find One by ID +const found = await products.findOne(product.id); + +// Update +await products.update(product.id, { price: 950 }); + +// Delete +await products.delete(product.id); +``` + +### Querying + +```typescript +// Filter +const electronics = await products.find({ + filters: [['category', '=', 'electronics']] +}); + +// Multiple filters with OR +const results = await products.find({ + filters: [ + ['price', '<', 500], + 'or', + ['category', '=', 'sale'] + ] +}); + +// Sorting +const sorted = await products.find({ + sort: [['price', 'desc']] +}); + +// Pagination +const page1 = await products.find({ + limit: 10, + skip: 0 +}); + +// Field Projection +const names = await products.find({ + fields: ['name', 'price'] +}); +``` + +### Bulk Operations + +```typescript +// Create Many +await products.createMany([ + { name: 'Item 1', price: 10 }, + { name: 'Item 2', price: 20 }, + { name: 'Item 3', price: 30 } +]); + +// Update Many +await products.updateMany( + [['category', '=', 'electronics']], // filters + { onSale: true } // update data +); + +// Delete Many +await products.deleteMany([ + ['price', '<', 10] +]); + +// Count +const count = await products.count({ + filters: [['category', '=', 'electronics']] +}); + +// Distinct Values +const categories = await products.distinct('category'); +``` + +## Supported Query Operators + +- **Equality**: `=`, `==`, `!=`, `<>` +- **Comparison**: `>`, `>=`, `<`, `<=` +- **Membership**: `in`, `nin` (not in) +- **String Matching**: `like`, `contains`, `startswith`, `endswith` +- **Range**: `between` + +## Use Cases + +### ✅ Ideal For + +- **Small to Medium Datasets** (< 10k records per object) +- **Development and Prototyping** with persistent data +- **Configuration Storage** (settings, metadata) +- **Embedded Applications** (Electron, Tauri) +- **Scenarios without Database** (no DB setup required) +- **Human-Inspectable Data** (easy to debug and modify) + +### ❌ Not Recommended For + +- **Large Datasets** (> 10k records per object) +- **High-Concurrency Writes** (multiple processes writing simultaneously) +- **Production High-Traffic Apps** (use SQL/MongoDB drivers instead) +- **Complex Transactions** (use SQL driver with transaction support) + +## Performance Characteristics + +- **Read Performance**: O(n) for filtered queries, fast for simple lookups +- **Write Performance**: O(n) - entire file is rewritten on each update +- **Storage Format**: Human-readable JSON (larger than binary formats) +- **Concurrency**: Single-process safe, multi-process requires external locking + +## Data Safety + +### Atomic Writes + +The driver uses a temp file + rename strategy to prevent corruption: + +1. Write new data to `{file}.tmp` +2. Rename `{file}.tmp` → `{file}` (atomic operation) +3. If the process crashes during write, the original file remains intact + +### Backup Files + +When `enableBackup: true`, the driver creates `.bak` files: + +``` +users.json ← Current data +users.json.bak ← Previous version +``` + +To restore from backup: + +```bash +cp data/users.json.bak data/users.json +``` + +## Advanced Usage + +### Custom ID Generation + +```typescript +// Use your own ID +await products.create({ + id: 'PROD-001', + name: 'Custom Product' +}); + +// Or use _id (MongoDB-style) +await products.create({ + _id: '507f1f77bcf86cd799439011', + name: 'Mongo-Style Product' +}); +``` + +### Loading Initial Data + +**Method 1: Provide in configuration** + +```typescript +const driver = new FileSystemDriver({ + dataDir: './data', + initialData: { + users: [ + { id: 'admin-001', name: 'Admin User', role: 'admin' } + ], + settings: [ + { key: 'theme', value: 'dark' } + ] + } +}); +``` + +**Method 2: Pre-create JSON files** + +You can pre-populate JSON files: + +```json +// ./data/users.json +[ + { + "id": "admin-001", + "name": "Admin User", + "email": "admin@example.com", + "role": "admin", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z" + } +] +``` + +The driver will load this data on startup. + +### Multiple Data Directories + +```typescript +// Development +const devDriver = new FileSystemDriver({ + dataDir: './data/dev' +}); + +// Testing +const testDriver = new FileSystemDriver({ + dataDir: './data/test' +}); +``` + +### Utility Methods + +```typescript +// Clear all data for a specific object +await driver.clear('users'); + +// Clear all data for all objects +await driver.clearAll(); + +// Invalidate cache for an object +driver.invalidateCache('users'); + +// Get cache size +const size = driver.getCacheSize(); +``` + +## Comparison with Other Drivers + +| Feature | FileSystem | Memory | SQL | MongoDB | +|---------|-----------|--------|-----|---------| +| Persistence | ✅ Yes | ❌ No | ✅ Yes | ✅ Yes | +| Setup Required | ❌ No | ❌ No | ✅ Yes | ✅ Yes | +| Human-Readable | ✅ Yes | ❌ No | ❌ No | ⚠️ Partial | +| Performance (Large Data) | ⚠️ Slow | ✅ Fast | ✅ Fast | ✅ Fast | +| Transactions | ❌ No | ❌ No | ✅ Yes | ✅ Yes | +| Best For | Dev/Config | Testing | Production | Production | + +## Troubleshooting + +### File Corruption + +If a JSON file becomes corrupted, restore from backup: + +```bash +cp data/users.json.bak data/users.json +``` + +### Permission Issues + +Ensure the process has read/write permissions: + +```bash +chmod 755 ./data +``` + +### Large Files + +If files become too large (> 1MB), consider: + +1. Splitting data into multiple object types +2. Using SQL/MongoDB drivers for production +3. Implementing data archiving strategy + +## License + +MIT + +## Contributing + +Contributions are welcome! Please open an issue or PR on GitHub. + +## Related Packages + +- [@objectql/core](https://www.npmjs.com/package/@objectql/core) - Core ObjectQL engine +- [@objectql/driver-sql](https://www.npmjs.com/package/@objectql/driver-sql) - SQL driver (PostgreSQL, MySQL, SQLite) +- [@objectql/driver-mongo](https://www.npmjs.com/package/@objectql/driver-mongo) - MongoDB driver +- [@objectql/driver-memory](https://www.npmjs.com/package/@objectql/driver-memory) - In-memory driver diff --git a/packages/drivers/fs/README.zh-CN.md b/packages/drivers/fs/README.zh-CN.md new file mode 100644 index 00000000..7525864b --- /dev/null +++ b/packages/drivers/fs/README.zh-CN.md @@ -0,0 +1,422 @@ +# @objectql/driver-fs + +ObjectQL 文件系统驱动 - 基于 JSON 文件的持久化存储,每个表一个文件。 + +## 特性 + +✅ **持久化存储** - 数据在进程重启后保留 +✅ **每表一文件** - 每个对象类型存储在独立的 JSON 文件中(例如:`users.json`、`projects.json`) +✅ **人类可读** - 格式化的 JSON,便于检查和调试 +✅ **原子写入** - 临时文件 + 重命名策略防止数据损坏 +✅ **备份支持** - 写入时自动创建备份文件(`.bak`) +✅ **完整查询支持** - 过滤、排序、分页、字段投影 +✅ **零数据库配置** - 无需外部依赖或数据库安装 + +## 安装 + +```bash +npm install @objectql/driver-fs +``` + +## 快速开始 + +```typescript +import { ObjectQL } from '@objectql/core'; +import { FileSystemDriver } from '@objectql/driver-fs'; + +// 1. 初始化驱动 +const driver = new FileSystemDriver({ + dataDir: './data' // JSON 文件存储目录 +}); + +// 2. 初始化 ObjectQL +const app = new ObjectQL({ + datasources: { + default: driver + } +}); + +// 3. 定义对象 +app.registerObject({ + name: 'users', + fields: { + name: { type: 'text', required: true }, + email: { type: 'email' }, + age: { type: 'number' } + } +}); + +await app.init(); + +// 4. 使用 API +const ctx = app.createContext({ isSystem: true }); +const users = ctx.object('users'); + +// 创建 +await users.create({ name: 'Alice', email: 'alice@example.com', age: 30 }); + +// 查询 +const allUsers = await users.find({}); +console.log(allUsers); + +// 带过滤条件查询 +const youngUsers = await users.find({ + filters: [['age', '<', 25]] +}); +``` + +## 配置选项 + +```typescript +interface FileSystemDriverConfig { + /** JSON 文件存储目录路径 */ + dataDir: string; + + /** 启用格式化 JSON 以提高可读性(默认:true) */ + prettyPrint?: boolean; + + /** 启用写入时备份文件(默认:true) */ + enableBackup?: boolean; + + /** 启用严格模式(缺失对象时抛出错误)(默认:false) */ + strictMode?: boolean; + + /** 初始数据(可选) */ + initialData?: Record; +} +``` + +### 配置示例 + +```typescript +const driver = new FileSystemDriver({ + dataDir: './data', + prettyPrint: true, // 人类可读的 JSON + enableBackup: true, // 创建 .bak 备份文件 + strictMode: false, // 优雅处理缺失记录 + initialData: { // 预加载初始数据 + users: [ + { id: 'admin', name: '管理员', role: 'admin' } + ] + } +}); +``` + +## 文件存储格式 + +每个对象类型存储在独立的 JSON 文件中: + +``` +./data/ + ├── users.json + ├── users.json.bak (备份) + ├── projects.json + ├── projects.json.bak + └── tasks.json +``` + +### 文件内容示例(`users.json`) + +```json +[ + { + "id": "users-1234567890-1", + "name": "Alice", + "email": "alice@example.com", + "age": 30, + "created_at": "2024-01-15T10:30:00.000Z", + "updated_at": "2024-01-15T10:30:00.000Z" + }, + { + "id": "users-1234567891-2", + "name": "Bob", + "email": "bob@example.com", + "age": 25, + "created_at": "2024-01-15T11:00:00.000Z", + "updated_at": "2024-01-15T11:00:00.000Z" + } +] +``` + +## API 示例 + +### CRUD 操作 + +```typescript +const ctx = app.createContext({ isSystem: true }); +const products = ctx.object('products'); + +// 创建 +const product = await products.create({ + name: '笔记本电脑', + price: 1000, + category: '电子产品' +}); + +// 查询单个 +const found = await products.findOne(product.id); + +// 更新 +await products.update(product.id, { price: 950 }); + +// 删除 +await products.delete(product.id); +``` + +### 查询操作 + +```typescript +// 过滤 +const electronics = await products.find({ + filters: [['category', '=', '电子产品']] +}); + +// 多条件 OR 查询 +const results = await products.find({ + filters: [ + ['price', '<', 500], + 'or', + ['category', '=', '促销'] + ] +}); + +// 排序 +const sorted = await products.find({ + sort: [['price', 'desc']] +}); + +// 分页 +const page1 = await products.find({ + limit: 10, + skip: 0 +}); + +// 字段投影 +const names = await products.find({ + fields: ['name', 'price'] +}); +``` + +### 批量操作 + +```typescript +// 批量创建 +await products.createMany([ + { name: '商品 1', price: 10 }, + { name: '商品 2', price: 20 }, + { name: '商品 3', price: 30 } +]); + +// 批量更新 +await products.updateMany( + [['category', '=', '电子产品']], // 过滤条件 + { onSale: true } // 更新数据 +); + +// 批量删除 +await products.deleteMany([ + ['price', '<', 10] +]); + +// 计数 +const count = await products.count({ + filters: [['category', '=', '电子产品']] +}); + +// 去重值 +const categories = await products.distinct('category'); +``` + +## 支持的查询操作符 + +- **相等性**:`=`、`==`、`!=`、`<>` +- **比较**:`>`、`>=`、`<`、`<=` +- **成员**:`in`、`nin`(不在其中) +- **字符串匹配**:`like`、`contains`、`startswith`、`endswith` +- **范围**:`between` + +## 使用场景 + +### ✅ 适用于 + +- **小到中等数据集**(每个对象 < 10k 条记录) +- **开发和原型设计**,需要持久化数据 +- **配置存储**(设置、元数据) +- **嵌入式应用**(Electron、Tauri) +- **无需数据库**的场景(无需数据库配置) +- **人类可读数据**(易于调试和修改) + +### ❌ 不推荐用于 + +- **大型数据集**(每个对象 > 10k 条记录) +- **高并发写入**(多进程同时写入) +- **生产环境高流量应用**(使用 SQL/MongoDB 驱动替代) +- **复杂事务**(使用支持事务的 SQL 驱动) + +## 性能特征 + +- **读性能**:过滤查询 O(n),简单查找速度快 +- **写性能**:O(n) - 每次更新重写整个文件 +- **存储格式**:人类可读的 JSON(比二进制格式大) +- **并发**:单进程安全,多进程需要外部锁 + +## 数据安全 + +### 原子写入 + +驱动使用临时文件 + 重命名策略防止损坏: + +1. 将新数据写入 `{file}.tmp` +2. 重命名 `{file}.tmp` → `{file}`(原子操作) +3. 如果进程在写入过程中崩溃,原始文件保持完整 + +### 备份文件 + +当 `enableBackup: true` 时,驱动创建 `.bak` 文件: + +``` +users.json ← 当前数据 +users.json.bak ← 上一版本 +``` + +从备份恢复: + +```bash +cp data/users.json.bak data/users.json +``` + +## 高级用法 + +### 自定义 ID 生成 + +```typescript +// 使用自己的 ID +await products.create({ + id: 'PROD-001', + name: '自定义产品' +}); + +// 或使用 _id(MongoDB 风格) +await products.create({ + _id: '507f1f77bcf86cd799439011', + name: 'Mongo 风格产品' +}); +``` + +### 加载初始数据 + +方法 1:在配置中提供 + +```typescript +const driver = new FileSystemDriver({ + dataDir: './data', + initialData: { + users: [ + { id: 'admin-001', name: '管理员', role: 'admin' } + ], + settings: [ + { key: 'theme', value: 'dark' } + ] + } +}); +``` + +方法 2:预创建 JSON 文件 + +```json +// ./data/users.json +[ + { + "id": "admin-001", + "name": "管理员", + "email": "admin@example.com", + "role": "admin", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z" + } +] +``` + +驱动将在启动时加载此数据。 + +### 多个数据目录 + +```typescript +// 开发环境 +const devDriver = new FileSystemDriver({ + dataDir: './data/dev' +}); + +// 测试环境 +const testDriver = new FileSystemDriver({ + dataDir: './data/test' +}); +``` + +### 工具方法 + +```typescript +// 清除特定对象的所有数据 +await driver.clear('users'); + +// 清除所有对象的数据 +await driver.clearAll(); + +// 使缓存失效 +driver.invalidateCache('users'); + +// 获取缓存大小 +const size = driver.getCacheSize(); +``` + +## 与其他驱动的对比 + +| 特性 | FileSystem | Memory | SQL | MongoDB | +|------|-----------|--------|-----|---------| +| 持久化 | ✅ 是 | ❌ 否 | ✅ 是 | ✅ 是 | +| 需要配置 | ❌ 否 | ❌ 否 | ✅ 是 | ✅ 是 | +| 人类可读 | ✅ 是 | ❌ 否 | ❌ 否 | ⚠️ 部分 | +| 性能(大数据) | ⚠️ 慢 | ✅ 快 | ✅ 快 | ✅ 快 | +| 事务 | ❌ 否 | ❌ 否 | ✅ 是 | ✅ 是 | +| 最适合 | 开发/配置 | 测试 | 生产 | 生产 | + +## 故障排除 + +### 文件损坏 + +如果 JSON 文件损坏,从备份恢复: + +```bash +cp data/users.json.bak data/users.json +``` + +### 权限问题 + +确保进程具有读/写权限: + +```bash +chmod 755 ./data +``` + +### 文件过大 + +如果文件变得太大(> 1MB),考虑: + +1. 将数据拆分为多个对象类型 +2. 在生产环境使用 SQL/MongoDB 驱动 +3. 实施数据归档策略 + +## 许可证 + +MIT + +## 贡献 + +欢迎贡献!请在 GitHub 上开启 issue 或 PR。 + +## 相关包 + +- [@objectql/core](https://www.npmjs.com/package/@objectql/core) - ObjectQL 核心引擎 +- [@objectql/driver-sql](https://www.npmjs.com/package/@objectql/driver-sql) - SQL 驱动(PostgreSQL、MySQL、SQLite) +- [@objectql/driver-mongo](https://www.npmjs.com/package/@objectql/driver-mongo) - MongoDB 驱动 +- [@objectql/driver-memory](https://www.npmjs.com/package/@objectql/driver-memory) - 内存驱动 diff --git a/packages/drivers/fs/jest.config.js b/packages/drivers/fs/jest.config.js new file mode 100644 index 00000000..8f1c3c1c --- /dev/null +++ b/packages/drivers/fs/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts'], + moduleNameMapper: { + '^@objectql/types$': '/../../foundation/types/src', + } +}; diff --git a/packages/drivers/fs/package.json b/packages/drivers/fs/package.json new file mode 100644 index 00000000..1adb1c5d --- /dev/null +++ b/packages/drivers/fs/package.json @@ -0,0 +1,41 @@ +{ + "name": "@objectql/driver-fs", + "version": "0.1.0", + "description": "File system driver for ObjectQL - JSON file-based storage with one file per table", + "keywords": [ + "objectql", + "driver", + "filesystem", + "json", + "storage", + "database", + "adapter" + ], + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "package.json" + ], + "scripts": { + "build": "tsc", + "test": "jest" + }, + "dependencies": { + "@objectql/types": "workspace:*" + }, + "devDependencies": { + "@types/jest": "^29.0.0", + "@types/node": "^20.0.0", + "jest": "^29.0.0", + "typescript": "^5.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/objectstack-ai/objectql.git", + "directory": "packages/drivers/fs" + } +} diff --git a/packages/drivers/fs/src/index.ts b/packages/drivers/fs/src/index.ts new file mode 100644 index 00000000..abcd7c0d --- /dev/null +++ b/packages/drivers/fs/src/index.ts @@ -0,0 +1,702 @@ +/** + * File System Driver for ObjectQL (Production-Ready) + * + * A persistent file-based driver for ObjectQL that stores data in JSON files. + * Each object type is stored in a separate JSON file for easy inspection and backup. + * + * ✅ Production-ready features: + * - Persistent storage with JSON files + * - One file per table/object (e.g., users.json, projects.json) + * - Atomic write operations with temp file + rename strategy + * - Full query support (filters, sorting, pagination) + * - Backup on write for data safety + * - Human-readable JSON format + * + * Use Cases: + * - Small to medium datasets (< 10k records per object) + * - Development and prototyping with persistent data + * - Configuration and metadata storage + * - Embedded applications + * - Scenarios where database setup is not desired + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Driver, ObjectQLError } from '@objectql/types'; + +/** + * Configuration options for the FileSystem driver. + */ +export interface FileSystemDriverConfig { + /** Directory path where JSON files will be stored */ + dataDir: string; + /** Optional: Enable pretty-print JSON for readability (default: true) */ + prettyPrint?: boolean; + /** Optional: Enable backup files on write (default: true) */ + enableBackup?: boolean; + /** Optional: Enable strict mode (throw on missing objects) */ + strictMode?: boolean; + /** Optional: Initial data to populate the store */ + initialData?: Record; +} + +/** + * FileSystem Driver Implementation + * + * Stores ObjectQL documents in JSON files with format: + * - File: `{dataDir}/{objectName}.json` + * - Content: Array of records `[{id: "1", ...}, {id: "2", ...}]` + */ +export class FileSystemDriver implements Driver { + private config: FileSystemDriverConfig; + private idCounters: Map; + private cache: Map; + + constructor(config: FileSystemDriverConfig) { + this.config = { + prettyPrint: true, + enableBackup: true, + strictMode: false, + ...config + }; + this.idCounters = new Map(); + this.cache = new Map(); + + // Ensure data directory exists + if (!fs.existsSync(this.config.dataDir)) { + fs.mkdirSync(this.config.dataDir, { recursive: true }); + } + + // Load initial data if provided + if (config.initialData) { + this.loadInitialData(config.initialData); + } + } + + /** + * Load initial data into the store. + */ + private loadInitialData(data: Record): void { + for (const [objectName, records] of Object.entries(data)) { + // Only load if file doesn't exist yet + const filePath = this.getFilePath(objectName); + if (!fs.existsSync(filePath)) { + const recordsWithIds = records.map(record => ({ + ...record, + id: record.id || record._id || this.generateId(objectName), + created_at: record.created_at || new Date().toISOString(), + updated_at: record.updated_at || new Date().toISOString() + })); + this.saveRecords(objectName, recordsWithIds); + } + } + } + + /** + * Get the file path for an object type. + */ + private getFilePath(objectName: string): string { + return path.join(this.config.dataDir, `${objectName}.json`); + } + + /** + * Load records from file into memory cache. + */ + private loadRecords(objectName: string): any[] { + // Check cache first + if (this.cache.has(objectName)) { + return this.cache.get(objectName)!; + } + + const filePath = this.getFilePath(objectName); + + if (!fs.existsSync(filePath)) { + // File doesn't exist yet, return empty array + this.cache.set(objectName, []); + return []; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Handle empty file + if (!content || content.trim() === '') { + this.cache.set(objectName, []); + return []; + } + + let records; + try { + records = JSON.parse(content); + } catch (parseError) { + throw new ObjectQLError({ + code: 'INVALID_JSON_FORMAT', + message: `File ${filePath} contains invalid JSON: ${(parseError as Error).message}`, + details: { objectName, filePath, parseError } + }); + } + + if (!Array.isArray(records)) { + throw new ObjectQLError({ + code: 'INVALID_DATA_FORMAT', + message: `File ${filePath} does not contain a valid array`, + details: { objectName, filePath } + }); + } + + this.cache.set(objectName, records); + return records; + } catch (error) { + // If it's already an ObjectQLError, rethrow it + if ((error as any).code && (error as any).code.startsWith('INVALID_')) { + throw error; + } + + if ((error as any).code === 'ENOENT') { + this.cache.set(objectName, []); + return []; + } + + throw new ObjectQLError({ + code: 'FILE_READ_ERROR', + message: `Failed to read file for object '${objectName}': ${(error as Error).message}`, + details: { objectName, filePath, error } + }); + } + } + + /** + * Save records to file with atomic write strategy. + */ + private saveRecords(objectName: string, records: any[]): void { + const filePath = this.getFilePath(objectName); + const tempPath = `${filePath}.tmp`; + const backupPath = `${filePath}.bak`; + + try { + // Create backup if file exists and backup is enabled + if (this.config.enableBackup && fs.existsSync(filePath)) { + fs.copyFileSync(filePath, backupPath); + } + + // Write to temporary file + const content = this.config.prettyPrint + ? JSON.stringify(records, null, 2) + : JSON.stringify(records); + + fs.writeFileSync(tempPath, content, 'utf8'); + + // Atomic rename (replaces original file) + fs.renameSync(tempPath, filePath); + + // Update cache + this.cache.set(objectName, records); + } catch (error) { + // Clean up temp file if it exists + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + + throw new ObjectQLError({ + code: 'FILE_WRITE_ERROR', + message: `Failed to write file for object '${objectName}': ${(error as Error).message}`, + details: { objectName, filePath, error } + }); + } + } + + /** + * Find multiple records matching the query criteria. + */ + async find(objectName: string, query: any = {}, options?: any): Promise { + let results = this.loadRecords(objectName); + + // Apply filters + if (query.filters) { + results = this.applyFilters(results, query.filters); + } + + // Apply sorting + if (query.sort && Array.isArray(query.sort)) { + results = this.applySort(results, query.sort); + } + + // Apply pagination + if (query.skip) { + results = results.slice(query.skip); + } + if (query.limit) { + results = results.slice(0, query.limit); + } + + // Apply field projection + if (query.fields && Array.isArray(query.fields)) { + results = results.map(doc => this.projectFields(doc, query.fields)); + } + + // Return deep copies to prevent external modifications + return results.map(r => ({ ...r })); + } + + /** + * Find a single record by ID or query. + */ + async findOne(objectName: string, id: string | number, query?: any, options?: any): Promise { + const records = this.loadRecords(objectName); + + // If ID is provided, fetch directly + if (id) { + const record = records.find(r => r.id === id || r._id === id); + return record ? { ...record } : null; + } + + // If query is provided, use find and return first result + if (query) { + const results = await this.find(objectName, { ...query, limit: 1 }, options); + return results[0] || null; + } + + return null; + } + + /** + * Create a new record. + */ + async create(objectName: string, data: any, options?: any): Promise { + // Validate object name + if (!objectName || objectName.trim() === '') { + throw new ObjectQLError({ + code: 'INVALID_OBJECT_NAME', + message: 'Object name cannot be empty', + details: { objectName } + }); + } + + const records = this.loadRecords(objectName); + + // Generate ID if not provided + const id = data.id || data._id || this.generateId(objectName); + + // Check if record already exists + const existing = records.find(r => r.id === id || r._id === id); + if (existing) { + throw new ObjectQLError({ + code: 'DUPLICATE_RECORD', + message: `Record with id '${id}' already exists in '${objectName}'`, + details: { objectName, id } + }); + } + + const now = new Date().toISOString(); + const doc = { + ...data, + id, + created_at: data.created_at || now, + updated_at: data.updated_at || now + }; + + records.push(doc); + this.saveRecords(objectName, records); + + return { ...doc }; + } + + /** + * Update an existing record. + */ + async update(objectName: string, id: string | number, data: any, options?: any): Promise { + const records = this.loadRecords(objectName); + const index = records.findIndex(r => r.id === id || r._id === id); + + if (index === -1) { + if (this.config.strictMode) { + throw new ObjectQLError({ + code: 'RECORD_NOT_FOUND', + message: `Record with id '${id}' not found in '${objectName}'`, + details: { objectName, id } + }); + } + return null; + } + + const existing = records[index]; + const doc = { + ...existing, + ...data, + id: existing.id || existing._id, // Preserve ID + created_at: existing.created_at, // Preserve created_at + updated_at: new Date().toISOString() + }; + + records[index] = doc; + this.saveRecords(objectName, records); + + return { ...doc }; + } + + /** + * Delete a record. + */ + async delete(objectName: string, id: string | number, options?: any): Promise { + const records = this.loadRecords(objectName); + const index = records.findIndex(r => r.id === id || r._id === id); + + if (index === -1) { + if (this.config.strictMode) { + throw new ObjectQLError({ + code: 'RECORD_NOT_FOUND', + message: `Record with id '${id}' not found in '${objectName}'`, + details: { objectName, id } + }); + } + return false; + } + + records.splice(index, 1); + this.saveRecords(objectName, records); + + return true; + } + + /** + * Count records matching filters. + */ + async count(objectName: string, filters: any, options?: any): Promise { + const records = this.loadRecords(objectName); + + // Extract actual filters from query object if needed + let actualFilters = filters; + if (filters && !Array.isArray(filters) && filters.filters) { + actualFilters = filters.filters; + } + + // If no filters or empty object/array, return total count + if (!actualFilters || + (Array.isArray(actualFilters) && actualFilters.length === 0) || + (typeof actualFilters === 'object' && !Array.isArray(actualFilters) && Object.keys(actualFilters).length === 0)) { + return records.length; + } + + // Count only records matching filters + return records.filter(record => this.matchesFilters(record, actualFilters)).length; + } + + /** + * Get distinct values for a field. + */ + async distinct(objectName: string, field: string, filters?: any, options?: any): Promise { + const records = this.loadRecords(objectName); + const values = new Set(); + + for (const record of records) { + if (!filters || this.matchesFilters(record, filters)) { + const value = record[field]; + if (value !== undefined && value !== null) { + values.add(value); + } + } + } + + return Array.from(values); + } + + /** + * Create multiple records at once. + */ + async createMany(objectName: string, data: any[], options?: any): Promise { + const results = []; + for (const item of data) { + const result = await this.create(objectName, item, options); + results.push(result); + } + return results; + } + + /** + * Update multiple records matching filters. + */ + async updateMany(objectName: string, filters: any, data: any, options?: any): Promise { + const records = this.loadRecords(objectName); + let count = 0; + + for (let i = 0; i < records.length; i++) { + if (this.matchesFilters(records[i], filters)) { + const updated = { + ...records[i], + ...data, + id: records[i].id || records[i]._id, // Preserve ID + created_at: records[i].created_at, // Preserve created_at + updated_at: new Date().toISOString() + }; + records[i] = updated; + count++; + } + } + + if (count > 0) { + this.saveRecords(objectName, records); + } + + return { modifiedCount: count }; + } + + /** + * Delete multiple records matching filters. + */ + async deleteMany(objectName: string, filters: any, options?: any): Promise { + const records = this.loadRecords(objectName); + const initialCount = records.length; + + const remaining = records.filter(record => !this.matchesFilters(record, filters)); + const deletedCount = initialCount - remaining.length; + + if (deletedCount > 0) { + this.saveRecords(objectName, remaining); + } + + return { deletedCount }; + } + + /** + * Disconnect (flush cache). + */ + async disconnect(): Promise { + this.cache.clear(); + } + + /** + * Clear all data from a specific object. + * Useful for testing or data reset scenarios. + */ + async clear(objectName: string): Promise { + const filePath = this.getFilePath(objectName); + + // Remove file if exists + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + // Remove backup if exists + const backupPath = `${filePath}.bak`; + if (fs.existsSync(backupPath)) { + fs.unlinkSync(backupPath); + } + + // Clear cache + this.cache.delete(objectName); + this.idCounters.delete(objectName); + } + + /** + * Clear all data from all objects. + * Removes all JSON files in the data directory. + */ + async clearAll(): Promise { + const files = fs.readdirSync(this.config.dataDir); + + for (const file of files) { + if (file.endsWith('.json') || file.endsWith('.json.bak') || file.endsWith('.json.tmp')) { + const filePath = path.join(this.config.dataDir, file); + fs.unlinkSync(filePath); + } + } + + this.cache.clear(); + this.idCounters.clear(); + } + + /** + * Invalidate cache for a specific object. + * Forces reload from file on next access. + */ + invalidateCache(objectName: string): void { + this.cache.delete(objectName); + } + + /** + * Get the size of the cache (number of objects cached). + */ + getCacheSize(): number { + return this.cache.size; + } + + // ========== Helper Methods ========== + + /** + * Apply filters to an array of records. + * + * Supports ObjectQL filter format with logical operators (AND/OR): + * [ + * ['field', 'operator', value], + * 'or', + * ['field2', 'operator', value2] + * ] + */ + private applyFilters(records: any[], filters: any[]): any[] { + if (!filters || filters.length === 0) { + return records; + } + return records.filter(record => this.matchesFilters(record, filters)); + } + + /** + * Check if a single record matches the filter conditions. + */ + private matchesFilters(record: any, filters: any[]): boolean { + if (!filters || filters.length === 0) { + return true; + } + + let conditions: boolean[] = []; + let operators: string[] = []; + + for (const item of filters) { + if (typeof item === 'string') { + // Logical operator (and/or) + operators.push(item.toLowerCase()); + } else if (Array.isArray(item)) { + const [field, operator, value] = item; + + // Handle nested filter groups + if (typeof field !== 'string') { + conditions.push(this.matchesFilters(record, item)); + } else { + const matches = this.evaluateCondition(record[field], operator, value); + conditions.push(matches); + } + } + } + + // Combine conditions with operators + if (conditions.length === 0) { + return true; + } + + let result = conditions[0]; + for (let i = 0; i < operators.length && i + 1 < conditions.length; i++) { + const op = operators[i]; + const nextCondition = conditions[i + 1]; + + if (op === 'or') { + result = result || nextCondition; + } else { // 'and' or default + result = result && nextCondition; + } + } + + return result; + } + + /** + * Evaluate a single filter condition. + */ + private evaluateCondition(fieldValue: any, operator: string, compareValue: any): boolean { + switch (operator) { + case '=': + case '==': + return fieldValue === compareValue; + case '!=': + case '<>': + return fieldValue !== compareValue; + case '>': + return fieldValue > compareValue; + case '>=': + return fieldValue >= compareValue; + case '<': + return fieldValue < compareValue; + case '<=': + return fieldValue <= compareValue; + case 'in': + return Array.isArray(compareValue) && compareValue.includes(fieldValue); + case 'nin': + case 'not in': + return Array.isArray(compareValue) && !compareValue.includes(fieldValue); + case 'contains': + case 'like': + return String(fieldValue).toLowerCase().includes(String(compareValue).toLowerCase()); + case 'startswith': + case 'starts_with': + return String(fieldValue).toLowerCase().startsWith(String(compareValue).toLowerCase()); + case 'endswith': + case 'ends_with': + return String(fieldValue).toLowerCase().endsWith(String(compareValue).toLowerCase()); + case 'between': + return Array.isArray(compareValue) && + fieldValue >= compareValue[0] && + fieldValue <= compareValue[1]; + default: + throw new ObjectQLError({ + code: 'UNSUPPORTED_OPERATOR', + message: `[FileSystemDriver] Unsupported operator: ${operator}`, + }); + } + } + + /** + * Apply sorting to an array of records. + */ + private applySort(records: any[], sort: any[]): any[] { + const sorted = [...records]; + + // Apply sorts in reverse order for correct precedence + for (let i = sort.length - 1; i >= 0; i--) { + const sortItem = sort[i]; + + let field: string; + let direction: string; + + if (Array.isArray(sortItem)) { + [field, direction] = sortItem; + } else if (typeof sortItem === 'object') { + field = sortItem.field; + direction = sortItem.order || sortItem.direction || sortItem.dir || 'asc'; + } else { + continue; + } + + sorted.sort((a, b) => { + const aVal = a[field]; + const bVal = b[field]; + + // Handle null/undefined + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + + // Compare values + if (aVal < bVal) return direction === 'asc' ? -1 : 1; + if (aVal > bVal) return direction === 'asc' ? 1 : -1; + return 0; + }); + } + + return sorted; + } + + /** + * Project specific fields from a document. + */ + private projectFields(doc: any, fields: string[]): any { + const result: any = {}; + for (const field of fields) { + if (doc[field] !== undefined) { + result[field] = doc[field]; + } + } + return result; + } + + /** + * Generate a unique ID for a record. + * Uses timestamp + counter for uniqueness. + * Note: For production use with high-frequency writes, consider using crypto.randomUUID(). + */ + private generateId(objectName: string): string { + const counter = (this.idCounters.get(objectName) || 0) + 1; + this.idCounters.set(objectName, counter); + + // Use timestamp + counter for better uniqueness + const timestamp = Date.now(); + return `${objectName}-${timestamp}-${counter}`; + } +} diff --git a/packages/drivers/fs/test/index.test.ts b/packages/drivers/fs/test/index.test.ts new file mode 100644 index 00000000..c351cf9f --- /dev/null +++ b/packages/drivers/fs/test/index.test.ts @@ -0,0 +1,421 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { FileSystemDriver } from '../src/index'; + +describe('FileSystemDriver', () => { + const testDataDir = path.join(__dirname, '.test-data'); + let driver: FileSystemDriver; + + beforeEach(() => { + // Clean up test directory + if (fs.existsSync(testDataDir)) { + fs.rmSync(testDataDir, { recursive: true }); + } + + driver = new FileSystemDriver({ + dataDir: testDataDir, + prettyPrint: true, + enableBackup: true + }); + }); + + afterEach(async () => { + await driver.disconnect(); + + // Clean up test directory + if (fs.existsSync(testDataDir)) { + fs.rmSync(testDataDir, { recursive: true }); + } + }); + + describe('Basic CRUD Operations', () => { + test('should create a record', async () => { + const result = await driver.create('users', { + name: 'Alice', + email: 'alice@example.com' + }); + + expect(result).toHaveProperty('id'); + expect(result.name).toBe('Alice'); + expect(result.email).toBe('alice@example.com'); + expect(result).toHaveProperty('created_at'); + expect(result).toHaveProperty('updated_at'); + }); + + test('should create a file for the object', async () => { + await driver.create('users', { name: 'Bob' }); + + const filePath = path.join(testDataDir, 'users.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); + expect(Array.isArray(content)).toBe(true); + expect(content.length).toBe(1); + }); + + test('should find all records', async () => { + await driver.create('users', { name: 'Alice' }); + await driver.create('users', { name: 'Bob' }); + + const results = await driver.find('users', {}); + expect(results.length).toBe(2); + }); + + test('should find a single record by ID', async () => { + const created = await driver.create('users', { name: 'Charlie' }); + const found = await driver.findOne('users', created.id); + + expect(found).not.toBeNull(); + expect(found.name).toBe('Charlie'); + expect(found.id).toBe(created.id); + }); + + test('should update a record', async () => { + const created = await driver.create('users', { name: 'David', age: 25 }); + + // Add small delay to ensure different timestamp + await new Promise(resolve => setTimeout(resolve, 10)); + + const updated = await driver.update('users', created.id, { age: 26 }); + + expect(updated.age).toBe(26); + expect(updated.name).toBe('David'); + expect(updated.id).toBe(created.id); + expect(updated.updated_at).not.toBe(created.updated_at); + }); + + test('should delete a record', async () => { + const created = await driver.create('users', { name: 'Eve' }); + const deleted = await driver.delete('users', created.id); + + expect(deleted).toBe(true); + + const found = await driver.findOne('users', created.id); + expect(found).toBeNull(); + }); + + test('should throw error on duplicate ID', async () => { + await driver.create('users', { id: 'user-1', name: 'Alice' }); + + await expect( + driver.create('users', { id: 'user-1', name: 'Bob' }) + ).rejects.toThrow(); + }); + }); + + describe('Query Operations', () => { + beforeEach(async () => { + await driver.create('products', { id: 'p1', name: 'Laptop', price: 1000, category: 'electronics' }); + await driver.create('products', { id: 'p2', name: 'Mouse', price: 25, category: 'electronics' }); + await driver.create('products', { id: 'p3', name: 'Desk', price: 300, category: 'furniture' }); + await driver.create('products', { id: 'p4', name: 'Chair', price: 150, category: 'furniture' }); + }); + + test('should filter records with equality', async () => { + const results = await driver.find('products', { + filters: [['category', '=', 'electronics']] + }); + + expect(results.length).toBe(2); + expect(results.every(r => r.category === 'electronics')).toBe(true); + }); + + test('should filter records with comparison operators', async () => { + const results = await driver.find('products', { + filters: [['price', '>', 100]] + }); + + expect(results.length).toBe(3); + expect(results.every(r => r.price > 100)).toBe(true); + }); + + test('should filter with IN operator', async () => { + const results = await driver.find('products', { + filters: [['id', 'in', ['p1', 'p3']]] + }); + + expect(results.length).toBe(2); + expect(results.map(r => r.id).sort()).toEqual(['p1', 'p3']); + }); + + test('should filter with LIKE operator', async () => { + const results = await driver.find('products', { + filters: [['name', 'like', 'a']] + }); + + expect(results.length).toBe(2); // Laptop, Chair + }); + + test('should sort records', async () => { + const results = await driver.find('products', { + sort: [['price', 'asc']] + }); + + expect(results[0].name).toBe('Mouse'); + expect(results[3].name).toBe('Laptop'); + }); + + test('should paginate records', async () => { + const page1 = await driver.find('products', { + sort: [['price', 'asc']], + limit: 2 + }); + + expect(page1.length).toBe(2); + expect(page1[0].name).toBe('Mouse'); + + const page2 = await driver.find('products', { + sort: [['price', 'asc']], + skip: 2, + limit: 2 + }); + + expect(page2.length).toBe(2); + expect(page2[0].name).toBe('Desk'); + }); + + test('should project specific fields', async () => { + const results = await driver.find('products', { + fields: ['name', 'price'] + }); + + expect(results.length).toBe(4); + expect(results[0]).toHaveProperty('name'); + expect(results[0]).toHaveProperty('price'); + expect(results[0]).not.toHaveProperty('category'); + }); + + test('should count all records', async () => { + const count = await driver.count('products', {}); + expect(count).toBe(4); + }); + + test('should count filtered records', async () => { + const count = await driver.count('products', { + filters: [['category', '=', 'electronics']] + }); + expect(count).toBe(2); + }); + + test('should get distinct values', async () => { + const categories = await driver.distinct('products', 'category'); + expect(categories.sort()).toEqual(['electronics', 'furniture']); + }); + }); + + describe('Bulk Operations', () => { + test('should create many records', async () => { + const data = [ + { name: 'User 1' }, + { name: 'User 2' }, + { name: 'User 3' } + ]; + + const results = await driver.createMany('users', data); + expect(results.length).toBe(3); + + const all = await driver.find('users', {}); + expect(all.length).toBe(3); + }); + + test('should update many records', async () => { + await driver.create('tasks', { id: 't1', status: 'pending', priority: 'high' }); + await driver.create('tasks', { id: 't2', status: 'pending', priority: 'low' }); + await driver.create('tasks', { id: 't3', status: 'completed', priority: 'high' }); + + const result = await driver.updateMany( + 'tasks', + [['status', '=', 'pending']], + { status: 'in_progress' } + ); + + expect(result.modifiedCount).toBe(2); + + const updated = await driver.find('tasks', { + filters: [['status', '=', 'in_progress']] + }); + expect(updated.length).toBe(2); + }); + + test('should delete many records', async () => { + await driver.create('logs', { id: 'l1', level: 'info' }); + await driver.create('logs', { id: 'l2', level: 'debug' }); + await driver.create('logs', { id: 'l3', level: 'error' }); + + const result = await driver.deleteMany( + 'logs', + [['level', 'in', ['info', 'debug']]] + ); + + expect(result.deletedCount).toBe(2); + + const remaining = await driver.find('logs', {}); + expect(remaining.length).toBe(1); + expect(remaining[0].level).toBe('error'); + }); + }); + + describe('File System Operations', () => { + test('should create backup file on update', async () => { + const created = await driver.create('configs', { key: 'theme', value: 'dark' }); + + // First update - creates backup + await driver.update('configs', created.id, { value: 'light' }); + + const backupPath = path.join(testDataDir, 'configs.json.bak'); + expect(fs.existsSync(backupPath)).toBe(true); + }); + + test('should handle missing file gracefully', async () => { + const results = await driver.find('nonexistent', {}); + expect(results).toEqual([]); + }); + + test('should write pretty-printed JSON', async () => { + await driver.create('settings', { key: 'test', value: 'data' }); + + const filePath = path.join(testDataDir, 'settings.json'); + const content = fs.readFileSync(filePath, 'utf8'); + + // Pretty-printed JSON should have newlines + expect(content).toContain('\n'); + }); + + test('should handle concurrent writes', async () => { + // Create multiple records concurrently + await Promise.all([ + driver.create('concurrent', { name: 'Item 1' }), + driver.create('concurrent', { name: 'Item 2' }), + driver.create('concurrent', { name: 'Item 3' }) + ]); + + const results = await driver.find('concurrent', {}); + expect(results.length).toBe(3); + }); + }); + + describe('Edge Cases', () => { + test('should handle empty object name', async () => { + await expect( + driver.create('', { name: 'test' }) + ).rejects.toThrow(); + }); + + test('should preserve custom ID', async () => { + const result = await driver.create('items', { + id: 'custom-id-123', + name: 'Custom Item' + }); + + expect(result.id).toBe('custom-id-123'); + }); + + test('should handle _id field', async () => { + const result = await driver.create('docs', { + _id: 'mongo-style-id', + title: 'Document' + }); + + const found = await driver.findOne('docs', 'mongo-style-id'); + expect(found).not.toBeNull(); + expect(found.title).toBe('Document'); + }); + + test('should return null for non-existent record', async () => { + const found = await driver.findOne('users', 'non-existent-id'); + expect(found).toBeNull(); + }); + + test('should handle update of non-existent record', async () => { + const result = await driver.update('users', 'non-existent', { name: 'Test' }); + expect(result).toBeNull(); + }); + }); + + describe('New Features', () => { + test('should load initial data', async () => { + const newDriver = new FileSystemDriver({ + dataDir: path.join(testDataDir, 'initial'), + initialData: { + products: [ + { id: 'p1', name: 'Product 1', price: 100 }, + { id: 'p2', name: 'Product 2', price: 200 } + ] + } + }); + + const results = await newDriver.find('products', {}); + expect(results.length).toBe(2); + expect(results[0].name).toBe('Product 1'); + + await newDriver.disconnect(); + }); + + test('should clear specific object data', async () => { + await driver.create('temp', { name: 'Test' }); + + const before = await driver.find('temp', {}); + expect(before.length).toBe(1); + + await driver.clear('temp'); + + const after = await driver.find('temp', {}); + expect(after.length).toBe(0); + }); + + test('should clear all data', async () => { + await driver.create('obj1', { name: 'Test 1' }); + await driver.create('obj2', { name: 'Test 2' }); + + await driver.clearAll(); + + const obj1 = await driver.find('obj1', {}); + const obj2 = await driver.find('obj2', {}); + + expect(obj1.length).toBe(0); + expect(obj2.length).toBe(0); + }); + + test('should invalidate cache', async () => { + await driver.create('cache_test', { name: 'Test' }); + + // Verify cache is populated + expect(driver.getCacheSize()).toBeGreaterThan(0); + + driver.invalidateCache('cache_test'); + + // Cache should reload on next access + const results = await driver.find('cache_test', {}); + expect(results.length).toBe(1); + }); + + test('should get cache size', async () => { + await driver.create('size1', { name: 'Test 1' }); + await driver.create('size2', { name: 'Test 2' }); + + const size = driver.getCacheSize(); + expect(size).toBeGreaterThanOrEqual(2); + }); + + test('should handle empty JSON file', async () => { + const filePath = path.join(testDataDir, 'empty.json'); + fs.writeFileSync(filePath, '', 'utf8'); + + const results = await driver.find('empty', {}); + expect(results).toEqual([]); + }); + + test('should handle invalid JSON file', async () => { + const filePath = path.join(testDataDir, 'invalid.json'); + fs.writeFileSync(filePath, '{invalid json}', 'utf8'); + + try { + await driver.find('invalid', {}); + fail('Should have thrown an error'); + } catch (error: any) { + expect(error.code).toBe('INVALID_JSON_FORMAT'); + expect(error.message).toContain('invalid JSON'); + } + }); + }); +}); diff --git a/packages/drivers/fs/tsconfig.json b/packages/drivers/fs/tsconfig.json new file mode 100644 index 00000000..a5d4f5c8 --- /dev/null +++ b/packages/drivers/fs/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "paths": { + "@objectql/types": ["../../foundation/types/src"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"], + "references": [ + { "path": "../../foundation/types" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ad453d1..58c10449 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,25 @@ importers: specifier: ^5.0.0 version: 5.9.3 + examples/drivers/fs-demo: + dependencies: + '@objectql/core': + specifier: workspace:* + version: link:../../../packages/foundation/core + '@objectql/driver-fs': + specifier: workspace:* + version: link:../../../packages/drivers/fs + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.29 + ts-node: + specifier: ^10.9.0 + version: 10.9.2(@types/node@20.19.29)(typescript@5.9.3) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + examples/integrations/browser: dependencies: '@objectql/core': @@ -258,6 +277,25 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/drivers/fs: + dependencies: + '@objectql/types': + specifier: workspace:* + version: link:../../foundation/types + devDependencies: + '@types/jest': + specifier: ^29.0.0 + version: 29.5.14 + '@types/node': + specifier: ^20.0.0 + version: 20.19.29 + jest: + specifier: ^29.0.0 + version: 29.7.0(@types/node@20.19.29)(ts-node@10.9.2(@types/node@20.19.29)(typescript@5.9.3)) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages/drivers/localstorage: dependencies: '@objectql/types': @@ -640,42 +678,22 @@ packages: resolution: {integrity: sha512-xRSAfH27bIp3vtjtTFyyhdm18lq2pzdoNG7DA2IH1fXzJ30mymryv0wK/Gph+x4y0Rx+5mMLU5JTPiCeQ75Aug==} engines: {node: '>=16'} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.28.6': resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.5': - resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} - engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.6': resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.5': - resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} - engines: {node: '>=6.9.0'} - '@babel/core@7.28.6': resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.28.6': resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} @@ -684,30 +702,16 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} - engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} @@ -724,19 +728,10 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} - engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.28.6': resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} engines: {node: '>=6.0.0'} @@ -837,26 +832,14 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.6': resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - '@babel/types@7.28.6': resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} @@ -6004,42 +5987,14 @@ snapshots: jsonwebtoken: 9.0.3 uuid: 8.3.2 - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.28.6': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.5': {} - '@babel/compat-data@7.28.6': {} - '@babel/core@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/core@7.28.6': dependencies: '@babel/code-frame': 7.28.6 @@ -6060,14 +6015,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.5': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.28.6': dependencies: '@babel/parser': 7.28.6 @@ -6076,14 +6023,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': - dependencies: - '@babel/compat-data': 7.28.5 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.28.6 @@ -6094,13 +6033,6 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.28.6 @@ -6108,15 +6040,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 @@ -6126,8 +6049,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.27.1': {} - '@babel/helper-plugin-utils@7.28.6': {} '@babel/helper-string-parser@7.27.1': {} @@ -6136,178 +6057,94 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.4': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - '@babel/helpers@7.28.6': dependencies: '@babel/template': 7.28.6 '@babel/types': 7.28.6 - '@babel/parser@7.28.5': - dependencies: - '@babel/types': 7.28.5 - '@babel/parser@7.28.6': dependencies: '@babel/types': 7.28.6 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.6)': dependencies: @@ -6316,30 +6153,12 @@ snapshots: '@babel/runtime@7.28.6': {} - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.28.6 '@babel/parser': 7.28.6 '@babel/types': 7.28.6 - '@babel/traverse@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.28.6': dependencies: '@babel/code-frame': 7.28.6 @@ -6352,11 +6171,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.28.6': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -8169,26 +7983,13 @@ snapshots: b4a@1.7.3: {} - babel-jest@29.7.0(@babel/core@7.28.5): + babel-jest@29.7.0(@babel/core@7.28.6): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.28.6 '@jest/transform': 29.7.0 '@types/babel__core': 7.20.5 babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.28.5) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - - babel-jest@30.2.0(@babel/core@7.28.5): - dependencies: - '@babel/core': 7.28.5 - '@jest/transform': 30.2.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 7.0.1 - babel-preset-jest: 30.2.0(@babel/core@7.28.5) + babel-preset-jest: 29.6.3(@babel/core@7.28.6) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -8207,7 +8008,6 @@ snapshots: slash: 3.0.0 transitivePeerDependencies: - supports-color - optional: true babel-plugin-istanbul@6.1.1: dependencies: @@ -8240,25 +8040,6 @@ snapshots: dependencies: '@types/babel__core': 7.20.5 - babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.5): - dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.5) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.5) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.5) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.5) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.5) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) - babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.6): dependencies: '@babel/core': 7.28.6 @@ -8278,24 +8059,17 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.6) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.6) - babel-preset-jest@29.6.3(@babel/core@7.28.5): + babel-preset-jest@29.6.3(@babel/core@7.28.6): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.28.6 babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) - - babel-preset-jest@30.2.0(@babel/core@7.28.5): - dependencies: - '@babel/core': 7.28.5 - babel-plugin-jest-hoist: 30.2.0 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6) babel-preset-jest@30.2.0(@babel/core@7.28.6): dependencies: '@babel/core': 7.28.6 babel-plugin-jest-hoist: 30.2.0 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6) - optional: true balanced-match@1.0.2: {} @@ -9772,10 +9546,10 @@ snapshots: jest-config@29.7.0(@types/node@20.19.29)(ts-node@10.9.2(@types/node@20.19.29)(typescript@5.9.3)): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.28.6 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) + babel-jest: 29.7.0(@babel/core@7.28.6) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -9803,12 +9577,12 @@ snapshots: jest-config@30.2.0(@types/node@20.19.29)(ts-node@10.9.2(@types/node@20.19.29)(typescript@5.9.3)): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.28.6 '@jest/get-type': 30.1.0 '@jest/pattern': 30.0.1 '@jest/test-sequencer': 30.2.0 '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.28.5) + babel-jest: 30.2.0(@babel/core@7.28.6) chalk: 4.1.2 ci-info: 4.3.1 deepmerge: 4.3.1