From 46228887c229a2c0c4523366d8aaf140551bf73b Mon Sep 17 00:00:00 2001 From: IbnAdam Date: Fri, 2 Jan 2026 20:41:21 +0100 Subject: [PATCH] Refactor: Convert to TypeScript, ESM, translate ALL COMMENTS & DOC files to English, and 'Typescriptify' the project structure --- .github/workflows/nodejs.yml | 10 +- cli/actions.js | 211 --- cli/index.js | 69 - cli/utils.js | 87 -- docs/00 - Introduction and Setup.md | 136 ++ docs/00-introduction-and-setup.md | 137 -- docs/01-collections-and-documents.md | 295 ++--- docs/02-querying-and-indexing.md | 222 ++-- docs/03-transactions.md | 125 +- docs/04-advanced-features.md | 252 ++-- docs/05-common-scenarios-cheatsheet.md | 242 ++-- docs/06-troubleshooting.md | 202 +-- docs/07-sync.md | 223 ++-- explorer/schema-analyzer.js | 125 -- explorer/schema-analyzer.ts | 159 +++ explorer/seed.js | 141 -- explorer/seed.ts | 174 +++ explorer/server.js | 102 -- explorer/server.ts | 247 ++++ explorer/utils.js | 53 - explorer/utils.ts | 58 + explorer/views/components/db-map.js | 259 ---- explorer/views/components/db-map.ts | 252 ++++ explorer/views/components/json-viewer.js | 114 -- explorer/views/components/json-viewer.ts | 126 ++ explorer/views/components/query-builder.js | 224 ---- explorer/views/components/query-builder.ts | 206 +++ .../views/components/toast-notificationa.ts | 90 ++ .../views/components/toast-notifications.js | 67 - explorer/views/index.html | 19 +- explorer/views/script.js | 332 ----- explorer/views/script.ts | 251 ++++ explorer/views/styles.css | 8 +- package.json | 41 +- pnpm-lock.yaml | 1151 +++++++++++++++++ pnpm-workspace.yaml | 2 + src/index.ts | 87 ++ src/lib/checkpoint-manager.ts | 207 +++ src/lib/cli/actions.ts | 219 ++++ src/lib/cli/index.ts | 88 ++ src/lib/cli/utils.ts | 101 ++ src/lib/collection/_ops-turned-BASE.ts | 294 +++++ .../_query-ops-turned-QUERY-BASE.ts | 377 ++++++ src/lib/collection/base.ts | 251 ++++ src/lib/collection/core.ts | 661 ++++++++++ src/lib/collection/data-exchange.ts | 111 ++ src/lib/collection/events.ts | 105 ++ src/lib/collection/file-lock.ts | 43 + src/lib/collection/indexes.ts | 273 ++++ src/lib/collection/query.base.ts | 475 +++++++ src/lib/collection/queue.ts | 68 + src/lib/collection/transaction-manager.ts | 155 +++ src/lib/collection/ttl.ts | 71 + src/lib/collection/utils.ts | 177 +++ src/lib/collection/wal-ops.ts | 181 +++ src/lib/errors.ts | 59 + src/lib/index.ts | 141 ++ src/lib/logger.ts | 93 ++ src/lib/storage-utils.ts | 128 ++ src/lib/sync/api-client.ts | 161 +++ .../lib/sync/sync-manager.ts | 140 +- src/lib/types.ts | 596 +++++++++ .../wal-manager.js => src/lib/wal-manager.ts | 203 +-- test/cli-all-exclude.ts | 150 +++ test/cli-all.js | 146 --- test/cli-and-api-all-exclude.ts | 210 +++ test/cli-and-api-all.js | 159 --- test/db-advanced-scenarios.js | 353 ----- test/db-advanced-scenarios.ts | 247 ++++ ...nded-api-all.js => db-extended-api-all.ts} | 130 +- test/db-functional-all.js | 110 -- test/db-functional-all.ts | 173 +++ test/{db-queries-all.js => db-queries-all.ts} | 116 +- test/db-sync-all.js | 211 --- test/db-sync-all.ts | 266 ++++ test/db-ttl-all.js | 72 -- test/db-ttl-all.ts | 107 ++ test/db-txn-batch-all.js | 62 - test/db-txn-batch-all.ts | 103 ++ test/db-unique-index-all.js | 88 -- test/db-unique-index-all.ts | 112 ++ test/run-all-tests.js | 88 -- test/run-all-tests.ts | 86 ++ test/server-ready-api-all.js | 96 -- test/server-ready-api-all.ts | 130 ++ test/test-index-proxy-all.js | 76 -- test/test-index-proxy-all.ts | 108 ++ tsconfig.json | 17 + wise-json/checkpoint-manager.js | 199 --- wise-json/collection/checkpoints.js | 124 -- wise-json/collection/core.js | 718 ---------- wise-json/collection/data-exchange.js | 123 -- wise-json/collection/events.js | 71 - wise-json/collection/file-lock.js | 42 - wise-json/collection/indexes.js | 259 ---- wise-json/collection/ops.js | 261 ---- wise-json/collection/query-ops.js | 349 ----- wise-json/collection/queue.js | 49 - wise-json/collection/transaction-manager.js | 133 -- wise-json/collection/ttl.js | 106 -- wise-json/collection/utils.js | 170 --- wise-json/collection/wal-ops.js | 198 --- wise-json/errors.js | 71 - wise-json/index.js | 189 --- wise-json/logger.js | 95 -- wise-json/storage-utils.js | 155 --- wise-json/sync/api-client.js | 138 -- 107 files changed, 10871 insertions(+), 7872 deletions(-) delete mode 100644 cli/actions.js delete mode 100644 cli/index.js delete mode 100644 cli/utils.js create mode 100644 docs/00 - Introduction and Setup.md delete mode 100644 docs/00-introduction-and-setup.md delete mode 100644 explorer/schema-analyzer.js create mode 100644 explorer/schema-analyzer.ts delete mode 100644 explorer/seed.js create mode 100644 explorer/seed.ts delete mode 100644 explorer/server.js create mode 100644 explorer/server.ts delete mode 100644 explorer/utils.js create mode 100644 explorer/utils.ts delete mode 100644 explorer/views/components/db-map.js create mode 100644 explorer/views/components/db-map.ts delete mode 100644 explorer/views/components/json-viewer.js create mode 100644 explorer/views/components/json-viewer.ts delete mode 100644 explorer/views/components/query-builder.js create mode 100644 explorer/views/components/query-builder.ts create mode 100644 explorer/views/components/toast-notificationa.ts delete mode 100644 explorer/views/components/toast-notifications.js delete mode 100644 explorer/views/script.js create mode 100644 explorer/views/script.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/index.ts create mode 100644 src/lib/checkpoint-manager.ts create mode 100644 src/lib/cli/actions.ts create mode 100644 src/lib/cli/index.ts create mode 100644 src/lib/cli/utils.ts create mode 100644 src/lib/collection/_ops-turned-BASE.ts create mode 100644 src/lib/collection/_query-ops-turned-QUERY-BASE.ts create mode 100644 src/lib/collection/base.ts create mode 100644 src/lib/collection/core.ts create mode 100644 src/lib/collection/data-exchange.ts create mode 100644 src/lib/collection/events.ts create mode 100644 src/lib/collection/file-lock.ts create mode 100644 src/lib/collection/indexes.ts create mode 100644 src/lib/collection/query.base.ts create mode 100644 src/lib/collection/queue.ts create mode 100644 src/lib/collection/transaction-manager.ts create mode 100644 src/lib/collection/ttl.ts create mode 100644 src/lib/collection/utils.ts create mode 100644 src/lib/collection/wal-ops.ts create mode 100644 src/lib/errors.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/storage-utils.ts create mode 100644 src/lib/sync/api-client.ts rename wise-json/sync/sync-manager.js => src/lib/sync/sync-manager.ts (69%) create mode 100644 src/lib/types.ts rename wise-json/wal-manager.js => src/lib/wal-manager.ts (56%) create mode 100644 test/cli-all-exclude.ts delete mode 100644 test/cli-all.js create mode 100644 test/cli-and-api-all-exclude.ts delete mode 100644 test/cli-and-api-all.js delete mode 100644 test/db-advanced-scenarios.js create mode 100644 test/db-advanced-scenarios.ts rename test/{db-extended-api-all.js => db-extended-api-all.ts} (52%) delete mode 100644 test/db-functional-all.js create mode 100644 test/db-functional-all.ts rename test/{db-queries-all.js => db-queries-all.ts} (62%) delete mode 100644 test/db-sync-all.js create mode 100644 test/db-sync-all.ts delete mode 100644 test/db-ttl-all.js create mode 100644 test/db-ttl-all.ts delete mode 100644 test/db-txn-batch-all.js create mode 100644 test/db-txn-batch-all.ts delete mode 100644 test/db-unique-index-all.js create mode 100644 test/db-unique-index-all.ts delete mode 100644 test/run-all-tests.js create mode 100644 test/run-all-tests.ts delete mode 100644 test/server-ready-api-all.js create mode 100644 test/server-ready-api-all.ts delete mode 100644 test/test-index-proxy-all.js create mode 100644 test/test-index-proxy-all.ts create mode 100644 tsconfig.json delete mode 100644 wise-json/checkpoint-manager.js delete mode 100644 wise-json/collection/checkpoints.js delete mode 100644 wise-json/collection/core.js delete mode 100644 wise-json/collection/data-exchange.js delete mode 100644 wise-json/collection/events.js delete mode 100644 wise-json/collection/file-lock.js delete mode 100644 wise-json/collection/indexes.js delete mode 100644 wise-json/collection/ops.js delete mode 100644 wise-json/collection/query-ops.js delete mode 100644 wise-json/collection/queue.js delete mode 100644 wise-json/collection/transaction-manager.js delete mode 100644 wise-json/collection/ttl.js delete mode 100644 wise-json/collection/utils.js delete mode 100644 wise-json/collection/wal-ops.js delete mode 100644 wise-json/errors.js delete mode 100644 wise-json/index.js delete mode 100644 wise-json/logger.js delete mode 100644 wise-json/storage-utils.js delete mode 100644 wise-json/sync/api-client.js diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index ac58a51..5e86954 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -12,6 +12,7 @@ jobs: strategy: matrix: + # We keep the matrix to ensure compatibility across active LTS versions node-version: [18.x, 20.x, 22.x] steps: @@ -27,5 +28,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests - run: npm test \ No newline at end of file + # 1. First, verify the TypeScript code actually compiles + - name: Type Check & Build + run: npx tsc + + # 2. Run your new orchestrated test suite + - name: Run Integration Tests + run: npx tsx test/run-all-tests.ts \ No newline at end of file diff --git a/cli/actions.js b/cli/actions.js deleted file mode 100644 index bd99a0e..0000000 --- a/cli/actions.js +++ /dev/null @@ -1,211 +0,0 @@ -// cli/actions.js - -const fs = require('fs/promises'); -const path = require('path'); -const { flattenDocToCsv } = require('../wise-json/collection/utils.js'); -const { confirmAction, prettyError } = require('./utils.js'); - -// --- Утилита для проверки существования коллекции --- -async function assertCollectionExists(db, collectionName) { - const names = await db.getCollectionNames(); - if (!names.includes(collectionName)) { - prettyError(`Collection "${collectionName}" does not exist.`); - } -} - -// ============================= -// --- Read-Only Actions --- -// ============================= - -async function listCollectionsAction(db) { - const collections = await db.getCollectionNames(); - const result = await Promise.all(collections.map(async (name) => { - const col = await db.collection(name); - await col.initPromise; - return { name, count: await col.count() }; - })); - if (result.length === 0) { - console.log('No collections found.'); - return; - } - console.table(result); -} - -async function showCollectionAction(db, [collectionName], options) { - if (!collectionName) prettyError('Usage: show-collection [options]'); - await assertCollectionExists(db, collectionName); - - const col = await db.collection(collectionName); - await col.initPromise; - - const limit = parseInt(options.limit || '10', 10); - const offset = parseInt(options.offset || '0', 10); - const sortField = options.sort; - const sortOrder = options.order || 'asc'; - const output = options.output || 'json'; - - let filter = {}; - if (options.filter) { - try { - filter = JSON.parse(options.filter); - } catch (e) { - prettyError(`Invalid JSON in --filter option: ${e.message}`); - } - } - - let docs = await col.find(filter); - - if (sortField) { - docs.sort((a, b) => { - if (a[sortField] === undefined) return 1; - if (b[sortField] === undefined) return -1; - if (a[sortField] < b[sortField]) return sortOrder === 'asc' ? -1 : 1; - if (a[sortField] > b[sortField]) return sortOrder === 'asc' ? 1 : -1; - return 0; - }); - } - - docs = docs.slice(offset, offset + limit); - - if (output === 'csv') console.log(flattenDocToCsv(docs)); - else if (output === 'table') console.table(docs); - else console.log(JSON.stringify(docs, null, 2)); -} - -async function listIndexesAction(db, [collectionName]) { - if (!collectionName) prettyError('Usage: list-indexes '); - await assertCollectionExists(db, collectionName); - const col = await db.collection(collectionName); - await col.initPromise; - console.log(JSON.stringify(await col.getIndexes(), null, 2)); -} - -async function getDocumentAction(db, [collectionName, docId]) { - if (!collectionName || !docId) prettyError('Usage: get-document '); - await assertCollectionExists(db, collectionName); - const col = await db.collection(collectionName); - await col.initPromise; - const doc = await col.getById(docId); - if (!doc) { - prettyError(`Document with ID "${docId}" not found in collection "${collectionName}".`); - } - console.log(JSON.stringify(doc, null, 2)); -} - -async function exportCollectionAction(db, [collectionName, filePath], options) { - if (!collectionName || !filePath) prettyError('Usage: export-collection '); - await assertCollectionExists(db, collectionName); - - const col = await db.collection(collectionName); - await col.initPromise; - - const outputFormat = options.output || 'json'; - const absoluteFilePath = path.resolve(process.cwd(), filePath); - - try { - if (outputFormat === 'csv') { - await col.exportCsv(absoluteFilePath); - } else { - await col.exportJson(absoluteFilePath); - } - console.log(`Collection "${collectionName}" exported to ${absoluteFilePath} as ${outputFormat}.`); - } catch (e) { - prettyError(`Failed to export to file: ${e.message}`); - } -} - -// ============================= -// --- Write Actions --- -// ============================= - -async function createIndexAction(db, [collectionName, fieldName], options) { - if (!collectionName || !fieldName) prettyError('Usage: create-index [--unique]'); - const col = await db.collection(collectionName); - await col.initPromise; - await col.createIndex(fieldName, { unique: !!options.unique }); - console.log(`Index on "${fieldName}" created successfully in collection "${collectionName}".`); -} - -async function dropIndexAction(db, [collectionName, fieldName]) { - if (!collectionName || !fieldName) prettyError('Usage: drop-index '); - await assertCollectionExists(db, collectionName); - const col = await db.collection(collectionName); - await col.initPromise; - await col.dropIndex(fieldName); - console.log(`Index on "${fieldName}" dropped from collection "${collectionName}".`); -} - -async function importCollectionAction(db, [collectionName, filePath], options) { - if (!collectionName || !filePath) prettyError('Usage: import-collection [--mode=replace|append]'); - - const col = await db.collection(collectionName); - await col.initPromise; - - const mode = options.mode || 'append'; - const absoluteFilePath = path.resolve(process.cwd(), filePath); - - try { - await col.importJson(absoluteFilePath, { mode }); - console.log(`Import to "${collectionName}" from ${absoluteFilePath} completed (mode: ${mode}).`); - } catch(e) { - prettyError(`Failed to import from file: ${e.message}`); - } -} - -async function dropCollectionAction(db, [collectionName], options) { - if (!collectionName) prettyError('Usage: collection-drop '); - await assertCollectionExists(db, collectionName); - - const confirmed = await confirmAction(`Are you sure you want to PERMANENTLY delete the collection "${collectionName}"?`, options); - - if (confirmed) { - const collectionPath = path.join(db.dbRootPath, collectionName); - await fs.rm(collectionPath, { recursive: true, force: true }); - console.log(`Collection "${collectionName}" dropped successfully.`); - } else { - console.log('Operation cancelled.'); - } -} - -async function insertDocumentAction(db, [collectionName, jsonString]) { - if (!collectionName || !jsonString) prettyError('Usage: doc-insert '); - const col = await db.collection(collectionName); - await col.initPromise; - try { - const doc = JSON.parse(jsonString); - const inserted = await col.insert(doc); - console.log(JSON.stringify(inserted, null, 2)); - } catch (e) { - prettyError(`Failed to insert document. Invalid JSON or db error: ${e.message}`); - } -} - -async function removeDocumentAction(db, [collectionName, docId]) { - if (!collectionName || !docId) prettyError('Usage: doc-remove '); - await assertCollectionExists(db, collectionName); - const col = await db.collection(collectionName); - await col.initPromise; - const success = await col.remove(docId); - if (!success) { - prettyError(`Document with ID "${docId}" not found for removal.`); - } - console.log(`Document "${docId}" removed from "${collectionName}".`); -} - -// --- Реестр команд --- -module.exports = { - // Read-only - 'list-collections': { handler: listCollectionsAction, isWrite: false, description: 'Lists all collections and their document counts.' }, - 'show-collection': { handler: showCollectionAction, isWrite: false, description: 'Shows documents in a collection with pagination and filtering.' }, - 'list-indexes': { handler: listIndexesAction, isWrite: false, description: 'Lists indexes for a collection.'}, - 'get-document': { handler: getDocumentAction, isWrite: false, description: 'Gets a single document by its ID.'}, - 'export-collection':{ handler: exportCollectionAction,isWrite: false, description: 'Exports a collection to a file. Use --output=csv for CSV.' }, - - // Write-enabled - 'create-index': { handler: createIndexAction, isWrite: true, description: 'Creates an index on a field. Use --unique for a unique index.' }, - 'drop-index': { handler: dropIndexAction, isWrite: true, description: 'Drops an index from a collection.' }, - 'import-collection':{ handler: importCollectionAction,isWrite: true, description: 'Imports documents from a JSON file. Use --mode=replace to clear first.' }, - 'collection-drop': { handler: dropCollectionAction, isWrite: true, description: 'Permanently deletes an entire collection. Use with caution.' }, - 'doc-insert': { handler: insertDocumentAction, isWrite: true, description: 'Inserts a single document from a JSON string.' }, - 'doc-remove': { handler: removeDocumentAction, isWrite: true, description: 'Removes a single document by its ID.' }, -}; \ No newline at end of file diff --git a/cli/index.js b/cli/index.js deleted file mode 100644 index 255111a..0000000 --- a/cli/index.js +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env node - -const path = require('path'); -const WiseJSON = require('../wise-json/index.js'); -const commandRegistry = require('./actions.js'); -const { parseArgs, prettyError } = require('./utils.js'); - -const DB_PATH = process.env.WISE_JSON_PATH || path.resolve(process.cwd(), 'wise-json-db-data'); - -function printHelp() { - console.log('WiseJSON DB Unified CLI\n'); - console.log('Usage: wise-json [args...] [--options...]\n'); - console.log('Global Options:'); - console.log(' --allow-write Required for any command that modifies data.'); - console.log(' --force, --yes Skip confirmation prompts for dangerous operations.'); - console.log(' --json-errors Output errors in JSON format.'); - console.log(' --help Show this help message.\n'); - console.log('Available Commands:'); - - // Форматируем вывод помощи - const commands = Object.entries(commandRegistry); - const maxLen = Math.max(...commands.map(([name]) => name.length)); - - commands.forEach(([name, { description }]) => { - console.log(` ${name.padEnd(maxLen + 2)} ${description || ''}`); - }); -} - -async function main() { - const allCliArgs = process.argv.slice(2); - const { args, options } = parseArgs(allCliArgs); - const commandName = args.shift(); - - if (!commandName || options.help) { - printHelp(); - return; - } - - const command = commandRegistry[commandName]; - if (!command) { - return prettyError(`Unknown command: "${commandName}". Use --help for usage.`); - } - - if (command.isWrite && !options['allow-write']) { - return prettyError(`Write command "${commandName}" requires the --allow-write flag.`); - } - - const db = new WiseJSON(DB_PATH, { - ttlCleanupIntervalMs: 0, - checkpointIntervalMs: 0, - }); - - try { - await db.init(); - // Передаем весь контекст в обработчик - await command.handler(db, args, options); - } finally { - if (db) { - await db.close(); - } - } -} - -// Перехватываем ошибки и выводим через нашу утилиту -main().catch(err => { - // Проверяем, есть ли опция json-errors в оригинальных аргументах - const jsonErrors = process.argv.slice(2).includes('--json-errors'); - prettyError(err.message, { json: jsonErrors }); -}); \ No newline at end of file diff --git a/cli/utils.js b/cli/utils.js deleted file mode 100644 index 5c55172..0000000 --- a/cli/utils.js +++ /dev/null @@ -1,87 +0,0 @@ -// cli/utils.js - -const readline = require('readline'); -const logger = require('../wise-json/logger'); - -/** - * Продвинутый парсер аргументов командной строки. - * Разделяет позиционные аргументы и именованные опции (флаги). - * Корректно обрабатывает значения, содержащие '='. - * Поддерживает: --flag, --option=value - * @param {string[]} rawCliArgs - Массив process.argv.slice(2). - * @returns {{args: string[], options: object}} - */ -function parseArgs(rawCliArgs) { - const options = {}; - const args = []; - - for (const arg of rawCliArgs) { - if (arg.startsWith('--')) { - const parts = arg.slice(2).split('='); - const key = parts[0]; - // Все, что после первого '=', - это значение. - const value = parts.slice(1).join('='); - - // Флаг без значения (e.g., --force, --unique) - if (value === '') { - options[key] = true; - } else { - // Опция со значением (e.g., --limit=10) - options[key] = value; - } - } else { - // Это позиционный аргумент - args.push(arg); - } - } - return { args, options }; -} - -/** - * Выводит форматированную ошибку и завершает процесс. - * @param {string} msg - Сообщение об ошибке. - * @param {object} [options={}] - * @param {boolean} [options.json=false] - Выводить ошибку в формате JSON. - * @param {number} [options.code=1] - Код завершения процесса. - */ -function prettyError(msg, { json = false, code = 1 } = {}) { - if (json) { - console.error(JSON.stringify({ error: true, message: msg, code })); - } else { - logger.error(msg); - } - process.exit(code); -} - -/** - * Запрашивает у пользователя подтверждение в интерактивном режиме. - * @param {string} prompt - Вопрос для пользователя. - * @param {object} options - Опции, полученные из parseArgs. - * @returns {Promise} - */ -async function confirmAction(prompt, options) { - if (options.force || options.yes) { - return true; - } - - if (!process.stdin.isTTY) { - // В неинтерактивной среде (например, в тестах `execSync` без tty) - // запрос на ввод заблокирует процесс. Считаем, что пользователь не согласился. - // Это заставит тест `collection-drop` без `--force` упасть, что является правильным поведением. - return false; - } - - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - return new Promise(resolve => { - rl.question(`${prompt} [y/N] `, answer => { - rl.close(); - resolve(answer.toLowerCase() === 'y'); - }); - }); -} - -module.exports = { - parseArgs, - prettyError, - confirmAction, -}; \ No newline at end of file diff --git a/docs/00 - Introduction and Setup.md b/docs/00 - Introduction and Setup.md new file mode 100644 index 0000000..fafb038 --- /dev/null +++ b/docs/00 - Introduction and Setup.md @@ -0,0 +1,136 @@ +# 00 - Introduction and Configuration + +Welcome to WiseJSON DB—a fast, reliable, and easy-to-use embeddable JSON database for Node.js. It's designed for high performance and data durability thanks to logging (WAL), checkpoints, atomic transactions, and index support. + +This document will help you get started quickly with WiseJSON DB. + +## Key Concepts + +* **Database:** Physical storage on disk, represented by a single directory. Contains one or more collections. +* **Collection:** Analogous to a table in SQL or a collection in MongoDB. It's a named group of JSON documents. +* **Document:** A single record in a collection, represented by a JavaScript object. Each document has a unique _id field. + +## Installation + +Install the package using npm or yarn: + +```bash +npm install wise-json-db +# or +yarn add wise-json-db +``` +This will install the `wise-json-db` package and all required dependencies (`uuid`, `proper-lockfile`). + +## Quick Start + +This example shows the full workflow: initialization, creation, reading, updating, and deleting data. + +```javascript +// Include the library +const WiseJSON = require('wise-json-db'); +const path = require('path'); + +async function main() { +// 1. Specify the path where the database will be stored. +const dbPath = path.resolve(__dirname, 'myAppData'); + +// 2. Create or open a DB instance and wait for it to initialize. +const db = new WiseJSON(dbPath); +await db.init(); + +// 3. Get (or create) the 'users' collection and wait for it to be ready. +const users = await db.collection('users'); +await users.initPromise; + +// To keep the example clean, we'll clear the collection before starting. +await users.clear(); + +// 4. Insert documents. +await users.insert({ name: 'Alice', age: 30, city: 'New York' }); +await users.insertMany([ +{ name: 'Bob', age: 25, city: 'London' }, +{ name: 'Charlie', age: 35, city: 'New York' } +]); +console.log(`After inserting a document into the collection, ${await users.count()}.`); + +// 5. Search documents +const userBob = await users.findOne({ name: 'Bob' }); +console.log('Found Bob:', userBob); + +const usersFromNY = await users.find({ city: 'New York' }); +console.log(`Users from New York: ${usersFromNY.length}`); + +// 6. Update the document +if (userBob) { +await users.update(userBob._id, { age: 26, status: 'active' }); +const updatedBob = await users.getById(userBob._id); +console.log('Updated Bob:', updatedBob); +} + +// 7. Delete the document +const charlie = await users.findOne({ name: 'Charlie' }); +if (charlie) { +await users.remove(charlie._id); +console.log(`User Charlie has been removed. Documents remaining: ${await users.count()}`); +} + +// 8. Be sure to close the database to save all changes. +await db.close(); +console.log('The database has been closed.'); +} + +main().catch(console.error); +``` + +## Public API Structure + +The main export of the `wise-json-db` package provides access to key classes and functions: + +```javascript +const { +WiseJSON, // Main database class +Collection, // Collection class (for type hinting or extensions) +SyncManager, // Sync manager (for advanced scenarios) +// and other utilities... +} = require('wise-json-db'); +``` + +### `new WiseJSON(dbPath, [options])` + +Constructor for creating a DB instance. + +* `dbPath {string}`: Path to the database root directory. +* `options {CollectionOptions}` (optional): Object for fine-tuning. + +### `db` Instance Methods + +* **`await db.init(): Promise`**: Asynchronously initializes the database. **Must be called** after creating the instance. +* **`await db.collection(name): Promise`**: Returns a collection instance. Don't forget to await `collection.initPromise`. +* **`await db.close(): Promise`**: Gracefully closes the database, saving all unsaved data and releasing locks. **Must be called** before application termination. +* **`db.beginTransaction(): TransactionManager`**: Begins a new transaction. + +### Main Collection Methods + +| Method | Description | +| ------------------------------ | -------------------------------------------------------------------------- | +| `await collection.insert(doc)` | Insert a single document. | +| `await collection.insertMany(docs)`| Insert an array of documents. | +| `await collection.find(filter)` | Find all documents matching the filter (query object). | +| `await collection.findOne(filter)` | Find the first document matching the filter. | +| `await collection.update(id, data)`| Partially update a document by its `_id`. | +| `await collection.updateMany(filter, update)`| Update all documents by filter (using the `$set`, `$inc` operators). | +| `await collection.remove(id)` | Delete a document by its `_id`. | +| `await collection.deleteMany(filter)` | Delete all documents matching the filter. | +| `await collection.count()` | Count the number of documents in the collection. | +| `await collection.clear()` | Delete all documents from the collection. | + +> **Note:** `filter` for `find`, `findOne`, and `deleteMany` is an object describing the search conditions, similar to MongoDB (e.g., `{ age: { $gt: 25 } }`). + +## Next Steps + +Now that you're familiar with the basics, you can move on to more detailed learning: + +* **[01 - Working with Collections and Documents](01-collections-and-documents.md)** +* **[02 - Data Querying and Indexing](02-querying-and-indexing.md)** +* **[03 - Working with Transactions](03-transactions.md)** +* **[04 - Advanced Features and Configuration](04-advanced-features.md)** diff --git a/docs/00-introduction-and-setup.md b/docs/00-introduction-and-setup.md deleted file mode 100644 index ef07fe8..0000000 --- a/docs/00-introduction-and-setup.md +++ /dev/null @@ -1,137 +0,0 @@ -```markdown -# 00 - Введение и Настройка - -Добро пожаловать в WiseJSON DB — быструю, надежную и простую в использовании встраиваемую JSON-базу данных для Node.js. Она разработана для высокой производительности и сохранности данных благодаря механизмам журналирования (WAL), чекпоинтов, атомарных транзакций и поддержки индексов. - -Этот документ поможет вам быстро начать работу с WiseJSON DB. - -## Ключевые концепции - -* **База данных (Database):** Физическое хранилище на диске, представленное одной директорией. Содержит одну или несколько коллекций. -* **Коллекция (Collection):** Аналог таблицы в SQL или коллекции в MongoDB. Это именованная группа JSON-документов. -* **Документ (Document):** Отдельная запись в коллекции, представленная JavaScript-объектом. Каждый документ имеет уникальное поле `_id`. - -## Установка - -Установите пакет с помощью npm или yarn: - -```bash -npm install wise-json-db -# или -yarn add wise-json-db -``` -Это установит пакет `wise-json-db` и все необходимые зависимости (`uuid`, `proper-lockfile`). - -## Быстрый старт - -Этот пример показывает полный цикл работы: инициализация, создание, чтение, обновление и удаление данных. - -```javascript -// Подключаем библиотеку -const WiseJSON = require('wise-json-db'); -const path = require('path'); - -async function main() { - // 1. Указываем путь, где будет храниться база данных. - const dbPath = path.resolve(__dirname, 'myAppData'); - - // 2. Создаем или открываем экземпляр БД и дожидаемся его инициализации. - const db = new WiseJSON(dbPath); - await db.init(); - - // 3. Получаем (или создаем) коллекцию 'users' и ждем ее готовности. - const users = await db.collection('users'); - await users.initPromise; - - // Для чистоты примера очистим коллекцию перед началом - await users.clear(); - - // 4. Вставляем документы - await users.insert({ name: 'Alice', age: 30, city: 'New York' }); - await users.insertMany([ - { name: 'Bob', age: 25, city: 'London' }, - { name: 'Charlie', age: 35, city: 'New York' } - ]); - console.log(`После вставки в коллекции ${await users.count()} документа.`); - - // 5. Ищем документы - const userBob = await users.findOne({ name: 'Bob' }); - console.log('Найден Bob:', userBob); - - const usersFromNY = await users.find({ city: 'New York' }); - console.log(`Пользователей из New York: ${usersFromNY.length}`); - - // 6. Обновляем документ - if (userBob) { - await users.update(userBob._id, { age: 26, status: 'active' }); - const updatedBob = await users.getById(userBob._id); - console.log('Обновленный Bob:', updatedBob); - } - - // 7. Удаляем документ - const charlie = await users.findOne({ name: 'Charlie' }); - if (charlie) { - await users.remove(charlie._id); - console.log(`Пользователь Charlie удален. Осталось документов: ${await users.count()}`); - } - - // 8. Обязательно закрываем БД для сохранения всех изменений. - await db.close(); - console.log('База данных закрыта.'); -} - -main().catch(console.error); -``` - -## Структура публичного API - -Основной экспорт пакета `wise-json-db` предоставляет доступ к ключевым классам и функциям: - -```javascript -const { - WiseJSON, // Основной класс базы данных - Collection, // Класс коллекции (для type hinting или расширения) - SyncManager, // Менеджер синхронизации (для продвинутых сценариев) - // и другие утилиты... -} = require('wise-json-db'); -``` - -### `new WiseJSON(dbPath, [options])` - -Конструктор для создания экземпляра БД. - -* `dbPath {string}`: Путь к корневой директории базы данных. -* `options {object}` (необязательно): Объект для тонкой настройки. - -### Методы экземпляра `db` - -* **`await db.init()`**: Асинхронно инициализирует базу данных. **Обязательно вызывать** после создания экземпляра. -* **`await db.collection(name)`**: Возвращает экземпляр коллекции. Не забывайте дожидаться `collection.initPromise`. -* **`await db.close()`**: Корректно закрывает базу данных, сохраняя все несохраненные данные и снимая блокировки. **Обязательно вызывать** перед завершением работы приложения. -* **`db.beginTransaction()`**: Начинает новую транзакцию. - -### Основные методы коллекции - -| Метод | Описание | -| ------------------------------ | ---------------------------------------------------------------------------- | -| `await collection.insert(doc)` | Вставить один документ. | -| `await collection.insertMany(docs)`| Вставить массив документов. | -| `await collection.find(filter)` | Найти все документы, соответствующие фильтру (объект-запрос). | -| `await collection.findOne(filter)` | Найти первый документ, соответствующий фильтру. | -| `await collection.update(id, data)`| Частично обновить документ по его `_id`. | -| `await collection.updateMany(filter, update)`| Обновить все документы по фильтру (используя операторы `$set`, `$inc`). | -| `await collection.remove(id)` | Удалить документ по его `_id`. | -| `await collection.deleteMany(filter)` | Удалить все документы, соответствующие фильтру. | -| `await collection.count()` | Посчитать количество документов в коллекции. | -| `await collection.clear()` | Удалить все документы из коллекции. | - -> **Примечание:** `filter` для `find`, `findOne` и `deleteMany` — это объект, описывающий условия поиска, аналогично MongoDB (например, `{ age: { $gt: 25 } }`). - -## Дальнейшие шаги - -Теперь, когда вы знакомы с основами, вы можете перейти к более детальному изучению: - -* **[01 - Работа с Коллекциями и Документами](01-collections-and-documents.md)** -* **[02 - Запросы к Данным и Индексирование](02-querying-and-indexing.md)** -* **[03 - Работа с Транзакциями](03-transactions.md)** -* **[04 - Расширенные Возможности и Конфигурация](04-advanced-features.md)** \ No newline at end of file diff --git a/docs/01-collections-and-documents.md b/docs/01-collections-and-documents.md index 9a264a7..bc539f6 100644 --- a/docs/01-collections-and-documents.md +++ b/docs/01-collections-and-documents.md @@ -1,289 +1,278 @@ -```markdown -# 01 - Работа с Коллекциями и Документами +# 01 - Working with Collections and Documents -В этом разделе подробно рассматриваются основные операции по управлению данными (CRUD - Create, Read, Update, Delete) в коллекциях WiseJSON DB, а также установка времени жизни (TTL) для документов. +This section covers in detail the basic data management operations (CRUD - Create, Read, Update, Delete) in WiseJSON DB collections, as well as setting the time-to-live (TTL) for documents. -**Предполагается, что у вас уже есть инициализированный экземпляр `db` и получен экземпляр `collection`, как описано в разделе `00-introduction-and-setup.md`.** +**This section assumes you already have an initialized `db` instance and have obtained a `collection` instance, as described in `00-introduction-and-setup.md`.** -## Добавление Документов (Create) +## Adding Documents (Create) -### Как добавить один документ (`insert`) +### How to insert a single document (`insert`) -Метод `collection.insert(document)` используется для добавления одного нового документа в коллекцию. +The `collection.insert(document)` method is used to add a single new document to a collection. -* **Параметры:** - * `document {object}`: JavaScript-объект, который вы хотите сохранить. - * Если вы предоставите поле `_id` в объекте `document`, оно будет использовано как уникальный идентификатор. - * Если поле `_id` не предоставлено, WiseJSON DB автоматически сгенерирует уникальный `_id` (согласно опции `idGenerator`). -* **Возвращает:** `Promise` - Промис, который разрешается объектом вставленного документа. Этот объект будет содержать поля `_id`, `createdAt` (время создания в формате ISO-строки) и `updatedAt` (время последнего обновления, изначально совпадает с `createdAt`). -* **Ошибки:** Может выбросить ошибку, если, например, нарушается уникальность индекса. +* **Parameters:** +* `document {object}`: The JavaScript object you want to save. +* If you provide an `_id` field in the `document` object, it will be used as a unique identifier. +* If the _id field is not provided, WiseJSON DB will automatically generate a unique _id (according to the idGenerator option). +* **Returns:** `Promise` - A promise that resolves to the inserted document object. This object will contain the fields `_id`, `createdAt` (creation time in ISO string format), and `updatedAt` (last updated time, initially the same as `createdAt`). +* **Errors:** May throw an error if, for example, the index is not unique. -**Пример:** +**Example:** ```javascript -// Добавляем новый документ с авто-ID +// Add a new document with an auto-ID const newUser = await usersCollection.insert({ - name: 'Alice Wonder', - email: 'alice@example.com', - age: 30 +name: 'Alice Wonder', +email: 'alice@example.com', +age: 30 }); -console.log('Добавлен пользователь:', newUser); -// Пример вывода newUser: +console.log('Added user:', newUser); +// Sample output newUser: // { -// name: 'Alice Wonder', -// email: 'alice@example.com', -// age: 30, -// _id: 'сгенерированный_id', -// createdAt: '2023-10-27T10:00:00.000Z', -// updatedAt: '2023-10-27T10:00:00.000Z' +// name: 'Alice Wonder', +// email: 'alice@example.com', +// age: 30, +// _id: 'generated_id', +// createdAt: '2023-10-27T10:00:00.000Z', +// updatedAt: '2023-10-27T10:00:00.000Z' // } -// Добавляем документ с предопределенным _id +// Add a document with a predefined _id const specificUser = await usersCollection.insert({ - _id: 'user123', - name: 'Bob The Builder', - role: 'admin' +_id: 'user123', +name: 'Bob The Builder', +role: 'admin' }); -console.log('Добавлен пользователь с конкретным ID:', specificUser); +console.log('Added user with specific ID:', specificUser); ``` -### Как добавить несколько документов сразу (`insertMany`) +### How to add multiple documents at once (`insertMany`) -Метод `collection.insertMany(documentsArray)` позволяет эффективно добавить массив документов за одну операцию. +The `collection.insertMany(documentsArray)` method allows you to efficiently add an array of documents in a single operation. -* **Параметры:** - * `documentsArray {Array}`: Массив JavaScript-объектов для вставки. -* **Возвращает:** `Promise>` - Промис, который разрешается массивом вставленных документов, каждый из которых будет содержать `_id`, `createdAt` и `updatedAt`. -* **Поведение при ошибках:** Если при обработке возникает ошибка (например, нарушение уникального индекса), операция прерывается, и будет выброшена ошибка. Документы до проблемного могут быть уже вставлены. +* **Parameters:** +* `documentsArray {Array}`: An array of JavaScript objects to insert. +* **Returns:** `Promise>` - A promise that resolves to an array of inserted documents, each containing `_id`, `createdAt`, and `updatedAt`. +* **Error Handling:** If an error occurs during processing (such as a unique index violation), the operation is aborted and an error is thrown. Documents before the problematic one may already have been inserted. -**Пример:** +**Example:** ```javascript const newProducts = [ - { name: 'Ноутбук', category: 'Электроника', price: 1200 }, - { name: 'Смартфон', category: 'Электроника', price: 800 }, - { _id: 'book-451', name: 'Книга "451 градус по Фаренгейту"', category: 'Книги', price: 15 } +{ name: 'Laptop', category: 'Electronics', price: 1200 }, +{ name: 'Smartphone', category: 'Electronics', price: 800 }, +{ _id: 'book-451', name: 'Fahrenheit 451', category: 'Books', price: 15 } ]; const insertedProducts = await productsCollection.insertMany(newProducts); -console.log(`Успешно добавлено ${insertedProducts.length} продуктов.`); +console.log(`Successfully added ${insertedProducts.length} products.`); ``` -### Как добавить документ с ограниченным временем жизни (TTL) +### How to add a document with a limited time to live (TTL) -WiseJSON DB позволяет устанавливать время жизни для документов, после истечения которого они будут автоматически удалены. Это делается с помощью полей `ttl` или `expireAt` в самом документе. +WiseJSON DB allows you to set a time to live for documents, after which they will be automatically deleted. This is done using the `ttl` or `expireAt` fields in the document itself. -* **`ttl {number}`**: Время жизни документа в миллисекундах с момента его создания (поле `createdAt`). -* **`expireAt {number | string}`**: Точное время (Unix timestamp в миллисекундах или строка в формате ISO 8601), когда документ должен истечь и быть удален. +* **`ttl {number}`**: The document's lifetime in milliseconds since its creation (the `createdAt` field). +* **`expireAt {number | string}`**: The exact time (Unix timestamp in milliseconds or an ISO 8601 string) when the document should expire and be deleted. -Если указаны оба поля, приоритет будет у `expireAt`. Очистка устаревших документов происходит периодически. +If both fields are specified, `expireAt` takes precedence. Expired documents are purged periodically. -**Пример:** +**Example:** ```javascript -// Документ, который "умрет" через 10 секунд после создания +// A document that will "die" 10 seconds after creation await tempCollection.insert({ - message: 'Это сообщение исчезнет через 10 секунд.', - ttl: 10000 // 10 секунд +message: 'This message will disappear in 10 seconds.', +ttl: 10000 // 10 seconds }); -// Документ, который "умрет" в определенное время +// A document that will expire at a certain time await tempCollection.insert({ - data: 'Информация, актуальная 1 минуту.', - expireAt: Date.now() + 60000 // Через 60 секунд +data: 'Information valid for 1 minute.', +expireAt: Date.now() + 60000 // After 60 seconds }); ``` -## Чтение Документов (Read) +## Reading Documents (Read) -Описание операций чтения (`getById`, `find`, `findOne`), использующих мощные фильтры и индексы, находится в следующем разделе: **`02-querying-and-indexing.md`**. +A description of read operations (`getById`, `find`, `findOne`) using powerful filters and indexes is in the following section: **`02-querying-and-indexing.md`**. -## Обновление Документов (Update) +## Updating Documents (Update) -WiseJSON DB предлагает несколько методов для обновления документов. +WiseJSON DB offers several methods for updating documents. -### Как обновить один документ по ID (`update`) - -Метод `collection.update(id, updates)` позволяет частично обновить поля существующего документа. Поля, присутствующие в `updates`, будут изменены или добавлены, а отсутствующие — останутся без изменений. - -* **Параметры:** - * `id {string}`: Уникальный `_id` документа для обновления. - * `updates {object}`: Объект, содержащий поля и их новые значения. -* **Возвращает:** `Promise` - Промис, который разрешается обновленным объектом документа, или `null`, если документ не найден. -* **Важно:** Этот метод не может изменить `_id` или `createdAt`. Поле `updatedAt` будет обновлено автоматически. - -**Пример:** +**Example:** ```javascript const userToUpdate = await usersCollection.findOne({ email: 'alice@example.com' }); if (userToUpdate) { - const updatedUser = await usersCollection.update(userToUpdate._id, { - age: 31, - status: 'active' // Добавляем новое поле - }); - console.log('Пользователь после обновления:', updatedUser); +const updatedUser = await usersCollection.update(userToUpdate._id, { +age: 31, +status: 'active' // Add a new field +}); +console.log('User after update:', updatedUser); } ``` -### Продвинутое обновление с фильтрами и операторами +### Advanced updating with filters and operators -Для более сложных обновлений используются методы, принимающие **фильтр** для поиска документов и **операторы обновления**, аналогичные MongoDB. +For more complex updates, methods that accept a **filter** for searching documents and **update operators**, similar to MongoDB, are used. -**Основные операторы обновления:** -* `$set`: Устанавливает значение поля. -* `$inc`: Увеличивает (или уменьшает) числовое поле. -* `$unset`: Удаляет поле из документа. -* `$push`: Добавляет элемент в массив. +**Basic update operators:** +* `$set`: Sets the value of a field. +* `$inc`: Increments (or decrements) a numeric field. +* `$unset`: Removes a field from a document. +* `$push`: Adds an element to an array. -#### Обновление одного документа по фильтру (`updateOne`) +#### Updating a single document by filter (`updateOne`) -Метод `collection.updateOne(filter, update)` находит **первый** документ, соответствующий `filter`, и применяет к нему изменения, описанные в `update`. +The `collection.updateOne(filter, update)` method finds the **first** document matching `filter` and applies the changes described in `update` to it. -* **Параметры:** - * `filter {object}`: Объект-фильтр для поиска (синтаксис как в `find`). - * `update {object}`: Объект с операторами обновления. -* **Возвращает:** `Promise<{ matchedCount: number, modifiedCount: number }>` - * `matchedCount`: Количество найденных документов (0 или 1). - * `modifiedCount`: Количество реально измененных документов (0 или 1). +* **Parameters:** +* `filter {object}`: The filter object to search for (syntax as in `find`). +* `update {object}`: An object with update operators. +* **Returns:** `Promise<{ matchedCount: number, modifiedCount: number }>` +* `matchedCount`: The number of found documents (0 or 1). +* `modifiedCount`: The number of actually modified documents (0 or 1). -**Пример:** +**Example:** ```javascript -// Увеличить возраст пользователя 'Alice' на 1 и установить ей новый статус +// Increase user 'Alice''s age by 1 and set her new status const filter = { email: 'alice@example.com' }; const update = { - $inc: { age: 1 }, - $set: { lastSeen: new Date().toISOString() } +$inc: { age: 1 }, +$set: { lastSeen: new Date().toISOString() } }; const result = await usersCollection.updateOne(filter, update); -console.log(`Найдено для обновления: ${result.matchedCount}, изменено: ${result.modifiedCount}`); +console.log(`Found to update: ${result.matchedCount}, modified: ${result.modifiedCount}`); ``` -#### Обновление нескольких документов по фильтру (`updateMany`) +#### Updating multiple documents by filter (`updateMany`) -Метод `collection.updateMany(filter, update)` применяет изменения ко **всем** документам, которые соответствуют `filter`. +The `collection.updateMany(filter, update)` method applies changes to **all** documents that match `filter`. -* **Параметры:** Аналогичны `updateOne`. -* **Возвращает:** `Promise<{ matchedCount: number, modifiedCount: number }>` +* **Parameters:** Similar to `updateOne`. +* **Returns:** `Promise<{ matchedCount: number, modifiedCount: number }>` -**Пример:** +**Example:** ```javascript -// Дать скидку 10% на все книги в наличии -const filter = { category: 'Книги', stock: { $gt: 0 } }; +// Give a 10% discount on all books in stock +const filter = { category: 'Books', stock: { $gt: 0 } }; const update = { $set: { onSale: true, discount: 0.1 } }; const result = await productsCollection.updateMany(filter, update); -console.log(`Найдено товаров для скидки: ${result.matchedCount}, обновлено: ${result.modifiedCount}`); +console.log(`Products found for discount: ${result.matchedCount}, updated: ${result.modifiedCount}`); ``` -#### Найти и обновить атомарно (`findOneAndUpdate`) +#### Find and update atomically (`findOneAndUpdate`) -Этот метод находит один документ, обновляет его и возвращает. Идеально подходит для сценариев, где нужно получить документ в его старом или новом состоянии сразу после изменения (например, для счетчиков). +This method finds a single document, updates it, and returns it. Ideal for scenarios where you need to retrieve a document in its old or new state immediately after a change (e.g., for counters). -* **Параметры:** - * `filter {object}`: Фильтр для поиска. - * `update {object}`: Объект с операторами обновления. - * `options.returnOriginal {boolean}`: Если `false` (по умолчанию), возвращает документ **после** обновления. Если `true`, возвращает документ **до** обновления. -* **Возвращает:** `Promise` - Документ (до или после обновления) или `null`, если ничего не найдено. +* **Parameters:** +* `filter {object}`: Filter to search for. +* `update {object}`: Object with update operators. +* `options.returnOriginal {boolean}`: If `false` (default), returns the document **after** the update. If `true`, returns the document **before** the update. +* **Returns:** `Promise` - The document (before or after the update) or `null` if nothing was found. -**Пример:** +**Example:** ```javascript -// Зарезервировать один товар и вернуть его состояние *до* резервации -const filter = { name: 'Ноутбук', stock: { $gt: 0 } }; +// Reserve one item and return its state *before* the reservation +const filter = { name: 'Laptop', stock: { $gt: 0 } }; const update = { $inc: { stock: -1 } }; const options = { returnOriginal: true }; const originalProductState = await productsCollection.findOneAndUpdate(filter, update, options); if (originalProductState) { - console.log(`Товар успешно зарезервирован. Остаток на складе был: ${originalProductState.stock}`); +console.log(`Product successfully reserved. Stock balance was: ${originalProductState.stock}`); } ``` -## Удаление Документов (Delete) +## Deleting Documents (Delete) -### Как удалить один документ по ID (`remove`) +### How to delete a single document by ID (`remove`) -Метод `collection.remove(id)` удаляет один документ из коллекции по его `_id`. +The `collection.remove(id)` method deletes a single document from the collection by its `_id`. -* **Параметры:** - * `id {string}`: Уникальный `_id` документа для удаления. -* **Возвращает:** `Promise` - `true`, если документ был найден и удален, иначе `false`. +* **Parameters:** +* `id {string}`: The unique `_id` of the document to delete. +* **Returns:** `Promise` - `true` if the document was found and removed, otherwise `false`. -**Пример:** +**Example:** ```javascript const wasRemoved = await itemsCollection.remove('some-item-id'); -console.log(`Документ был удален: ${wasRemoved}`); +console.log(`The document was removed: ${wasRemoved}`); ``` -### Продвинутое удаление с фильтрами +### Advanced deletion with filters -#### Удаление одного документа по фильтру (`deleteOne`) +#### Deleting a single document by filter (`deleteOne`) -Метод `collection.deleteOne(filter)` удаляет **первый** документ, соответствующий `filter`. +The `collection.deleteOne(filter)` method deletes the **first** document matching `filter`. -* **Параметры:** - * `filter {object}`: Фильтр для поиска документа на удаление. -* **Возвращает:** `Promise<{ deletedCount: number }>` - Объект, где `deletedCount` равен 0 или 1. +* **Parameters:** +* `filter {object}`: Filter for finding the document to delete. +* **Returns:** `Promise<{ deletedCount: number }>` - An object where `deletedCount` is 0 or 1. -**Пример:** +**Example:** ```javascript -// Удалить один неактивный лог +// Delete one inactive log const result = await logsCollection.deleteOne({ level: 'debug', processed: true }); -console.log(`Удалено логов: ${result.deletedCount}`); +console.log(`Logs deleted: ${result.deletedCount}`); ``` -#### Удаление нескольких документов по фильтру (`deleteMany`) +#### Deleting multiple documents by filter (`deleteMany`) -Метод `collection.deleteMany(filter)` удаляет **все** документы, соответствующие `filter`. +The `collection.deleteMany(filter)` method deletes **all** documents matching `filter`. -* **Параметры:** - * `filter {object}`: Фильтр для поиска документов на удаление. -* **Возвращает:** `Promise<{ deletedCount: number }>` - Объект с количеством удаленных документов. +* **Parameters:** +* `filter {object}`: Filter for finding documents to delete. +* **Returns:** `Promise<{ deletedCount: number }>` - An object with the number of deleted documents. -**Пример:** +**Example:** ```javascript -// Удалить все сессии пользователя, которые истекли +// Delete all expired user sessions const filter = { - userId: 'user-123', - expiresAt: { $lt: new Date().toISOString() } +userId: 'user-123', +expiresAt: { $lt: new Date().toISOString() } }; const result = await sessionsCollection.deleteMany(filter); -console.log(`Удалено устаревших сессий: ${result.deletedCount}`); +console.log(`Expired sessions deleted: ${result.deletedCount}`); ``` -### Как удалить все документы из коллекции (`clear`) +### How to clear all documents from a collection -Метод `collection.clear()` удаляет **все** документы из коллекции. Используйте с осторожностью. +The `collection.clear()` method removes **all** documents from the collection. Use with caution. -* **Параметры:** Нет. -* **Возвращает:** `Promise` - Промис, который разрешается `true` при успешной очистке. +* **Parameters:** None. +* **Returns:** `Promise` - A promise that resolves to `true` upon successful clearing. -**Пример:** +**Example:** ```javascript const clearResult = await logsCollection.clear(); -console.log(`Результат очистки коллекции: ${clearResult}`); +console.log(`Result of clearing the collection: ${clearResult}`); ``` -## Подсчет Документов (`count`) +## Counting Documents (`count`) -Метод `collection.count()` возвращает количество "живых" (не истекших по TTL) документов в коллекции. +The `collection.count()` method returns the number of live (not expired by TTL) documents in the collection. -* **Параметры:** Нет. -* **Возвращает:** `Promise` - Промис, который разрешается числом документов. +* **Parameters:** None. +* **Returns:** `Promise` - A promise that resolves to a number of documents. -**Пример:** +**Example:** ```javascript const totalUsers = await usersCollection.count(); -console.log(`Всего пользователей в системе: ${totalUsers}`); +console.log(`Total users in the system: ${totalUsers}`); ``` -В следующем разделе мы подробно рассмотрим, как эффективно искать и фильтровать документы с помощью `find`, `findOne` и индексов. \ No newline at end of file +In the next section, we'll take a detailed look at how to effectively search and filter documents using `find`, `findOne`, and indexes. diff --git a/docs/02-querying-and-indexing.md b/docs/02-querying-and-indexing.md index 039e57b..de83d8a 100644 --- a/docs/02-querying-and-indexing.md +++ b/docs/02-querying-and-indexing.md @@ -1,207 +1,207 @@ -```markdown docs/02-querying-and-indexing.md -# 02 - Запросы к Данным и Индексирование +# 02 - Data Querying and Indexing -Этот раздел посвящен тому, как извлекать документы из коллекций WiseJSON DB. Мы рассмотрим как базовый поиск по ID, так и мощные запросы с использованием фильтров, операторов и индексов для ускорения операций. +This section covers how to retrieve documents from WiseJSON DB collections. We'll cover both basic ID searching and powerful queries using filters, operators, and indexes to speed up operations. -**Предполагается, что у вас уже есть инициализированный экземпляр `WiseJSON` и рабочая коллекция, как описано в предыдущих разделах.** +**This section assumes you already have an initialized `WiseJSON` instance and a working collection, as described in the previous sections.** -## Базовые Методы Чтения +## Basic Reading Methods -### Как получить документ по его ID (`getById`) +### Getting a document by its ID (`getById`) -Это самый быстрый и прямой способ получить один конкретный документ, если вы знаете его уникальный идентификатор `_id`. +This is the fastest and most direct way to retrieve a single document if you know its unique `_id`. -* **Параметры:** - * `id {string}`: Уникальный `_id` искомого документа. -* **Возвращает:** `Promise` - Промис, который разрешается объектом найденного документа. Если документ с таким `id` не существует или его срок жизни (TTL) истек, промис разрешается значением `null`. +* **Parameters:** +* `id {string}`: The unique `_id` of the document to retrieve. +* **Returns:** `Promise` - A promise that resolves to the found document object. If a document with the specified `id` doesn't exist or its TTL has expired, the promise resolves to `null`. -**Пример:** +**Example:** ```javascript const article = await articlesCollection.getById('article-123'); if (article) { - console.log('Найдена статья:', article); +console.log('Article found:', article); } else { - console.log('Статья с ID "article-123" не найдена.'); +console.log('Article with ID "article-123" not found.'); } ``` -### Как получить все документы из коллекции (`getAll`) +### How to get all documents from a collection (`getAll`) -Метод `collection.getAll()` извлекает все "живые" (не истекшие по TTL) документы из коллекции. +The `collection.getAll()` method retrieves all "live" (not expired by TTL) documents from the collection. -* **Внимание:** Используйте этот метод с осторожностью на очень больших коллекциях, так как он загружает все документы в оперативную память. -* **Параметры:** Нет. -* **Возвращает:** `Promise>` - Промис, который разрешается массивом всех найденных документов. +* **Warning:** Use this method with caution on very large collections, as it loads all documents into memory. +* **Parameters:** None. +* **Returns:** `Promise>` - A promise that resolves to an array of all found documents. -**Пример:** +**Example:** ```javascript const allTasks = await tasksCollection.getAll(); -console.log(`Всего найдено ${allTasks.length} задач.`); +console.log(`${allTasks.length} tasks found.`); ``` -## Продвинутые Запросы с `find` и `findOne` +## Advanced Queries with `find` and `findOne` -Для гибкого поиска по различным критериям WiseJSON DB предоставляет методы `find` и `findOne`, которые поддерживают мощный синтаксис запросов, аналогичный MongoDB. +For flexible searching by various criteria, WiseJSON DB provides the `find` and `findOne` methods, which support a powerful query syntax similar to MongoDB. -### Поиск нескольких документов по условию (`find`) +### Searching for Multiple Documents by Condition (`find`) -Метод `collection.find(query, projection)` позволяет найти все документы, которые удовлетворяют заданному фильтру. +The `collection.find(query, projection)` method finds all documents that match a given filter. -* **Параметры:** - * `query {object}`: Объект-фильтр, описывающий условия поиска. Это основной и рекомендуемый способ. - * `projection {object}` (необязательно): Объект, указывающий, какие поля следует включить или исключить из результирующих документов (см. ниже). -* **Возвращает:** `Promise>` - Промис, который разрешается массивом документов, соответствующих запросу. +* **Parameters:** +* `query {object}`: A filter object describing the search conditions. This is the basic and recommended method. +* `projection {object}` (optional): An object specifying which fields to include or exclude from the resulting documents (see below). +* **Returns:** `Promise>` - A promise that resolves to an array of documents matching the query. -#### Синтаксис Запросов (Query Syntax) +#### Query Syntax -Объект-фильтр состоит из пар `поле: значение` для точного совпадения или использует специальные операторы для более сложных условий. +A filter object consists of `field: value` pairs for exact matching or uses special operators for more complex conditions. -**Операторы сравнения:** -* `$eq`: равно (обычно опускается, ` { age: 30 } ` эквивалентно ` { age: { $eq: 30 } } `) -* `$ne`: не равно (`!=`) -* `$gt`: больше чем (`>`) -* `$gte`: больше или равно (`>=`) -* `$lt`: меньше чем (`<`) -* `$lte`: меньше или равно (`<=`) -* `$in`: значение поля находится в указанном массиве -* `$nin`: значение поля не находится в указанном массиве +**Comparison Operators:** +* `$eq`: equal to (usually omitted, ` { age: 30 } ` is equivalent to ` { age: { $eq: 30 } } `) +* `$ne`: not equal to (`!=`) +* `$gt`: greater than (`>`) +* `$gte`: greater than or equal to (`>=`) +* `$lt`: less than (`<`) +* `$lte`: less than or equal to (`<=`) +* `$in`: the field value is in the specified array +* `$nin`: the field value is not in the specified array -**Логические операторы:** -* `$or`: соответствует любому из условий в массиве. ` { $or: [ { <условие1> }, { <условие2> } ] } ` -* `$and`: соответствует всем условиям в массиве. Обычно неявно, но полезен для сложных группировок. +**Logical Operators:** +* `$or`: matches any of the conditions in the array. ` { $or: [ { }, { } ] } ` +* `$and`: matches all conditions in the array. Usually implicit, but useful for complex groupings. -**Операторы элементов:** -* `$exists`: поле существует (`true`) или не существует (`false`). +**Element Operators:** +* `$exists`: whether the field exists (`true`) or does not exist (`false`). -**Пример 1: Простой фильтр** -Найти всех пользователей из города 'Москва'. +**Example 1: Simple Filter** +Find all users from the city 'Moscow'. ```javascript -const moscowUsers = await usersCollection.find({ city: 'Москва' }); +const moscowUsers = await usersCollection.find({ city: 'Moscow' }); ``` -**Пример 2: Использование операторов сравнения** -Найти всех пользователей старше 30 лет, но младше 40. +**Example 2: Using Comparison Operators** +Find all users over 30 but under 40. ```javascript const usersInTheir30s = await usersCollection.find({ - age: { $gt: 30, $lt: 40 } +age: { $gt: 30, $lt: 40 } }); ``` -**Пример 3: Использование оператора `$in`** -Найти товары из категорий 'Электроника' или 'Книги'. +**Example 3: Using the `$in` Operator** +Find products from the 'Electronics' or 'Books' categories. ```javascript const desiredProducts = await productsCollection.find({ - category: { $in: ['Электроника', 'Книги'] } +category: { $in: ['Electronics', 'Books'] } }); ``` -**Пример 4: Сложный запрос с логикой `$or`** -Найти всех активных пользователей из Нью-Йорка ИЛИ всех пользователей с тегом 'vip'. +**Example 4: Complex query with `$or` logic** +Find all active users from New York OR all users with the tag 'vip'. ```javascript const query = { - $or: [ - { city: 'New York', status: 'active' }, // Условие 1 - { tags: 'vip' } // Условие 2 (для полей-массивов простое совпадение работает как "содержит") - ] +$or: [ +{ city: 'New York', status: 'active' }, // Condition 1 +{ tags: 'vip' } // Condition 2 (for array fields, a simple match works like "contains") +] }; const results = await usersCollection.find(query); ``` -### Поиск одного документа по условию (`findOne`) +### Find a single document by condition (`findOne`) -Работает аналогично `find`, но возвращает только **первый** найденный документ, удовлетворяющий условию, или `null`. Это более эффективно, если вам нужен только один результат или вы проверяете наличие документа. +Works similar to `find`, but returns only the first document found that satisfies the condition, or `null`. This is more efficient if you only need one result or are checking for the existence of a document. -* **Параметры и Возвращаемое значение:** Аналогичны `find`, но возвращает `Promise`. +* **Parameters and Return Value:** Similar to `find`, but returns a `Promise`. -**Пример:** -Найти одного пользователя с email 'admin@example.com'. +**Example:** +Find a single user with the email address 'admin@example.com'. ```javascript const adminUser = await usersCollection.findOne({ email: 'admin@example.com' }); if (adminUser) { - console.log('Администратор найден:', adminUser); +console.log('Administrator found:', adminUser); } ``` -### Проекции: Выборка нужных полей +### Projections: Selecting the Right Fields -Иногда вам не нужны все поля документа, а только некоторые из них. Проекции позволяют указать, какие поля **включить** или **исключить** из результата. Это уменьшает объем передаваемых данных и может повысить производительность. +Sometimes you don't need all the fields in a document, but only some of them. Projections allow you to specify which fields to include or exclude from the result. This reduces the amount of data transferred and can improve performance. -Проекции передаются вторым аргументом в `find` и `findOne`. -* `{ field: 1 }`: Включить поле `field`. -* `{ field: 0 }`: Исключить поле `field`. +Projections are passed as the second argument to `find` and `findOne`. +* `{ field: 1 }`: Include the field `field`. +* `{ field: 0 }`: Exclude the field `field`. -**Правила:** -1. Вы не можете смешивать режимы включения и исключения в одном объекте проекции (кроме особого случая с исключением `_id`). -2. Поле `_id` включается по умолчанию. Чтобы его исключить, нужно явно указать `{ _id: 0 }`. +**Rules:** +1. You cannot mix inclusion and exclusion modes in a single projection object (except for the special case of excluding `_id`). +2. The `_id` field is included by default. To exclude it, you must explicitly specify `{ _id: 0 }`. -**Пример 1: Включение конкретных полей** -Получить только имена и email всех пользователей, оставив `_id` по умолчанию. +**Example 1: Including Specific Fields** +Get only the names and email addresses of all users, leaving `_id` as the default. ```javascript const userList = await usersCollection.find({}, { name: 1, email: 1 }); -// Результат: [{ _id: '...', name: '...', email: '...' }, ...] +// Result: [{ _id: '...', name: '...', email: '...' }, ...] ``` -**Пример 2: Включение полей с исключением `_id`** +**Example 2: Including fields but excluding `_id`** + ```javascript const userListNoId = await usersCollection.find({}, { name: 1, email: 1, _id: 0 }); -// Результат: [{ name: '...', email: '...' }, ...] +// Result: [{ name: '...', email: '...' }, ...] ``` -**Пример 3: Исключение полей** -Получить все данные о пользователях, кроме их детальной истории и тегов. +**Example 3: Excluding fields** +Get all user data except their detailed history and tags. ```javascript const usersWithoutHistory = await usersCollection.find({}, { history: 0, tags: 0 }); ``` -## Ускорение Поиска с Помощью Индексов +## Speeding Up Search with Indexes -Индексы — это специальные структуры данных, которые позволяют базе данных находить документы гораздо быстрее, не перебирая всю коллекцию. WiseJSON DB **автоматически использует существующие индексы**, если поле в запросе проиндексировано и используется для поиска по точному совпадению (`{ field: 'value' }`) или с операторами диапазона (`$gt`, `$lt` и т.д.). +Indexes are special data structures that allow the database to find documents much faster without iterating through the entire collection. WiseJSON DB **automatically uses existing indexes** if a field in the query is indexed and used for exact match search (`{ field: 'value' }`) or with range operators (`$gt`, `$lt`, etc.). -### Как создать индекс (`createIndex`) +### How to Create an Index (`createIndex`) -Метод `collection.createIndex(fieldName, options)` создает индекс для указанного поля. +The `collection.createIndex(fieldName, options)` method creates an index for the specified field. -* **Параметры:** - * `fieldName {string}`: Имя индексируемого поля. - * `options {object}` (необязательно): - * `unique {boolean}`: Если `true`, индекс будет уникальным. Это гарантирует, что не будет двух документов с одинаковым значением в этом поле. Попытка вставить дубликат вызовет ошибку. По умолчанию `false`. +* **Parameters:** +* `fieldName {string}`: The name of the field to index. +* `options {object}` (optional): +* `unique {boolean}`: If `true`, the index will be unique. This ensures that no two documents have the same value in this field. Attempting to insert a duplicate will result in an error. Defaults to `false`. -**Пример:** +**Example:** ```javascript -// Создаем стандартный (неуникальный) индекс по полю 'city' для быстрого поиска по городам +// Create a standard (non-unique) index on the 'city' field for fast city searching await customersCollection.createIndex('city'); -// Создаем уникальный индекс по полю 'email', чтобы не было двух пользователей с одинаковым email +// Create a unique index on the 'email' field to ensure that no two users have the same email await customersCollection.createIndex('email', { unique: true }); ``` -### Управление индексами +### Managing Indexes -* **`collection.getIndexes()`**: Возвращает массив объектов, описывающих все существующие индексы в коллекции. - ```javascript - const indexes = await customersCollection.getIndexes(); - // Пример вывода: [{ fieldName: 'city', type: 'standard' }, { fieldName: 'email', type: 'unique' }] - console.log('Текущие индексы:', indexes); - ``` -* **`collection.dropIndex(fieldName)`**: Удаляет индекс с указанного поля. - ```javascript - await customersCollection.dropIndex('city'); - console.log('Индекс по полю "city" удален.'); - ``` +* **`collection.getIndexes()`**: Returns an array of objects describing all existing indexes in the collection. +```javascript +const indexes = await customersCollection.getIndexes(); +// Sample output: [{ fieldName: 'city', type: 'standard' }, { fieldName: 'email', type: 'unique' }] +console.log('Current indexes:', indexes); +``` +* **`collection.dropIndex(fieldName)`**: Removes an index from the specified field. +```javascript +await customersCollection.dropIndex('city'); +console.log('The index on the "city" field has been removed.'); +``` -### Методы поиска по индексу (для обратной совместимости) +### Index Search Methods (for Backward Compatibility) -Хотя современный метод `find` автоматически использует индексы, для обратной совместимости и в некоторых случаях для явного указания поиска по индексу сохранены следующие методы. Они могут быть менее гибкими, чем `find`. +While the modern `find` method automatically uses indexes, the following methods are retained for backward compatibility and, in some cases, for explicitly specifying index searches. They may be less flexible than `find`. -* **`collection.findByIndexedValue(fieldName, value)`**: Находит все документы с точным значением `value` в индексированном поле `fieldName`. Это эквивалентно `find({ [fieldName]: value })`. -* **`collection.findOneByIndexedValue(fieldName, value)`**: Находит один документ. Эквивалент `findOne({ [fieldName]: value })`. +* **`collection.findByIndexedValue(fieldName, value)`**: Finds all documents with the exact value `value` in the indexed field `fieldName`. This is equivalent to `find({ [fieldName]: value })`. +* **`collection.findOneByIndexedValue(fieldName, value)`**: Finds a single document. This is equivalent to `findOne({ [fieldName]: value })`. -**Пример:** +**Example:** ```javascript -// Эти два вызова дадут одинаковый результат, но `find` более универсален. -const usersFromSpb_legacy = await usersCollection.findByIndexedValue('city', 'Санкт-Петербург'); -const usersFromSpb_modern = await usersCollection.find({ city: 'Санкт-Петербург' }); +// These two calls will produce the same result, but `find` is more versatile. +const usersFromSpb_legacy = await usersCollection.findByIndexedValue('city', 'Saint Petersburg'); +const usersFromSpb_modern = await usersCollection.find({ city: 'Saint Petersburg' }); ``` diff --git a/docs/03-transactions.md b/docs/03-transactions.md index d682966..f5c2df1 100644 --- a/docs/03-transactions.md +++ b/docs/03-transactions.md @@ -1,82 +1,81 @@ -```markdown -# 03 - Работа с Транзакциями +# 03 - Working with Transactions -Транзакции в WiseJSON DB позволяют сгруппировать несколько операций записи (таких как вставка, обновление, удаление) в одну атомарную единицу. Это гарантирует, что либо все операции в транзакции успешно выполняются и их изменения сохраняются, либо, если на любом этапе до фиксации (commit) возникает ошибка, ни одна из операций не применяется, и база данных остается в состоянии, предшествующем началу транзакции. +Transactions in WiseJSON DB allow you to group multiple write operations (such as inserts, updates, and deletes) into a single atomic unit. This ensures that either all operations in a transaction are successfully executed and their changes are saved, or, if an error occurs at any stage before commit, none of the operations are applied, and the database remains in the state before the transaction began. -Транзакции обеспечивают **консистентность данных** при выполнении сложных, многошаговых изменений и могут затрагивать одну или несколько коллекций в рамках одного экземпляра базы данных. +Transactions ensure data consistency when performing complex, multi-step changes and can affect one or more collections within a single database instance. -**Предполагается, что у вас уже есть инициализированный экземпляр `WiseJSON` (переменная `db`), как описано в разделе `00-introduction-and-setup.md`.** +**This section assumes you already have an initialized WiseJSON instance (the db variable), as described in section 00-introduction-and-setup.md.** -## Когда использовать транзакции? +## When to Use Transactions? -Транзакции незаменимы в следующих случаях: +Transactions are indispensable in the following cases: -* **Атомарность нескольких операций**: Когда вам нужно, чтобы несколько связанных изменений данных произошли по принципу "все или ничего". Классический пример — перевод средств со счета на счет: списание с одного счета и зачисление на другой должны либо оба выполниться, либо оба отмениться. -* **Согласованность данных при сложных изменениях**: Если вы обновляете несколько логически связанных документов (возможно, в разных коллекциях), транзакция предотвратит состояние, когда часть данных обновлена, а часть — нет, из-за ошибки в середине процесса. -* **Изоляция**: Операции внутри транзакции не видны другим частям приложения до момента вызова `commit()`. Это обеспечивает базовый уровень изоляции и предотвращает чтение "грязных" или неполных данных. +- **Atomicity of Multiple Operations**: When you need multiple related data changes to occur on an all-or-nothing basis. A classic example is transferring funds between accounts: a debit from one account and a credit to another must either both occur, or both must be reversed. +- **Data Consistency During Complex Updates**: If you're updating multiple logically related documents (possibly in different collections), a transaction will prevent a situation where some data is updated and some is not, due to an error mid-process. +- **Isolation**: Operations within a transaction are not visible to other parts of the application until the commit() call. This provides a basic level of isolation and prevents reading "dirty" or incomplete data. -## Как работать с транзакциями +## How to Work with Transactions -Процесс работы с транзакциями включает четыре основных шага: +Working with transactions involves four main steps: -### Шаг 1: Начало транзакции +### Step 1: Beginning a Transaction -Чтобы начать транзакцию, вызовите метод `db.beginTransaction()`. Этот метод возвращает объект транзакции (`txn`), через который вы будете выполнять все последующие операции. +To begin a transaction, call the `db.beginTransaction()` method. This method returns a transaction object (TransactionManager), through which you will perform all subsequent operations. ```javascript const txn = db.beginTransaction(); ``` -### Шаг 2: Получение транзакционных коллекций +### Step 2: Obtaining Transactional Collections -Для выполнения операций внутри транзакции вы должны получить "транзакционную" версию коллекции через объект транзакции, используя метод `txn.collection('collectionName')`. +To perform operations within a transaction, you must obtain the "transactional" version of the collection through the transaction object using the `txn.collection('collectionName')` method. -* **Важно:** Для транзакционных коллекций **НЕ нужно** вызывать `initPromise`. Предполагается, что "родительские" коллекции уже были инициализированы при запуске приложения (например, `await db.collection('users').initPromise`). +- **Important:** For transactional collections, you **DO NOT** need to call `initPromise`. It is assumed that the "parent" collections have already been initialized at application startup (e.g., `await db.collection('users').initPromise`). ```javascript -// Получаем обертки для коллекций, которые будут участвовать в транзакции +// Obtain wrappers for the collections that will participate in the transaction const usersTxn = txn.collection('users'); const logsTxn = txn.collection('logs'); ``` -### Шаг 3: Выполнение операций +### Step 3: Executing Operations -Теперь вы можете вызывать методы записи (`insert`, `insertMany`, `update`, `remove`, `clear`) на этих транзакционных коллекциях. +You can now call the write methods (`insert`, `insertMany`, `update`, `remove`, `clear`) on these transactional collections. -* **Ключевой момент:** Эти операции не применяются к базе данных немедленно. Они лишь регистрируются внутри объекта транзакции и будут выполнены единым блоком только после вызова `txn.commit()`. -* **Возвращаемые значения:** Транзакционные методы записи в текущей реализации **не возвращают** результат операции (например, вставленный документ). Они возвращают `Promise`. -* **Генерация ID:** Если вам нужен ID нового документа для последующих операций в той же транзакции (например, вставить пользователя и сразу же записать лог с его ID), вы должны **сгенерировать этот ID на стороне клиента** перед вызовом `insert`. +- **Key Point:** These operations are not immediately applied to the database. They are merely logged within the transaction object and will only be executed as a single unit after `txn.commit()` is called. +- **Return Values:** Transactional write methods in the current implementation **do not** return the result of the operation (e.g., the inserted document). They return `Promise`. +- **ID Generation:** If you need a new document ID for subsequent operations in the same transaction (for example, inserting a user and immediately writing a log with their ID), you must **generate this ID on the client side** before calling `insert`. ```javascript -// Генерируем ID пользователя заранее, так как он понадобится для лога +// Generate the user ID in advance, as it will be needed for the log const { v4: uuidv4 } = require('uuid'); const newUserId = uuidv4(); -// Регистрируем операции в транзакции +// Log operations in the transaction await usersTxn.insert({ _id: newUserId, name: 'Diana Prince', - department: 'Justice League' + department: 'Justice League', }); await logsTxn.insert({ timestamp: new Date().toISOString(), action: 'USER_CREATED_IN_TXN', - userId: newUserId, // Используем заранее сгенерированный ID - details: 'User Diana Prince added via transaction' + userId: newUserId, // Using a pre-generated ID + details: 'User Diana Prince added via transaction', }); ``` -### Шаг 4: Завершение транзакции (`commit` или `rollback`) +### Step 4: Committing the transaction (`commit` or `rollback`) -У вас есть два способа завершить транзакцию: +You have two ways to commit a transaction: -* **`await txn.commit()`**: Если все операции должны быть применены, вызовите `commit()`. WiseJSON DB атомарно запишет все зарегистрированные операции в WAL-файлы соответствующих коллекций и применит изменения к данным в памяти. Если на этом этапе произойдет сбой, механизм восстановления из WAL гарантирует, что незавершенная транзакция не будет применена, сохраняя целостность данных. -* **`await txn.rollback()`**: Если возникла ошибка или вы решили отменить изменения до вызова `commit()`, вызовите `rollback()`. Этот метод просто отменяет все зарегистрированные в транзакции операции, и никаких изменений в базе данных не происходит. +- **`await txn.commit()`**: If all operations should be applied, call `commit()`. WiseJSON DB will atomically write all logged operations to the WAL files of the corresponding collections and apply the changes to the in-memory data. If a failure occurs at this stage, the WAL recovery mechanism ensures that the uncommitted transaction is not applied, preserving data integrity. +- **`await txn.rollback()`**: If an error occurs or you decide to roll back changes before calling `commit()`, call `rollback()`. This method simply rolls back all operations registered in the transaction, and no changes are made to the database. -#### Полный Пример Сценария с `commit` +#### Complete Example Scenario with `commit` -Этот пример демонстрирует создание нового пользователя и запись лога об этом событии в рамках одной атомарной операции. +This example demonstrates creating a new user and writing a log about this event in a single atomic operation. ```javascript const WiseJSON = require('wise-json-db'); @@ -90,8 +89,8 @@ async function transactionCommitExample() { await usersCollection.initPromise; const logsCollection = await db.collection('logs'); await logsCollection.initPromise; - - // Начинаем транзакцию + + // Begin a transaction const txn = db.beginTransaction(); const newUserId = uuidv4(); @@ -99,30 +98,29 @@ async function transactionCommitExample() { const txnUsers = txn.collection('users'); const txnLogs = txn.collection('logs'); - console.log('Регистрируем операции в транзакции...'); + console.log('Registering operations in the transaction...'); await txnUsers.insert({ _id: newUserId, name: 'Clark Kent', - email: 'clark@dailyplanet.com' + email: 'clark@dailyplanet.com', }); await txnLogs.insert({ event: 'USER_REGISTRATION_TXN', userId: newUserId, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); - - // Если все успешно, коммитим транзакцию - console.log('Применяем транзакцию (commit)...'); - await txn.commit(); - console.log('Транзакция успешно применена.'); + // If everything is successful, commit the transaction + console.log('Committing the transaction...'); + await txn.commit(); + console.log('The transaction was successfully committed.'); } catch (transactionError) { - console.error('Ошибка внутри блока транзакции, откатываем:', transactionError); + console.error('Error inside the transaction block, rolling back:', transactionError); await txn.rollback(); - console.log('Транзакция отменена (rollback).'); + console.log('The transaction was rolled back.'); } finally { if (db) { - await db.close(); + await db.close(); } } } @@ -130,12 +128,12 @@ async function transactionCommitExample() { transactionCommitExample(); ``` -#### Полный Пример Сценария с `rollback` +#### Full Example Scenario with `rollback` -Этот пример показывает, как транзакция отменяется при возникновении ошибки. Предположим, у нас есть коллекция `accounts` с документами `{ _id: 'acc1', balance: 100 }` и `{ _id: 'acc2', balance: 50 }`. +This example shows how a transaction is rolled back when an error occurs. Let's assume we have an 'accounts' collection with documents { \_id: 'acc1', balance: 100 } and { \_id: 'acc2', balance: 50 }. ```javascript -// ... инициализация db и коллекции 'accounts' ... +// ... initialize db and the 'accounts' collection ... const txn = db.beginTransaction(); const transferAmount = 30; @@ -143,29 +141,28 @@ const transferAmount = 30; try { const txnAccounts = txn.collection('accounts'); - // Списание с acc1 + // Withdraw from acc1 await txnAccounts.update('acc1', { balance: 100 - transferAmount }); - console.log('Списание запланировано.'); + console.log('Withdrawal scheduled.'); - // Имитируем ошибку (например, проверка показала, что получатель заблокирован) - throw new Error('Получатель не может принять перевод!'); + // Simulate an error (for example, verification shows that the recipient is blocked) + throw new Error('The recipient cannot accept the transfer!'); - // Этот код не выполнится + // This code will not execute await txnAccounts.update('acc2', { balance: 50 + transferAmount }); await txn.commit(); - } catch (transactionError) { - console.error('Ошибка во время транзакции:', transactionError.message); - console.log('Откатываем транзакцию (rollback)...'); + console.error('Error during transaction:', transactionError.message); + console.log('Rolling back the transaction...'); await txn.rollback(); - console.log('Транзакция отменена. Балансы остались неизменными.'); + console.log('The transaction has been canceled. The balances remain unchanged.'); } -// Проверка после транзакции покажет, что балансы не изменились. +// A post-transaction check will show that the balances have not changed. ``` -### Важные Замечания по Транзакциям +### Important Notes on Transactions -* **Производительность**: Транзакции, особенно затрагивающие множество операций или коллекций, могут быть немного медленнее отдельных операций из-за накладных расходов на управление состоянием транзакции и запись в WAL. Используйте их там, где целостность данных важнее максимальной скорости. -* **Ошибки при `commit`**: Если `txn.commit()` выбрасывает ошибку (например, из-за невозможности записать на диск), состояние данных останется консистентным. Механизм восстановления WiseJSON DB из WAL при следующем запуске не применит незавершенные транзакционные блоки. -* **Длительные транзакции**: Избегайте очень длительных транзакций, которые могут долго удерживать ресурсы. Хотя WiseJSON DB не использует традиционные блокировки строк/таблиц до момента коммита, файловая блокировка на уровне коллекции может быть задействована при фиксации транзакции. \ No newline at end of file +- **Performance**: Transactions, especially those involving many operations or collections, can be slightly slower than individual operations due to the overhead of managing transaction state and writing to WAL. Use them where data integrity is more important than maximum speed. +- **Commit Errors**: If `txn.commit()` throws an error (for example, due to an inability to write to disk), the data state will remain consistent. WiseJSON DB's WAL recovery mechanism will not reapply uncommitted transaction blocks on the next startup. +- **Long-running Transactions**: Avoid very long-running transactions that may hold resources for a long time. Although WiseJSON DB does not use traditional row/table locks until the commit, collection-level file locking may be applied when the transaction is committed. diff --git a/docs/04-advanced-features.md b/docs/04-advanced-features.md index 325330a..d19d875 100644 --- a/docs/04-advanced-features.md +++ b/docs/04-advanced-features.md @@ -1,184 +1,184 @@ -# 04 - Расширенные Возможности и Конфигурация +# 04 - Advanced Features and Configuration -В этом разделе рассматриваются дополнительные возможности WiseJSON DB, которые позволяют тонко настроить поведение базы данных, управлять данными через командную строку (CLI) и выполнять операции импорта/экспорта. +This section covers advanced WiseJSON DB features that allow you to fine-tune database behavior, manage data via the command line (CLI), and perform import/export operations. -## Конфигурация Экземпляра `WiseJSON` +## Configuring a WiseJSON Instance -При создании нового экземпляра `WiseJSON` вы можете передать второй аргумент — объект с опциями конфигурации, чтобы адаптировать базу данных под нужды вашего приложения. +When creating a new WiseJSON instance, you can pass a second argument—an object with configuration options—to tailor the database to your application's needs. -**Синтаксис:** +**Syntax:** `const db = new WiseJSON(dbPath, options);` -### Основные доступные опции: +### Main available options: -* **`ttlCleanupIntervalMs {number}`** - * **Описание:** Интервал в миллисекундах, с которым база данных будет автоматически проверять и удалять документы с истекшим сроком жизни (TTL). - * **По умолчанию:** `60000` (1 минута). - * **Пример:** `3600000` для проверки раз в час. +* **`ttlCleanupIntervalMs {number}`** +* **Description:** The interval in milliseconds at which the database will automatically check and delete documents with expired TTL. +* **Default:** `60000` (1 minute). +* **Example:** `3600000` to check once per hour. -* **`checkpointIntervalMs {number}`** - * **Описание:** Интервал в миллисекундах для автоматического создания чекпоинтов (снапшотов данных). Чекпоинты ускоряют запуск и восстановление. - * **По умолчанию:** `300000` (5 минут). - * **Пример:** `0` чтобы отключить создание чекпоинтов по таймеру. +* **`checkpointIntervalMs {number}`** +* **Description:** The interval in milliseconds for automatically creating checkpoints (data snapshots). Checkpoints speed up startup and recovery. +* **Default:** `300000` (5 minutes). +* **Example:** `0` to disable timer-based checkpoint creation. -* **`maxWalEntriesBeforeCheckpoint {number}`** - * **Описание:** Максимальное количество записей в журнале упреждающей записи (WAL), после которого будет принудительно запущен процесс создания чекпоинта, независимо от таймера. - * **По умолчанию:** `1000`. +* **`maxWalEntriesBeforeCheckpoint {number}`** +* **Description:** The maximum number of write-ahead log (WAL) entries after which a checkpoint creation process will be forced, regardless of the timer. +* **Default:** `1000`. -* **`checkpointsToKeep {number}`** - * **Описание:** Количество последних чекпоинтов, которые будут храниться на диске. Более старые будут автоматически удаляться для экономии места. - * **По умолчанию:** `5`. - * **Минимальное значение:** `1`. +* **`checkpointsToKeep {number}`** +* **Description:** The number of recent checkpoints to keep on disk. Older ones will be automatically deleted to save space. +* **Default:** `5`. +* **Minimum value:** `1`. -* **`idGenerator {function}`** - * **Описание:** Пользовательская функция для генерации `_id` документов, если `_id` не предоставлен при вставке. Должна возвращать уникальную строку. - * **По умолчанию:** Функция, генерирующая `uuid v4`. - * **Пример:** `() => \`doc-\${Date.now()}\`` +* **`idGenerator {function}`** +* **Description:** A user-defined function for generating `_id` for documents if `_id` is not provided during insertion. Should return a unique string. +* **Default:** A function that generates `uuid v4`. +* **Example:** ``() => doc-${Date.now()}`` -* **`walReadOptions {object}`** - * **Описание:** Опции для обработки WAL-файлов при запуске, особенно если они повреждены. - * **По умолчанию:** `{ recover: false, strict: false }`. В этом режиме поврежденные строки WAL пропускаются с выводом предупреждения. - * **Опции:** - * `recover: true`: Агрессивно пытаться восстановить данные, пропуская битые строки WAL. - * `strict: true`: Выбрасывать ошибку при первой же ошибке парсинга строки WAL, останавливая инициализацию. +* **`walReadOptions {object}`** +* **Description:** Options for processing WAL files at startup, especially if they are corrupted. * **Default:** `{ recover: false, strict: false }`. In this mode, corrupted WAL lines are skipped and a warning is issued. + * **Options:** + * `recover: true`: Aggressively attempt to recover data, skipping corrupted WAL lines. + * `strict: true`: Raise an error at the first WAL line parsing error, stopping initialization. -**Пример использования опций:** +**Example of using options:** ```javascript const { v4: uuidv4 } = require('uuid'); const dbOptions = { - checkpointIntervalMs: 10 * 60 * 1000, // Чекпоинт каждые 10 минут - checkpointsToKeep: 3, // Хранить 3 последних чекпоинта - idGenerator: () => `user-${uuidv4()}`,// Кастомный ID - walReadOptions: { recover: true } // Пытаться восстановить из поврежденного WAL +checkpointIntervalMs: 10 * 60 * 1000, // Checkpoint every 10 minutes +checkpointsToKeep: 3, // Keep the last 3 checkpoints +idGenerator: () => `user-${uuidv4()}`, // Custom ID +walReadOptions: { recover: true } // Attempt to recover from corrupted WAL }; const db = new WiseJSON('/path/to/my-app-db', dbOptions); ``` -## Импорт и Экспорт Данных (через API) +## Data Import and Export (via API) -Вы можете легко переносить данные в/из коллекций с помощью встроенных методов. +You can easily transfer data to and from collections using built-in methods. -* **`collection.exportJson(filePath)`**: Сохраняет все "живые" документы коллекции в указанный файл в формате JSON (массив объектов). - ```javascript - await usersCollection.exportJson('./backups/users_backup.json'); - ``` -* **`collection.exportCsv(filePath, options)`**: Сохраняет данные в формате CSV. Можно настроить разделители и заголовки. - ```javascript - await usersCollection.exportCsv('./backups/users_backup.csv'); - ``` -* **`collection.importJson(filePath, options)`**: Импортирует документы из JSON-файла. - * `options.mode`: - * `'append'` (по умолчанию): Добавляет документы из файла к существующим в коллекции. - * `'replace'`: **Полностью очищает** коллекцию перед импортом документов из файла. - ```javascript - // Добавить новых пользователей из файла - await usersCollection.importJson('./new_users.json'); +* **`collection.exportJson(filePath)`**: Saves all "live" documents of a collection to the specified file in JSON format (an array of objects). +```javascript +await usersCollection.exportJson('./backups/users_backup.json'); +``` +* **`collection.exportCsv(filePath, options)`**: Saves data in CSV format. Separators and headers can be customized. +```javascript +await usersCollection.exportCsv('./backups/users_backup.csv'); +``` +* **`collection.importJson(filePath, options)`**: Imports documents from a JSON file. + * `options.mode`: + * `'append'` (default): Appends documents from a file to existing ones in the collection. + * `'replace'`: **Completely clears** the collection before importing documents from a file. - // Полностью заменить данные в коллекции - await productsCollection.importJson('./full_product_list.json', { mode: 'replace' }); - ``` +```javascript +// Add new users from a file +await usersCollection.importJson('./new_users.json'); -## Интерфейс Командной Строки (CLI) +// Completely replace the data in the collection +await productsCollection.importJson('./full_product_list.json', { mode: 'replace' }); +``` + +## Command Line Interface (CLI) -WiseJSON DB включает мощный инструмент командной строки `wisejson-explorer` для администрирования базы данных без написания кода. +WiseJSON DB includes a powerful command-line tool, `wisejson-explorer`, for database administration without writing code. -**Важно:** -* **Путь к БД:** Укажите путь к вашей базе данных через переменную окружения `WISE_JSON_PATH`. -* **Разрешение на запись:** Для выполнения команд, изменяющих данные (`import`, `create-index`, `doc-remove` и др.), необходимо использовать глобальный флаг `--allow-write`. +**Important:** +* **DB Path:** Specify the path to your database using the `WISE_JSON_PATH` environment variable. +* **Write Permission:** To execute commands that modify data (`import`, `create-index`, `doc-remove`, etc.), you must use the `--allow-write` global flag. -**Примеры команд:** +**Example Commands:** -### Команды для чтения и анализа данных +### Commands for reading and analyzing data -* **`list-collections`**: Показать все коллекции и количество документов в них. - ```bash - wisejson-explorer list-collections - ``` -* **`show-collection `**: Показать документы в коллекции с фильтрацией, сортировкой и пагинацией. - ```bash - # Показать первые 5 документов из 'users', отсортированных по возрасту (по убыванию) - wisejson-explorer show-collection users --limit 5 --sort age --order desc +* **`list-collections`**: Show all collections and the number of documents in them. +```bash +wisejson-explorer list-collections +``` +* **`show-collection `**: Show documents in a collection with filtering, sorting, and pagination. +```bash +# Show the first 5 documents from 'users', sorted by age (descending) +wisejson-explorer show-collection users --limit 5 --sort age --order desc - # Найти пользователей старше 30 с помощью JSON-фильтра - wisejson-explorer show-collection users --filter '{"age":{"$gt":30}}' - ``` -* **`get-document `**: Получить один документ по его `_id`. -* **`list-indexes `**: Показать список индексов для коллекции. -* **`export-collection `**: Экспортировать коллекцию в файл (JSON по умолчанию, CSV через опцию). - ```bash - wisejson-explorer export-collection users users_backup.csv --output csv - ``` +# Find users over 30 using a JSON filter +wisejson-explorer show-collection users --filter '{"age":{"$gt":30}}' +``` +* **`get-document `**: Get a single document by its `_id`. +* **`list-indexes `**: Show a list of indexes for a collection. +* **`export-collection `**: Export a collection to a file (JSON by default, CSV via option). +```bash +wisejson-explorer export-collection users users_backup.csv --output csv +``` -### Команды для управления данными (требуют `--allow-write`) +### Commands for data management (require `--allow-write`) -* **`doc-insert ''`**: Вставить один новый документ. JSON-строку необходимо заключать в кавычки. -* **`doc-remove `**: Удалить документ по `_id`. -* **`import-collection `**: Импортировать документы из JSON-файла. -* **`create-index `**: Создать индекс. -* **`drop-index `**: Удалить индекс. +* **`doc-insert ''`**: Insert a single new document. The JSON string must be enclosed in quotes. +* **`doc-remove `**: Delete a document by `_id`. +* **`import-collection `**: Import documents from a JSON file. +* **`create-index `**: Create an index. +* **`drop-index `**: Delete an index. -## Data Explorer (Веб-интерфейс) +## Data Explorer (Web Interface) -Для визуального просмотра и управления данными вы можете запустить встроенный веб-интерфейс. Он обладает мощными функциями, включая интерактивную карту схемы данных и визуальный конструктор запросов. +For visual viewing and management of your data, you can launch the built-in web interface. It has powerful features, including an interactive data schema map and a visual query builder. -### Запуск Сервера +### Starting the Server -Запустите сервер одной из команд из корня вашего проекта: +Start the server with one of the following commands from the root of your project: ```bash -# Если wisejson-explorer установлен глобально или как зависимость +# If wisejson-explorer is installed globally or as a dependency wisejson-explorer-server -# Или напрямую через Node.js +# Or directly via Node.js node explorer/server.js ``` -По умолчанию, интерфейс будет доступен по адресу **http://127.0.0.1:3000**. Вы можете изменить порт через переменную окружения `PORT`. +By default, the interface will be accessible at **http://127.0.0.1:3000**. You can change the port using the `PORT` environment variable. -### Режимы Работы +### Operation Modes -Data Explorer может работать в двух режимах для обеспечения безопасности ваших данных. +Data Explorer can operate in two modes to ensure the security of your data. -#### 1. Режим "Только для чтения" (Read-Only Mode) — По умолчанию +#### 1. Read-Only Mode — Default -Это самый безопасный режим, используемый по умолчанию. В нем вы можете: -* Просматривать интерактивную карту схемы данных. -* Просматривать список коллекций и их содержимое. -* Использовать мощный визуальный конструктор для фильтрации данных. -* Сортировать и просматривать документы постранично. -* Просматривать список существующих индексов. +This is the most secure mode and is used by default. In it, you can: +* View an interactive map of the data schema. +* View a list of collections and their contents. +* Use a powerful visual designer to filter data. +* Sort and view documents page by page. +* View a list of existing indexes. -В этом режиме **невозможно** изменить или удалить какие-либо данные. +In this mode, it is **impossible** to modify or delete any data. -#### 2. Режим Записи (Write-Enabled Mode) +#### 2. Write-Enabled Mode -Чтобы выполнять операции, изменяющие данные (удаление документов, создание и удаление индексов), необходимо запустить сервер со специальной переменной окружения `WISEJSON_EXPLORER_ALLOW_WRITE=true`. +To perform operations that modify data (deleting documents, creating and deleting indexes), you must start the server with the special environment variable 'WISEJSON_EXPLORER_ALLOW_WRITE=true'. -**Внимание:** Используйте этот режим с осторожностью, так как изменения, сделанные через интерфейс, необратимы. +**Caution:** Use this mode with caution, as changes made through the interface are irreversible. -**Как запустить в режиме записи:** +**How ​​to run in write mode:** -* **Для Linux или macOS:** - ```bash - WISEJSON_EXPLORER_ALLOW_WRITE=true node explorer/server.js - ``` -* **Для Windows (в терминале CMD):** - ```bash - set "WISEJSON_EXPLORER_ALLOW_WRITE=true" && node explorer/server.js - ``` -* **Для Windows (в PowerShell):** - ```powershell - $env:WISEJSON_EXPLORER_ALLOW_WRITE="true"; node explorer/server.js - ``` +* **For Linux or macOS:** +```bash +WISEJSON_EXPLORER_ALLOW_WRITE=true node explorer/server.js +``` +* **For Windows (in the CMD terminal):** +```bash +set "WISEJSON_EXPLORER_ALLOW_WRITE=true" && node explorer/server.js +``` +* **For Windows (in PowerShell):** +```powershell +$env:WISEJSON_EXPLORER_ALLOW_WRITE="true"; node explorer/server.js +``` -Когда этот режим активен, в интерфейсе появятся дополнительные элементы управления: -* Кнопки **"Delete"** для удаления документов в таблице. -* Форма для **создания новых индексов**. -* Кнопки **"×"** для **удаления существующих индексов**. +When this mode is active, additional controls will appear in the interface: +* **"Delete"** buttons for deleting documents in the table. +* A form for **creating new indexes**. +* Use the **"×"** buttons to **delete existing indexes**. -### Защита Доступа +### Access Protection -Для защиты вашего Data Explorer паролем, используйте переменные окружения `WISEJSON_AUTH_USER` и `WISEJSON_AUTH_PASS` при запуске сервера: +To password-protect your Data Explorer, use the `WISEJSON_AUTH_USER` and `WISEJSON_AUTH_PASS` environment variables when starting the server: ```bash -WISEJSON_AUTH_USER=admin WISEJSON_AUTH_PASS=mySuperSecretPassword node explorer/server.js \ No newline at end of file +WISEJSON_AUTH_USER=admin WISEJSON_AUTH_PASS=mySuperSecretPassword node explorer/server.js diff --git a/docs/05-common-scenarios-cheatsheet.md b/docs/05-common-scenarios-cheatsheet.md index 54e4cff..7983127 100644 --- a/docs/05-common-scenarios-cheatsheet.md +++ b/docs/05-common-scenarios-cheatsheet.md @@ -1,76 +1,75 @@ -```markdown -# 05 - Распространенные Сценарии и Шпаргалка +# 05 - Common Scenarios and Cheat Sheet -Этот раздел содержит готовые примеры кода для решения типичных задач с помощью WiseJSON DB, а также краткую и актуальную шпаргалку по основным операциям. Эти сценарии помогут вам быстро интегрировать базу данных в ваши проекты. +This section contains ready-made code examples for solving typical problems using WiseJSON DB, as well as a short and up-to-date cheat sheet for basic operations. These scenarios will help you quickly integrate the database into your projects. -## Сценарий 1: Хранилище Пользовательских Профилей +## Scenario 1: User Profile Storage -**Задача:** Создать простое хранилище для профилей пользователей с уникальным email, возможностью поиска, добавления и обновления информации. +**Task:** Create a simple storage for user profiles with a unique email address, the ability to search, add, and update information. ```javascript const WiseJSON = require('wise-json-db'); const path = require('path'); async function userProfileManagement() { - const dbPath = path.resolve(__dirname, 'userProfilesDb'); - let db; +const dbPath = path.resolve(__dirname, 'userProfilesDb'); +let db; + +try { +db = new WiseJSON(dbPath); +await db.init(); +console.log('The profiles database has been initialized.'); + +const profiles = await db.getCollection('profiles'); +await profiles.clear(); // Let's clear it for clarity. + +// Create a unique index by email for quick search and to prevent duplicates. +await profiles.createIndex('email', { unique: true }); +console.log('A unique index by "email" has been created.'); + +// Adding new profiles +await profiles.insertMany([ +{ name: 'Elena Smirnova', email: 'elena@example.com', age: 28, city: 'Moscow' }, +{ name: 'Alexey Ivanov', email: 'alex@example.com', age: 34, city: 'Saint Petersburg' } +]); +console.log('Profiles added.'); + +// Attempting to add a user with an existing email (will raise an error) +try { +await profiles.insert({ name: 'Another Elena', email: 'elena@example.com', age: 30 }); +} catch (e) { +console.log(`\nExpected error: ${e.message}`); // Report a uniqueness violation +} - try { - db = new WiseJSON(dbPath); - await db.init(); - console.log('База данных профилей инициализирована.'); - - const profiles = await db.getCollection('profiles'); - await profiles.clear(); // Очистим для чистоты примера - - // Создадим уникальный индекс по email для быстрого поиска и предотвращения дубликатов - await profiles.createIndex('email', { unique: true }); - console.log('Уникальный индекс по "email" создан.'); - - // Добавление новых профилей - await profiles.insertMany([ - { name: 'Елена Смирнова', email: 'elena@example.com', age: 28, city: 'Москва' }, - { name: 'Алексей Иванов', email: 'alex@example.com', age: 34, city: 'Санкт-Петербург' } - ]); - console.log('Профили добавлены.'); - - // Попытка добавить пользователя с существующим email (вызовет ошибку) - try { - await profiles.insert({ name: 'Другая Елена', email: 'elena@example.com', age: 30 }); - } catch (e) { - console.log(`\nОжидаемая ошибка: ${e.message}`); // Сообщение о нарушении уникальности - } - - // Поиск профиля по email (автоматически использует индекс) - console.log('\nИщем профиль Алексея по email...'); - const foundAlex = await profiles.findOne({ email: 'alex@example.com' }); - if (foundAlex) { - console.log('Найден профиль:', foundAlex); - - // Обновление информации в профиле Алексея - console.log('\nОбновляем возраст и город Алексея...'); - const updatedAlex = await profiles.update(foundAlex._id, { age: 35, city: 'Новосибирск' }); - console.log('Обновленный профиль Алексея:', updatedAlex); - } - - // Получение всех профилей - console.log('\nВсе профили в базе:'); - const allProfiles = await profiles.getAll(); - allProfiles.forEach(p => console.log(`- ${p.name}, email: ${p.email}, возраст: ${p.age}`)); - - } catch (error) { - console.error('Ошибка в сценарии управления профилями:', error); - } finally { - if (db) await db.close(); - } +// Search for a profile by email (automatically uses the index) +console.log('\n Searching for Alexey's profile by email...'); +const foundAlex = await profiles.findOne({ email: 'alex@example.com' }); +if (foundAlex) { +console.log('Found profile:', foundAlex); + +// Updating Alexey's profile information +console.log('\nUpdating Alexey's age and city...'); +const updatedAlex = await profiles.update(foundAlex._id, { age: 35, city: 'Novosibirsk' }); +console.log('Updated Alexey's profile:', updatedAlex); +} + +// Get all profiles +console.log('\nAll profiles in the database:'); +const allProfiles = await profiles.getAll(); +allProfiles.forEach(p => console.log(`- ${p.name}, email: ${p.email}, age: ${p.age}`)); + +} catch (error) { +console.error('Error in profile management script:', error); +} finally { +if (db) await db.close(); +} } userProfileManagement(); ``` -## Сценарий 2: Логирование Событий с Автоудалением (TTL) +## Scenario 2: Logging Events with Auto-Delete (TTL) -**Задача:** Записывать события приложения в коллекцию логов. Старые или временные логи должны автоматически удаляться через заданное время. +**Task:** Write application events to a log collection. Old or temporary logs should be automatically deleted after a specified time. ```javascript const WiseJSON = require('wise-json-db'); @@ -78,34 +77,34 @@ const path = require('path'); async function eventLoggingWithTTL() { const dbPath = path.resolve(__dirname, 'eventLogsDb'); - // Настроим частую проверку TTL для демонстрации (каждые 3 секунды) + // Set up frequent TTL checking for the demo (every 3 seconds) const db = new WiseJSON(dbPath, { ttlCleanupIntervalMs: 3000 }); await db.init(); - + const eventLogs = await db.collection('event_logs'); await eventLogs.initPromise; await eventLogs.clear(); - console.log('Записываем события...'); + console.log('Logging events...'); await eventLogs.insert({ level: 'INFO', - message: 'Приложение запущено.', - ttl: 7 * 24 * 60 * 60 * 1000 // Этот лог будет жить 7 дней + message: 'The application has started.', + ttl: 7 * 24 * 60 * 60 * 1000, // This log will live for 7 days }); await eventLogs.insert({ level: 'DEBUG', - message: 'Отладочное сообщение, исчезнет через 5 секунд.', - ttl: 5000 // 5 секунд + message: 'Debug message, will disappear in 5 seconds.', + ttl: 5000, // 5 seconds }); - console.log(`\nТекущее количество логов: ${await eventLogs.count()}`); // Ожидаем 2 + console.log(`Current number of logs: ${await eventLogs.count()}`); // Waiting for 2 - console.log('Ожидаем 6 секунд, чтобы отладочный лог истек...'); - await new Promise(resolve => setTimeout(resolve, 6000)); + console.log('Waiting for 6 seconds for the debug log to expire...'); + await new Promise((resolve) => setTimeout(resolve, 6000)); - // TTL очистка произойдет либо по таймеру, либо при следующем чтении (например, count). + // TTL clearing will occur either on a timer or on the next read (e.g., count). const countAfterTTL = await eventLogs.count(); - console.log(`Количество логов после ожидания: ${countAfterTTL}`); // Ожидаем 1 + console.log(`Number of logs after wait: ${countAfterTTL}`); // Waiting for 1 await db.close(); } @@ -113,14 +112,14 @@ async function eventLoggingWithTTL() { eventLoggingWithTTL(); ``` -## Сценарий 3: Атомарная Регистрация (Транзакция) +## Scenario 3: Atomic Registration (Transaction) -**Задача:** При регистрации нового пользователя необходимо создать запись о нем в коллекции `users` и одновременно создать запись о его начальном балансе в коллекции `balances`. Обе операции должны либо выполниться успешно, либо не выполниться вовсе. +**Task:** When registering a new user, we need to create a record about them in the `users` collection and simultaneously create a record about their initial balance in the `balances` collection. Both operations must either succeed or fail. ```javascript const WiseJSON = require('wise-json-db'); const path = require('path'); -const { v4: uuidv4 } = require('uuid'); // Для генерации ID +const { v4: uuidv4 } = require('uuid'); // To generate an ID async function userRegistrationWithBalance() { const dbPath = path.resolve(__dirname, 'registrationDb'); @@ -134,87 +133,78 @@ async function userRegistrationWithBalance() { await users.clear(); await balances.clear(); - // Генерируем ID пользователя заранее, так как он нужен для обеих операций + // Generate the user ID in advance, as it is needed for both operations const newUserId = uuidv4(); const txn = db.beginTransaction(); - console.log(`\nНачинаем транзакцию для регистрации пользователя ${newUserId}...`); + console.log(`\nBeginning a transaction to register user ${newUserId}...`); try { const txnUsers = txn.collection('users_reg'); const txnBalances = txn.collection('balances_reg'); - // Операция 1: Создание пользователя + // Operation 1: Create a user await txnUsers.insert({ _id: newUserId, - name: 'Новый Пользователь', - email: 'newuser@example.com' + name: 'New User', + email: 'newuser@example.com', }); - // Операция 2: Создание начального баланса + // Operation 2: Creating the initial balance await txnBalances.insert({ userId: newUserId, currency: 'RUB', - amount: 0 + amount: 0, }); - console.log('Применяем транзакцию (commit)...'); + console.log('Applying the transaction (commit)...'); await txn.commit(); - console.log('Транзакция регистрации успешно применена.'); - + console.log('The registration transaction was successfully applied.'); } catch (transactionError) { - console.error('Ошибка в транзакции, откатываем:', transactionError.message); + console.error('Transaction error, rolling back:', transactionError.message); await txn.rollback(); } - // Проверяем, что обе записи были созданы + // Check that both records were created const registeredUser = await users.getById(newUserId); const userBalance = await balances.findOne({ userId: newUserId }); - console.log('\nПользователь создан:', !!registeredUser); - console.log('Баланс создан:', !!userBalance); - + console.log('User created:', !!registeredUser); + console.log('Balance created:', !!userBalance); + await db.close(); } userRegistrationWithBalance(); ``` -## Шпаргалка (Cheatsheet) по Основным Операциям - -| Задача | Метод API / Пример Кода | -| :-------------------------------------------- | :------------------------------------------------------------------------------------ | -| **Инициализация** | | -| Подключить библиотеку | `const WiseJSON = require('wise-json-db');` | -| Создать и инициализировать БД | `const db = new WiseJSON('path/to/db'); await db.init();` | -| Получить/создать и инициализировать коллекцию | `const col = await db.collection('name'); await col.initPromise;` | -| Закрыть БД (сохранить всё) | `await db.close();` | -| **Документы - Создание (Create)** | | -| Вставить один документ | `await col.insert({ name: 'A', value: 1 });` | -| Вставить массив документов | `await col.insertMany([{ name: 'B' }, { name: 'C' }]);` | -| Вставить документ с TTL (1 час) | `await col.insert({ data: 'temp', ttl: 3600000 });` | -| **Документы - Чтение (Read)** | | -| Получить документ по ID | `const doc = await col.getById('someId123');` | -| Получить все документы | `const allDocs = await col.getAll();` | -| Найти документы по условию | `await col.find({ age: { $gt: 30 }, status: 'active' });` | -| Найти один документ по условию | `await col.findOne({ email: 'a@b.c' });` | -| Подсчитать количество документов | `const count = await col.count();` | -| **Документы - Обновление (Update)** | | -| Обновить документ по ID (частично) | `await col.update('id123', { status: 'completed', score: 100 });` | -| Обновить один по фильтру (с операторами) | `await col.updateOne({ status: 'pending' }, { $set: { status: 'processing' } });` | -| Обновить несколько по фильтру | `await col.updateMany({ category: 'X' }, { $set: { processed: true } });` | -| Найти и обновить (вернуть новый) | `await col.findOneAndUpdate({ status: 'new' }, { $set: { status: 'claimed' } });` | -| **Документы - Удаление (Delete)** | | -| Удалить документ по ID | `await col.remove('id456');` | -| Удалить один по фильтру | `await col.deleteOne({ status: 'archived' });` | -| Удалить несколько по фильтру | `await col.deleteMany({ timestamp: { $lt: Date.now() - 86400000 } });` | -| Очистить всю коллекцию | `await col.clear();` | -| **Индексы** | | -| Создать стандартный индекс | `await col.createIndex('fieldName');` | -| Создать уникальный индекс | `await col.createIndex('email', { unique: true });` | -| Получить список индексов | `const indexes = await col.getIndexes();` | -| Удалить индекс | `await col.dropIndex('fieldName');` | -| **Транзакции** | | -| Начать транзакцию | `const txn = db.beginTransaction();` | -| Получить коллекцию в транзакции | `const txnCol = txn.collection('myCollection');` | -| Зарегистрировать операцию и применить | `await txnCol.insert(...); await txn.commit();` | -| Откатить транзакцию | `await txn.rollback();` | \ No newline at end of file +## Cheatsheet for Basic Operations + +| Task | API Method / Code Example | +| :--------------------------------------- | :-------------------------------------------------------------------------------- | +| **Initialization** | | +| Include the library | `const WiseJSON = require('wise-json-db');` | +| Create and initialize the DB | `const db = new WiseJSON('path/to/db'); await db.init();` | +| Get/create and initialize the collection | `const col = await db.collection('name'); await col.initPromise;` | +| Close the DB (save everything) | `await db.close();` | +| **Documents - Create** | | +| Insert a single document | `await col.insert({ name: 'A', value: 1 });` | +| Insert an array of documents | `await col.insertMany([{ name: 'B' }, { name: 'C' }]);` | +| Insert a document with a TTL (1 hour) | `await col.insert({ data: 'temp', ttl: 3600000 });` | +| **Documents - Read** | | +| Get a document by ID | `const doc = await col.getById('someId123');` | +| Get all documents | `const allDocs = await col.getAll();` | +| Find documents by condition | `await col.find({ age: { $gt: 30 }, status: 'active' });` | +| Find one document by condition | `await col.findOne({ email: 'a@b.c' });` | +| Count the number of documents | `const count = await col.count();` | +| **Documents - Update** | | +| Update a document by ID (partially) | `await col.update('id123', { status: 'completed', score: 100 });` | +| Update one by filter (with operators) | `await col.updateOne({ status: 'pending' }, { $set: { status: 'processing' } });` | +| Update multiple by filter | `await col.updateMany({ category: 'X' }, { $set: { processed: true } });` | +| Find and update (return new) | `await col.findOneAndUpdate({ status: 'new' }, { $set: { status: 'claimed' } });` | +| **Documents - Delete** | | +| Delete document by ID | `await col.remove('id456');` | +| Delete one by filter | `await col.deleteOne({ status: 'archived' });` | +| Delete multiple by filter | `await col.deleteMany({ timestamp: { $lt: Date.now() - 86400000 } });` | +| Clear the entire collection | `await col.clear();` | + +| **Indexes** diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index a017c81..e49aac1 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -1,136 +1,142 @@ -```markdown docs/06-troubleshooting.md -# 06 - Диагностика и Решение Проблем (FAQ) -В этом разделе собраны ответы на часто задаваемые вопросы и способы решения распространенных проблем, с которыми вы можете столкнуться при работе с WiseJSON DB. +# 06 - Troubleshooting and Troubleshooting (FAQ) -### Q1: Мои данные не сохраняются после перезапуска приложения. Что делать? +This section contains answers to frequently asked questions and solutions to common issues you may encounter when working with WiseJSON DB. -**A1:** Наиболее вероятная причина — вы не закрываете базу данных должным образом перед завершением работы приложения. WiseJSON DB выполняет финальное сохранение данных (включая запись чекпоинтов и компакцию WAL) при вызове метода `await db.close()`. +### Q1: My data isn't saved after I restart my application. What should I do? -* **Решение:** Убедитесь, что в вашем коде есть блок `finally` или обработчик завершения процесса, который вызывает `await db.close()`. +**A1:** The most likely cause is that you're not closing the database properly before terminating your application. WiseJSON DB performs final data saving (including writing checkpoints and WAL compaction) when you call the `await db.close()` method. - ```javascript - let db; - try { - db = new WiseJSON(dbPath); - await db.init(); - // ... ваша работа с БД ... - } catch (error) { - console.error(error); - } finally { - if (db) { - await db.close(); // Обязательный вызов! - } - } - ``` +- **Solution:** Ensure your code has a `finally` block or a process termination handler that calls `await db.close()`. -* WiseJSON DB пытается автоматически сохраниться при получении сигналов `SIGINT` и `SIGTERM`, но это не всегда надежно, особенно при аварийном завершении. Явный вызов `db.close()` — лучшая практика. +```javascript +let db; +try { + db = new WiseJSON(dbPath); + await db.init(); + // ... your work with the database ... +} catch (error) { + console.error(error); +} finally { + if (db) { + await db.close(); // Required! + } +} +``` -### Q2: Я получаю ошибку `Duplicate value '...' for unique index '...'`. Что это значит? +- WiseJSON DB tries to automatically save when receiving `SIGINT` and `SIGTERM` signals, but this isn't always reliable, especially in the event of a crash. Explicitly calling `db.close()` is best practice. -**A2:** Эта ошибка возникает, когда вы пытаетесь выполнить операцию (`insert`, `insertMany`, `update`, `updateMany`), которая приведет к нарушению уникальности значения в поле, для которого создан уникальный индекс. +### Q2: I get the error `Duplicate value '...' for unique index '...'`. What does this mean? -* **Решение:** - 1. **Проверьте данные:** Убедитесь, что значение, которое вы пытаетесь вставить или на которое пытаетесь обновить, действительно уникально для этого поля в коллекции. - 2. **Логика приложения:** Возможно, вам нужно добавить проверку на существование такого значения перед выполнением операции записи. - ```javascript - const existingDoc = await usersCollection.findOneByIndexedValue('email', newUser.email); - if (existingDoc) { - console.error(`Пользователь с email ${newUser.email} уже существует!`); - } else { - await usersCollection.insert(newUser); - } - ``` - 3. **Тип индекса:** Если это поле не должно быть строго уникальным, возможно, вам следует удалить уникальный индекс и создать вместо него стандартный (неуникальный) индекс, или не создавать индекс вовсе, если поиск по нему не частый. +**A2:** This error occurs when you attempt an operation (`insert`, `insertMany`, `update`, `updateMany`) that would violate the uniqueness of a value in a field for which a unique index has been created. -### Q3: Как посмотреть содержимое базы данных вручную (файлы на диске)? +- **Solution:** -**A3:** Данные WiseJSON DB хранятся в файловой системе в директории, которую вы указали при создании экземпляра `WiseJSON`. Для каждой коллекции создается своя поддиректория. +1. **Validate the data:** Make sure that the value you are trying to insert or update is indeed unique for this field in the collection. +2. **Application logic:** You may need to add a check for the existence of such a value before performing the write operation. -* **Путь к коллекции:** ` // ` -* **Чекпоинты (основные данные):** ` //_checkpoints/ ` - * В этой директории хранятся файлы чекпоинтов. Каждый чекпоинт состоит из: - * Одного `checkpoint_meta__.json` файла (метаданные коллекции, включая информацию об индексах). - * Одного или нескольких `checkpoint_data___segX.json` файлов (сегменты с данными документов в формате JSON-массива). - * Самые свежие данные обычно находятся в файлах чекпоинта с последней временной меткой. -* **WAL (Write-Ahead Log):** ` //wal_.log ` - * Этот файл содержит операции, которые были выполнены после последнего чекпоинта. Каждая строка — это JSON-объект, описывающий операцию. -* **Для удобного просмотра:** - * Используйте веб-интерфейс **Data Explorer** (`wisejson-explorer-server`), который предоставляет GUI для просмотра коллекций и документов. - * Используйте CLI-утилиту **`wisejson-explorer show-collection `** или **`wise-json find `**. +```javascript +const existingDoc = await usersCollection.findOneByIndexedValue('email', newUser.email); +if (existingDoc) { + console.error(`User with email ${newUser.email} already exists!`); +} else { + await usersCollection.insert(newUser); +} +``` -### Q4: Мое приложение падает с ошибкой `EMFILE: too many open files`. +3. **Index Type:** If this field doesn't need to be strictly unique, you might want to drop the unique index and create a standard (non-unique) index instead, or not create an index at all if it's not frequently searched. -**A4:** Эта ошибка операционной системы означает, что ваш процесс открыл слишком много файловых дескрипторов. Применительно к WiseJSON DB, это может произойти, если: +### Q3: How can I view the database contents manually (files on disk)? -1. **Не закрываются экземпляры `WiseJSON` или коллекции:** Каждый экземпляр и коллекция удерживают файловые дескрипторы для WAL, чекпоинтов и блокировок. Если вы постоянно создаете новые экземпляры `WiseJSON` или получаете коллекции без их последующего закрытия (через `db.close()`), количество открытых файлов будет расти. - * **Решение:** Убедитесь, что вы используете один экземпляр `WiseJSON` на протяжении жизни приложения (или правильно управляете его жизненным циклом). Вызывайте `db.close()`, когда экземпляр больше не нужен. -2. **Очень частые операции, создающие временные файлы:** Хотя WiseJSON DB использует атомарные операции записи через временные файлы, при экстремально высокой частоте таких операций теоретически возможно исчерпание лимита, если ОС не успевает освобождать дескрипторы. Однако, это менее вероятно, чем первая причина. -3. **Другие части вашего приложения также активно работают с файлами.** +**A3:** WiseJSON DB data is stored on the file system in the directory you specified when creating the WiseJSON instance. A subdirectory is created for each collection. -* **Диагностика:** Используйте утилиты операционной системы (например, `lsof -p ` в Linux/macOS) для проверки, какие файлы открыты вашим процессом. +- **Collection Path:** `//` +- **Checkpoints (Master Data):** `//_checkpoints/` + - This directory stores the checkpoint files. Each checkpoint consists of: + - One `checkpoint_meta__.json` file (collection metadata, including index information). + - One or more `checkpoint_data___segX.json` files (segments containing document data in JSON array format). + - The most recent data is typically found in the checkpoint files with the latest timestamp. +- **WAL (Write-Ahead Log):** `//wal_.log` + - This file contains operations performed since the last checkpoint. Each line is a JSON object describing the operation. +- **For easy viewing:** + - Use the **Data Explorer** web interface (`wisejson-explorer-server`), which provides a GUI for viewing collections and documents. \* Use the CLI utility **`wisejson-explorer show-collection `** or **`wise-json find `**. -### Q5: Как сделать бэкап базы данных WiseJSON DB? +### Q4: My application crashes with the error `EMFILE: too many open files`. -**A5:** Поскольку WiseJSON DB является файловой базой данных, бэкап можно сделать простым копированием всей директории базы данных (`dbPath`). +**A4:** This operating system error means that your process has opened too many file descriptors. In the case of WiseJSON DB, this can happen if: -* **Рекомендации:** - 1. **Остановите приложение (или убедитесь, что нет активных операций записи):** Это гарантирует, что все данные будут консистентны и WAL-файлы не будут в процессе изменения. - 2. **Скопируйте всю директорию `dbPath`** в безопасное место. - * Если остановка приложения невозможна, вызовите `await db.flushToDisk()` для всех активных коллекций или `await db.close()` (если это допустимо) перед копированием, чтобы минимизировать количество операций в WAL, которые не попали в последний чекпоинт. Однако, копирование "живой" базы без остановки записи не гарантирует 100% консистентности на момент копирования, хотя механизм восстановления из WAL обычно справляется с этим при восстановлении из такого бэкапа. +1. **`WiseJSON` instances or collections are not closed:** Each instance and collection holds file descriptors for WAL, checkpoints, and locks. If you continually create new `WiseJSON` instances or retrieve collections without closing them (using `db.close()`), the number of open files will grow. -### Q6: Что делать, если WAL-файл или файл чекпоинта поврежден? + - **Solution:** Ensure that you use a single `WiseJSON` instance for the lifetime of your application (or properly manage its lifecycle). Call `db.close()` when the instance is no longer needed. -**A6:** WiseJSON DB имеет механизмы для работы с такими ситуациями: +2. **Very frequent operations creating temporary files:** Although WiseJSON DB uses atomic writes to temporary files, with an extremely high frequency of such operations, it is theoretically possible to exhaust the limit if the OS does not keep up with the release of descriptors. However, this is less likely than the first reason. +3. **Other parts of your application also actively access files.** -* **Поврежденный WAL-файл:** - * При инициализации коллекции, если парсинг строки WAL не удается, по умолчанию (с `walReadOptions: { recover: false, strict: false }`) эта строка будет пропущена с выводом предупреждения в консоль, и WiseJSON DB попытается продолжить загрузку. - * Вы можете установить опцию `walReadOptions: { recover: true }` при создании экземпляра `WiseJSON`, чтобы более агрессивно пытаться восстановить данные, пропуская битые строки. - * Если WAL сильно поврежден, вы можете потерять операции, совершенные после последнего успешного чекпоинта. -* **Поврежденный файл чекпоинта:** - * Если файл метаданных чекпоинта (`checkpoint_meta_...json`) или один из его сегментов данных (`checkpoint_data_..._segX.json`) поврежден (например, невалидный JSON), WiseJSON DB при загрузке попытается его проигнорировать (с выводом предупреждения) и загрузить предыдущий доступный (неповрежденный) чекпоинт, если он есть. - * Если самый последний чекпоинт поврежден, а предыдущего нет, коллекция может инициализироваться как пустая (или только с данными из WAL, если он применялся к пустому состоянию). -* **Восстановление из бэкапа:** Если повреждение серьезное, лучшим решением будет восстановление из последней резервной копии. +- **Diagnostics:** Use operating system utilities (e.g., `lsof -p ` on Linux/macOS) to check which files your process has open. -### Q7: Есть ли ограничения на размер документа или коллекции? +### Q5: How do I back up a WiseJSON DB database? + +**A5:** Since WiseJSON DB is a file-based database, a backup can be made by simply copying the entire database directory (`dbPath`). + +- **Recommendations:** + +1. **Stop the application (or ensure there are no active write operations):** This ensures that all data is consistent and WAL files are not being modified. +2. **Copy the entire `dbPath`** directory to a safe location. + +- If stopping the application is not possible, call await db.flushToDisk() for all active collections or await db.close() (if applicable) before copying to minimize the number of WAL operations that missed the last checkpoint. However, copying a live database without stopping writes does not guarantee 100% consistency at the time of copying, although the WAL recovery mechanism usually handles this when restoring from such a backup. + +### Q6: What should I do if the WAL file or checkpoint file is corrupted? + +**A6:** WiseJSON DB has mechanisms for handling these situations: + +- **Corrupted WAL file:** +- When initializing a collection, if parsing a WAL row fails, by default (with `walReadOptions: { recover: false, strict: false }`) this row will be skipped, a warning will be printed to the console, and WiseJSON DB will attempt to continue loading. +- You can set the `walReadOptions: { recover: true }` option when creating a `WiseJSON` instance to more aggressively attempt to recover data, skipping broken rows. +- If the WAL is severely corrupted, you may lose operations performed after the last successful checkpoint. +* **Corrupted checkpoint file:** +- If the checkpoint metadata file (`checkpoint_meta_...json`) or one of its data segments (`checkpoint_data_..._segX.json`) is corrupted (for example, invalid JSON), WiseJSON DB will attempt to ignore it during loading (with a warning) and load the previous available (uncorrupted) checkpoint, if one exists. +- If the most recent checkpoint is corrupted and there is no previous checkpoint, the collection may be initialized as empty (or with only WAL data, if it was applied to an empty state). +- **Restoring from backup:** If the corruption is severe, the best solution is to restore from the latest backup. + +### Q7: Is there a limit on the size of a document or collection? **A7:** -* **Размер документа:** Теоретически, размер одного JSON-документа ограничен доступной оперативной памятью Node.js (V8) для его сериализации/десериализации и обработки. Практически, очень большие документы (много мегабайт) могут быть неэффективны для хранения и обработки. Рекомендуется держать документы в разумных пределах. -* **Размер коллекции:** Общий размер коллекции (суммарный размер всех ее документов и индексов) ограничен доступным дисковым пространством. WiseJSON DB использует сегментированные чекпоинты для эффективной работы с большими коллекциями, разбивая данные на более мелкие файлы при сохранении. -* **Количество документов:** Ограничено в основном производительностью и доступными ресурсами (память для хранения в Map, дисковое пространство). На очень больших количествах документов (миллионы) производительность операций без индексов или со сложными `find` предикатами может снижаться. -### Q8: Можно ли использовать WiseJSON DB в нескольких процессах одновременно? +- **Document Size:** Theoretically, the size of a single JSON document is limited by the available RAM in Node.js (V8) for serialization/deserialization and processing. In practice, very large documents (many megabytes) may be inefficient to store and process. It is recommended to keep document sizes within reasonable limits. +- **Collection Size:** The total size of a collection (the combined size of all its documents and indexes) is limited by available disk space. WiseJSON DB uses sharded checkpoints to efficiently work with large collections, breaking data into smaller files when storing. +- **Number of Documents:** Limited primarily by performance and available resources (memory for storing Maps, disk space). With very large document counts (millions), the performance of operations without indexes or with complex find predicates may decrease. -**A8:** Да, WiseJSON DB использует библиотеку `proper-lockfile` для обеспечения безопасности при доступе к файлам базы данных из нескольких **разных процессов Node.js**, запущенных на одной машине и работающих с одной и той же директорией БД. Это предотвращает гонки данных и повреждение файлов. +### Q8: Can WiseJSON DB be used in multiple processes simultaneously? -* Каждая операция записи (insert, update, remove, clear, создание/удаление индекса, flushToDisk) на уровне коллекции захватывает эксклюзивную блокировку на директорию коллекции на время выполнения операции. Если другой процесс пытается выполнить операцию записи в ту же коллекцию, он будет ожидать освобождения блокировки. -* Операции чтения обычно не требуют таких строгих блокировок и могут выполняться параллельно более эффективно, но они всегда будут читать консистентное состояние, зафиксированное последней операцией записи. +**A8:** Yes, WiseJSON DB uses the `proper-lockfile` library to ensure security when accessing database files from multiple **different Node.js** processes running on the same machine and accessing the same database directory. This prevents data races and file corruption. -### Q9: Как WiseJSON DB обеспечивает ACID-свойства для транзакций? +- Each write operation (insert, update, remove, clear, create/delete index, flushToDisk) at the collection level acquires an exclusive lock on the collection directory for the duration of the operation. If another process attempts to write to the same collection, it will wait for the lock to be released. +- Read operations typically do not require such strict locks and can be executed in parallel more efficiently, but they will always read the consistent state committed by the last write operation. -**A9:** WiseJSON DB стремится к ACID-свойствам следующим образом: +### Q9: How does WiseJSON DB ensure ACID properties for transactions? -* **Atomicity (Атомарность):** - * *На уровне одной операции:* Каждая отдельная операция (insert, update, remove) атомарна благодаря записи в WAL перед изменением данных в памяти и механизму восстановления. - * *На уровне транзакции (`db.beginTransaction()`):* Все операции внутри блока `txn.commit()` записываются как единый блок в WAL-файлы всех затронутых коллекций. Если запись этого блока в WAL или последующее применение к памяти прерывается, при восстановлении незавершенный транзакционный блок не будет применен, обеспечивая атомарность "все или ничего" для этого блока. -* **Consistency (Согласованность):** - * Уникальные индексы помогают поддерживать согласованность данных, предотвращая дубликаты. - * Транзакции переводят базу данных из одного согласованного состояния в другое. Если транзакция прерывается, данные откатываются (или не применяются) к предыдущему согласованному состоянию. -* **Isolation (Изолированность):** - * Операции внутри транзакции не видны другим частям приложения (или другим транзакциям) до вызова `commit()`. Это обеспечивает базовый уровень изоляции (read committed для данных вне транзакции). - * WiseJSON DB не реализует сложные уровни изоляции SQL (например, serializable). При одновременном доступе из нескольких процессов, файловые блокировки `proper-lockfile` на уровне директории коллекции сериализуют операции записи, обеспечивая изоляцию на уровне файловой системы. -* **Durability (Долговечность):** - * После успешного завершения операции записи (или коммита транзакции) и записи данных в WAL (и, в конечном итоге, в чекпоинт), данные считаются сохраненными и переживут перезапуск приложения или сбой системы (с учетом особенностей кэширования ОС). +**A9:** WiseJSON DB strives for ACID properties as follows: +- **Atomicity:** +- _At the single operation level:_ Each individual operation (insert, update, delete) is atomic due to a write to WAL before modifying the data in memory and the recovery mechanism. +- _At the transaction level (`db.beginTransaction()`):_ All operations within a `txn.commit()` block are written as a single block to the WAL files of all affected collections. If the write of this block to WAL or the subsequent application to memory is aborted, the uncommitted transaction block will not be applied during recovery, ensuring all-or-nothing atomicity for this block. +- **Consistency:** +- Unique indexes help maintain data consistency by preventing duplicates. +- Transactions move the database from one consistent state to another. If a transaction is aborted, the data is rolled back (or unapplied) to the previous consistent state. +- **Isolation:** +- Operations within a transaction are not visible to other parts of the application (or other transactions) until `commit()` is called. This provides a basic level of isolation (read committed for data outside the transaction). +- WiseJSON DB does not implement complex SQL isolation levels (e.g., serializable). When accessed concurrently by multiple processes, `proper-lockfile` file locks at the collection directory level serialize write operations, providing filesystem-level isolation. +- **Durability:** +- Once a write operation (or transaction commit) successfully completes and the data is written to the WAL (and ultimately to the checkpoint), the data is considered persistent and will survive an application restart or system crash (subject to OS caching). -### Q10: [Checkpoint] WARN (или отсутствие WARN) при удалении старых чекпоинтов: `ENOENT` +### Q10: [Checkpoint] WARN (or missing WARN) when deleting old checkpoints: `ENOENT` -При автоматической ротации старых чекпоинтов (когда сохраняется только определенное количество последних, например, `checkpointsToKeep: 5`), система пытается удалить файлы чекпоинтов, которые больше не нужны. +When automatically rotating old checkpoints (when only a certain number of the most recent ones are kept, for example, `checkpointsToKeep: 5`), the system attempts to delete checkpoint files that are no longer needed. -* **Если вы видите предупреждения `[WARN] [Checkpoint] Не удалось удалить data/meta checkpoint: ... ENOENT: no such file or directory ...` (в более старых версиях или при специфических ошибках):** - Это означает, что файл чекпоинта, который система пыталась удалить, уже отсутствовал на диске. Это обычно не является проблемой для целостности данных и может произойти, если файл был удален вручную, или предыдущая операция очистки была прервана. +- **If you see warnings like `[WARN] [Checkpoint] Failed to delete data/meta checkpoint: ... ENOENT: no such file or directory ...` (in older versions or with specific errors):** + This means that the checkpoint file the system attempted to delete was no longer on disk. This is typically not a data integrity issue and can occur if the file was deleted manually or a previous cleanup operation was interrupted. -* **В актуальных версиях WiseJSON DB:** Логика очистки чекпоинтов была улучшена. Ошибка `ENOENT` (файл не найден) при попытке удалить уже отсутствующий файл чекпоинта **больше не логируется как предупреждение (`WARN`)**, так как это не является индикатором проблемы. Если же при удалении файла возникает другая ошибка (например, отказ в доступе `EACCES`), она по-прежнему может быть залогирована как предупреждение. +- **In current versions of WiseJSON DB:** The checkpoint cleanup logic has been improved. The `ENOENT` (file not found) error when attempting to delete a missing checkpoint file is no longer logged as a warning (`WARN`)\*\*, as it does not indicate a problem. However, if another error occurs when deleting the file (such as an `EACCES` access denied error), it may still be logged as a warning. -Если у вас возникли другие вопросы или проблемы, рекомендуется также проверить GitHub Issues проекта на предмет похожих ситуаций или создать новый issue с подробным описанием проблемы. \ No newline at end of file +If you have other questions or problems, we recommend checking the project's GitHub Issues for similar situations or creating a new issue with a detailed description of the problem. diff --git a/docs/07-sync.md b/docs/07-sync.md index 48c4c05..c102fc2 100644 --- a/docs/07-sync.md +++ b/docs/07-sync.md @@ -1,166 +1,165 @@ -```markdown -# 07 - Продвинутая Синхронизация Данных (WiseJSON Sync) +# 07 - Advanced Data Synchronization (WiseJSON Sync) -WiseJSON DB предлагает мощную и надежную систему двусторонней синхронизации данных между локальной базой и удаленным сервером. Эта система спроектирована с упором на отказоустойчивость, предсказуемость и прозрачность для разработчика. +WiseJSON DB offers a powerful and reliable bidirectional data synchronization system between a local database and a remote server. This system is designed with fault tolerance, predictability, and developer transparency in mind. -## Ключевые Принципы и Возможности +## Key Principles and Features -* **PULL -> PUSH Модель**: Для уменьшения количества конфликтов, клиент сначала запрашивает и применяет изменения с сервера (`PULL`), и только потом отправляет свои локальные изменения (`PUSH`). -* **Токенизация (LSN)**: Вместо ненадежных временных меток клиента, синхронизация использует серверный **LSN** (Log Sequence Number) — монотонно возрастающий номер последней операции. Это гарантирует, что клиент получит все изменения и ничего не пропустит, независимо от настроек времени. -* **Идемпотентность PUSH**: Каждая порция (батч) локальных изменений отправляется с уникальным ID (`batchId`). Сервер отслеживает полученные ID и игнорирует дубликаты, что защищает от повторного применения данных при сбоях сети. -* **Пакетная отправка (Batching)**: Большое количество локальных изменений автоматически разбивается на небольшие пакеты для отправки, что предотвращает ошибки, связанные с большими телами запросов. -* **Адаптивный интервал**: `SyncManager` автоматически регулирует частоту синхронизации. В периоды бездействия интервал увеличивается, а при активной работе — уменьшается, что снижает нагрузку на сеть. -* **Механизм "Карантина"**: Если с сервера приходит "битая" операция, которую невозможно применить (например, из-за нарушения уникального индекса), она не останавливает всю синхронизацию, а помещается в специальный лог-файл `quarantine_.log` для последующего анализа. -* **Heartbeat (Сердцебиение)**: Для проверки "живости" соединения в периоды бездействия `SyncManager` периодически отправляет легкие health-чеки на сервер. -* **Прозрачная обработка ошибок**: Все ошибки синхронизации (сетевые, серверные) не "роняют" приложение, а генерируют событие `sync:error`, на которое можно подписаться. +* **PULL -> PUSH Model**: To reduce conflicts, the client first requests and applies changes from the server (`PULL`), and only then sends its local changes (`PUSH`). +* **Tokenization (LSN)**: Instead of unreliable client timestamps, synchronization uses the server's **LSN** (Log Sequence Number)—a monotonically increasing number representing the last operation. This ensures that the client receives all changes and doesn't miss anything, regardless of the time settings. +* **PUSH Idempotency**: Each batch of local changes is sent with a unique ID (`batchId`). The server tracks received IDs and ignores duplicates, preventing data reapplication during network failures. +* **Batching**: Large numbers of local changes are automatically split into small batches for sending, preventing errors associated with large request bodies. +* **Adaptive Interval**: `SyncManager` automatically adjusts the synchronization frequency. During periods of inactivity, the interval increases, and during periods of activity, it decreases, reducing network load. +* **Quarantine Mechanism**: If a failed operation is received from the server that cannot be applied (for example, due to a unique index violation), it does not stop the entire synchronization, but is instead placed in a special log file `quarantine_.log` for later analysis. +* **Heartbeat**: To ensure the connection remains active during periods of inactivity, `SyncManager` periodically sends lightweight health checks to the server. +* **Transparent error handling**: All synchronization errors (network or server) do not crash the application, but generate a `sync:error` event, which you can subscribe to. -## Как использовать +## How to use -### Шаг 1: Подключение и настройка +### Step 1: Connection and configuration -Включить синхронизацию для коллекции можно с помощью метода `collection.enableSync()`. +You can enable synchronization for a collection using the `collection.enableSync()` method. ```javascript const WiseJSON = require('wise-json-db'); -// Важно: apiClient импортируется из корневого модуля, а не из wise-json/sync +// Important: apiClient is imported from the root module, not from wise-json/sync const { apiClient: ApiClient } = require('wise-json-db'); const path = require('path'); async function setupSync() { - // 1. Инициализируем БД и коллекцию - const db = new WiseJSON(path.resolve(__dirname, 'my-sync-db')); - await db.init(); - const articles = await db.collection('articles'); - await articles.initPromise; - - // 2. Создаем экземпляр API-клиента - const apiClientInstance = new ApiClient( - 'https://api.example.com', // Базовый URL вашего сервера - 'YOUR-SECRET-API-KEY' // Ваш ключ API - ); - - // 3. Включаем синхронизацию, передавая клиент и другие опции - articles.enableSync({ - apiClient: apiClientInstance, - // Эти параметры обязательны для внутренней проверки, даже если передан apiClient - url: 'https://api.example.com', - apiKey: 'YOUR-SECRET-API-KEY', - - // Необязательные параметры для тонкой настройки - minSyncIntervalMs: 10000, // мин. интервал (10 сек) - maxSyncIntervalMs: 300000, // макс. интервал (5 мин) - pushBatchSize: 200, // отправлять по 200 операций за раз - }); +// 1. Initialize the database and collection +const db = new WiseJSON(path.resolve(__dirname, 'my-sync-db')); +await db.init(); +const articles = await db.collection('articles'); +await articles.initPromise; + +// 2. Create an API client instance +const apiClientInstance = new ApiClient( +'https://api.example.com', // Your server's base URL +'YOUR-SECRET-API-KEY' // Your API key +); + +// 3. Enable synchronization by passing the client and other options +articles.enableSync({ +apiClient: apiClientInstance, +// These parameters are required for internal validation, even if apiClient is passed +url: 'https://api.example.com', +apiKey: 'YOUR-SECRET-API-KEY', + +// Optional parameters for fine-tuning +minSyncIntervalMs: 10000, // min. interval (10 sec) +maxSyncIntervalMs: 300000, // max. interval (5 min) +pushBatchSize: 200, // send 200 operations at a time +}); } ``` -### Шаг 2: Обработка событий синхронизации +### Step 2: Handling Sync Events -Это самый важный шаг для создания надежного приложения. Подпишитесь на события, чтобы понимать, что происходит с синхронизацией. +This is the most important step in building a reliable application. Subscribe to events to understand what's happening with synchronization. ```javascript -// Подписываемся на события ДО начала активной работы с коллекцией +// Subscribe to events BEFORE actively working with the collection -// Успешное завершение полного цикла PULL -> PUSH +// Successful completion of the full PULL -> PUSH cycle articles.on('sync:success', (payload) => { - console.log(`[SYNC] Цикл завершен. Активность: ${payload.activityDetected}. LSN сервера: ${payload.lsn}`); +console.log(`[SYNC] Cycle completed. Activity: ${payload.activityDetected}. Server LSN: ${payload.lsn}`); }); -// Критическая ошибка в цикле синхронизации +// Critical error in the sync cycle articles.on('sync:error', (errorPayload) => { - console.error(`[SYNC ERROR] ${errorPayload.message}`, errorPayload.originalError); - // Здесь можно показать уведомление пользователю или записать в систему мониторинга +console.error(`[SYNC ERROR] ${errorPayload.message}`, errorPayload.originalError); +// Here you can display a notification to the user or log it to the monitoring system }); -// Операция с сервера помещена в карантин +// The operation from the server has been quarantined articles.on('sync:quarantine', (quarantinePayload) => { - console.warn('[SYNC QUARANTINE] Не удалось применить операцию:', quarantinePayload.operation); - console.warn('Причина:', quarantinePayload.error.message); +console.warn('[SYNC QUARANTINE] Failed to apply operation:', quarantinePayload.operation); +console.warn('Reason:', quarantinePayload.error.message); }); -// Другие полезные события для отладки: -articles.on('sync:initial_start', () => console.log('[SYNC] Начальная полная синхронизация...')); -articles.on('sync:initial_complete', (p) => console.log(`[SYNC] Начальная синхронизация завершена. Загружено: ${p.documentsLoaded} док.`)); -articles.on('sync:push_success', (p) => console.log(`[SYNC] Успешно отправлен батч ${p.batchId} (${p.pushed} операций).`)); -articles.on('sync:pull_success', (p) => console.log(`[SYNC] Получено ${p.pulled} операций с сервера.`)); +// Other useful debugging events: +articles.on('sync:initial_start', () => console.log('[SYNC] Initial full sync...')); +articles.on('sync:initial_complete', (p) => console.log(`[SYNC] Initial sync complete. Loaded: ${p.documentsLoaded} doc.`)); +articles.on('sync:push_success', (p) => console.log(`[SYNC] Successfully sent batch ${p.batchId} (${p.pushed} operations).`)); +articles.on('sync:pull_success', (p) => console.log(`[SYNC] Received ${p.pulled} operations from the server.`)); ``` -### Шаг 3: Работа с данными и ручное управление +### Step 3: Working with Data and Manual Management -После включения синхронизации просто работайте с коллекцией как обычно. Все изменения (`insert`, `update`, `remove`) будут автоматически поставлены в очередь на отправку. +After enabling sync, simply work with the collection as usual. All changes (insert, update, remove) will be automatically queued for submission. ```javascript -// Это изменение будет автоматически отправлено на сервер в следующем цикле sync -await articles.insert({ title: 'Новая статья', content: '...' }); +// This change will be automatically submitted to the server in the next sync cycle +await articles.insert({ title: 'New article', content: '...' }); -// Вы можете принудительно запустить цикл синхронизации в любой момент +// You can force a sync cycle at any time await articles.triggerSync(); -// Получить текущий статус синхронизации +// Get the current sync status const status = articles.getSyncStatus(); console.log(status); // { state: 'idle', isSyncing: false, ... } -// Отключить синхронизацию (например, при выходе пользователя) +// Disable sync (e.g., when the user logs out) articles.disableSync(); ``` -## Требования к серверному API +## Server API Requirements -Чтобы WiseJSON Sync работал корректно, ваш бэкенд должен реализовывать следующие эндпоинты: +For WiseJSON Sync to work correctly, your backend must implement the following endpoints: ### GET /sync/snapshot -* **Назначение:** Для начальной полной синхронизации. -* **Ответ:** - ```json - { - "server_lsn": 12345, - "documents": [ - { "_id": "...", "title": "...", "createdAt": "...", "updatedAt": "..." }, - // ... - ] - } - ``` +* **Purpose:** For the initial full sync. +* **Response:** +```json +{ +"server_lsn": 12345, +"documents": [ +{ "_id": "...", "title": "...", "createdAt": "...", "updatedAt": "..." }, +// ... +] +} +``` ### GET /sync/pull?since_lsn=\ -* **Назначение:** Получить дельту (новые операции) с сервера. -* **Параметр:** `since_lsn` — последний LSN, известный клиенту. Сервер должен вернуть все операции с LSN > `since_lsn`. -* **Ответ:** - ```json - { - "server_lsn": 12350, - "ops": [ - { "op": "INSERT", "doc": { "_id": "doc1", "data": "..." } }, - { "op": "UPDATE", "id": "doc2", "data": { "status": "done" } } - ] - } - ``` +* **Purpose:** Get the delta (new operations) from the server. +* **Parameter:** `since_lsn` is the latest LSN known to the client. The server should return all operations with LSN > `since_lsn`. +* **Response:** +```json +{ +"server_lsn": 12350, +"ops": [ +{ "op": "INSERT", "doc": { "_id": "doc1", "data": "..." } }, +{ "op": "UPDATE", "id": "doc2", "data": { "status": "done" } } +] +} +``` ### POST /sync/push -* **Назначение:** Принять батч операций от клиента. -* **Тело запроса:** - ```json - { - "batchId": "уникальный-id-батча-uuid", - "ops": [ /* массив операций из WAL клиента */ ] - } - ``` -* **Логика:** Сервер **ДОЛЖЕН** проверять `batchId` на уникальность. Если батч с таким ID уже был обработан, сервер должен вернуть успешный ответ, но не применять операции повторно (идемпотентность). -* **Ответ:** - ```json - { - "status": "ok", - "server_lsn": 12355 - } - ``` +* **Purpose:** Receive a batch of operations from the client. +* **Request Body:** +```json +{ +"batchId": "unique-batch-id-uuid", +"ops": [ /* array of operations from client WAL */ ] +} +``` +* **Logic:** The server **MUST** check `batchId` for uniqueness. If a batch with this ID has already been processed, the server should return a successful response but not retry the operations (idempotence). +* **Response:** +```json +{ +"status": "ok", +"server_lsn": 12355 +} +``` ### GET /sync/health -* **Назначение:** Проверка доступности сервера. -* **Ответ:** - ```json - { - "status": "ok" - } - ``` +* **Purpose:** Check server availability. +* **Response:** +```json +{ +"status": "ok" +} +``` -С этой продвинутой системой синхронизации вы можете создавать надежные оффлайн-приложения или распределенные системы с центральным сервером, будучи уверенными в целостности и сохранности данных. \ No newline at end of file +With this advanced synchronization system, you can create reliable offline applications or distributed systems with a central server, confident in the integrity and security of your data. diff --git a/explorer/schema-analyzer.js b/explorer/schema-analyzer.js deleted file mode 100644 index 4ec6480..0000000 --- a/explorer/schema-analyzer.js +++ /dev/null @@ -1,125 +0,0 @@ -// explorer/schema-analyzer.js - -/** - * Анализирует одну коллекцию и возвращает информацию о ее полях и индексах. - * @param {import('../wise-json/collection/core')} collection - Экземпляр коллекции. - * @returns {Promise} - */ -async function analyzeCollection(collection) { - const SAMPLE_SIZE = 100; // Анализируем первые 100 документов для определения полей - const fields = new Map(); - - // Получаем существующие индексы - const indexes = await collection.getIndexes(); - const indexedFields = new Map(indexes.map(idx => [idx.fieldName, idx])); - - // Берем выборку документов - const allDocs = await collection.getAll(); - const sampleDocs = allDocs.slice(0, SAMPLE_SIZE); - - for (const doc of sampleDocs) { - for (const key in doc) { - if (!fields.has(key)) { - fields.set(key, { name: key, types: new Set(), isIndexed: false }); - } - const fieldInfo = fields.get(key); - - // Определяем тип данных - const value = doc[key]; - if (value === null) fieldInfo.types.add('null'); - else if (Array.isArray(value)) fieldInfo.types.add('array'); - else fieldInfo.types.add(typeof value); - - // Проверяем, есть ли индекс - if (indexedFields.has(key)) { - fieldInfo.isIndexed = true; - fieldInfo.isUnique = indexedFields.get(key).type === 'unique'; - } - } - } - - // Преобразуем Set в массив для JSON-сериализации - const finalFields = Array.from(fields.values()).map(f => ({ ...f, types: Array.from(f.types) })); - - return { - name: collection.name, - docCount: allDocs.length, - fields: finalFields, - }; -} - -/** - * Ищет потенциальные связи между коллекциями на основе именования полей. - * @param {Array} collectionsData - Массив с проанализированными данными коллекций. - * @returns {Array} - */ -function detectRelationships(collectionsData) { - const links = []; - const collectionNames = new Set(collectionsData.map(c => c.name)); - - for (const sourceCollection of collectionsData) { - for (const sourceField of sourceCollection.fields) { - // Эвристика: ищем поля, заканчивающиеся на 'Id' или '_id', но не являющиеся самим '_id' - const fieldName = sourceField.name; - if (fieldName === '_id') continue; - - let potentialTargetName = null; - if (fieldName.toLowerCase().endsWith('id')) { - potentialTargetName = fieldName.slice(0, -2); - } else if (fieldName.toLowerCase().endsWith('_id')) { - potentialTargetName = fieldName.slice(0, -3); - } - - if (potentialTargetName) { - // Пытаемся найти коллекцию-цель (например, для 'userId' ищем 'users') - const targetNameSingular = potentialTargetName; - const targetNamePlural = `${potentialTargetName}s`; // простое добавление 's' - - if (collectionNames.has(targetNamePlural)) { - links.push({ - source: sourceCollection.name, - sourceField: fieldName, - target: targetNamePlural, - targetField: '_id', - }); - } else if (collectionNames.has(targetNameSingular)) { - links.push({ - source: sourceCollection.name, - sourceField: fieldName, - target: targetNameSingular, - targetField: '_id', - }); - } - } - } - } - return links; -} - -/** - * Основная функция. Анализирует всю базу данных и возвращает граф ее структуры. - * @param {import('../wise-json/index')} db - Экземпляр WiseJSON DB. - * @returns {Promise<{collections: Array, links: Array}>} - */ -async function analyzeDatabaseGraph(db) { - const collectionNames = await db.getCollectionNames(); - - const collectionsData = await Promise.all( - collectionNames.map(async name => { - const col = await db.collection(name); - await col.initPromise; - return analyzeCollection(col); - }) - ); - - const links = detectRelationships(collectionsData); - - return { - collections: collectionsData, - links: links, - }; -} - -module.exports = { - analyzeDatabaseGraph, -}; \ No newline at end of file diff --git a/explorer/schema-analyzer.ts b/explorer/schema-analyzer.ts new file mode 100644 index 0000000..c89fd86 --- /dev/null +++ b/explorer/schema-analyzer.ts @@ -0,0 +1,159 @@ +/** + * explorer/schema-analyzer.ts + * Analyzes collection structures to determine field types, indexing status, + * and infer relationships between different data sets. + */ + +import { Collection, WiseJSON } from "../src/index"; + +/** + * Metadata for an individual field within a collection. + */ +interface FieldInfo { + name: string; + types: string[]; + isIndexed: boolean; + isUnique?: boolean; +} + +/** + * Metadata representing a collection's structure. + */ +interface CollectionMetadata { + name: string; + docCount: number; + fields: FieldInfo[]; +} + +/** + * Metadata representing an inferred foreign-key relationship. + */ +interface Relationship { + source: string; + sourceField: string; + target: string; + targetField: string; +} + +/** + * Analyzes a single collection to extract its schema definition based on a sample of documents. + * @param collection - The collection instance to analyze. + * @returns A promise resolving to the collection's metadata. + */ +async function analyzeCollection(collection: Collection): Promise { + const SAMPLE_SIZE = 100; // Analyze the first 100 documents to infer types + const fieldsMap = new Map; isIndexed: boolean; isUnique?: boolean }>(); + + // Retrieve current indexing state + const indexes = await collection.getIndexes(); + const indexedFields = new Map(indexes.map(idx => [idx.fieldName, idx])); + + // Retrieve a sample of the data + const allDocs = await collection.find({}); + const sampleDocs = allDocs.slice(0, SAMPLE_SIZE); + + for (const doc of sampleDocs) { + for (const key in doc) { + if (!fieldsMap.has(key)) { + fieldsMap.set(key, { name: key, types: new Set(), isIndexed: false }); + } + + const fieldInfo = fieldsMap.get(key)!; + + // Infer Data Type + const value = doc[key]; + if (value === null) fieldInfo.types.add('null'); + else if (Array.isArray(value)) fieldInfo.types.add('array'); + else fieldInfo.types.add(typeof value); + + // Check for Indexing + if (indexedFields.has(key)) { + const indexMetadata = indexedFields.get(key)!; + fieldInfo.isIndexed = true; + fieldInfo.isUnique = (indexMetadata as any).type === 'unique'; + } + } + } + + // Transform internal Set to Array for JSON serialization compatibility + const finalFields: FieldInfo[] = Array.from(fieldsMap.values()).map(f => ({ + ...f, + types: Array.from(f.types) + })); + + return { + name: collection.name, + docCount: allDocs.length, + fields: finalFields, + }; +} + +/** + * Detects potential relationships between collections using naming heuristics. + * For example: 'userId' in collection 'orders' suggests a link to '_id' in 'users'. + * @param collectionsData - Array of analyzed collection metadata. + * @returns An array of detected relationships. + */ +function detectRelationships(collectionsData: CollectionMetadata[]): Relationship[] { + const links: Relationship[] = []; + const collectionNames = new Set(collectionsData.map(c => c.name)); + + for (const sourceCollection of collectionsData) { + for (const sourceField of sourceCollection.fields) { + const fieldName = sourceField.name; + + // Ignore primary IDs for link detection + if (fieldName === '_id') continue; + + let potentialTargetName: string | null = null; + + // Heuristic: look for fields ending in 'Id' or '_id' + if (fieldName.toLowerCase().endsWith('id')) { + potentialTargetName = fieldName.slice(0, -2); + } else if (fieldName.toLowerCase().endsWith('_id')) { + potentialTargetName = fieldName.slice(0, -3); + } + + if (potentialTargetName) { + // Match against singular and plural collection names + const targetSingular = potentialTargetName; + const targetPlural = `${potentialTargetName}s`; + + const findTarget = [targetPlural, targetSingular].find(name => collectionNames.has(name)); + + if (findTarget) { + links.push({ + source: sourceCollection.name, + sourceField: fieldName, + target: findTarget, + targetField: '_id', + }); + } + } + } + } + return links; +} + +/** + * Entry point: Analyzes the entire database to return a structural graph. + * Useful for visual schema representations and ER diagrams. + * @param db - The WiseJSON database instance. + */ +export async function analyzeDatabaseGraph(db: WiseJSON): Promise<{ collections: CollectionMetadata[], links: Relationship[] }> { + const collectionNames = await db.getCollectionNames(); + + const collectionsData = await Promise.all( + collectionNames.map(async name => { + const col = await db.getCollection(name); + return analyzeCollection(col); + }) + ); + + const links = detectRelationships(collectionsData); + + return { + collections: collectionsData, + links, + }; +} diff --git a/explorer/seed.js b/explorer/seed.js deleted file mode 100644 index a6e9163..0000000 --- a/explorer/seed.js +++ /dev/null @@ -1,141 +0,0 @@ -// explorer/seed.js -const path = require('path'); -const fs = require('fs'); -const WiseJSON = require('../wise-json/index.js'); - -// --- Конфигурация --- -const DB_PATH = path.resolve(process.cwd(), 'wise-json-db-data'); -const USER_COUNT = 150; -const ORDER_COUNT = 400; -const LOG_COUNT = 1000; - -// --- Вспомогательные данные для генерации --- -const FIRST_NAMES = ['Иван', 'Петр', 'Алиса', 'Елена', 'Дмитрий', 'Мария', 'Сергей', 'Анна']; -const LAST_NAMES = ['Иванов', 'Петров', 'Смирнова', 'Попова', 'Волков', 'Кузнецова', 'Зайцев']; -const CITIES = ['Москва', 'Санкт-Петербург', 'Новосибирск', 'Екатеринбург', 'Казань', 'Лондон']; -const TAGS = ['dev', 'qa', 'pm', 'design', 'js', 'python', 'go', 'devops', 'vip']; -const LOG_LEVELS = ['INFO', 'WARN', 'ERROR', 'DEBUG']; -const LOG_COMPONENTS = ['API', 'WebApp', 'PaymentGateway', 'AuthService']; -const ORDER_STATUSES = ['pending', 'shipped', 'delivered', 'cancelled']; -const PRODUCTS = [ - { name: 'Ноутбук Pro', price: 120000 }, - { name: 'Смартфон X', price: 80000 }, - { name: 'Беспроводные наушники', price: 15000 }, - { name: 'Умные часы', price: 25000 } -]; - -// --- Вспомогательные функции --- -const getRandom = (arr) => arr[Math.floor(Math.random() * arr.length)]; -const getRandomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; - -// --- Основной скрипт --- -async function seedDatabase() { - console.log(`\n🌱 Запускаем наполнение базы данных в: ${DB_PATH}`); - - if (fs.existsSync(DB_PATH)) { - console.log(' - Удаляем старую директорию базы данных...'); - fs.rmSync(DB_PATH, { recursive: true, force: true }); - } - - const db = new WiseJSON(DB_PATH); - await db.init(); - console.log(' - База данных инициализирована.'); - - try { - // --- 1. Коллекция Users --- - console.log(`\n👤 Генерируем и вставляем ${USER_COUNT} пользователей...`); - const usersCollection = await db.getCollection('users'); - await usersCollection.initPromise; - - const users = []; - for (let i = 0; i < USER_COUNT; i++) { - const user = { - _id: `user_${i}`, - name: `${getRandom(FIRST_NAMES)} ${getRandom(LAST_NAMES)}`, - email: `user${i}@example.com`, - age: getRandomInt(18, 65), - city: getRandom(CITIES), - tags: [getRandom(TAGS), getRandom(TAGS)].filter((v, i, a) => a.indexOf(v) === i), // 1-2 уникальных тега - active: Math.random() > 0.2, // 80% активных - }; - if (i % 10 === 0) { // Каждый 10-й пользователь имеет менеджера - user.managerId = `user_${getRandomInt(0, USER_COUNT - 1)}`; - } - if (i % 25 === 0) { // Каждый 25-й истечет через час - user.expireAt = Date.now() + 3600 * 1000; - } - users.push(user); - } - await usersCollection.insertMany(users); - - console.log(' - Создаем индексы для "users"...'); - await usersCollection.createIndex('city'); - await usersCollection.createIndex('age'); - await usersCollection.createIndex('email', { unique: true }); - console.log(`✅ Коллекция "users" создана с ${await usersCollection.count()} документами.`); - - // --- 2. Коллекция Orders --- - console.log(`\n🛒 Генерируем и вставляем ${ORDER_COUNT} заказов...`); - const ordersCollection = await db.getCollection('orders'); - await ordersCollection.initPromise; - - const orders = []; - for (let i = 0; i < ORDER_COUNT; i++) { - const productCount = getRandomInt(1, 3); - const orderProducts = Array.from({ length: productCount }, () => getRandom(PRODUCTS)); - orders.push({ - userId: `user_${getRandomInt(0, USER_COUNT - 1)}`, - status: getRandom(ORDER_STATUSES), - products: orderProducts, - totalAmount: orderProducts.reduce((sum, p) => sum + p.price, 0), - createdAt: new Date(Date.now() - getRandomInt(0, 30) * 86400000).toISOString(), // за последний месяц - }); - } - await ordersCollection.insertMany(orders); - - console.log(' - Создаем индексы для "orders"...'); - await ordersCollection.createIndex('userId'); - await ordersCollection.createIndex('status'); - console.log(`✅ Коллекция "orders" создана с ${await ordersCollection.count()} документами.`); - - // --- 3. Коллекция Logs --- - console.log(`\n📄 Генерируем и вставляем ${LOG_COUNT} логов...`); - const logsCollection = await db.getCollection('logs'); - await logsCollection.initPromise; - - const logs = []; - for (let i = 0; i < LOG_COUNT; i++) { - const log = { - level: getRandom(LOG_LEVELS), - component: getRandom(LOG_COMPONENTS), - message: `Operation ${i} completed with status code ${getRandomInt(200, 500)}.`, - timestamp: new Date(Date.now() - getRandomInt(0, 24 * 60) * 60000).toISOString(), // за последние сутки - }; - if (log.level === 'DEBUG') { // Debug-логи живут 5 минут - log.ttl = 5 * 60 * 1000; - } - if (i % 5 === 0) { // Привязываем некоторые логи к пользователям - log.userId = `user_${getRandomInt(0, USER_COUNT - 1)}`; - } - logs.push(log); - } - await logsCollection.insertMany(logs); - - console.log(' - Создаем индекс для "logs"...'); - await logsCollection.createIndex('level'); - console.log(`✅ Коллекция "logs" создана с ${await logsCollection.count()} документами.`); - - } catch (error) { - console.error('\n🔥 Произошла ошибка во время наполнения базы данных:', error); - } finally { - if (db) { - console.log('\n- Завершаем соединение с базой данных, сохраняя все изменения...'); - await db.close(); - } - } - - console.log('\n✨ Наполнение базы данных завершено! ✨'); - console.log('Теперь вы можете запустить сервер: node explorer/server.js'); -} - -seedDatabase(); \ No newline at end of file diff --git a/explorer/seed.ts b/explorer/seed.ts new file mode 100644 index 0000000..9936739 --- /dev/null +++ b/explorer/seed.ts @@ -0,0 +1,174 @@ +/** + * explorer/seed.ts + * Database seeding script to populate WiseJSON with realistic mock data + * for testing and demonstration purposes. + */ + +import path from 'path'; +import fs from 'fs'; +import { WiseJSON } from '../src/index.js'; + +// --- Configuration --- +const DB_PATH = process.env['WISE_JSON_PATH'] || path.resolve(process.cwd(), 'wise-json-db-data'); +const USER_COUNT = 150; +const ORDER_COUNT = 400; +const LOG_COUNT = 1000; + +// --- Mock Data Constants --- +const FIRST_NAMES = ['Ivan', 'Peter', 'Alice', 'Elena', 'Dmitry', 'Maria', 'Sergey', 'Anna']; +const LAST_NAMES = ['Ivanov', 'Petrov', 'Smirnova', 'Popova', 'Volkov', 'Kuznetsova', 'Zaitsev']; +const CITIES = ['Moscow', 'St. Petersburg', 'Novosibirsk', 'Ekaterinburg', 'Kazan', 'London']; +const TAGS = ['dev', 'qa', 'pm', 'design', 'js', 'python', 'go', 'devops', 'vip']; +const LOG_LEVELS = ['INFO', 'WARN', 'ERROR', 'DEBUG']; +const LOG_COMPONENTS = ['API', 'WebApp', 'PaymentGateway', 'AuthService']; +const ORDER_STATUSES = ['pending', 'shipped', 'delivered', 'cancelled']; +const PRODUCTS = [ + { name: 'Laptop Pro', price: 120000 }, + { name: 'Smartphone X', price: 80000 }, + { name: 'Wireless Headphones', price: 15000 }, + { name: 'Smart Watch', price: 25000 } +]; + +// --- Interfaces for Seed Data --- +interface UserSeed { + _id: string; + name: string; + email: string; + age: number; + city: string; + tags: string[]; + active: boolean; + managerId?: string; + expireAt?: number; +} + +interface OrderSeed { + userId: string; + status: string; + products: typeof PRODUCTS; + totalAmount: number; + createdAt: string; +} + +interface LogSeed { + level: string; + component: string; + message: string; + timestamp: string; + ttl?: number; + userId?: string; +} + +// --- Helper Functions --- +const getRandom = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]; +const getRandomInt = (min: number, max: number): number => Math.floor(Math.random() * (max - min + 1)) + min; + + + +async function seedDatabase() { + console.log(`\n🌱 Starting database seeding at: ${DB_PATH}`); + + if (fs.existsSync(DB_PATH)) { + console.log(' - Removing existing database directory...'); + fs.rmSync(DB_PATH, { recursive: true, force: true }); + } + + const db = new WiseJSON(DB_PATH); + await db.init(); + console.log(' - Database initialized.'); + + try { + // --- 1. Users Collection --- + console.log(`\n👤 Generating ${USER_COUNT} users...`); + const usersCollection = await db.getCollection('users'); + + const users: UserSeed[] = []; + for (let i = 0; i < USER_COUNT; i++) { + const user: UserSeed = { + _id: `user_${i}`, + name: `${getRandom(FIRST_NAMES)} ${getRandom(LAST_NAMES)}`, + email: `user${i}@example.com`, + age: getRandomInt(18, 65), + city: getRandom(CITIES), + tags: [getRandom(TAGS), getRandom(TAGS)].filter((v, i, a) => a.indexOf(v) === i), + active: Math.random() > 0.2, + }; + if (i % 10 === 0) { + user.managerId = `user_${getRandomInt(0, USER_COUNT - 1)}`; + } + if (i % 25 === 0) { + user.expireAt = Date.now() + 3600 * 1000; // Expire in 1 hour + } + users.push(user); + } + await usersCollection.insertMany(users); + + console.log(' - Creating indexes for "users"...'); + await usersCollection.createIndex('city'); + await usersCollection.createIndex('age'); + await usersCollection.createIndex('email', { unique: true }); + console.log(`✅ "users" collection created with ${await usersCollection.count()} documents.`); + + // --- 2. Orders Collection --- + console.log(`\n🛒 Generating ${ORDER_COUNT} orders...`); + const ordersCollection = await db.getCollection('orders'); + + const orders: OrderSeed[] = []; + for (let i = 0; i < ORDER_COUNT; i++) { + const productCount = getRandomInt(1, 3); + const orderProducts = Array.from({ length: productCount }, () => getRandom(PRODUCTS)); + orders.push({ + userId: `user_${getRandomInt(0, USER_COUNT - 1)}`, + status: getRandom(ORDER_STATUSES), + products: orderProducts, + totalAmount: orderProducts.reduce((sum, p) => sum + p.price, 0), + createdAt: new Date(Date.now() - getRandomInt(0, 30) * 86400000).toISOString(), + }); + } + await ordersCollection.insertMany(orders); + + console.log(' - Creating indexes for "orders"...'); + await ordersCollection.createIndex('userId'); + await ordersCollection.createIndex('status'); + console.log(`✅ "orders" collection created with ${await ordersCollection.count()} documents.`); + + // --- 3. Logs Collection --- + console.log(`\n📄 Generating ${LOG_COUNT} logs...`); + const logsCollection = await db.getCollection('logs'); + + const logs: LogSeed[] = []; + for (let i = 0; i < LOG_COUNT; i++) { + const log: LogSeed = { + level: getRandom(LOG_LEVELS), + component: getRandom(LOG_COMPONENTS), + message: `Operation ${i} completed with status code ${getRandomInt(200, 500)}.`, + timestamp: new Date(Date.now() - getRandomInt(0, 24 * 60) * 60000).toISOString(), + }; + if (log.level === 'DEBUG') { + log.ttl = 5 * 60 * 1000; // TTL: 5 minutes + } + if (i % 5 === 0) { + log.userId = `user_${getRandomInt(0, USER_COUNT - 1)}`; + } + logs.push(log); + } + await logsCollection.insertMany(logs); + + console.log(' - Creating index for "logs"...'); + await logsCollection.createIndex('level'); + console.log(`✅ "logs" collection created with ${await logsCollection.count()} documents.`); + + } catch (error) { + console.error('\n🔥 Seed failed:', error); + } finally { + if (db) { + console.log('\n- Closing database connection and flushing WAL...'); + await db.close(); + } + } + + console.log('\n✨ Database seeding complete! ✨'); + console.log('You can now start the explorer: node dist/explorer/server.js'); +} + +seedDatabase(); diff --git a/explorer/server.js b/explorer/server.js deleted file mode 100644 index 1c70cfe..0000000 --- a/explorer/server.js +++ /dev/null @@ -1,102 +0,0 @@ -/** - * explorer/server.js - * WiseJSON Data Explorer - HTTP Server - */ - -const http = require('http'); -const url = require('url'); -const fs =require('fs'); -const path = require('path'); -const WiseJSON = require('../wise-json/index.js'); -const { matchFilter } = require('../wise-json/collection/utils.js'); -const logger = require('../wise-json/logger'); -const { analyzeDatabaseGraph } = require('./schema-analyzer.js'); - -// --- Конфигурация --- -const PORT = process.env.PORT || 3000; -const DB_PATH = process.env.WISE_JSON_PATH || path.resolve(process.cwd(), 'wise-json-db-data'); -const AUTH_USER = process.env.WISEJSON_AUTH_USER; -const AUTH_PASS = process.env.WISEJSON_AUTH_PASS; -const USE_AUTH = !!(AUTH_USER && AUTH_PASS); -const ALLOW_WRITE = process.env.WISEJSON_EXPLORER_ALLOW_WRITE === 'true'; - -const LOGO_PATH = path.resolve(process.cwd(), 'logo.png'); - -logger.log(`[Server] DB Path: ${DB_PATH}`); -logger.log(`[Server] Write Operations Allowed: ${ALLOW_WRITE}`); - -const db = new WiseJSON(DB_PATH); - -// --- Вспомогательные функции --- -function sendJson(res, statusCode, data) { res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data, null, 2)); } -function sendError(res, statusCode, message) { sendJson(res, statusCode, { error: message }); } -function checkAuth(req, res) { if (!USE_AUTH) return true; const authHeader = req.headers['authorization']; if (!authHeader || !authHeader.startsWith('Basic ')) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="WiseJSON Data Explorer"' }); res.end('Unauthorized'); return false; } const b64 = authHeader.slice('Basic '.length).trim(); const [user, pass] = Buffer.from(b64, 'base64').toString().split(':'); if (user === AUTH_USER && pass === AUTH_PASS) { return true; } res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="WiseJSON Data Explorer"' }); res.end('Unauthorized'); return false; } -function serveStaticFile(filename, res) { const potentialPaths = [ path.join(__dirname, 'views', filename), path.join(__dirname, 'views', 'components', filename) ]; const filePath = potentialPaths.find(p => fs.existsSync(p)); if (!filePath) { return sendError(res, 404, 'Static file not found.'); } const ext = path.extname(filePath); const contentTypes = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', }; try { res.writeHead(200, { 'Content-Type': contentTypes[ext] || 'text/plain' }); fs.createReadStream(filePath).pipe(res); } catch { sendError(res, 500, 'Error reading static file.'); } } -function parseFilterFromQuery(query) { const filter = {}; for (const [key, value] of Object.entries(query)) { if (key.startsWith('filter_')) { const tail = key.slice('filter_'.length); const [field, op] = tail.split('__'); let v = value; if (/^-?\d+(\.\d+)?$/.test(v)) v = parseFloat(v); if (op) { if (!filter[field]) filter[field] = {}; filter[field][`$${op}`] = v; } else { filter[field] = v; } } } return filter; } - - -// --- Основной обработчик запросов --- -async function requestHandler(req, res) { - if (!checkAuth(req, res)) return; - - const parsedUrl = url.parse(req.url, true); - const { pathname, query } = parsedUrl; - const method = req.method.toUpperCase(); - - if (pathname === '/favicon.ico') { try { await fs.promises.access(LOGO_PATH); res.writeHead(200, { 'Content-Type': 'image/png' }); fs.createReadStream(LOGO_PATH).pipe(res); } catch (error) { res.writeHead(204); res.end(); } return; } - - // --- Роутинг --- - if (pathname === '/') return serveStaticFile('index.html', res); - if (pathname.startsWith('/static/')) return serveStaticFile(pathname.slice('/static/'.length), res); - - if (pathname === '/api/permissions' && method === 'GET') { return sendJson(res, 200, { writeMode: ALLOW_WRITE }); } - if (pathname === '/api/collections' && method === 'GET') { const names = await db.getCollectionNames(); const result = await Promise.all(names.map(async (name) => { const col = await db.collection(name); await col.initPromise; return { name, count: await col.count() }; })); return sendJson(res, 200, result); } - if (pathname === '/api/schema-graph' && method === 'GET') { try { const graphData = await analyzeDatabaseGraph(db); return sendJson(res, 200, graphData); } catch (error) { logger.error(`[Server] Error analyzing database graph: ${error.message}`); return sendError(res, 500, 'Failed to analyze database schema.'); } } - - const collectionRouteMatch = pathname.match(/^\/api\/collections\/([^\/]+)\/?$/); - if (collectionRouteMatch && method === 'GET') { - // +++ ИЗМЕНЕНИЕ: Декодируем имя коллекции +++ - const colName = decodeURIComponent(collectionRouteMatch[1]); - const col = await db.collection(colName); await col.initPromise; - const filter = parseFilterFromQuery(query); let filterObj = {}; if (query.filter) { try { filterObj = JSON.parse(query.filter); } catch {} } - let docs = await col.find({ ...filter, ...filterObj }); - if (query.sort) { docs.sort((a, b) => { if (a[query.sort] < b[query.sort]) return query.order === 'desc' ? 1 : -1; if (a[query.sort] > b[query.sort]) return query.order === 'desc' ? -1 : 1; return 0; }); } - const offset = parseInt(query.offset || '0', 10); const limit = parseInt(query.limit || '10', 10); - return sendJson(res, 200, docs.slice(offset, offset + limit)); - } - - const statsRouteMatch = pathname.match(/^\/api\/collections\/([^\/]+)\/stats$/); - if (statsRouteMatch && method === 'GET') { - const colName = decodeURIComponent(statsRouteMatch[1]); - const col = await db.collection(colName); await col.initPromise; - const stats = await col.stats(); const indexes = await col.getIndexes(); - return sendJson(res, 200, { ...stats, indexes }); - } - - const docRouteMatch = pathname.match(/^\/api\/collections\/([^\/]+)\/doc\/(.+)$/); - if (docRouteMatch) { - // +++ ИЗМЕНЕНИЕ: Декодируем имя коллекции и ID документа +++ - const colName = decodeURIComponent(docRouteMatch[1]); - const docId = decodeURIComponent(docRouteMatch[2]); - const col = await db.collection(colName); await col.initPromise; - if (method === 'GET') { const doc = await col.getById(docId); return doc ? sendJson(res, 200, doc) : sendError(res, 404, 'Document not found.'); } - if (method === 'DELETE') { if (!ALLOW_WRITE) return sendError(res, 403, 'Write operations are disabled.'); const success = await col.remove(docId); return success ? sendJson(res, 200, { message: 'Document removed' }) : sendError(res, 404, 'Document not found.'); } - } - - const indexRouteMatch = pathname.match(/^\/api\/collections\/([^\/]+)\/indexes\/?([^\/]+)?$/); - if (indexRouteMatch) { - if (!ALLOW_WRITE) return sendError(res, 403, 'Write operations are disabled.'); - // +++ ИЗМЕНЕНИЕ: Декодируем имя коллекции и поля индекса +++ - const colName = decodeURIComponent(indexRouteMatch[1]); - const fieldName = indexRouteMatch[2] ? decodeURIComponent(indexRouteMatch[2]) : null; - const col = await db.collection(colName); await col.initPromise; - if (method === 'POST' && !fieldName) { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { fieldName: newFieldName, unique } = JSON.parse(body); if (!newFieldName) return sendError(res, 400, 'fieldName is required.'); await col.createIndex(newFieldName, { unique: !!unique }); sendJson(res, 201, { message: `Index on "${newFieldName}" created.` }); } catch (e) { sendError(res, 500, e.message); } }); return; } - if (method === 'DELETE' && fieldName) { try { await col.dropIndex(fieldName); sendJson(res, 200, { message: `Index on "${fieldName}" dropped.` }); } catch(e) { sendError(res, 500, e.message); } return; } - } - - return sendError(res, 404, 'Not Found'); -} - -// --- Запуск сервера --- -async function startServer() { await db.init(); const server = http.createServer((req, res) => { requestHandler(req, res).catch(err => { logger.error(`[Server] Unhandled request error: ${err.message}`); sendError(res, 500, 'Internal Server Error'); }); }); server.listen(PORT, () => { if (USE_AUTH) { logger.log(`WiseJSON Data Explorer (auth required) is running at http://127.0.0.1:${PORT}/`); } else { logger.log(`WiseJSON Data Explorer is running at http://127.0.0.1:${PORT}/`); } if (!ALLOW_WRITE) { logger.warn('Server is in read-only mode. Set WISEJSON_EXPLORER_ALLOW_WRITE=true to enable changes.'); } }); } -startServer(); \ No newline at end of file diff --git a/explorer/server.ts b/explorer/server.ts new file mode 100644 index 0000000..751773a --- /dev/null +++ b/explorer/server.ts @@ -0,0 +1,247 @@ +/* eslint-disable no-useless-escape */ +/** + * explorer/server.ts + * WiseJSON Data Explorer - Lightweight HTTP Server + */ + +import http from 'http'; +import url from 'url'; +import fs from 'fs'; +import path from 'path'; +import { analyzeDatabaseGraph } from './schema-analyzer.js'; +import { WiseJSON } from '../src/index.js'; +import logger from '../src/lib/logger.js'; + +// --- Configuration & Environment --- +const PORT = process.env['PORT'] || 3000; +const DB_PATH = process.env['WISE_JSON_PATH'] || path.resolve(process.cwd(), 'wise-json-db-data'); +const AUTH_USER = process.env['WISEJSON_AUTH_USER']; +const AUTH_PASS = process.env['WISEJSON_AUTH_PASS']; +const USE_AUTH = !!(AUTH_USER && AUTH_PASS); +const ALLOW_WRITE = process.env['WISEJSON_EXPLORER_ALLOW_WRITE'] === 'true'; + +const LOGO_PATH = path.resolve(process.cwd(), 'logo.png'); + +const db = new WiseJSON(DB_PATH); + +/** + * Utility to send standardized JSON responses + */ +function sendJson(res: http.ServerResponse, statusCode: number, data: any) { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data, null, 2)); +} + +/** + * Utility to send standardized Error responses + */ +function sendError(res: http.ServerResponse, statusCode: number, message: string) { + sendJson(res, statusCode, { error: message }); +} + +/** + * Basic Authentication check + */ +function checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean { + if (!USE_AUTH) return true; + + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Basic ')) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="WiseJSON Data Explorer"' }); + res.end('Unauthorized'); + return false; + } + + const b64 = authHeader.slice('Basic '.length).trim(); + const [user, pass] = Buffer.from(b64, 'base64').toString().split(':'); + + if (user === AUTH_USER && pass === AUTH_PASS) return true; + + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="WiseJSON Data Explorer"' }); + res.end('Unauthorized'); + return false; +} + +/** + * Static file server for HTML/JS/CSS assets + */ +function serveStaticFile(filename: string, res: http.ServerResponse) { + const potentialPaths = [ + path.join(__dirname, 'views', filename), + path.join(__dirname, 'views', 'components', filename) + ]; + + const filePath = potentialPaths.find(p => fs.existsSync(p)); + if (!filePath) return sendError(res, 404, 'Static file not found.'); + + const ext = path.extname(filePath); + const contentTypes: Record = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.png': 'image/png' + }; + + res.writeHead(200, { 'Content-Type': contentTypes[ext] || 'text/plain' }); + fs.createReadStream(filePath).pipe(res); +} + +/** + * Parses filter_ queries into MongoDB-like filter objects + */ +function parseFilterFromQuery(query: url.UrlWithParsedQuery['query']): any { + const filter: any = {}; + for (const [key, value] of Object.entries(query)) { + if (key.startsWith('filter_') && typeof value === 'string') { + const tail = key.slice('filter_'.length); + const [field, op] = tail.split('__'); + let v: any = value; + + // Auto-convert numbers + if (/^-?\d+(\.\d+)?$/.test(v)) v = parseFloat(v); + + if (op) { + if (!filter[field]) filter[field] = {}; + filter[field][`$${op}`] = v; + } else { + filter[field] = v; + } + } + } + return filter; +} + + + +/** + * Main Request Routing Logic + */ +async function requestHandler(req: http.IncomingMessage, res: http.ServerResponse) { + if (!checkAuth(req, res)) return; + + const parsedUrl = url.parse(req.url || '/', true); + const { pathname, query } = parsedUrl; + const method = (req.method || 'GET').toUpperCase(); + + // Favicon Handling + if (pathname === '/favicon.ico') { + if (fs.existsSync(LOGO_PATH)) { + res.writeHead(200, { 'Content-Type': 'image/png' }); + fs.createReadStream(LOGO_PATH).pipe(res); + } else { + res.writeHead(204); + res.end(); + } + return; + } + + // Static Assets + if (pathname === '/') return serveStaticFile('index.html', res); + if (pathname?.startsWith('/static/')) return serveStaticFile(pathname.slice('/static/'.length), res); + + // API: Permissions + if (pathname === '/api/permissions' && method === 'GET') { + return sendJson(res, 200, { writeMode: ALLOW_WRITE }); + } + + // API: Collection List + if (pathname === '/api/collections' && method === 'GET') { + const names = await db.getCollectionNames(); + const result = await Promise.all(names.map(async (name) => { + const col = await db.getCollection(name); + return { name, count: await col.count() }; + })); + return sendJson(res, 200, result); + } + + // API: Schema Graph Analysis + if (pathname === '/api/schema-graph' && method === 'GET') { + try { + const graphData = await analyzeDatabaseGraph(db); + return sendJson(res, 200, graphData); + } catch (error: any) { + logger.error(`[Server] Schema analysis error: ${error.message}`); + return sendError(res, 500, 'Failed to analyze schema.'); + } + } + + // API: Documents in Collection (with pagination/sort/filter) + const collectionRouteMatch = pathname?.match(/^\/api\/collections\/([^\/]+)\/?$/); + if (collectionRouteMatch && method === 'GET') { + const colName = decodeURIComponent(collectionRouteMatch[1]); + const col = await db.getCollection(colName); + + const queryFilter = parseFilterFromQuery(query); + let jsonFilter = {}; + if (query['filter'] && typeof query['filter'] === 'string') { + try { jsonFilter = JSON.parse(query['filter']); } catch { /* empty */ } + } + + const docs = await col.find({ ...queryFilter, ...jsonFilter }); + + if (query['sort'] && typeof query['sort'] === 'string') { + const sortField = query['sort']; + docs.sort((a: any, b: any) => { + if (a[sortField] < b[sortField]) return query['order'] === 'desc' ? 1 : -1; + if (a[sortField] > b[sortField]) return query['order'] === 'desc' ? -1 : 1; + return 0; + }); + } + + const offset = parseInt(query['offset'] as string || '0', 10); + const limit = parseInt(query['limit'] as string || '10', 10); + return sendJson(res, 200, docs.slice(offset, offset + limit)); + } + + // API: Collection Stats & Indexes + const statsRouteMatch = pathname?.match(/^\/api\/collections\/([^\/]+)\/stats$/); + if (statsRouteMatch && method === 'GET') { + const colName = decodeURIComponent(statsRouteMatch[1]); + const col = await db.getCollection(colName); + const stats = await col.stats(); + const indexes = await col.getIndexes(); + return sendJson(res, 200, { ...stats, indexes }); + } + + // API: Individual Document (GET / DELETE) + const docRouteMatch = pathname?.match(/^\/api\/collections\/([^\/]+)\/doc\/(.+)$/); + if (docRouteMatch) { + const colName = decodeURIComponent(docRouteMatch[1]); + const docId = decodeURIComponent(docRouteMatch[2]); + const col = await db.getCollection(colName); + + if (method === 'GET') { + const doc = await col.findOne({ _id: docId }); + return doc ? sendJson(res, 200, doc) : sendError(res, 404, 'Document not found.'); + } + + if (method === 'DELETE') { + if (!ALLOW_WRITE) return sendError(res, 403, 'Write operations disabled.'); + const success = await col.deleteOne({ _id: docId }); + return success ? sendJson(res, 200, { message: 'Document removed' }) : sendError(res, 404, 'Document not found.'); + } + } + + return sendError(res, 404, 'Not Found'); +} + +/** + * Server Lifecycle + */ +async function startServer() { + await db.init(); + const server = http.createServer((req, res) => { + requestHandler(req, res).catch(err => { + logger.error(`[Server] Unhandled request error: ${err.message}`); + sendError(res, 500, 'Internal Server Error'); + }); + }); + + server.listen(PORT, () => { + logger.log(`[Explorer] Running at http://localhost:${PORT}/`); + if (USE_AUTH) logger.log(`[Explorer] Basic Auth enabled for user: ${AUTH_USER}`); + if (!ALLOW_WRITE) logger.warn('[Explorer] Running in READ-ONLY mode.'); + }); +} + +startServer(); diff --git a/explorer/utils.js b/explorer/utils.js deleted file mode 100644 index 6222f0b..0000000 --- a/explorer/utils.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * explorer/utils.js - * Вспомогательные утилиты для WiseJSON Data Explorer - */ - -const ansi = { - reset: '\x1b[0m', - key: '\x1b[34m', // Синий для ключей - string: '\x1b[32m', // Зелёный для строк - number: '\x1b[33m', // Жёлтый для чисел - boolean: '\x1b[35m', // Фиолетовый для true/false - null: '\x1b[90m', // Серый для null -}; - -/** - * Подсветка JSON для CLI. - * @param {string} jsonString - * @returns {string} - */ -function colorizeJson(jsonString) { - return jsonString - .replace(/"([^"]+)":/g, `"${ansi.key}$1${ansi.reset}":`) - .replace(/"([^"]*)"/g, (match, p1) => { - if (match.endsWith('":')) return match; - return `"${ansi.string}${p1}${ansi.reset}"`; - }) - .replace(/\b(-?\d+(\.\d+)?)\b/g, `${ansi.number}$1${ansi.reset}`) - .replace(/\b(true|false)\b/g, `${ansi.boolean}$1${ansi.reset}`) - .replace(/\b(null)\b/g, `${ansi.null}$1${ansi.reset}`); -} - -/** - * Экранирование HTML. - * @param {string} str - * @returns {string} - */ -function escapeHtml(str) { - if (typeof str !== 'string') return str; - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// flattenDocToCsv перенесена в wise-json/collection/utils.js - -module.exports = { - colorizeJson, - escapeHtml, - // flattenDocToCsv - больше не экспортируется здесь, см. wise-json/collection/utils.js -}; diff --git a/explorer/utils.ts b/explorer/utils.ts new file mode 100644 index 0000000..78c3d58 --- /dev/null +++ b/explorer/utils.ts @@ -0,0 +1,58 @@ +/** + * explorer/utils.ts + * Formatting and security utilities for the WiseJSON Data Explorer and CLI output. + */ + +/** + * ANSI Escape codes for terminal styling. + */ +const ansi = { + reset: '\x1b[0m', + key: '\x1b[34m', // Blue for keys + string: '\x1b[32m', // Green for strings + number: '\x1b[33m', // Yellow for numbers + boolean: '\x1b[35m', // Purple for booleans + null: '\x1b[90m', // Gray for null values +} as const; + +/** + * Applies ANSI color coding to a JSON string for enhanced readability in the terminal. + * * @param jsonString - The raw JSON string to colorize. + * @returns A string containing ANSI escape sequences for terminal coloring. + */ +export function colorizeJson(jsonString: string): string { + return jsonString + // Colorize Keys: "key": + .replace(/"([^"]+)":/g, `"${ansi.key}$1${ansi.reset}":`) + // Colorize Strings (avoiding keys already matched) + .replace(/"([^"]*)"/g, (match, p1) => { + if (match.endsWith('":')) return match; + return `"${ansi.string}${p1}${ansi.reset}"`; + }) + // Colorize Numbers + .replace(/\b(-?\d+(\.\d+)?)\b/g, `${ansi.number}$1${ansi.reset}`) + // Colorize Booleans + .replace(/\b(true|false)\b/g, `${ansi.boolean}$1${ansi.reset}`) + // Colorize Nulls + .replace(/\b(null)\b/g, `${ansi.null}$1${ansi.reset}`); +} + +/** + * Escapes special HTML characters to prevent XSS (Cross-Site Scripting) + * when rendering database content in the Explorer web interface. + * * @param str - The string to be escaped. + * @returns The HTML-safe string. + */ +export function escapeHtml(str: any): string { + if (typeof str !== 'string') return String(str); + + const htmlMap: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + + return str.replace(/[&<>"']/g, (m) => htmlMap[m]); +} diff --git a/explorer/views/components/db-map.js b/explorer/views/components/db-map.js deleted file mode 100644 index 03a6548..0000000 --- a/explorer/views/components/db-map.js +++ /dev/null @@ -1,259 +0,0 @@ -// explorer/views/components/db-map.js - -class DbMapComponent extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - this.graphData = null; - this.selectedCollection = null; - - // --- НОВОЕ: Свойства для логики перетаскивания --- - this.isDragging = false; - this.draggedNode = null; - this.offsetX = 0; - this.offsetY = 0; - this.storageKey = 'wisejson-db-map-positions'; - } - - connectedCallback() { - this.shadowRoot.innerHTML = ` - -
- -
- `; - this._canvas = this.shadowRoot.getElementById('canvas'); - this._svgLinks = this.shadowRoot.getElementById('svg-links'); - - // --- НОВОЕ: Вешаем слушатели для перетаскивания --- - this._canvas.addEventListener('mousedown', (e) => this._onMouseDown(e)); - // Обработчики mousemove и mouseup будут добавляться к документу динамически - } - - render(graphData) { - this.graphData = graphData; - const nodesContainer = document.createDocumentFragment(); - - if (!graphData || !graphData.collections) return; - - const positions = this._initializeNodePositions(graphData.collections); - - graphData.collections.forEach(col => { - const pos = positions[col.name]; - const nodeEl = this._createCollectionNode(col, pos); - nodesContainer.appendChild(nodeEl); - }); - - // Очищаем и вставляем все сразу для производительности - this._canvas.innerHTML = ''; - this._canvas.appendChild(this._svgLinks); - this._canvas.appendChild(nodesContainer); - - this._drawLinks(); - } - - _initializeNodePositions(collections) { - const savedPositions = this._loadPositions(); - const finalPositions = {}; - const PADDING = 50; - const NODE_WIDTH = 220; - const NODE_HEIGHT_ESTIMATE = 150; - const COLS = Math.floor(this.offsetWidth / (NODE_WIDTH + PADDING)) || 1; - let layoutIndex = 0; - - collections.forEach(col => { - if (savedPositions && savedPositions[col.name]) { - finalPositions[col.name] = savedPositions[col.name]; - } else { - // Если позиция не сохранена, вычисляем ее по сетке - const row = Math.floor(layoutIndex / COLS); - const colIndex = layoutIndex % COLS; - finalPositions[col.name] = { - x: PADDING + colIndex * (NODE_WIDTH + PADDING), - y: PADDING + row * (NODE_HEIGHT_ESTIMATE + PADDING), - }; - layoutIndex++; - } - }); - return finalPositions; - } - - _createCollectionNode(col, pos) { - const nodeEl = document.createElement('div'); - nodeEl.className = 'collection-node'; - nodeEl.style.left = `${pos.x}px`; - nodeEl.style.top = `${pos.y}px`; - nodeEl.dataset.collectionName = col.name; - - const fieldsHtml = col.fields.map(field => ` -
  • - ${field.isIndexed ? (field.isUnique ? '🔑' : '⚡️') : '•'} - ${field.name}: ${field.types.join(', ')} -
  • - `).join(''); - - nodeEl.innerHTML = `

    ${col.name} (${col.docCount})

      ${fieldsHtml}
    `; - return nodeEl; - } - - _drawLinks() { - if (!this.graphData || !this.graphData.links) return; - this._svgLinks.innerHTML = ''; - this.graphData.links.forEach(link => { - const sourceNode = this.shadowRoot.querySelector(`[data-collection-name="${link.source}"]`); - const targetNode = this.shadowRoot.querySelector(`[data-collection-name="${link.target}"]`); - if (!sourceNode || !targetNode) return; - - const startX = parseFloat(sourceNode.style.left) + sourceNode.offsetWidth; - const startY = parseFloat(sourceNode.style.top) + sourceNode.offsetHeight / 2; - const endX = parseFloat(targetNode.style.left); - const endY = parseFloat(targetNode.style.top) + targetNode.offsetHeight / 2; - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const d = `M ${startX} ${startY} C ${startX + 50} ${startY}, ${endX - 50} ${endY}, ${endX} ${endY}`; - path.setAttribute('d', d); - this._svgLinks.appendChild(path); - }); - } - - // --- НОВОЕ: Методы для перетаскивания и сохранения --- - - _onMouseDown(e) { - const node = e.target.closest('.collection-node'); - if (!node) return; - - e.preventDefault(); // Предотвращаем стандартное поведение (например, выделение текста) - this.draggedNode = node; - this.isDragging = true; - - this.offsetX = e.clientX - this.draggedNode.offsetLeft + this.scrollLeft; - this.offsetY = e.clientY - this.draggedNode.offsetTop + this.scrollTop; - - // Привязываем обработчики к документу, чтобы перетаскивание работало за пределами узла - this.onMouseMove = (ev) => this._onMouseMove(ev); - this.onMouseUp = () => this._onMouseUp(); - document.addEventListener('mousemove', this.onMouseMove); - document.addEventListener('mouseup', this.onMouseUp, { once: true }); // сработает один раз и удалится - } - - _onMouseMove(e) { - if (!this.isDragging) return; - e.preventDefault(); - - let newX = e.clientX - this.offsetX + this.scrollLeft; - let newY = e.clientY - this.offsetY + this.scrollTop; - - // Ограничиваем перемещение в пределах холста - newX = Math.max(0, newX); - newY = Math.max(0, newY); - - this.draggedNode.style.left = `${newX}px`; - this.draggedNode.style.top = `${newY}px`; - - // Перерисовываем связи в реальном времени - this._drawLinks(); - } - - _onMouseUp() { - if (!this.isDragging) return; - this.isDragging = false; - document.removeEventListener('mousemove', this.onMouseMove); - this._savePositions(); - - // Выделяем узел после перетаскивания - this._handleNodeSelection(this.draggedNode); - } - - _handleNodeSelection(node) { - if (!node) return; - const collectionName = node.dataset.collectionName; - this.shadowRoot.querySelectorAll('.collection-node').forEach(n => n.classList.remove('selected')); - node.classList.add('selected'); - this.selectedCollection = collectionName; - this.dispatchEvent(new CustomEvent('collection-selected', { - detail: { collectionName }, bubbles: true, composed: true - })); - } - - _savePositions() { - const positions = {}; - this.shadowRoot.querySelectorAll('.collection-node').forEach(node => { - const name = node.dataset.collectionName; - positions[name] = { - x: parseFloat(node.style.left), - y: parseFloat(node.style.top), - }; - }); - localStorage.setItem(this.storageKey, JSON.stringify(positions)); - } - - _loadPositions() { - try { - const saved = localStorage.getItem(this.storageKey); - return saved ? JSON.parse(saved) : null; - } catch (e) { - console.error("Failed to load node positions from localStorage", e); - return null; - } - } -} - -customElements.define('db-map', DbMapComponent); \ No newline at end of file diff --git a/explorer/views/components/db-map.ts b/explorer/views/components/db-map.ts new file mode 100644 index 0000000..c2a6b82 --- /dev/null +++ b/explorer/views/components/db-map.ts @@ -0,0 +1,252 @@ +/** + * explorer/views/components/db-map.ts + * Custom Web Component for visualizing the database schema as a draggable graph. + */ + +interface NodePosition { + x: number; + y: number; +} + +interface GraphData { + collections: Array<{ + name: string; + docCount: number; + fields: Array<{ + name: string; + isIndexed: boolean; + isUnique?: boolean; + types: string[]; + }>; + }>; + links: Array<{ + source: string; + target: string; + }>; +} + +class DbMapComponent extends HTMLElement { + private graphData: GraphData | null = null; + private isDragging = false; + private draggedNode: HTMLElement | null = null; + private offsetX = 0; + private offsetY = 0; + private storageKey = 'wisejson-db-map-positions'; + + // Shadow DOM elements + private _canvas!: HTMLElement; + private _svgLinks!: SVGSVGElement; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.shadowRoot!.innerHTML = ` + +
    + +
    + `; + this._canvas = this.shadowRoot!.getElementById('canvas')!; + this._svgLinks = this.shadowRoot!.getElementById('svg-links') as unknown as SVGSVGElement; + + this._canvas.addEventListener('mousedown', (e) => this._onMouseDown(e)); + } + + /** + * Renders the visual graph from the provided data. + */ + public render(graphData: GraphData): void { + this.graphData = graphData; + const fragment = document.createDocumentFragment(); + if (!graphData.collections) return; + + const positions = this._initializeNodePositions(graphData.collections); + + graphData.collections.forEach(col => { + const pos = positions[col.name]; + const nodeEl = this._createCollectionNode(col, pos); + fragment.appendChild(nodeEl); + }); + + // Clear canvas while keeping the SVG layer + this._canvas.innerHTML = ''; + this._canvas.appendChild(this._svgLinks); + this._canvas.appendChild(fragment); + + // Initial draw of Bezier links + requestAnimationFrame(() => this._drawLinks()); + } + + private _initializeNodePositions(collections: GraphData['collections']): Record { + const saved = this._loadPositions(); + const finalPositions: Record = {}; + const PADDING = 60; + const NODE_WIDTH = 240; + const COLS = Math.floor(this.offsetWidth / (NODE_WIDTH + PADDING)) || 1; + + collections.forEach((col, i) => { + if (saved && saved[col.name]) { + finalPositions[col.name] = saved[col.name]; + } else { + const row = Math.floor(i / COLS); + const colIdx = i % COLS; + finalPositions[col.name] = { + x: PADDING + colIdx * (NODE_WIDTH + PADDING), + y: PADDING + row * (180 + PADDING), + }; + } + }); + return finalPositions; + } + + private _createCollectionNode(col: GraphData['collections'][0], pos: NodePosition): HTMLElement { + const nodeEl = document.createElement('div'); + nodeEl.className = 'collection-node'; + nodeEl.style.left = `${pos.x}px`; + nodeEl.style.top = `${pos.y}px`; + nodeEl.dataset["collectionName"] = col.name; + + const fieldsHtml = col.fields.map(field => ` +
  • + ${field.isIndexed ? (field.isUnique ? '🔑' : '⚡') : '•'} + ${field.name}: ${field.types.join('|')} +
  • + `).join(''); + + nodeEl.innerHTML = `

    ${col.name}

      ${fieldsHtml}
    `; + return nodeEl; + } + + private _drawLinks(): void { + if (!this.graphData?.links) return; + this._svgLinks.innerHTML = ''; + + this.graphData.links.forEach(link => { + const src = this.shadowRoot!.querySelector(`[data-collection-name="${link.source}"]`) as HTMLElement; + const target = this.shadowRoot!.querySelector(`[data-collection-name="${link.target}"]`) as HTMLElement; + if (!src || !target) return; + + const sX = parseFloat(src.style.left) + src.offsetWidth; + const sY = parseFloat(src.style.top) + src.offsetHeight / 2; + const eX = parseFloat(target.style.left); + const eY = parseFloat(target.style.top) + target.offsetHeight / 2; + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const cp1 = sX + 40; + const cp2 = eX - 40; + path.setAttribute('d', `M ${sX} ${sY} C ${cp1} ${sY}, ${cp2} ${eY}, ${eX} ${eY}`); + this._svgLinks.appendChild(path); + }); + } + + // --- Drag & Drop Engine --- + + private _onMouseDown(e: MouseEvent): void { + const node = (e.target as HTMLElement).closest('.collection-node') as HTMLElement; + if (!node) return; + + this.draggedNode = node; + this.isDragging = true; + this.offsetX = e.clientX - node.offsetLeft; + this.offsetY = e.clientY - node.offsetTop; + + const onMove = (ev: MouseEvent) => { + if (!this.isDragging || !this.draggedNode) return; + const x = ev.clientX - this.offsetX; + const y = ev.clientY - this.offsetY; + + this.draggedNode.style.left = `${Math.max(0, x)}px`; + this.draggedNode.style.top = `${Math.max(0, y)}px`; + this._drawLinks(); + }; + + const onUp = () => { + this.isDragging = false; + document.removeEventListener('mousemove', onMove); + this._savePositions(); + this._selectNode(this.draggedNode!); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp, { once: true }); + } + + private _selectNode(node: HTMLElement): void { + const name = node.dataset["collectionName"]!; + this.shadowRoot!.querySelectorAll('.collection-node').forEach(n => n.classList.remove('selected')); + node.classList.add('selected'); + + this.dispatchEvent(new CustomEvent('collection-selected', { + detail: { collectionName: name }, bubbles: true, composed: true + })); + } + + private _savePositions(): void { + const pos: Record = {}; + this.shadowRoot!.querySelectorAll('.collection-node').forEach(node => { + const n = node as HTMLElement; + pos[n.dataset["collectionName"]!] = { x: parseFloat(n.style.left), y: parseFloat(n.style.top) }; + }); + localStorage.setItem(this.storageKey, JSON.stringify(pos)); + } + + private _loadPositions(): Record | null { + try { + return JSON.parse(localStorage.getItem(this.storageKey) || 'null'); + } catch { return null; } + } +} + +customElements.define('db-map', DbMapComponent); diff --git a/explorer/views/components/json-viewer.js b/explorer/views/components/json-viewer.js deleted file mode 100644 index 50d4066..0000000 --- a/explorer/views/components/json-viewer.js +++ /dev/null @@ -1,114 +0,0 @@ -// explorer/views/components/json-viewer.js - -class JsonViewerComponent extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - } - - connectedCallback() { - this.shadowRoot.innerHTML = ` - -
    
    -        `;
    -        this._contentElement = this.shadowRoot.getElementById('content');
    -    }
    -
    -    /**
    -     * Статический геттер, чтобы указать, за какими атрибутами следить.
    -     */
    -    static get observedAttributes() {
    -        return ['value'];
    -    }
    -
    -    /**
    -     * Вызывается при изменении атрибутов, перечисленных в observedAttributes.
    -     */
    -    attributeChangedCallback(name, oldValue, newValue) {
    -        if (name === 'value') {
    -            this.render(newValue);
    -        }
    -    }
    -
    -    /**
    -     * Публичный метод для установки значения.
    -     * @param {string | object} data - Данные для отображения.
    -     */
    -    set value(data) {
    -        const valueString = (typeof data === 'object')
    -            ? JSON.stringify(data, null, 2)
    -            : String(data);
    -        this.setAttribute('value', valueString);
    -    }
    -    
    -    get value() {
    -        return this.getAttribute('value');
    -    }
    -
    -    /**
    -     * Рендерит отформатированный и подсвеченный JSON.
    -     * @param {string} jsonString
    -     */
    -    render(jsonString) {
    -        if (!jsonString) {
    -            this._contentElement.textContent = '';
    -            return;
    -        }
    -
    -        try {
    -            // Убедимся, что это валидный JSON, и переформатируем его
    -            const jsonObj = JSON.parse(jsonString);
    -            const formattedJson = JSON.stringify(jsonObj, null, 2);
    -            this._contentElement.innerHTML = this._syntaxHighlight(formattedJson);
    -        } catch (e) {
    -            // Если это не JSON, просто отображаем как текст
    -            this._contentElement.textContent = jsonString;
    -        }
    -    }
    -
    -    _syntaxHighlight(json) {
    -        json = json.replace(/&/g, '&').replace(//g, '>');
    -        return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => {
    -            let cls = 'number';
    -            if (/^"/.test(match)) {
    -                if (/:$/.test(match)) {
    -                    cls = 'key';
    -                } else {
    -                    cls = 'string';
    -                }
    -            } else if (/true|false/.test(match)) {
    -                cls = 'boolean';
    -            } else if (/null/.test(match)) {
    -                cls = 'null';
    -            }
    -            return `${match}`;
    -        });
    -    }
    -}
    -
    -customElements.define('json-viewer', JsonViewerComponent);
    \ No newline at end of file
    diff --git a/explorer/views/components/json-viewer.ts b/explorer/views/components/json-viewer.ts
    new file mode 100644
    index 0000000..d6e03b6
    --- /dev/null
    +++ b/explorer/views/components/json-viewer.ts
    @@ -0,0 +1,126 @@
    +/* eslint-disable no-useless-escape */
    +/**
    + * explorer/views/components/json-viewer.ts
    + * A syntax-highlighting viewer for JSON objects and raw strings.
    + */
    +
    +class JsonViewerComponent extends HTMLElement {
    +  private _contentElement!: HTMLPreElement;
    +
    +  constructor() {
    +    super();
    +    this.attachShadow({ mode: 'open' });
    +  }
    +
    +  static get observedAttributes(): string[] {
    +    return ['value'];
    +  }
    +
    +  connectedCallback() {
    +    this.shadowRoot!.innerHTML = `
    +      
    +      
    
    +    `;
    +    this._contentElement = this.shadowRoot!.getElementById('content') as HTMLPreElement;
    +
    +    // Initial render if value attribute is present
    +    const initialValue = this.getAttribute('value');
    +    if (initialValue) this.render(initialValue);
    +  }
    +
    +  /**
    +   * Public setter to update the displayed data.
    +   * Automatically stringifies objects or displays raw input.
    +   */
    +  set value(data: any) {
    +    const valueString = (typeof data === 'object' && data !== null)
    +      ? JSON.stringify(data, null, 2)
    +      : String(data);
    +    this.setAttribute('value', valueString);
    +  }
    +
    +  get value(): string | null {
    +    return this.getAttribute('value');
    +  }
    +
    +  attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
    +    if (name === 'value' && this._contentElement) {
    +      this.render(newValue);
    +    }
    +  }
    +
    +  private render(jsonString: string): void {
    +    if (!jsonString) {
    +      this._contentElement.textContent = '';
    +      return;
    +    }
    +
    +    try {
    +      // Validate and reformat
    +      const jsonObj = JSON.parse(jsonString);
    +      const formatted = JSON.stringify(jsonObj, null, 2);
    +      this._contentElement.innerHTML = this._syntaxHighlight(formatted);
    +    } catch (e) {
    +      // Fallback for non-JSON strings
    +      this._contentElement.textContent = jsonString;
    +    }
    +  }
    +
    +  /**
    +   * Uses regex to wrap JSON tokens in styled  elements.
    +   */
    +  private _syntaxHighlight(json: string): string {
    +    // Escape HTML characters
    +    json = json.replace(/&/g, '&').replace(//g, '>');
    +
    +    const regex = /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g;
    +
    +    return json.replace(regex, (match) => {
    +      let cls = 'number';
    +      if (/^"/.test(match)) {
    +        if (/:$/.test(match)) {
    +          cls = 'key';
    +        } else {
    +          cls = 'string';
    +        }
    +      } else if (/true|false/.test(match)) {
    +        cls = 'boolean';
    +      } else if (/null/.test(match)) {
    +        cls = 'null';
    +      }
    +      return `${match}`;
    +    });
    +  }
    +}
    +
    +
    +
    +customElements.define('json-viewer', JsonViewerComponent);
    diff --git a/explorer/views/components/query-builder.js b/explorer/views/components/query-builder.js
    deleted file mode 100644
    index 5de1790..0000000
    --- a/explorer/views/components/query-builder.js
    +++ /dev/null
    @@ -1,224 +0,0 @@
    -// explorer/views/components/query-builder.js
    -
    -class QueryBuilderComponent extends HTMLElement {
    -    constructor() {
    -        super();
    -        this.attachShadow({ mode: 'open' });
    -        this.rules = [];
    -        this.fields = [];
    -        this.logicalOperator = 'AND'; // Логика по умолчанию
    -    }
    -
    -    connectedCallback() {
    -        this.shadowRoot.innerHTML = `
    -            
    -            
    - -
    -
    - - -
    - `; - - this._rulesContainer = this.shadowRoot.getElementById('rules-container'); - this.shadowRoot.getElementById('add-rule-btn').addEventListener('click', () => this.addRule()); - } - - /** - * Публичный метод для заполнения списка полей, доступных для фильтрации. - * @param {Array} fields - Массив имен полей. - */ - setFields(fields = []) { - this.fields = fields; - // Если правил еще нет, добавляем первое пустое правило - if (this.rules.length === 0) { - this.addRule(); - } else { - // Иначе перерисовываем существующие с новым списком полей - this.render(); - } - } - - addRule(ruleData = { field: '', op: '$eq', value: '' }) { - this.rules.push(ruleData); - this.render(); - } - - removeRule(index) { - this.rules.splice(index, 1); - this.render(); - this._emitFilterChange(); // Отправляем событие после удаления - } - - render() { - this._rulesContainer.innerHTML = ''; - this.rules.forEach((rule, index) => { - const ruleEl = this._createRuleElement(rule, index); - this._rulesContainer.appendChild(ruleEl); - }); - } - - _createRuleElement(rule, index) { - const div = document.createElement('div'); - div.className = 'rule'; - - // 1. Выпадающий список полей - const fieldOptions = this.fields.map(f => ``).join(''); - const fieldSelect = document.createElement('select'); - fieldSelect.innerHTML = `${fieldOptions}`; - fieldSelect.dataset.index = index; - fieldSelect.dataset.type = 'field'; - fieldSelect.value = rule.field; - - // 2. Выпадающий список операторов - const operators = { - '$eq': '=', - '$ne': '!=', - '$gt': '>', - '$gte': '>=', - '$lt': '<', - '$lte': '<=', - '$in': 'in (comma sep.)', - '$regex': 'matches (regex)', - '$exists': 'exists' - }; - const opOptions = Object.entries(operators).map(([key, val]) => ``).join(''); - const opSelect = document.createElement('select'); - opSelect.innerHTML = opOptions; - opSelect.dataset.index = index; - opSelect.dataset.type = 'op'; - opSelect.value = rule.op; - - // 3. Поле для ввода значения - const valueInput = document.createElement('input'); - valueInput.type = 'text'; - valueInput.placeholder = 'Value'; - valueInput.dataset.index = index; - valueInput.dataset.type = 'value'; - valueInput.value = rule.value; - if (rule.op === '$exists') { - valueInput.placeholder = 'true / false'; - } - - // 4. Кнопка удаления правила - const removeBtn = document.createElement('button'); - removeBtn.className = 'remove-btn'; - removeBtn.textContent = '−'; - removeBtn.title = 'Remove this rule'; - removeBtn.addEventListener('click', () => this.removeRule(index)); - - div.appendChild(fieldSelect); - div.appendChild(opSelect); - div.appendChild(valueInput); - div.appendChild(removeBtn); - - // Вешаем слушателей на изменения - fieldSelect.addEventListener('change', (e) => this._handleRuleChange(e)); - opSelect.addEventListener('change', (e) => this._handleRuleChange(e)); - valueInput.addEventListener('input', (e) => this._handleRuleChange(e)); - - return div; - } - - _handleRuleChange(event) { - const { index, type } = event.target.dataset; - const value = event.target.value; - this.rules[index][type] = value; - // Перерисовываем, если нужно изменить плейсхолдер - if(type === 'op') this.render(); - this._emitFilterChange(); - } - - _emitFilterChange() { - const filter = {}; - const validRules = this.rules.filter(r => r.field); - - if (validRules.length === 0) { - this.dispatchEvent(new CustomEvent('filter-changed', { detail: { filter: {} } })); - return; - } - - const conditions = validRules.map(rule => { - let value = rule.value; - // Преобразование типов - if (rule.op === '$exists') { - value = value.toLowerCase() === 'true'; - } else if (rule.op === '$in') { - value = value.split(',').map(s => s.trim()).filter(Boolean); - } else if (value.trim() !== '' && !isNaN(Number(value))) { - value = Number(value); - } - - if (rule.op === '$eq') { - return { [rule.field]: value }; - } - return { [rule.field]: { [rule.op]: value } }; - }); - - if (conditions.length > 1) { - // Для AND-логики - Object.assign(filter, ...conditions.map(cond => ({...cond}))); - } else if (conditions.length === 1) { - Object.assign(filter, conditions[0]); - } - - this.dispatchEvent(new CustomEvent('filter-changed', { - bubbles: true, - composed: true, - detail: { filter } - })); - } -} - -customElements.define('query-builder', QueryBuilderComponent); \ No newline at end of file diff --git a/explorer/views/components/query-builder.ts b/explorer/views/components/query-builder.ts new file mode 100644 index 0000000..44f9377 --- /dev/null +++ b/explorer/views/components/query-builder.ts @@ -0,0 +1,206 @@ +/** + * explorer/views/components/query-builder.ts + * A dynamic UI component for building complex database filters. + */ + +interface FilterRule { + field: string; + op: string; + value: string; +} + +class QueryBuilderComponent extends HTMLElement { + private rules: FilterRule[] = []; + private fields: string[] = []; + private _rulesContainer!: HTMLElement; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.shadowRoot!.innerHTML = ` + +
    +
    + +
    + `; + + this._rulesContainer = this.shadowRoot!.getElementById('rules-container')!; + this.shadowRoot!.getElementById('add-rule-btn')!.addEventListener('click', () => this.addRule()); + } + + /** + * Sets the available fields for the dropdowns. + */ + public setFields(fields: string[] = []): void { + this.fields = fields; + if (this.rules.length === 0) { + this.addRule(); + } else { + this.render(); + } + } + + private addRule(data: FilterRule = { field: '', op: '$eq', value: '' }): void { + this.rules.push(data); + this.render(); + } + + private removeRule(index: number): void { + this.rules.splice(index, 1); + this.render(); + this._emitFilterChange(); + } + + private render(): void { + this._rulesContainer.innerHTML = ''; + this.rules.forEach((rule, index) => { + this._rulesContainer.appendChild(this._createRuleElement(rule, index)); + }); + } + + private _createRuleElement(rule: FilterRule, index: number): HTMLElement { + const div = document.createElement('div'); + div.className = 'rule'; + + // 1. Field Selection + const fieldSelect = document.createElement('select'); + fieldSelect.innerHTML = `` + + this.fields.map(f => ``).join(''); + + // 2. Operator Selection + const operators: Record = { + '$eq': '=', '$ne': '!=', '$gt': '>', '$gte': '>=', + '$lt': '<', '$lte': '<=', '$in': 'in', '$regex': 'regex', '$exists': 'exists' + }; + const opSelect = document.createElement('select'); + opSelect.innerHTML = Object.entries(operators).map(([k, v]) => + ``).join(''); + + // 3. Value Input + const valInput = document.createElement('input'); + valInput.type = 'text'; + valInput.placeholder = rule.op === '$exists' ? 'true/false' : 'Value...'; + valInput.value = rule.value; + + // 4. Delete Action + const delBtn = document.createElement('button'); + delBtn.className = 'remove-btn'; + delBtn.innerHTML = '×'; + delBtn.onclick = () => this.removeRule(index); + + // Event Listeners + fieldSelect.onchange = () => { this.rules[index].field = fieldSelect.value; this._emitFilterChange(); }; + opSelect.onchange = () => { + this.rules[index].op = opSelect.value; + this.render(); // Re-render to update placeholder + this._emitFilterChange(); + }; + valInput.oninput = () => { this.rules[index].value = valInput.value; this._emitFilterChange(); }; + + div.append(fieldSelect, opSelect, valInput, delBtn); + return div; + } + + private _emitFilterChange(): void { + const filter: Record = {}; + const validRules = this.rules.filter(r => r.field); + + validRules.forEach(rule => { + let val: any = rule.value; + + // Type Casting Logic + if (rule.op === '$exists') { + val = val.toLowerCase() === 'true'; + } else if (rule.op === '$in') { + val = val.split(',').map((s: string) => s.trim()).filter(Boolean); + } else if (val.trim() !== '' && !isNaN(Number(val))) { + val = Number(val); + } + + // Construction of Query Object + if (!filter[rule.field]) filter[rule.field] = {}; + + if (rule.op === '$eq') { + filter[rule.field] = val; // Direct equality + } else { + // Handle cases where multiple operators are on the same field + if (typeof filter[rule.field] !== 'object') filter[rule.field] = { '$eq': filter[rule.field] }; + filter[rule.field][rule.op] = val; + } + }); + + this.dispatchEvent(new CustomEvent('filter-changed', { + bubbles: true, + composed: true, + detail: { filter } + })); + } +} + + + +customElements.define('query-builder', QueryBuilderComponent); diff --git a/explorer/views/components/toast-notificationa.ts b/explorer/views/components/toast-notificationa.ts new file mode 100644 index 0000000..3087247 --- /dev/null +++ b/explorer/views/components/toast-notificationa.ts @@ -0,0 +1,90 @@ +/** + * explorer/views/components/toast-notifications.ts + * A self-contained Web Component for displaying transient feedback messages. + */ + +type ToastType = 'success' | 'error' | 'info'; + +class ToastNotificationsComponent extends HTMLElement { + private container!: HTMLElement; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.shadowRoot!.innerHTML = ` + +
    + `; + this.container = this.shadowRoot!.getElementById('container')!; + } + + /** + * Triggers a toast notification. + * @param message - The text to display to the user. + * @param type - The semantic style of the toast. + */ + public show(message: string, type: ToastType = 'info'): void { + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + + // Add text content + const textSpan = document.createElement('span'); + textSpan.textContent = message; + toast.appendChild(textSpan); + + this.container.appendChild(toast); + + // Remove from DOM exactly after the fadeOut animation finishes + // (2.5s delay + 0.5s duration = 3000ms) + setTimeout(() => { + if (toast.parentNode) { + this.container.removeChild(toast); + } + }, 3000); + } +} + +customElements.define('toast-notifications', ToastNotificationsComponent); diff --git a/explorer/views/components/toast-notifications.js b/explorer/views/components/toast-notifications.js deleted file mode 100644 index 34fccd1..0000000 --- a/explorer/views/components/toast-notifications.js +++ /dev/null @@ -1,67 +0,0 @@ -// explorer/views/components/toast-notifications.js - -class ToastNotificationsComponent extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - } - - connectedCallback() { - this.shadowRoot.innerHTML = ` - -
    - `; - this.container = this.shadowRoot.getElementById('container'); - } - - /** - * Показывает уведомление. - * @param {string} message - Текст сообщения. - * @param {string} type - 'success', 'error', или 'info'. - */ - show(message, type = 'info') { - const toast = document.createElement('div'); - toast.className = `toast ${type}`; - toast.textContent = message; - - this.container.appendChild(toast); - - // Удаляем элемент из DOM после завершения анимации - setTimeout(() => { - toast.remove(); - }, 3000); // 3 секунды = 2.5с задержка + 0.5с анимация - } -} - -customElements.define('toast-notifications', ToastNotificationsComponent); \ No newline at end of file diff --git a/explorer/views/index.html b/explorer/views/index.html index 9318db5..66cc5ed 100644 --- a/explorer/views/index.html +++ b/explorer/views/index.html @@ -3,7 +3,6 @@ WiseJSON Data Explorer - @@ -18,7 +17,7 @@

    WiseJSON Data Explorer

    Database Map

    - +
    @@ -27,22 +26,26 @@

    Database Map

    +
    +
    - +
    +
    +
    @@ -64,8 +67,8 @@

    Database Map

    Index Management

    - -
    + +

    - +

    Document Viewer

    - + - \ No newline at end of file + diff --git a/explorer/views/script.js b/explorer/views/script.js deleted file mode 100644 index 9eaa076..0000000 --- a/explorer/views/script.js +++ /dev/null @@ -1,332 +0,0 @@ -// explorer/views/script.js - -document.addEventListener('DOMContentLoaded', () => { - // --- Глобальные переменные и состояние --- - const state = { - collections: [], - currentCollection: null, - documents: [], - indexes: [], - currentPage: 0, - pageSize: 10, - totalDocs: 0, - filter: {}, - sort: { field: '_id', order: 'asc' }, - writeMode: false, - }; - - // --- DOM элементы --- - const toastNotifications = document.getElementById('toastNotifications'); - const dbMap = document.getElementById('dbMap'); - const collectionSelect = document.getElementById('collectionSelect'); - const queryBuilder = document.getElementById('queryBuilder'); - const refreshBtn = document.getElementById('refreshBtn'); - const applyBtn = document.getElementById('applyBtn'); - const sortInput = document.getElementById('sortInput'); - const orderSelect = document.getElementById('orderSelect'); - const pageSizeInput = document.getElementById('pageSizeInput'); - const prevBtn = document.getElementById('prevBtn'); - const nextBtn = document.getElementById('nextBtn'); - const pageInfo = document.getElementById('pageInfo'); - const dataTable = document.getElementById('dataTable'); - const documentViewer = document.getElementById('documentViewer'); - const indexList = document.getElementById('index-list'); - const createIndexForm = document.getElementById('create-index-form'); - const indexFieldInput = document.getElementById('indexFieldInput'); - const uniqueCheckbox = document.getElementById('uniqueCheckbox'); - const createIndexBtn = document.getElementById('createIndexBtn'); - const serverModeEl = document.getElementById('server-mode'); - - // --- Утилитарная функция для уведомлений --- - function showToast(message, type = 'info') { - // "Лениво" получаем элемент в момент вызова - const toastElement = document.getElementById('toastNotifications'); - if (toastElement && typeof toastElement.show === 'function') { - toastElement.show(message, type); - } else { - // Фоллбэк, если компонент еще не готов - console.warn('Toast component not ready, falling back to alert.', { message, type }); - alert(`${type.toUpperCase()}: ${message}`); - } - } - - // --- API Функции --- - async function apiFetch(url, options = {}) { - try { - const response = await fetch(url, options); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `HTTP error! status: ${response.status}`); - } - const contentType = response.headers.get("content-type"); - if (contentType && contentType.indexOf("application/json") !== -1) { - return response.json(); - } - return {}; - } catch (error) { - showToast(error.message, 'error'); - console.error('API Fetch Error:', error); - throw error; - } - } - - // --- Функции рендеринга --- - function renderCollections() { - collectionSelect.innerHTML = ''; - state.collections.forEach(col => { - const option = document.createElement('option'); - option.value = col.name; - option.textContent = `${col.name} (${col.count} docs)`; - collectionSelect.appendChild(option); - }); - if (state.currentCollection) { - collectionSelect.value = state.currentCollection; - } - } - - function renderDocuments() { - const thead = dataTable.querySelector('thead'); - const tbody = dataTable.querySelector('tbody'); - thead.innerHTML = ''; - tbody.innerHTML = ''; - - if (state.documents.length === 0) { - tbody.innerHTML = 'No documents found for the current selection.'; - return; - } - - const headers = new Set(['_actions']); - state.documents.forEach(doc => Object.keys(doc).forEach(key => headers.add(key))); - - const fieldsForBuilder = Array.from(headers).filter(h => h !== '_actions'); - queryBuilder.setFields(fieldsForBuilder); - - const headerRow = document.createElement('tr'); - headers.forEach(key => { - const th = document.createElement('th'); - th.textContent = key; - headerRow.appendChild(th); - }); - thead.appendChild(headerRow); - - state.documents.forEach(doc => { - const row = document.createElement('tr'); - row.addEventListener('click', () => { - documentViewer.value = doc; - document.querySelectorAll('#dataTable tr').forEach(r => r.classList.remove('selected')); - row.classList.add('selected'); - }); - - headers.forEach(header => { - const td = document.createElement('td'); - if (header === '_actions') { - const viewBtn = document.createElement('button'); - viewBtn.textContent = 'View'; - viewBtn.onclick = (e) => { - e.stopPropagation(); - documentViewer.value = doc; - }; - td.appendChild(viewBtn); - - if (state.writeMode) { - const deleteBtn = document.createElement('button'); - deleteBtn.textContent = 'Delete'; - deleteBtn.className = 'delete-btn write-op'; - deleteBtn.onclick = (e) => { - e.stopPropagation(); - handleDeleteDocument(doc._id); - }; - td.appendChild(deleteBtn); - } - } else { - const value = doc[header]; - td.textContent = (typeof value === 'object' && value !== null) ? JSON.stringify(value) : value; - } - row.appendChild(td); - }); - tbody.appendChild(row); - }); - } - - function renderIndexes() { - indexList.innerHTML = ''; - if (state.indexes.length === 0) { - indexList.innerHTML = '

    No indexes defined for this collection.

    '; - } else { - state.indexes.forEach(index => { - const item = document.createElement('div'); - item.className = `index-item ${index.type}`; - item.textContent = index.fieldName; - if (state.writeMode) { - const deleteBtn = document.createElement('button'); - deleteBtn.textContent = '×'; - deleteBtn.className = 'delete-btn write-op'; - deleteBtn.title = `Delete index on ${index.fieldName}`; - deleteBtn.onclick = () => handleDeleteIndex(index.fieldName); - item.appendChild(deleteBtn); - } - indexList.appendChild(item); - }); - } - } - - function renderPagination() { - pageInfo.textContent = `Page ${state.currentPage + 1}`; - prevBtn.disabled = state.currentPage === 0; - nextBtn.disabled = state.documents.length < state.pageSize; - } - - function updateWriteModeUI() { - document.querySelectorAll('.write-op').forEach(el => { - el.style.display = state.writeMode ? 'flex' : 'none'; - }); - if (state.writeMode) { - serverModeEl.textContent = 'Server Mode: Write-Enabled'; - serverModeEl.className = 'server-mode-write-enabled'; - } - } - - // --- Логика обработчиков событий --- - async function loadInitialData() { - try { - const [collectionsData, graphData, permissions] = await Promise.all([ - apiFetch('/api/collections'), - apiFetch('/api/schema-graph'), - apiFetch('/api/permissions') - ]); - - state.collections = collectionsData; - dbMap.render(graphData); - renderCollections(); - - state.writeMode = permissions.writeMode; - updateWriteModeUI(); - } catch (e) { - // Ошибки уже обработаны и показаны в apiFetch - } - } - - async function loadCollectionData(collectionName) { - state.currentCollection = collectionName; - collectionSelect.value = collectionName; - - if (!state.currentCollection) { - state.documents = []; - state.indexes = []; - renderDocuments(); - renderIndexes(); - queryBuilder.setFields([]); - return; - }; - - const offset = state.currentPage * state.pageSize; - const query = new URLSearchParams({ - limit: state.pageSize, - offset: offset, - sort: state.sort.field, - order: state.sort.order, - filter: JSON.stringify(state.filter) - }); - - const [docs, statsData] = await Promise.all([ - apiFetch(`/api/collections/${state.currentCollection}?${query}`), - apiFetch(`/api/collections/${state.currentCollection}/stats`) - ]); - - state.documents = docs; - state.indexes = statsData.indexes || []; - - renderDocuments(); - renderIndexes(); - renderPagination(); - } - - function handleSelectCollection() { - state.currentPage = 0; - const selectedName = collectionSelect.value; - loadCollectionData(selectedName); - } - - function handleMapSelection(event) { - state.currentPage = 0; - const { collectionName } = event.detail; - loadCollectionData(collectionName); - } - - function handleFilterChange(event) { - state.filter = event.detail.filter; - } - - function handleApplyFilters() { - if (!state.currentCollection) { - showToast('Please select a collection first.', 'error'); - return; - } - state.currentPage = 0; - state.sort.field = sortInput.value || '_id'; - state.sort.order = orderSelect.value; - state.pageSize = parseInt(pageSizeInput.value, 10) || 10; - loadCollectionData(state.currentCollection); - } - - async function handleDeleteDocument(docId) { - if (!confirm(`Are you sure you want to delete document with ID: ${docId}?`)) return; - await apiFetch(`/api/collections/${state.currentCollection}/doc/${docId}`, { method: 'DELETE' }); - showToast(`Document ${docId.substring(0, 8)}... removed.`, 'success'); - await loadCollectionData(state.currentCollection); - } - - async function handleCreateIndex() { - if (!state.currentCollection) { - showToast('Please select a collection first to create an index.', 'error'); - return; - } - const fieldName = indexFieldInput.value.trim(); - if (!fieldName) { - showToast('Index field name cannot be empty.', 'error'); - return; - } - const isUnique = uniqueCheckbox.checked; - await apiFetch(`/api/collections/${state.currentCollection}/indexes`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fieldName: fieldName, unique: isUnique }) - }); - showToast(`Index on "${fieldName}" created.`, 'success'); - indexFieldInput.value = ''; - uniqueCheckbox.checked = false; - await loadCollectionData(state.currentCollection); - } - - async function handleDeleteIndex(fieldName) { - if (!confirm(`Are you sure you want to delete the index on field: "${fieldName}"?`)) return; - await apiFetch(`/api/collections/${state.currentCollection}/indexes/${fieldName}`, { method: 'DELETE' }); - showToast(`Index on "${fieldName}" dropped.`, 'success'); - await loadCollectionData(state.currentCollection); - } - - function handlePrevPage() { - if (state.currentPage > 0) { - state.currentPage--; - loadCollectionData(state.currentCollection); - } - } - - function handleNextPage() { - state.currentPage++; - loadCollectionData(state.currentCollection); - } - - // --- Назначение обработчиков --- - dbMap.addEventListener('collection-selected', handleMapSelection); - queryBuilder.addEventListener('filter-changed', handleFilterChange); - collectionSelect.addEventListener('change', handleSelectCollection); - refreshBtn.addEventListener('click', loadInitialData); - applyBtn.addEventListener('click', handleApplyFilters); - prevBtn.addEventListener('click', handlePrevPage); - nextBtn.addEventListener('click', handleNextPage); - createIndexBtn.addEventListener('click', handleCreateIndex); - - // --- Инициализация --- - loadInitialData(); -}); \ No newline at end of file diff --git a/explorer/views/script.ts b/explorer/views/script.ts new file mode 100644 index 0000000..8d434f0 --- /dev/null +++ b/explorer/views/script.ts @@ -0,0 +1,251 @@ +/** + * explorer/views/script.ts + * Frontend logic for the WiseJSON Data Explorer. + */ + +interface CollectionInfo { + name: string; + count: number; +} + +interface IndexInfo { + fieldName: string; + type: 'index' | 'unique'; +} + +interface ExplorerState { + collections: CollectionInfo[]; + currentCollection: string | null; + documents: any[]; + indexes: IndexInfo[]; + currentPage: number; + pageSize: number; + totalDocs: number; + filter: Record; + sort: { field: string; order: 'asc' | 'desc' }; + writeMode: boolean; +} + +document.addEventListener('DOMContentLoaded', () => { + // --- State Initialization --- + const state: ExplorerState = { + collections: [], + currentCollection: null, + documents: [], + indexes: [], + currentPage: 0, + pageSize: 10, + totalDocs: 0, + filter: {}, + sort: { field: '_id', order: 'asc' }, + writeMode: false, + }; + + // --- DOM Elements (Casting to specific types for TS safety) --- + const dbMap = document.getElementById('dbMap') as any; // Custom Web Component + const collectionSelect = document.getElementById('collectionSelect') as HTMLSelectElement; + const queryBuilder = document.getElementById('queryBuilder') as any; // Custom Web Component + const refreshBtn = document.getElementById('refreshBtn') as HTMLButtonElement; + const applyBtn = document.getElementById('applyBtn') as HTMLButtonElement; + const sortInput = document.getElementById('sortInput') as HTMLInputElement; + const orderSelect = document.getElementById('orderSelect') as HTMLSelectElement; + const pageSizeInput = document.getElementById('pageSizeInput') as HTMLInputElement; + const prevBtn = document.getElementById('prevBtn') as HTMLButtonElement; + const nextBtn = document.getElementById('nextBtn') as HTMLButtonElement; + const pageInfo = document.getElementById('pageInfo') as HTMLElement; + const dataTable = document.getElementById('dataTable') as HTMLTableElement; + const documentViewer = document.getElementById('documentViewer') as any; // Custom Viewer + const indexList = document.getElementById('index-list') as HTMLElement; + const indexFieldInput = document.getElementById('indexFieldInput') as HTMLInputElement; + const uniqueCheckbox = document.getElementById('uniqueCheckbox') as HTMLInputElement; + const createIndexBtn = document.getElementById('createIndexBtn') as HTMLButtonElement; + const serverModeEl = document.getElementById('server-mode') as HTMLElement; + + /** + * Display a UI toast notification. + */ + function showToast(message: string, type: 'info' | 'success' | 'error' = 'info'): void { + const toastElement = document.getElementById('toastNotifications') as any; + if (toastElement && typeof toastElement.show === 'function') { + toastElement.show(message, type); + } else { + console.warn(`[${type.toUpperCase()}] ${message}`); + } + } + + /** + * Typed API Fetcher + */ + async function apiFetch(url: string, options: RequestInit = {}): Promise { + try { + const response = await fetch(url, options); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP ${response.status}`); + } + return response.status === 204 ? ({} as T) : await response.json(); + } catch (error: any) { + showToast(error.message, 'error'); + throw error; + } + } + + // --- Rendering Logic --- + + function renderCollections(): void { + collectionSelect.innerHTML = ''; + state.collections.forEach(col => { + const option = document.createElement('option'); + option.value = col.name; + option.textContent = `${col.name} (${col.count} docs)`; + collectionSelect.appendChild(option); + }); + if (state.currentCollection) collectionSelect.value = state.currentCollection; + } + + + + function renderDocuments(): void { + const thead = dataTable.querySelector('thead')!; + const tbody = dataTable.querySelector('tbody')!; + thead.innerHTML = ''; + tbody.innerHTML = ''; + + if (state.documents.length === 0) { + tbody.innerHTML = 'No documents found.'; + return; + } + + // Dynamically build headers based on keys present in the current page sample + const headers = new Set(['_actions']); + state.documents.forEach(doc => Object.keys(doc).forEach(key => headers.add(key))); + + // Update Query Builder fields + const fields = Array.from(headers).filter(h => h !== '_actions'); + if (queryBuilder.setFields) queryBuilder.setFields(fields); + + const headerRow = document.createElement('tr'); + headers.forEach(key => { + const th = document.createElement('th'); + th.textContent = key; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + + state.documents.forEach(doc => { + const row = document.createElement('tr'); + row.onclick = () => { + documentViewer.value = doc; + dataTable.querySelectorAll('tr').forEach(r => r.classList.remove('selected')); + row.classList.add('selected'); + }; + + headers.forEach(header => { + const td = document.createElement('td'); + if (header === '_actions') { + renderActionButtons(td, doc); + } else { + const val = doc[header]; + td.textContent = (typeof val === 'object' && val !== null) ? JSON.stringify(val) : String(val); + } + row.appendChild(td); + }); + tbody.appendChild(row); + }); + } + + function renderActionButtons(container: HTMLElement, doc: any): void { + const viewBtn = document.createElement('button'); + viewBtn.textContent = 'View'; + viewBtn.onclick = (e) => { e.stopPropagation(); documentViewer.value = doc; }; + container.appendChild(viewBtn); + + if (state.writeMode) { + const delBtn = document.createElement('button'); + delBtn.textContent = 'Delete'; + delBtn.className = 'delete-btn write-op'; + delBtn.onclick = (e) => { e.stopPropagation(); handleDeleteDocument(doc._id); }; + container.appendChild(delBtn); + } + } + + // --- Action Handlers --- + + async function loadCollectionData(name: string): Promise { + state.currentCollection = name; + if (!name) return; + + const offset = state.currentPage * state.pageSize; + const params = new URLSearchParams({ + limit: state.pageSize.toString(), + offset: offset.toString(), + sort: state.sort.field, + order: state.sort.order, + filter: JSON.stringify(state.filter) + }); + + const [docs, stats] = await Promise.all([ + apiFetch(`/api/collections/${encodeURIComponent(name)}?${params}`), + apiFetch(`/api/collections/${encodeURIComponent(name)}/stats`) + ]); + + state.documents = docs; + state.indexes = stats.indexes || []; + + renderDocuments(); + renderPagination(); + updateWriteModeUI(); + } + + async function handleDeleteDocument(id: string): Promise { + if (!confirm(`Delete document ${id}?`)) return; + await apiFetch(`/api/collections/${state.currentCollection}/doc/${encodeURIComponent(id)}`, { method: 'DELETE' }); + showToast('Document deleted', 'success'); + loadCollectionData(state.currentCollection!); + } + + function renderPagination() { + pageInfo.textContent = `Page ${state.currentPage + 1}`; + prevBtn.disabled = state.currentPage === 0; + nextBtn.disabled = state.documents.length < state.pageSize; + } + + function updateWriteModeUI(): void { + document.querySelectorAll('.write-op').forEach(el => { + el.style.display = state.writeMode ? 'inline-flex' : 'none'; + }); + serverModeEl.textContent = state.writeMode ? 'Mode: Read/Write' : 'Mode: Read-Only'; + serverModeEl.className = state.writeMode ? 'mode-rw' : 'mode-ro'; + } + + // --- Event Listeners --- + collectionSelect.onchange = () => { + state.currentPage = 0; + loadCollectionData(collectionSelect.value); + }; + + applyBtn.onclick = () => { + state.sort.field = sortInput.value || '_id'; + state.sort.order = orderSelect.value as 'asc' | 'desc'; + state.pageSize = parseInt(pageSizeInput.value) || 10; + state.currentPage = 0; + if (state.currentCollection) loadCollectionData(state.currentCollection); + }; + + prevBtn.onclick = () => { if (state.currentPage > 0) { state.currentPage--; loadCollectionData(state.currentCollection!); } }; + nextBtn.onclick = () => { state.currentPage++; loadCollectionData(state.currentCollection!); }; + + // --- Initialization --- + (async () => { + const [cols, graph, perms] = await Promise.all([ + apiFetch('/api/collections'), + apiFetch('/api/schema-graph'), + apiFetch<{ writeMode: boolean }>('/api/permissions') + ]); + state.collections = cols; + state.writeMode = perms.writeMode; + if (dbMap.render) dbMap.render(graph); + renderCollections(); + updateWriteModeUI(); + })(); +}); diff --git a/explorer/views/styles.css b/explorer/views/styles.css index 1c0a2ff..909633e 100644 --- a/explorer/views/styles.css +++ b/explorer/views/styles.css @@ -109,7 +109,7 @@ button.delete-btn:hover { } .apply-button { - grid-column: 1 / -1; /* Растягиваем на всю ширину грида */ + grid-column: 1 / -1; /* Stretch to the full width of the grid */ margin-top: 10px; background-color: #0366d6; } @@ -206,7 +206,7 @@ hr { color: #24292e; } -/* Стили для элементов, требующих права на запись */ +/* Styles for elements that require write access */ .write-op { - /* по умолчанию скрыты, будут показаны через JS */ -} \ No newline at end of file +/* hidden by default, will be shown via JS */ +} diff --git a/package.json b/package.json index 02a6550..89cf43d 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,9 @@ "name": "wise-json-db", "version": "6.0.4", "description": "Blazing fast, crash-proof embedded JSON database for Node.js with batch operations, TTL, indexes, and segmented checkpointing.", - "main": "wise-json/index.js", - "type": "commonjs", - "scripts": { - "test": "node test/run-all-tests.js", - "start-explorer": "node explorer/server.js" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/Xzdes/WiseJSON.git" - }, + "type": "module", + "main": "./dist/lib/index.js", + "types": "./dist/lib/index.d.ts", "keywords": [ "json", "database", @@ -42,6 +35,17 @@ "javascript", "npm" ], + "scripts": { + "build": "tsc", + "dev": "tsx watch src/explorer/server.ts", + "test": "tsx test/run-all-tests.ts", + "lint": "eslint src/**/*.ts", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Xzdes/WiseJSON.git" + }, "author": "Xzdes ", "license": "MIT", "bugs": { @@ -62,8 +66,19 @@ "bin": { "wise-json": "cli/index.js" }, + "exports": { + ".": "./dist/lib/index.js", + "./cli": "./dist/cli/index.js" + }, "dependencies": { - "proper-lockfile": "^4.1.2", - "uuid": "^11.1.0" + "proper-lockfile": "4.1.2", + "uuid": "11.1.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/proper-lockfile": "^4.1.4", + "eslint": "^8.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d747ce0 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1151 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 + uuid: + specifier: 11.1.0 + version: 11.1.0 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.27 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 + eslint: + specifier: ^8.0.0 + version: 8.57.1 + tsx: + specifier: ^4.0.0 + version: 4.21.0 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@types/node@20.19.27': + resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@types/node@20.19.27': + dependencies: + undici-types: 6.21.0 + + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + + '@types/retry@0.12.5': {} + + '@ungap/structured-clone@1.3.0': {} + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + escape-string-regexp@4.0.0: {} + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-path-inside@3.0.3: {} + + isexe@2.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + prelude-ls@1.2.1: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + retry@0.12.0: {} + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + text-table@0.2.0: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + uuid@11.1.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..efc037a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - esbuild diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..86dd3c3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,87 @@ +import { Collection } from './lib/collection/core.js'; +import { TransactionManager } from './lib/collection/transaction-manager.js'; +import { WiseJSONError, UniqueConstraintError, DocumentNotFoundError, ConfigurationError } from './lib/errors.js'; +import {WiseJSON} from './lib/index.js' +import logger from './lib/logger.js'; +import { SyncManager } from './lib/sync/sync-manager.js'; + +import * as WALManager from './lib/wal-manager.js'; +import * as CheckpointManager from './lib/checkpoint-manager.js'; +import { ApiClient } from './lib/sync/api-client.js'; + +/** + * index.ts (Package Root) + * Primary entry point for the WiseJSON library. + */ + +/** + * Configuration options for the WiseJSON instance. + */ +export interface WiseJSONOptions { + ttlCleanupIntervalMs?: number; + walReadOptions?: { + recover?: boolean; + strict?: boolean; + }; + maxSegmentSizeBytes?: number; + checkpointsToKeep?: number; + checkpointIntervalMs?: number; + [key: string]: any; +} + +/** + * Factory function to create a WiseJSON instance. + * This is the recommended entry point for most users. + * * @param dbRootPath - Path to the database root directory. + * @param options - Configuration options for persistence, TTL, and sync. + * @returns A new WiseJSON instance. + */ +export function connect(dbRootPath: string, options?: WiseJSONOptions): WiseJSON { + const db = new WiseJSON(dbRootPath, options); + // Note: `init()` is handled lazily upon the first data access, + // so the user does not need to call it explicitly. + return db; +} + +// =========================================== +// --- Public Package API --- +// =========================================== + +export { + // --- Core --- + WiseJSON, + Collection, + + // --- Custom Errors (for try...catch blocks) --- + WiseJSONError, + UniqueConstraintError, + DocumentNotFoundError, + ConfigurationError, + + // --- Advanced Components and Utilities --- + SyncManager, + TransactionManager, + logger, + ApiClient, + + // --- Low-level components (for advanced scenarios or testing) --- + WALManager, + CheckpointManager +}; + +// Default export for compatibility with CommonJS and simpler imports +export default { + WiseJSON, + connect, + Collection, + WiseJSONError, + ApiClient, + UniqueConstraintError, + DocumentNotFoundError, + ConfigurationError, + SyncManager, + TransactionManager, + logger, + WALManager, + CheckpointManager +}; diff --git a/src/lib/checkpoint-manager.ts b/src/lib/checkpoint-manager.ts new file mode 100644 index 0000000..6c3d1f9 --- /dev/null +++ b/src/lib/checkpoint-manager.ts @@ -0,0 +1,207 @@ +import path from 'path'; +import fs from 'fs/promises'; +import { cleanupExpiredDocs } from './collection/ttl.js'; +import { CheckpointLoadResult, CheckpointMeta, Document } from './types.js'; + + + +/** + * Internal helper to list checkpoint files (meta or data) for a specific collection. + */ +async function getCheckpointFiles( + checkpointsDir: string, + collectionName: string, + type: 'meta' | 'data' = 'meta', + logger: any +): Promise { + try { + try { + await fs.access(checkpointsDir); + } catch (accessError: any) { + if (accessError.code === 'ENOENT') return []; + throw accessError; + } + const files = await fs.readdir(checkpointsDir); + return files + .filter(f => f.startsWith(`checkpoint_${type}_${collectionName}_`) && f.endsWith('.json')) + .sort(); + } catch (e: any) { + logger.error(`[Checkpoint] Error reading checkpoint directory ${checkpointsDir}: ${e.message}`); + throw e; + } +} + +/** + * Extracts the file-system timestamp string from a meta filename. + */ +function extractTimestampFromMetaFile(metaFileName: string, collectionName: string): string | null { + const escapedName = collectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`^checkpoint_meta_${escapedName}_([\\dTZ-]+)\\.json$`); + const match = metaFileName.match(re); + return match ? match[1] : null; +} + +/** + * Finds and loads the most recent valid checkpoint for a collection. + * It iterates backwards through meta files until a complete and valid set of segments is found. + * * @param checkpointsDir - Path to the checkpoints directory. + * @param collectionName - Name of the collection to load. + * @param logger - Logger instance for reporting progress/warnings. + */ +export async function loadLatestCheckpoint( + checkpointsDir: string, + collectionName: string, + logger: any +): Promise { + const metaFiles = await getCheckpointFiles(checkpointsDir, collectionName, 'meta', logger); + + if (metaFiles.length === 0) { + logger.log(`[Checkpoint] No meta-checkpoints found for '${collectionName}'. Initializing empty.`); + return { documents: new Map(), indexesMeta: [], timestamp: null }; + } + + // Iterate from newest to oldest + for (let i = metaFiles.length - 1; i >= 0; i--) { + const currentMetaFile = metaFiles[i]; + const timestampFromFile = extractTimestampFromMetaFile(currentMetaFile, collectionName); + + if (!timestampFromFile) { + logger.warn(`[Checkpoint] Failed to extract timestamp from '${currentMetaFile}'. Skipping.`); + continue; + } + + const allDataFilesRaw = await getCheckpointFiles(checkpointsDir, collectionName, 'data', logger); + const escapedName = collectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const dataSegmentFiles = allDataFilesRaw.filter(f => { + const segMatch = f.match(new RegExp(`^checkpoint_data_${escapedName}_${timestampFromFile}_seg\\d+\\.json$`)); + return !!segMatch; + }); + dataSegmentFiles.sort(); + + let metaContent: CheckpointMeta; + try { + const rawMeta = await fs.readFile(path.join(checkpointsDir, currentMetaFile), 'utf8'); + metaContent = JSON.parse(rawMeta); + if (!metaContent.timestamp || typeof metaContent.timestamp !== 'string') { + logger.warn(`[Checkpoint] Meta-file '${currentMetaFile}' lacks valid timestamp. Skipping.`); + continue; + } + } catch (e: any) { + logger.warn(`[Checkpoint] Error reading meta-file '${currentMetaFile}': ${e.message}. Skipping.`); + continue; + } + + // Handle empty collection checkpoints + if (metaContent.documentCount === 0 && dataSegmentFiles.length === 0) { + return { documents: new Map(), indexesMeta: metaContent.indexesMeta || [], timestamp: metaContent.timestamp }; + } + + if (metaContent.documentCount > 0 && dataSegmentFiles.length === 0) { + logger.warn(`[Checkpoint] Meta-file for '${collectionName}' expects docs but segments are missing. Skipping.`); + continue; + } + + const documents = new Map(); + let allSegmentsLoadedSuccessfully = true; + + for (const segFile of dataSegmentFiles) { + try { + const segmentDocsArray = JSON.parse(await fs.readFile(path.join(checkpointsDir, segFile), 'utf8')); + if (Array.isArray(segmentDocsArray)) { + for (const doc of segmentDocsArray) { + if (doc && typeof doc._id !== 'undefined') { + documents.set(doc._id, doc); + } + } + } else { + allSegmentsLoadedSuccessfully = false; + break; + } + } catch (e: any) { + logger.warn(`[Checkpoint] Error loading data-segment '${segFile}': ${e.message}`); + allSegmentsLoadedSuccessfully = false; + break; + } + } + + if (!allSegmentsLoadedSuccessfully) continue; + + const removedByTtl = cleanupExpiredDocs(documents as Map); + if (removedByTtl > 0) { + logger.log(`[Checkpoint] Removed ${removedByTtl} expired documents during load of '${collectionName}'.`); + } + + return { + documents, + indexesMeta: metaContent.indexesMeta || [], + timestamp: metaContent.timestamp + }; + } + + return { documents: new Map(), indexesMeta: [], timestamp: null }; +} + +/** + * Removes old checkpoint files to save disk space, keeping only the 'keep' most recent versions. + * Includes a retry mechanism for file deletion to handle potential OS locks. + * * @param checkpointsDir - Directory to clean. + * @param collectionName - Target collection. + * @param keep - Number of recent checkpoints to retain. + * @param logger - Logger instance. + */ +export async function cleanupOldCheckpoints( + checkpointsDir: string, + collectionName: string, + keep = 5, + logger: any +): Promise { + if (keep <= 0) return; + + const metaFiles = await getCheckpointFiles(checkpointsDir, collectionName, 'meta', logger); + const allDataFiles = await getCheckpointFiles(checkpointsDir, collectionName, 'data', logger); + + if (metaFiles.length <= keep) return; + + const metaFilesToRemove = metaFiles.slice(0, metaFiles.length - keep); + const timestampsToKeep = new Set( + metaFiles.slice(-keep).map(f => extractTimestampFromMetaFile(f, collectionName)).filter(Boolean) + ); + + const unlinkWithRetry = async (filePath: string, fileName: string) => { + let retries = 10; + let delay = 500; + while (retries > 0) { + try { + await fs.unlink(filePath); + return true; + } catch (err: any) { + if (err.code === 'ENOENT') return true; + retries--; + if (retries === 0) { + logger.warn(`[Checkpoint] Failed to delete '${fileName}' after retries: ${err.message}`); + return false; + } + await new Promise(r => setTimeout(r, delay)); + delay = Math.min(delay + 500, 3000); + } + } + return false; + }; + + // Remove meta files + for (const file of metaFilesToRemove) { + await unlinkWithRetry(path.join(checkpointsDir, file), file); + } + + // Remove orphaned data segments + const escapedName = collectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const dataFilesToRemove = allDataFiles.filter(dataFile => { + const match = dataFile.match(new RegExp(`^checkpoint_data_${escapedName}_([\\dTZ-]+)_seg\\d+\\.json$`)); + const dataTimestamp = match ? match[1] : null; + return dataTimestamp && !timestampsToKeep.has(dataTimestamp); + }); + + for (const file of dataFilesToRemove) { + await unlinkWithRetry(path.join(checkpointsDir, file), file); + } +} diff --git a/src/lib/cli/actions.ts b/src/lib/cli/actions.ts new file mode 100644 index 0000000..39c8487 --- /dev/null +++ b/src/lib/cli/actions.ts @@ -0,0 +1,219 @@ +/** + * cli/actions.ts + * Implementation of individual CLI commands. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { confirmAction, prettyError, CliOptions } from './utils.js'; +import { WiseJSON } from '../index.js'; +import { flattenDocToCsv } from '../collection/utils.js'; + +/** + * Interface for the registry entry of a CLI command. + */ +interface CliAction { + handler: (db: WiseJSON, args: string[], options: CliOptions) => Promise; + isWrite: boolean; + description: string; +} + +/** + * Ensures a collection exists before performing operations. + */ +async function assertCollectionExists(db: WiseJSON, collectionName: string): Promise { + const names = await db.getCollectionNames(); + if (!names.includes(collectionName)) { + prettyError(`Collection "${collectionName}" does not exist.`); + } +} + +// ============================= +// --- Read-Only Actions --- +// ============================= + +/** + * Lists all available collections and their document counts. + */ +async function listCollectionsAction(db: WiseJSON): Promise { + const collections = await db.getCollectionNames(); + const result = await Promise.all( + collections.map(async (name: string) => { + const col = await db.getCollection(name); + return { name, count: await col.count() }; + }) + ); + + if (result.length === 0) { + console.log('No collections found.'); + return; + } + console.table(result); +} + +/** + * Displays documents with support for filtering, sorting, and pagination. + */ +async function showCollectionAction(db: WiseJSON, args: string[], options: CliOptions): Promise { + const [collectionName] = args; + if (!collectionName) prettyError('Usage: show-collection [options]'); + await assertCollectionExists(db, collectionName); + + const col = await db.getCollection(collectionName); + + const limit = parseInt((options['limit'] as string) || '10', 10); + const offset = parseInt((options['offset'] as string) || '0', 10); + const sortField = options['sort'] as string | undefined; + const sortOrder = options['order'] || 'asc'; + const output = options['output'] || 'json'; + + let filter = {}; + if (options['filter']) { + try { + filter = JSON.parse(options['filter'] as string); + } catch (e: any) { + prettyError(`Invalid JSON in --filter option: ${e.message}`); + } + } + + let docs = await col.find(filter); + + if (sortField) { + docs.sort((a: any, b: any) => { + if (a[sortField] === undefined) return 1; + if (b[sortField] === undefined) return -1; + if (a[sortField] < b[sortField]) return sortOrder === 'asc' ? -1 : 1; + if (a[sortField] > b[sortField]) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + + docs = docs.slice(offset, offset + limit); + + if (output === 'csv') console.log(flattenDocToCsv(docs)); + else if (output === 'table') console.table(docs); + else console.log(JSON.stringify(docs, null, 2)); +} + +/** + * Lists all defined indexes for a specific collection. + */ +async function listIndexesAction(db: WiseJSON, args: string[]): Promise { + const [collectionName] = args; + if (!collectionName) prettyError('Usage: list-indexes '); + await assertCollectionExists(db, collectionName); + + const col = await db.getCollection(collectionName); + console.log(JSON.stringify(await col.getIndexes(), null, 2)); +} + +/** + * Retrieves a single document by its unique ID. + */ +async function getDocumentAction(db: WiseJSON, args: string[]): Promise { + const [collectionName, docId] = args; + if (!collectionName || !docId) prettyError('Usage: get-document '); + await assertCollectionExists(db, collectionName); + + const col = await db.getCollection(collectionName); + const doc = await col.findOne({ _id: docId }); + if (!doc) { + prettyError(`Document with ID "${docId}" not found in collection "${collectionName}".`); + } + console.log(JSON.stringify(doc, null, 2)); +} + +// ============================= +// --- Write Actions --- +// ============================= + +/** + * Creates a new index on a specific field. + */ +async function createIndexAction(db: WiseJSON, args: string[], options: CliOptions): Promise { + const [collectionName, fieldName] = args; + if (!collectionName || !fieldName) prettyError('Usage: create-index [--unique]'); + + const col = await db.getCollection(collectionName); + await col.createIndex(fieldName, { unique: !!options['unique'] }); + console.log(`Index on "${fieldName}" created successfully in "${collectionName}".`); +} + +/** + * Permanently deletes a collection and its files. + */ +async function dropCollectionAction(db: WiseJSON, args: string[], options: CliOptions): Promise { + const [collectionName] = args; + if (!collectionName) prettyError('Usage: collection-drop '); + await assertCollectionExists(db, collectionName); + + const confirmed = await confirmAction( + `Are you sure you want to PERMANENTLY delete the collection "${collectionName}"?`, + options + ); + + if (confirmed) { + // Accessing internal path for deletion + const collectionPath = path.join((db as any).dbRootPath, collectionName); + await fs.rm(collectionPath, { recursive: true, force: true }); + console.log(`Collection "${collectionName}" dropped successfully.`); + } else { + console.log('Operation cancelled.'); + } +} + +/** + * Inserts a single document provided as a JSON string. + */ +async function insertDocumentAction(db: WiseJSON, args: string[]): Promise { + const [collectionName, jsonString] = args; + if (!collectionName || !jsonString) prettyError('Usage: doc-insert '); + + const col = await db.getCollection(collectionName); + try { + const doc = JSON.parse(jsonString); + const inserted = await col.insert(doc); + console.log(JSON.stringify(inserted, null, 2)); + } catch (e: any) { + prettyError(`Failed to insert document: ${e.message}`); + } +} + +// --- Command Registry --- +export const actions: Record = { + 'list-collections': { + handler: listCollectionsAction, + isWrite: false, + description: 'Lists all collections and their document counts.' + }, + 'show-collection': { + handler: showCollectionAction, + isWrite: false, + description: 'Shows documents in a collection with pagination and filtering.' + }, + 'list-indexes': { + handler: listIndexesAction, + isWrite: false, + description: 'Lists indexes for a collection.' + }, + 'get-document': { + handler: getDocumentAction, + isWrite: false, + description: 'Gets a single document by its ID.' + }, + 'create-index': { + handler: createIndexAction, + isWrite: true, + description: 'Creates an index on a field. Use --unique for a unique index.' + }, + 'collection-drop': { + handler: dropCollectionAction, + isWrite: true, + description: 'Permanently deletes an entire collection. Use with caution.' + }, + 'doc-insert': { + handler: insertDocumentAction, + isWrite: true, + description: 'Inserts a single document from a JSON string.' + } +}; diff --git a/src/lib/cli/index.ts b/src/lib/cli/index.ts new file mode 100644 index 0000000..ba07189 --- /dev/null +++ b/src/lib/cli/index.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +/** + * cli/index.ts + * Main entry point for the WiseJSON CLI. + * Coordinates command routing, security checks, and database lifecycle. + */ + +import path from 'path'; +import { actions as commandRegistry } from './actions.js'; +import { parseArgs, prettyError, CliOptions } from './utils.js'; +import { WiseJSON } from '../../index.js'; + +/** + * The root directory where database files are stored. + * Resolved from WISE_JSON_PATH or defaults to a local data folder. + */ +const DB_PATH: string = process.env['WISE_JSON_PATH'] || path.resolve(process.cwd(), 'wise-json-db-data'); + +/** + * Prints the usage guide and available commands to the console. + */ +function printHelp(): void { + console.log('WiseJSON DB Unified CLI\n'); + console.log('Usage: wise-json [args...] [--options...]\n'); + console.log('Global Options:'); + console.log(' --allow-write Required for any command that modifies data.'); + console.log(' --force, --yes Skip confirmation prompts for dangerous operations.'); + console.log(' --json-errors Output errors in JSON format.'); + console.log(' --help Show this help message.\n'); + console.log('Available Commands:'); + + const commands = Object.entries(commandRegistry); + const maxLen = Math.max(...commands.map(([name]) => name.length)); + + commands.forEach(([name, { description }]) => { + console.log(` ${name.padEnd(maxLen + 2)} ${description || ''}`); + }); +} + +/** + * Execution pipeline: parses arguments, initializes DB, and routes to action handlers. + */ +async function main(): Promise { + console.log("CLI ARGS, ", process.argv) + const allCliArgs = process.argv.slice(2); + const { args, options } = parseArgs(allCliArgs); + const commandName = args.shift(); + + // Show help if no command is provided or help flag is set + if (!commandName || options['help']) { + printHelp(); + return; + } + + const command = commandRegistry[commandName]; + if (!command) { + return prettyError(`Unknown command: "${commandName}". Use --help for usage.`); + } + + // Security Guard: Prevent accidental data modification + if (command.isWrite && !options['allow-write']) { + return prettyError(`Write command "${commandName}" requires the --allow-write flag to confirm changes.`); + } + + // Initialize DB instance + // We disable auto-cleanup tasks for CLI operations to ensure fast execution + const db = new WiseJSON(DB_PATH, { + ttlCleanupIntervalMs: 0, + checkpointIntervalMs: 0, + }); + + try { + await db.init(); + // Route control to the specific action handler + await command.handler(db, args, options); + } finally { + if (db) { + await db.close(); + } + } +} + +// Global error handler for unhandled rejections and async errors +main().catch((err: Error) => { + const jsonErrors = process.argv.slice(2).includes('--json-errors'); + prettyError(err.message, { json: jsonErrors }); +}); diff --git a/src/lib/cli/utils.ts b/src/lib/cli/utils.ts new file mode 100644 index 0000000..a7e8e3b --- /dev/null +++ b/src/lib/cli/utils.ts @@ -0,0 +1,101 @@ +/** + * cli/utils.ts + * Utility functions for the WiseJSON Command Line Interface. + */ + +import readline from 'readline'; +import logger from '../logger.js'; + +/** + * Interface representing parsed CLI options. + */ +export interface CliOptions { + [key: string]: string | boolean | undefined; +} + +/** + * Advanced command line argument parser. + * Separates positional arguments from named flags and options. + * Correcty handles values containing '=' and supports --flag or --option=value. + * * @param rawCliArgs - The array from process.argv.slice(2). + * @returns An object containing positional 'args' and named 'options'. + */ +export function parseArgs(rawCliArgs: string[]): { args: string[]; options: CliOptions } { + const options: CliOptions = {}; + const args: string[] = []; + + for (const arg of rawCliArgs) { + if (arg.startsWith('--')) { + const parts = arg.slice(2).split('='); + const key = parts[0]; + // Everything after the first '=' is considered the value. + const value = parts.slice(1).join('='); + + // Flag without value (e.g., --force, --unique) + if (value === '') { + options[key] = true; + } else { + // Option with value (e.g., --limit=10) + options[key] = value; + } + } else { + // Positional argument + args.push(arg); + } + } + return { args, options }; +} + +/** + * Interface for error formatting options. + */ +interface PrettyErrorOptions { + json?: boolean; + code?: number; +} + +/** + * Outputs a formatted error message and terminates the process. + * * @param msg - The error message to display. + * @param options - Configuration for output format and exit code. + */ +export function prettyError(msg: string, { json = false, code = 1 }: PrettyErrorOptions = {}): void { + if (json) { + console.error(JSON.stringify({ error: true, message: msg, code })); + } else { + logger.error(msg); + } + process.exit(code); +} + +/** + * Requests user confirmation in interactive mode. + * Automatically resolves to true if 'force' or 'yes' flags are present. + * * @param prompt - The question to ask the user. + * @param options - The options object retrieved from parseArgs. + * @returns A promise that resolves to a boolean representing the user's choice. + */ +export async function confirmAction(prompt: string, options: CliOptions): Promise { + // Shortcut if bypass flags are provided + if (options['force'] || options['yes']) { + return true; + } + + // In non-interactive environments (e.g., execSync in tests or CI), + // blocking for input would hang the process. Default to safe 'false'. + if (!process.stdin.isTTY) { + return false; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + return new Promise(resolve => { + rl.question(`${prompt} [y/N] `, (answer: string) => { + rl.close(); + resolve(answer.toLowerCase() === 'y'); + }); + }); +} diff --git a/src/lib/collection/_ops-turned-BASE.ts b/src/lib/collection/_ops-turned-BASE.ts new file mode 100644 index 0000000..966a5b7 --- /dev/null +++ b/src/lib/collection/_ops-turned-BASE.ts @@ -0,0 +1,294 @@ +import { isAlive } from './ttl.js'; +import logger from '../logger.js'; +import { CollectionOps, Document } from '../types.js'; + + +/** + * Inserts a single document into the collection. + * Validates object type, generates a unique _id, and sets timestamps. + * The operation is enqueued to ensure atomicity and proper locking. + * * @param doc - The document object to insert. + * @returns The inserted document with generated metadata (_id, createdAt, updatedAt). + * @throws {Error} If the argument is not a plain object. + */ +export async function insert(this: CollectionOps, doc: T): Promise { + if (!this.isPlainObject(doc)) { + throw new Error('insert: argument must be an object.'); + } + return this._enqueue(async () => { + const _id = (doc as any)._id || this._idGenerator(); + const now = new Date().toISOString(); + const finalDoc = { + ...doc, + _id, + createdAt: (doc as any).createdAt || now, + updatedAt: now, + } as T & Document; + + const result = await this._enqueueDataModification( + { op: 'INSERT', doc: finalDoc }, + 'INSERT', + (_prev, insertedDoc) => insertedDoc + ); + this._stats.inserts++; + return result; + }); +} + +/** + * Inserts multiple documents in chunks to maintain performance. + * Splits the array into smaller batches based on configuration to avoid oversized WAL entries. + * The entire process is wrapped in a single queue task for collection-level atomicity. + * * @param docs - An array of document objects to insert. + * @returns A promise resolving to an array of all successfully inserted documents. + */ +export async function insertMany(this: CollectionOps, docs: T[]): Promise<(T & Document)[]> { + if (!Array.isArray(docs)) { + throw new Error('insertMany: argument must be an array.'); + } + if (docs.length === 0) { + return []; + } + + // Maximum number of documents in a single BATCH_INSERT WAL record. + // Can be made configurable via this.options, if needed. + const MAX_DOCS_PER_BATCH_WAL_ENTRY = this.options?.maxDocsPerBatchWalEntry || 1000; + // If maxDocsPerBatchWalEntry isn't in this.options, we use the default of 1000. + + // The entire insertMany operation (including all chunks) must be atomic + // from a collection locking perspective, so we wrap everything in a single _enqueue. + + return this._enqueue(async () => { + await this._acquireLock();// Acquire the lock at the beginning + const allInsertedDocs: (T & Document)[] = []; + let totalProcessed = 0; + + try { + for (let i = 0; i < docs.length; i += MAX_DOCS_PER_BATCH_WAL_ENTRY) { + const chunk = docs.slice(i, i + MAX_DOCS_PER_BATCH_WAL_ENTRY); + const now = new Date().toISOString(); + + const preparedChunk = chunk.map(doc => ({ + ...doc, + _id: (doc as any)._id || this._idGenerator(), + createdAt: (doc as any).createdAt || now, + updatedAt: now, + })) as (T & Document)[]; + + // Each chunk is written as a separate BATCH_INSERT operation to the WAL. + // _enqueueDataModification writes to the WAL and applies it to memory. + // Important: _enqueueDataModification itself should not call _acquireLock/_releaseLock, + // since we are already under a shared lock. + const insertedChunk = await this._enqueueDataModification( + { op: 'BATCH_INSERT', docs: preparedChunk }, + 'BATCH_INSERT', + (_prev, inserted) => inserted + ); + + if (Array.isArray(insertedChunk)) { + allInsertedDocs.push(...insertedChunk); + this._stats.inserts += insertedChunk.length; + totalProcessed += insertedChunk.length; + } else { + // This should not happen if _enqueueDataModification for BATCH_INSERT returns an array + logger.warn(`[Ops] insertMany: _enqueueDataModification for chunk did not return an array.`); + } + } + return allInsertedDocs; + } catch (error: any) { + // If an error occurs while processing any of the chunks (for example, a uniqueness violation + // that was checked inside _enqueueDataModification, or a WAL write error for the chunk), + // the entire insertMany operation is rolled back (since we are under the same _enqueue). + // In the current implementation, _enqueueDataModification itself will throw an error, and it will be caught + // by the error handler in _processQueue, which will call task.reject(err). + // So here we simply re-project the error. + logger.error(`[Ops] insertMany: error during chunk processing: ${error.message}.`); + throw error; + } finally { + await this._releaseLockIfHeld(); + } + }); +} + +/** + * Updates an existing document identified by its ID. + * Merges new data into the document and refreshes the updatedAt timestamp. + * * @param id - The unique ID of the document to update. + * @param updates - An object containing the fields to be updated. + * @returns The updated document, or null if the document was not found. + */ +export async function update(this: CollectionOps, id: string, updates: Partial): Promise<(T & Document) | null> { + if (typeof id !== 'string' || id.length === 0) { + throw new Error('update: id must be a non-empty string.'); + } + if (!this.isPlainObject(updates)) { + throw new Error('update: updates must be an object.'); + } + + return this._enqueue(async () => { + if (!this.documents.has(id)) { + return null; + } + const now = new Date().toISOString(); + const result = await this._enqueueDataModification( + { op: 'UPDATE', id, data: { ...updates, updatedAt: now } }, + 'UPDATE', + (_prev, updatedDoc) => updatedDoc, + { idToUpdate: id } + ); + if (result) { + this._stats.updates++; + } + return result; + }); +} + +/** + * Updates multiple documents that match a specific query function. + * Filters the collection for alive documents and enqueues individual update tasks. + * * @param queryFn - A predicate function that receives a document and returns true for a match. + * @param updates - An object containing the fields to be updated in matching documents. + * @returns The count of successfully updated documents. + */ +export async function updateMany(this: CollectionOps, queryFn: (doc: T & Document) => boolean, updates: Partial): Promise { + if (typeof queryFn !== 'function') { + throw new Error('updateMany: queryFn must be a function.'); + } + if (!this.isPlainObject(updates)) { + throw new Error('updateMany: updates must be an object.'); + } + // We collect the ID BEFORE putting it into the queue, so as not to iterate over the mutable collection. + const idsToUpdate: string[] = []; + // This part runs outside of _enqueue, reading the current state of this.documents. + // This is fine, since the actual changes will be in _enqueue. + for (const [id, doc] of this.documents.entries()) { + if (isAlive(doc) && queryFn(doc)) { + idsToUpdate.push(id); + } + } + + if (idsToUpdate.length === 0) { + return 0; + } + + // All updates to updateMany are performed within a single _enqueue call + // to ensure atomicity across the entire updateMany operation, if possible. + // However, this.update itself calls _enqueue inside the loop. + // To make updateMany truly atomic (all or nothing for all found documents), + // a different design for _enqueueDataModification would be required, accepting an array of updates. + // The current implementation makes each individual update operation atomic, but not the entire updateMany. + + // We'll keep the current implementation, where each update is a separate queued operation. + // This is simpler, but less atomic for the entire set. + let successfullyUpdatedCount = 0; + for (const id of idsToUpdate) { + try { + // Each this.update will be queued and executed sequentially. + const updatedDoc = await (this as any).update(id, updates); + if (updatedDoc) { + successfullyUpdatedCount++; + } + } catch (error: any) { + // If one of the updates fails (for example, a uniqueness violation), + // then updateMany is aborted here, and previous successful updates remain. + logger.error(`[Ops] Error updating document ID '${id}' in updateMany: ${error.message}`); + throw error; + } + } + return successfullyUpdatedCount; +} + +/** + * Removes a document from the collection by its ID. + * The removal is logged in the WAL and applied to memory after acquisition of the lock. + * * @param id - The unique ID of the document to remove. + * @returns True if the document was successfully removed, false if it didn't exist. + */ +export async function remove(this: CollectionOps, id: string): Promise { + if (typeof id !== 'string' || id.length === 0) { + throw new Error('remove: id must be a non-empty string.'); + } + + if (!this.documents.has(id)) { + return false; + } + + return this._enqueue(async () => { + if (!this.documents.has(id)) { + return false; + } + + const success = await this._enqueueDataModification( + { op: 'REMOVE', id }, + 'REMOVE', + (_prev, _next) => true, + { idToRemove: id } + ); + + if (success) { + this._stats.removes++; + } + return success; + }); +} + +/** + * Removes multiple documents that satisfy the provided predicate. + * Collects IDs first to prevent iteration issues while the collection is being modified. + * * @param predicate - A function that returns true for documents to be deleted. + * @returns The number of documents removed. + */ +export async function removeMany(this: CollectionOps, predicate: (doc: T & Document) => boolean): Promise { + if (typeof predicate !== 'function') { + throw new Error('removeMany: predicate must be a function.'); + } + + const idsToRemove: string[] = []; + for (const [id, doc] of this.documents.entries()) { + if (isAlive(doc) && predicate(doc)) { + idsToRemove.push(id); + } + } + + if (idsToRemove.length === 0) { + return 0; + } + + let removedCount = 0; + for (const id of idsToRemove) {// Same as updateMany, loop outside _enqueue + try { + const success = await (this as any).remove(id); + if (success) { + removedCount++; + } + } catch (error: any) { + logger.error(`[Ops] Error removing document ID '${id}' in removeMany: ${error.message}`); + throw error; + } + } + return removedCount; +} + +/** + * Completely clears all documents from the collection. + * Resets memory storage, clears indexes, and resets internal operation statistics. + * * @returns True if the collection was successfully cleared. + */ +export async function clear(this: CollectionOps): Promise { + return this._enqueue(async () => { + const success = await this._enqueueDataModification( + { op: 'CLEAR' }, + 'CLEAR', + () => true + ); + + if (success) { + this._stats.clears++; + this._stats.inserts = 0; + this._stats.updates = 0; + this._stats.removes = 0; + this._stats.walEntriesSinceCheckpoint = 0; + } + return success; + }); +} diff --git a/src/lib/collection/_query-ops-turned-QUERY-BASE.ts b/src/lib/collection/_query-ops-turned-QUERY-BASE.ts new file mode 100644 index 0000000..a0aa7fa --- /dev/null +++ b/src/lib/collection/_query-ops-turned-QUERY-BASE.ts @@ -0,0 +1,377 @@ +import { cleanupExpiredDocs, isAlive } from './ttl.js'; +import { matchFilter } from './utils.js'; +import { CollectionOps, Document, FilterQuery, Projection, TTLDocument, UpdateQuery } from '../types.js'; + +/** + * Internal helper to apply update operators ($set, $inc, $unset, etc.) to a document. + * If no $-prefixed operators are found, it performs a complete replacement of the document data. + * * @param doc - The current version of the document. + * @param updateQuery - The query object containing update instructions. + * @returns A new document object with updates applied. + */ +function applyUpdateOperators(doc: T & Document, updateQuery: UpdateQuery): T & Document { + let newDoc = { ...doc }; + const hasOperators = Object.keys(updateQuery).some(key => key.startsWith('$')); + + if (!hasOperators) { + const { _id, createdAt } = newDoc; + newDoc = { ...(updateQuery as any), _id, createdAt }; + return newDoc; + } + + for (const op in updateQuery) { + const opArgs = (updateQuery as any)[op]; + switch (op) { + case '$set': + Object.assign(newDoc, opArgs); + break; + case '$inc': + for (const field in opArgs) { + (newDoc as any)[field] = ((newDoc as any)[field] || 0) + opArgs[field]; + } + break; + case '$unset': + for (const field in opArgs) { + delete (newDoc as any)[field]; + } + break; + case '$push': + for (const field in opArgs) { + if (!Array.isArray((newDoc as any)[field])) (newDoc as any)[field] = []; + if (opArgs[field] && opArgs[field].$each) { + (newDoc as any)[field].push(...opArgs[field].$each); + } else { + (newDoc as any)[field].push(opArgs[field]); + } + } + break; + case '$pull': + for (const field in opArgs) { + if (Array.isArray((newDoc as any)[field])) { + (newDoc as any)[field] = (newDoc as any)[field].filter((item: any) => item !== opArgs[field]); + } + } + break; + } + } + return newDoc; +} + +/** + * Internal helper to filter document fields based on a projection object. + * Logic ensures that inclusion and exclusion aren't mixed, with the exception of the _id field. + * * @param doc - The document to be projected. + * @param projection - Map specifying fields to include (1) or exclude (0). + * @returns A filtered version of the document. + */ +function applyProjection(doc: T & Document, projection: Projection): any { + if (!projection || Object.keys(projection).length === 0) { + return doc; + } + + const newDoc: any = {}; + const values = Object.values(projection); + const hasInclusion = values.some(v => v === 1); + const hasExclusion = values.some(v => v === 0); + + if (hasInclusion && hasExclusion && !Object.prototype.hasOwnProperty.call(projection, '_id')) { + throw new Error('Projection cannot have a mix of inclusion and exclusion.'); + } + + if (hasInclusion) { + for (const key in projection) { + if ((projection as any)[key] === 1 && Object.prototype.hasOwnProperty.call(doc, key)) { + newDoc[key] = (doc as any)[key]; + } + } + if (projection['_id'] !== 0) { + newDoc._id = doc._id; + } + } else {// Exception mode + const excludedKeys = new Set(Object.keys(projection).filter(k => projection[k as keyof typeof projection] === 0)); + for (const key in doc) { + if (!excludedKeys.has(key)) { + newDoc[key] = (doc as any)[key]; + } + } + } + + return newDoc; +} + + +// --- Main API methods --- + +/** + * Fetches a document by its ID. + * Returns null if the document does not exist or has expired based on TTL. + * * @param id - Document unique identifier. + */ +export async function getById(this: CollectionOps, id: string): Promise<(T & Document) | null> { + const doc = this.documents.get(id); + return doc && isAlive(doc as TTLDocument) ? doc : null; +} + +/** + * Returns all documents in the collection that have not expired. + * Automatically runs a TTL cleanup before returning values. + */ +export async function getAll(this: CollectionOps): Promise<(T & Document)[]> { + cleanupExpiredDocs(this.documents as Map, (this as any)._indexManager); + return Array.from(this.documents.values()); +} + +/** + * Returns the number of documents matching a query. + * If no query is provided, returns the total count of active documents. + * * @param query - Optional filter object or function. + */ +export async function count(this: CollectionOps, query?: any): Promise { + cleanupExpiredDocs(this.documents as Map, (this as any)._indexManager); + if (!query || Object.keys(query).length === 0) { + return this.documents.size; + } + const results = await (this as any).find(query); + return results.length; +} + +/** + * Performs a search for documents matching a filter. + * Optimized to use indexes for exact matches and specific range operators ($gt, $lt, etc.). + * * @param query - Filter object (MongoDB-style) or a function. + * @param projection - Optional map to include/exclude specific fields. + * @returns Array of matching (and projected) documents. + */ +export async function find(this: CollectionOps, query: any, projection: Projection = {}): Promise { + const indexManager = (this as any)._indexManager; + if(query) + cleanupExpiredDocs(this.documents as Map, indexManager); + + if (typeof query === 'function') { + const docs = Array.from(this.documents.values()).filter(doc => isAlive(doc as TTLDocument)).filter(query); + return docs.map(doc => applyProjection(doc, projection)); + } + + if (typeof query === 'object' && query !== null) { + let bestIndexField: { field: string, type: 'exact' | 'range' } | null = null; + + let initialDocIds: Set | null = null; + + for (const fieldName in query) { + const condition = query[fieldName]; + if (indexManager.indexes.has(fieldName)) { + if (typeof condition !== 'object') { + bestIndexField = { field: fieldName, type: 'exact' }; + break; + } + if (typeof condition === 'object' && Object.keys(condition).some(op => ['$gt', '$gte', '$lt', '$lte'].includes(op))) { + if (!bestIndexField) { + bestIndexField = { field: fieldName, type: 'range' }; + } + } + } + } + + if (bestIndexField) { + initialDocIds = new Set(); + const indexDef = indexManager.indexes.get(bestIndexField.field); + const condition = query[bestIndexField.field]; + + if (bestIndexField.type === 'exact') { + const ids = indexDef.type === 'unique' + ? [indexManager.findOneIdByIndex(bestIndexField.field, condition)].filter(Boolean) + : indexManager.findIdsByIndex(bestIndexField.field, condition); + ids.forEach((id: string) => initialDocIds!.add(id)); + } else if (bestIndexField.type === 'range') { + for (const [indexedValue, idsOrId] of indexDef.data.entries()) { + const pseudoDoc = { [bestIndexField.field]: indexedValue }; + if (matchFilter(pseudoDoc, { [bestIndexField.field]: condition })) { + if (indexDef.type === 'unique') initialDocIds.add(idsOrId); + else (idsOrId as Set).forEach(id => initialDocIds!.add(id)); + } + } + } + } + + const results: any[] = []; + const source: any = initialDocIds !== null + ? Array.from(initialDocIds).map(id => this.documents.get(id)).filter(Boolean) + : this.documents.values(); + + for (const doc of source) { + if (isAlive(doc as TTLDocument) && matchFilter(doc, query)) { + results.push(applyProjection(doc, projection)); + } + } + return results; + } + + throw new Error('find: query must be a function or a filter object.'); +} + +/** + * Finds the first document that matches the query. + * * @param query - Filter object or function. + * @param projection - Optional field mapping. + * @returns The first found document or null. + */ +export async function findOne(this: CollectionOps, query: any, projection: Projection = {}): Promise { + if (typeof query === 'function') { + cleanupExpiredDocs(this.documents as Map, (this as any)._indexManager); + for (const doc of this.documents.values()) { + if (isAlive(doc as TTLDocument) && query(doc)) { + return applyProjection(doc, projection); + } + } + return null; + } + + if (typeof query === 'object' && query !== null) { + const results = await (this as any).find(query, projection); + return results.length > 0 ? results[0] : null; + } + + throw new Error('findOne: query must be a function or a filter object.'); +} + +/** + * Finds a single document by filter and applies update operators. + * * @param filter - Search criteria. + * @param updateQuery - Update instructions ($set, $inc, etc.). + * @returns Result object containing matchedCount and modifiedCount. + */ +export async function updateOne(this: CollectionOps, filter: any, updateQuery: UpdateQuery): Promise<{ matchedCount: number, modifiedCount: number }> { + const docToUpdate = await (this as any).findOne(filter); + if (!docToUpdate) { + return { matchedCount: 0, modifiedCount: 0 }; + } + + const newDocData = applyUpdateOperators(docToUpdate, updateQuery); + const updatedDoc = await (this as any).update(docToUpdate._id, newDocData); + + return { matchedCount: 1, modifiedCount: updatedDoc ? 1 : 0 }; +} + +/** + * Updates multiple documents that match the filter. + * Uses index-optimized search to identify target documents before applying updates. + * * @param filter - Search criteria. + * @param updateQuery - Update instructions ($set, $inc, etc.). + * @returns Result object containing matchedCount and modifiedCount. + */ +export async function updateMany(this: CollectionOps, filter: any, updateQuery: UpdateQuery): Promise<{ matchedCount: number, modifiedCount: number }> { + const docsToUpdate = await (this as any).find(filter); + if (docsToUpdate.length === 0) { + return { matchedCount: 0, modifiedCount: 0 }; + } + + let modifiedCount = 0; + for (const doc of docsToUpdate) { + const result = await (this as any).updateOne({ _id: doc._id }, updateQuery); + if (result.modifiedCount > 0) { + modifiedCount++; + } + } + + return { matchedCount: docsToUpdate.length, modifiedCount }; +} + +/** + * Finds one document, updates it, and returns either the original or the updated version. + * * @param filter - Search criteria. + * @param updateQuery - Update operators. + * @param options - Set returnOriginal to true to get the document state before update. + */ +export async function findOneAndUpdate(this: CollectionOps, filter: FilterQuery, updateQuery: UpdateQuery, options: { returnOriginal?: boolean } = {}): Promise { + const { returnOriginal = false } = options; + const docToUpdate = await (this as any).findOne(filter); + if (!docToUpdate) return null; + + const newDocData = applyUpdateOperators(docToUpdate, updateQuery); + const updatedDoc = await (this as any).update(docToUpdate._id, newDocData); + + return returnOriginal ? docToUpdate : updatedDoc; +} + +/** + * Deletes the first document matching the filter. + * * @param filter - Search criteria. + * @returns Result object with deletedCount. + */ +export async function deleteOne(this: CollectionOps, filter: any): Promise<{ deletedCount: number }> { + const docToRemove = await (this as any).findOne(filter); + if (!docToRemove) { + return { deletedCount: 0 }; + } + const success = await (this as any).remove(docToRemove._id); + return { deletedCount: success ? 1 : 0 }; +} + +/** + * Deletes multiple documents matching the filter. + * * @param filter - Search criteria. + * @returns Result object with deletedCount. + */ +export async function deleteMany(this: CollectionOps, filter: any): Promise<{ deletedCount: number }> { + const docsToRemove = await (this as any).find(filter); + const idsToRemove = docsToRemove.map((d: { _id: any; }) => d._id); + if (idsToRemove.length === 0) { + return { deletedCount: 0 }; + } + + const deletedCount = await (this as any).removeMany((doc: any) => idsToRemove.includes(doc._id)); + return { deletedCount }; +} + +/** + * [Legacy] Directly searches an index for documents with a specific field value. + * * @param fieldName - The indexed field to search. + * @param value - The value to look for. + */ +export async function findByIndexedValue(this: CollectionOps, fieldName: string, value: any): Promise<(T & Document)[]> { + cleanupExpiredDocs(this.documents as Map, (this as any)._indexManager); + + const index = (this as any)._indexManager.indexes.get(fieldName); + if (!index) return []; + + let idsToFetch = new Set(); + if (index.type === 'unique') { + const id = (this as any)._indexManager.findOneIdByIndex(fieldName, value); + if (id) idsToFetch.add(id); + } else { + idsToFetch = (this as any)._indexManager.findIdsByIndex(fieldName, value); + } + + const result: (T & Document)[] = []; + for (const id of idsToFetch) { + const doc = this.documents.get(id); + if (doc && isAlive(doc as TTLDocument)) { + result.push(doc); + } + } + return result; +} + +/** + * [Legacy] Finds the first document matching an indexed value. + * * @param fieldName - The indexed field to search. + * @param value - The value to look for. + */ +export async function findOneByIndexedValue(this: CollectionOps, fieldName: string, value: any): Promise<(T & Document) | null> { + const index = (this as any)._indexManager.indexes.get(fieldName); + if (!index) return null; + + if (index.type === 'unique') { + const id = (this as any)._indexManager.findOneIdByIndex(fieldName, value); + if (id) { + const potentialDoc = this.documents.get(id); + if (potentialDoc && isAlive(potentialDoc as TTLDocument)) { + return potentialDoc; + } + } + return null; + } else { + const results = await (this as any).findByIndexedValue(fieldName, value); + return results.length > 0 ? results[0] : null; + } +} diff --git a/src/lib/collection/base.ts b/src/lib/collection/base.ts new file mode 100644 index 0000000..ff42cfc --- /dev/null +++ b/src/lib/collection/base.ts @@ -0,0 +1,251 @@ +import { isAlive } from './ttl.js'; +import logger from '../logger.js'; +import * as utils from './utils.js'; +import { + Document, + CollectionOptions, + CollectionStats, + UpdateResult, + UpdateQuery, + Filter, +} from '../types.js'; +import { IndexManager } from './indexes.js'; + +/** + * Base class providing core CRUD operations. + * This class is abstract because it relies on the persistence, locking, + * and queueing logic implemented in the final Collection class. + */ +export abstract class CollectionBase { + // Primary in-memory data store + public documents: Map = new Map(); + + // Abstract requirements to be fulfilled by the child Collection class + protected abstract options: Required; + protected abstract _stats: CollectionStats; + protected abstract _idGenerator: () => string; + protected abstract _enqueue(task: () => Promise): Promise; + protected abstract _enqueueDataModification( + data: any, + opType: string, + getResult: (prev: any, next: any) => R, + options?: any, + ): Promise; + protected abstract _acquireLock(): Promise; + protected abstract _releaseLockIfHeld(): Promise; + + protected abstract _indexManager: IndexManager; + + // Utility exposed to the base methods + protected isPlainObject = utils.isPlainObject; + + /** + * Inserts a single document into the collection. + * Validates object type, generates a unique _id, and sets timestamps. + */ + public async insert(doc: T): Promise { + if (!this.isPlainObject(doc)) { + throw new Error('insert: argument must be an object.'); + } + return this._enqueue(async () => { + const _id = (doc as any)._id || this._idGenerator(); + const now = new Date().toISOString(); + const finalDoc = { + ...doc, + _id, + createdAt: (doc as any).createdAt || now, + updatedAt: now, + } as T; + + const result = await this._enqueueDataModification( + { op: 'INSERT', doc: finalDoc }, + 'INSERT', + (_prev, insertedDoc) => insertedDoc, + ); + this._stats.inserts++; + return result; + }); + } + + /** + * Inserts multiple documents in chunks to maintain performance. + */ + public async insertMany(docs: T[]): Promise { + if (!Array.isArray(docs)) { + throw new Error('insertMany: argument must be an array.'); + } + if (docs.length === 0) { + return []; + } + + const MAX_DOCS_PER_BATCH_WAL_ENTRY = + this.options.maxWalEntriesBeforeCheckpoint || 1000; + + return this._enqueue(async () => { + await this._acquireLock(); + const allInsertedDocs: T[] = []; + + try { + for (let i = 0; i < docs.length; i += MAX_DOCS_PER_BATCH_WAL_ENTRY) { + const chunk = docs.slice(i, i + MAX_DOCS_PER_BATCH_WAL_ENTRY); + const now = new Date().toISOString(); + + const preparedChunk = chunk.map((doc) => ({ + ...doc, + _id: (doc as any)._id || this._idGenerator(), + createdAt: (doc as any).createdAt || now, + updatedAt: now, + })) as T[]; + + const insertedChunk = await this._enqueueDataModification( + { op: 'BATCH_INSERT', docs: preparedChunk }, + 'BATCH_INSERT', + (_prev, inserted) => inserted, + ); + + if (Array.isArray(insertedChunk)) { + allInsertedDocs.push(...insertedChunk); + this._stats.inserts += insertedChunk.length; + } else { + logger.warn( + `[Ops] insertMany: _enqueueDataModification did not return an array.`, + ); + } + } + return allInsertedDocs; + } catch (error: any) { + logger.error( + `[Ops] insertMany error during chunk processing: ${error.message}.`, + ); + throw error; + } finally { + await this._releaseLockIfHeld(); + } + }); + } + + /** + * Updates an existing document identified by its ID. + */ + public async update(id: string, updates: Partial): Promise { + if (typeof id !== 'string' || id.length === 0) { + throw new Error('update: id must be a non-empty string.'); + } + if (!this.isPlainObject(updates)) { + throw new Error('update: updates must be an object.'); + } + + return this._enqueue(async () => { + if (!this.documents.has(id)) { + return null; + } + const now = new Date().toISOString(); + const result = await this._enqueueDataModification( + { op: 'UPDATE', id, data: { ...updates, updatedAt: now } }, + 'UPDATE', + (_prev, updatedDoc) => updatedDoc, + { idToUpdate: id }, + ); + if (result) { + this._stats.updates++; + } + return result; + }); + } + + /** + * Removes a document from the collection by its ID. + */ + public async remove(id: string): Promise { + if (typeof id !== 'string' || id.length === 0) { + throw new Error('remove: id must be a non-empty string.'); + } + + if (!this.documents.has(id)) { + return false; + } + + return this._enqueue(async () => { + if (!this.documents.has(id)) { + return false; + } + + const success = await this._enqueueDataModification( + { op: 'REMOVE', id }, + 'REMOVE', + (_prev, _next) => true, + { idToRemove: id }, + ); + + if (success) { + this._stats.removes++; + } + return success; + }); + } + + /** + * Removes multiple documents that satisfy the provided predicate. + */ + public async removeMany(predicate: (doc: T) => boolean): Promise { + if (typeof predicate !== 'function') { + throw new Error('removeMany: predicate must be a function.'); + } + + const idsToRemove: string[] = []; + for (const [id, doc] of this.documents.entries()) { + if (isAlive(doc) && predicate(doc)) { + idsToRemove.push(id); + } + } + + if (idsToRemove.length === 0) { + return 0; + } + + let removedCount = 0; + for (const id of idsToRemove) { + try { + const success = await this.remove(id); + if (success) { + removedCount++; + } + } catch (error: any) { + logger.error( + `[Ops] Error removing document ID '${id}' in removeMany: ${error.message}`, + ); + throw error; + } + } + return removedCount; + } + + /** + * Completely clears all documents from the collection. + */ + public async clear(): Promise { + return this._enqueue(async () => { + const success = await this._enqueueDataModification( + { op: 'CLEAR' }, + 'CLEAR', + () => true, + ); + + if (success) { + this._stats.clears++; + this._stats.inserts = 0; + this._stats.updates = 0; + this._stats.removes = 0; + this._stats.walEntriesSinceCheckpoint = 0; + } + return success; + }); + } + + // /** + // * Returns the current number of documents in the collection. + // */ + // public get count(): number { + // return this.documents.size; + // } +} diff --git a/src/lib/collection/core.ts b/src/lib/collection/core.ts new file mode 100644 index 0000000..0cc012c --- /dev/null +++ b/src/lib/collection/core.ts @@ -0,0 +1,661 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import path from 'path'; +import fs from 'fs/promises'; +import { v4 as uuidv4 } from 'uuid'; +import { CollectionEventEmitter } from './events.js'; +import { IndexManager } from './indexes.js'; +import { SyncManager } from '../sync/sync-manager.js'; +import { UniqueConstraintError } from '../errors.js'; +import * as utils from './utils.js'; +import * as wal from '../wal-manager.js'; +import * as checkpoint from '../checkpoint-manager.js'; +import * as dataExchange from './data-exchange.js'; + +import { cleanupExpiredDocs, isAlive } from './ttl.js'; +import { acquireCollectionLock, releaseCollectionLock } from './file-lock.js'; +import { createWriteQueue } from './queue.js'; +import { writeJsonFileSafe } from '../storage-utils.js'; +import defaultLogger from '../logger.js'; + +import { + IndexOptions, + IndexMetadata, + Document, + CollectionStats, + CollectionOptions, + WalTransactionEntry, + SyncManagerEventMap, + CollectionEventMap, + SyncOptions, + SyncStatus, +} from '../types.js'; +import { CollectionQueryBase } from './query.base.js'; +import { exportJson } from './data-exchange.js'; +import { ApiClient } from '../sync/api-client.js'; + +/** + * The core Collection implementation responsible for data persistence, + * Write-Ahead Logging (WAL), indexing, and optional cloud synchronization. + */ +export class Collection extends CollectionQueryBase { + /** The unique name of the collection (used for folder naming). */ + public readonly name: string; + /** The absolute root directory where the database resides. */ + public readonly dbRootPath: string; + /** The specific directory path for this collection's data. */ + public readonly collectionDirPath: string; + /** The subdirectory where segmented checkpoint JSON files are stored. */ + public readonly checkpointsDir: string; + /** The absolute path to the Write-Ahead Log (.log) file. */ + public readonly walPath: string; + /** Path to the log file used for storing failed remote operations for manual review. */ + public readonly quarantinePath: string; + /** The consolidated configuration settings for this collection. */ + public readonly options: Required; + /** Logger instance used for system diagnostics (defaults to console). */ + public readonly logger: any; + /** A promise that resolves to true once the collection has finished loading from disk. */ + public readonly isReady: Promise; + /** Manager handling WebSocket/Cloud synchronization logic. */ + public syncManager: SyncManager | null = null; + + /** Handles the internal indexing logic for fast lookups and unique constraints. */ + protected _indexManager: IndexManager; + /** Internal event bus for broadcasting lifecycle events (insert, update, remove, etc). */ + protected _emitter: CollectionEventEmitter; + /** Live tracking of collection operations and WAL state. */ + protected _stats: CollectionStats; + /** A serialized execution queue that prevents data race conditions during write operations. */ + protected _enqueue!: (task: () => Promise) => Promise; + + /** Interval ID for the periodic flushToDisk persistence task. */ + private _checkpointTimerId: NodeJS.Timeout | null = null; + /** Interval ID for the background removal of expired documents. */ + private _ttlCleanupTimer: NodeJS.Timeout | null = null; + /** A cleanup function returned by the file-locking utility to release directory access. */ + private _releaseLock: (() => Promise) | null = null; + + apiClient: ApiClient + + /** + * @param name - The unique collection name. + * @param dbRootPath - Path where the database folder resides. + * @param options - Configuration for segments, WAL, and TTL. + */ + constructor(name: string, dbRootPath: string, options: CollectionOptions = {}) { + super(); + if (!utils.isNonEmptyString(name)) { + throw new Error('Collection: collection name must be a non-empty string.'); + } + + this.name = name; + this.dbRootPath = utils.makeAbsolutePath(dbRootPath); + this.options = this._validateOptions(options); + this.logger = this.options.logger || console; + + this.collectionDirPath = path.resolve(this.dbRootPath, this.name); + this.checkpointsDir = path.join(this.collectionDirPath, '_checkpoints'); + this.walPath = wal.getWalPath(this.collectionDirPath, this.name); + this.quarantinePath = path.join(this.collectionDirPath, `quarantine_${this.name}.log`); + + this._emitter = new CollectionEventEmitter(this.name); + this._indexManager = new IndexManager(this.name, this.logger); + + this._stats = { + inserts: 0, updates: 0, removes: 0, clears: 0, + walEntriesSinceCheckpoint: 0, + lastCheckpointTimestamp: null, + count: 0, + }; + + createWriteQueue(this as any); + this.isReady = this._initialize(); + this.apiClient = options.apiClient as ApiClient + } + + /** + * Orchestrates the startup sequence: folder creation, checkpoint loading, and WAL replay. + * @internal + */ + private async _initialize(): Promise { + try { + await fs.mkdir(this.collectionDirPath, { recursive: true }); + await fs.mkdir(this.checkpointsDir, { recursive: true }); + + await wal.initializeWal(this.walPath, this.collectionDirPath, this.logger); + + const loaded = await checkpoint.loadLatestCheckpoint(this.checkpointsDir, this.name, this.logger); + this.documents = loaded.documents; + this._stats.lastCheckpointTimestamp = loaded.timestamp || null; + + for (const idx of loaded.indexesMeta || []) { + try { + this._indexManager.createIndex(idx.fieldName, { unique: idx.type === 'unique' }); + } catch (e) { /* ignore existing */ } + } + this._indexManager.rebuildIndexesFromData(this.documents); + + const walEntries = await wal.readWal(this.walPath, this._stats.lastCheckpointTimestamp, { + ...this.options.walReadOptions, + isInitialLoad: true, + logger: this.logger + }); + + for (const entry of walEntries) { + if (entry.txn === 'op' && entry._txn_applied_from_wal) { + await this._applyTransactionWalOp(entry, true); + } else if (!entry.txn) { + this._applyWalEntryToMemory(entry, false, true); + } + } + + this._stats.walEntriesSinceCheckpoint = walEntries.length; + this._indexManager.rebuildIndexesFromData(this.documents); + + this._startCheckpointTimer(); + this._startTtlCleanupTimer(); + + this._emitter.emit('initialized'); + return true; + } catch (err) { + this.logger.error(`Init failed for ${this.name}:`, err); + throw err; + } + } + + /** * Satisfies parent requirement to lock the file system for this collection. + * @protected + */ + protected async _acquireLock(): Promise { + if (this._releaseLock) return; + this._releaseLock = await acquireCollectionLock(this.collectionDirPath); + } + + /** * Safely releases the file system lock if it is currently held. + * @protected + */ + protected async _releaseLockIfHeld(): Promise { + if (this._releaseLock) { + await releaseCollectionLock(this._releaseLock); + this._releaseLock = null; + } + } + + /** + * Segments memory data into JSON files and clears the WAL. + * This is the primary persistence mechanism used for recovery and startup. + * @public + */ + public async flushToDisk(): Promise { + return this._enqueue(async () => { + cleanupExpiredDocs(this.documents as any, this._indexManager); + const timestamp = new Date().toISOString(); + const tsFile = timestamp.replace(/[:.]/g, '-'); + + const meta = { + collectionName: this.name, + timestamp, + documentCount: this.documents.size, + indexesMeta: this._indexManager.getIndexesMeta() || [] + }; + await writeJsonFileSafe(path.join(this.checkpointsDir, `checkpoint_meta_${this.name}_${tsFile}.json`), meta, null); + + const docs = Array.from(this.documents.values()); + let segIdx = 0, currentSeg: T[] = [], currentSize = 2; + + for (const d of docs) { + const dStr = JSON.stringify(d); + const dSize = Buffer.byteLength(dStr, 'utf8') + (currentSeg.length > 0 ? 1 : 0); + if (currentSize + dSize > this.options.maxSegmentSizeBytes && currentSeg.length > 0) { + await writeJsonFileSafe(path.join(this.checkpointsDir, `checkpoint_data_${this.name}_${tsFile}_seg${segIdx++}.json`), currentSeg, null); + currentSeg = []; currentSize = 2; + } + currentSeg.push(d); + currentSize += dSize; + } + if (currentSeg.length > 0) { + await writeJsonFileSafe(path.join(this.checkpointsDir, `checkpoint_data_${this.name}_${tsFile}_seg${segIdx++}.json`), currentSeg, null); + } + + this._stats.lastCheckpointTimestamp = timestamp; + this._stats.walEntriesSinceCheckpoint = 0; + await wal.compactWal(this.walPath, timestamp, this.logger); + if (this.options.checkpointsToKeep > 0) { + await checkpoint.cleanupOldCheckpoints(this.checkpointsDir, this.name, this.options.checkpointsToKeep, this.logger); + } + this._emitter.emit('checkpoint', { timestamp }); + }); + } + + /** + * Enables the synchronization engine for this collection. + * * Configures a SyncManager, forwards internal sync events to the collection emitter, + * and initiates the synchronization loop. + * * @param syncOptions - Configuration including remote URL and API credentials. + * @throws {Error} If required synchronization parameters are missing. + */ + public enableSync(syncOptions: SyncOptions): void { + if (this.syncManager) { + this.logger.warn(`[Sync] Sync for collection '${this.name}' is already enabled.`); + return; + } + + const { url, apiKey } = syncOptions; + if (!url || !apiKey) { + throw new Error('Sync requires `url` and `apiKey`.'); + } + + // Initialize the SyncManager with this collection instance + this.syncManager = new SyncManager({ + collection: this, + apiClient: this.apiClient, + logger: this.logger, + ...syncOptions, + }); + + this.syncManager.start(); + } + + /** + * Gracefully stops the synchronization engine and cleans up the SyncManager instance. + */ + public disableSync(): void { + if (this.syncManager) { + this.syncManager.stop(); + this.syncManager = null; + this.logger.log(`[Sync] Sync for collection '${this.name}' stopped.`); + } + } + + /** + * Manually triggers a synchronization cycle (Push and Pull). + * * @returns A promise that resolves when the synchronization cycle completes. + */ + public async triggerSync(): Promise { + if (!this.syncManager) { + this.logger.warn(`[Sync] Cannot trigger sync for '${this.name}', sync is not enabled.`); + return Promise.resolve(); + } + return this.syncManager.runSync(); + } + + /** + * Retrieves the current status of the synchronization engine. + * * @returns An object containing the current sync state, LSN, and progress. + */ + public getSyncStatus(): SyncStatus { + if (!this.syncManager) { + return { + state: 'disabled', + isSyncing: false, + lastKnownServerLSN: 0, + initialSyncComplete: false, + }; + } + return this.syncManager.getStatus(); + } + + /** + * Routes complex transactional operations (from WAL or sync) to specific memory handlers. + * @param entry - The transactional operation entry. + * @param isInitialLoad - Whether this is being applied during collection startup. + * @internal + */ + public async _applyTransactionWalOp(entry: WalTransactionEntry, isInitialLoad = false): Promise { + const txidForLog = entry.txid || entry.id || 'unknown_txid'; + switch (entry.type) { + case 'insert': await this._applyTransactionInsert(entry.args[0], txidForLog, isInitialLoad); break; + case 'insertMany': await this._applyTransactionInsertMany(entry.args[0], txidForLog, isInitialLoad); break; + case 'update': await this._applyTransactionUpdate(entry.args[0], entry.args[1], txidForLog, isInitialLoad); break; + case 'remove': await this._applyTransactionRemove(entry.args[0], txidForLog, isInitialLoad); break; + case 'clear': await this._applyTransactionClear(txidForLog, isInitialLoad); break; + default: + this.logger.warn(`[Collection] Unknown transactional op '${(entry as any).type}' for ${this.name}, txid: ${txidForLog}`); + } + } + + /** Handles a single document insertion within a transaction. */ + private async _applyTransactionInsert(docData: any, txid: string, isInitialLoad = false) { + const _id = docData._id || this._idGenerator(); + if (!isInitialLoad && this.documents.has(_id)) { + throw new Error(`Cannot apply transaction insert: document ${_id} already exists.`); + } + const now = new Date().toISOString(); + const finalDoc = { ...docData, _id, createdAt: docData.createdAt || now, updatedAt: docData.updatedAt || now, _txn: txid }; + this.documents.set(_id, finalDoc); + this._indexManager.afterInsert(finalDoc); + this._stats.inserts++; + this._emitter.emit('insert', finalDoc); + return finalDoc; + } + + /** Handles multiple document insertions within a transaction. */ + private async _applyTransactionInsertMany(docsData: any[], txid: string, isInitialLoad = false) { + if (!isInitialLoad) { + for (const d of docsData) { + const _id = d._id || this._idGenerator(); + if (this.documents.has(_id)) throw new Error(`Document ${_id} already exists.`); + } + } + const now = new Date().toISOString(), insertedDocs = []; + for (const d of docsData) { + const _id = d._id || this._idGenerator(); + const finalDoc = { ...d, _id, createdAt: d.createdAt || now, updatedAt: d.updatedAt || now, _txn: txid }; + this.documents.set(_id, finalDoc); + this._indexManager.afterInsert(finalDoc); + this._stats.inserts++; + this._emitter.emit('insert', finalDoc); + insertedDocs.push(finalDoc); + } + return insertedDocs; + } + + /** Handles a document update within a transaction. */ + private async _applyTransactionUpdate(id: string, updates: any, txid: string, isInitialLoad = false) { + const oldDoc = this.documents.get(id); + if (!oldDoc && !isInitialLoad) throw new Error(`Document ${id} not found.`); + if (!oldDoc) return null; + const { _id, createdAt, ...rest } = updates; + const now = new Date().toISOString(); + const newDoc = { ...oldDoc, ...rest, updatedAt: updates.updatedAt || now, _txn: txid }; + this.documents.set(id, newDoc); + this._indexManager.afterUpdate(oldDoc, newDoc); + this._stats.updates++; + this._emitter.emit('update', { + newDoc, oldDoc, + id + }); + return newDoc; + } + + /** Handles document removal within a transaction. */ + private async _applyTransactionRemove(id: string, txid: string, isInitialLoad = false) { + const doc = this.documents.get(id); + if (!doc) return false; + this.documents.delete(id); + this._indexManager.afterRemove(doc); + this._stats.removes++; + this._emitter.emit('remove', {doc}); + return true; + } + + /** Handles collection clearing within a transaction. */ + private async _applyTransactionClear(txid: string, isInitialLoad = false) { + const clearedCount = this.documents.size; + this.documents.clear(); + this._indexManager.clearAllData(); + this._stats.clears++; + this._stats.inserts = 0; this._stats.updates = 0; this._stats.removes = 0; + this._stats.walEntriesSinceCheckpoint = 0; + this._emitter.emit('trnx:clear', { clearedCount, _txn: txid }); + return true; + } + + /** + * Directly modifies memory Map and indexes based on a standard WAL entry. + * @param entry - The WAL entry object. + * @param emitEvents - Whether to broadcast events to listeners. + * @param isInitialLoad - Whether this is being applied during startup. + * @internal + */ + protected _applyWalEntryToMemory(entry: any, emitEvents = true, isInitialLoad = false) { + switch (entry.op) { + case 'INSERT': { + const doc = entry.doc; + if (!doc || !doc._id) throw new Error('Cannot apply INSERT: document or _id missing.'); + if (!isInitialLoad && this.documents.has(doc._id) && !entry._remote) { + throw new Error(`Cannot apply INSERT: document ${doc._id} already exists.`); + } + this.documents.set(doc._id, doc); + this._indexManager.afterInsert(doc); + if (emitEvents) this._emitter.emit('insert', doc); + break; + } + case 'BATCH_INSERT': { + const docs = Array.isArray(entry.docs) ? entry.docs : []; + if (!isInitialLoad) { + for (const d of docs) { + if (!d?._id) throw new Error('Cannot apply BATCH_INSERT: _id missing.'); + if (this.documents.has(d._id) && !entry._remote) throw new Error(`Duplicate _id ${d._id}`); + } + } + for (const d of docs) { + this.documents.set(d._id, d); + this._indexManager.afterInsert(d); + if (emitEvents) this._emitter.emit('insert', d); + } + break; + } + case 'UPDATE': { + const id = entry.id; + const data = entry.data; + const prev = this.documents.get(id); + if (!prev) { + if (isInitialLoad && data) { + const newDoc = { _id: id, createdAt: new Date().toISOString(), ...data, updatedAt: data.updatedAt || new Date().toISOString() }; + this.documents.set(id, newDoc); + this._indexManager.afterInsert(newDoc); + if (emitEvents) this._emitter.emit('insert', newDoc); + return; + } + if (!entry._remote) throw new Error(`Document ${id} not found.`); + return; + } + const updated = { ...prev, ...data }; + this.documents.set(id, updated); + this._indexManager.afterUpdate(prev, updated); + if (emitEvents) this._emitter.emit('update', { id, oldDoc: prev, newDoc: updated }); + break; + } + case 'REMOVE': { + const doc = this.documents.get(entry.id); + if (doc) { + this.documents.delete(entry.id); + this._indexManager.afterRemove(doc); + if (emitEvents) this._emitter.emit('remove', {doc}); + } + break; + } + case 'CLEAR': + { const count = this.documents.size; + this.documents.clear(); + this._indexManager.clearAllData(); + if (emitEvents) this._emitter.emit('clear', { clearedCount: count }); + break; } + default: throw new Error(`Unknown op: ${entry.op}`); + } + } + + async compactWalAfterPush() { + this.logger.log(`[Collection] Compacting local state for '${this.name}' after successful sync push by flushing to disk.`); + return this.flushToDisk(); + } + + /** * Validates unique constraints, appends to the WAL, and triggers the in-memory update. + * @internal + */ + protected async _enqueueDataModification(entry: any, opType: string, getResultFn?: (err: any, res: any) => any) { + this._validateUniqueConstraints(entry, opType); + await wal.appendWalEntry(this.walPath, { ...entry, opId: uuidv4() }, this.logger); + this._applyWalEntryToMemory(entry, true); + this._handlePotentialCheckpointTrigger(); + const result = opType === 'INSERT' ? entry.doc : (opType === 'BATCH_INSERT' ? entry.docs : this.documents.get(entry.id)); + return getResultFn ? getResultFn(undefined, result) : result; + } + + /** + * Processes operations received from a remote sync server. Handles timestamp-based conflict resolution. + * @param remoteOp - The operation received from the sync manager. + * @internal + */ + public async _applyRemoteOperation(remoteOp: any) { + if (!remoteOp?.op) return; + return this._enqueue(async () => { + const docId = remoteOp.id || remoteOp.doc?._id; + const localDoc = docId ? this.documents.get(docId) : null; + + if (remoteOp.op === 'INSERT' && localDoc) return; + if (remoteOp.op === 'UPDATE' && !localDoc) return; + + const remoteTsStr = remoteOp.ts || remoteOp.doc?.updatedAt || remoteOp.data?.updatedAt; + if (localDoc && remoteTsStr) { + const remoteTs = new Date(remoteTsStr).getTime(); + const localTs = new Date(localDoc.updatedAt!).getTime(); + if (localTs > remoteTs) { + this._emitter.emit('sync:conflict_resolved', { type: 'ignored_remote', reason: 'local_is_newer', docId }); + return; + } + } + + try { + this._applyWalEntryToMemory(remoteOp, true, false); + await wal.appendWalEntry(this.walPath, { ...remoteOp, _remote: true }, this.logger); + } catch (err: any) { + await this._quarantineOperation(remoteOp, err); + } + }); + } + + /** Logs a failed remote operation to the quarantine file for safety. */ + private async _quarantineOperation(op: any, error: any) { + const entry = { quarantinedAt: new Date().toISOString(), operation: op, error: { message: error.message, stack: error.stack } }; + await fs.appendFile(this.quarantinePath, JSON.stringify(entry) + '\n', 'utf8'); + this._emitter.emit('sync:quarantine', entry); + } + + /** Scans unique indexes to ensure a write operation does not violate constraints. */ + private _validateUniqueConstraints(entry: any, opType: string) { + const uniques = this._indexManager.getIndexesMeta().filter(i => i.type === 'unique'); + if (uniques.length === 0) return; + + const check = (doc: any, id?: string) => { + for (const idx of uniques) { + const val = doc[idx.fieldName]; + if (val === undefined || val === null) continue; + const existingId = this._indexManager.findOneIdByIndex(idx.fieldName, val); + if (existingId && existingId !== id) throw new UniqueConstraintError(idx.fieldName, val); + } + }; + + if (opType === 'INSERT') check(entry.doc); + else if (opType === 'BATCH_INSERT') entry.docs.forEach((d: any) => check(d)); + else if (opType === 'UPDATE') check(entry.data, entry.id); + } + + /** Merges user-provided options with system defaults. */ + private _validateOptions(opts: CollectionOptions): Required { + return utils.validateOptions(opts) + } + + /** Default ID generator bridging the options to the class logic. */ + protected _idGenerator = (): string => this.options.idGenerator(); + + /** Starts the recurring persistence timer. */ + private _startCheckpointTimer() { + this.stopCheckpointTimer(); + if (this.options.checkpointIntervalMs > 0) { + this._checkpointTimerId = setInterval(() => this.flushToDisk().catch(() => {}), this.options.checkpointIntervalMs); + if (this._checkpointTimerId?.unref) this._checkpointTimerId.unref(); + } + } + + /** Starts the recurring TTL cleanup timer. */ + private _startTtlCleanupTimer() { + this._stopTtlCleanupTimer() + if (this.options.ttlCleanupIntervalMs > 0) { + this._ttlCleanupTimer = setInterval(() => cleanupExpiredDocs(this.documents as any, this._indexManager), this.options.ttlCleanupIntervalMs); + if (this._ttlCleanupTimer?.unref) this._ttlCleanupTimer.unref(); + } + } + + /** Checks if the WAL entry count has reached the threshold to trigger an auto-checkpoint. */ + private _handlePotentialCheckpointTrigger() { + this._stats.walEntriesSinceCheckpoint++; + if (this.options.maxWalEntriesBeforeCheckpoint > 0 && this._stats.walEntriesSinceCheckpoint >= this.options.maxWalEntriesBeforeCheckpoint) { + this.flushToDisk().catch(() => {}); + } + } + + /** * Stops the background persistence timer. + * @public + */ + public stopCheckpointTimer() { + if (this._checkpointTimerId) { + clearInterval(this._checkpointTimerId); + this._checkpointTimerId = null; + } + } + + /** * Stops the TTL cleanup timer. + * @private + */ + private _stopTtlCleanupTimer() { + if (this._ttlCleanupTimer) { + clearInterval(this._ttlCleanupTimer); + this._ttlCleanupTimer = null; + } + } + + /** * Shuts down the collection, stopping all timers, flushing data to disk, and releasing file locks. + * @public + */ + public async close() { + if (this.syncManager) this.syncManager.stop(); + this.stopCheckpointTimer(); + this._stopTtlCleanupTimer() + await this.flushToDisk(); + await this._releaseLockIfHeld(); + this._emitter.emit('closed'); + } + + /** * Returns the current usage and health statistics of the collection. + * @public + */ + public stats(): CollectionStats { + cleanupExpiredDocs(this.documents as any, this._indexManager); + return { ...this._stats, count: this.documents.size }; + } + + /** * Creates a new index on the specified field and rebuilds index data. + * @param fieldName - The field to index. + * @param options - Configuration for index uniqueness. + * @public + */ + public async createIndex(fieldName: string, options: IndexOptions = {}): Promise { + return this._enqueue(async () => { + this._indexManager.createIndex(fieldName, options); + this._indexManager.rebuildIndexesFromData(this.documents); + }); + } + + async dropIndex(fieldName: string) { + return this._enqueue(async () => { + this._indexManager.dropIndex(fieldName); + }); + } + + async getIndexes() { + return this._indexManager.getIndexesMeta(); + } + + /** + * Exports collection to JSON + */ + public exportJson = dataExchange.exportJson.bind(this as any); + + /** + * Exports collection to CSV + */ + public exportCsv = dataExchange.exportCsv.bind(this as any); + + /** + * Imports documents from JSON + */ + public importJson = dataExchange.importJson.bind(this as any); + + /** Register an event listener. */ + public on>(event: K, listener: (payload: CollectionEventMap[K]) => void) { this._emitter.on(event, listener); } + /** Remove an event listener. */ + public off>(event: K, fn: (...args: any[]) => void) { this._emitter.off(event, fn); } + +} diff --git a/src/lib/collection/data-exchange.ts b/src/lib/collection/data-exchange.ts new file mode 100644 index 0000000..1ce63c7 --- /dev/null +++ b/src/lib/collection/data-exchange.ts @@ -0,0 +1,111 @@ +import fs from 'fs/promises'; +import logger from '../logger.js'; +import { flattenDocToCsv } from './utils.js'; +import { DataExchangeContext, Document, ImportOptions } from '../types.js'; + +/** + * Exports all active (non-expired) documents from the collection to a JSON file. + * The output is formatted with a 2-space indentation for readability. + * * @param filePath - The destination path on the filesystem. + * @param options - Reserved for future configuration (currently unused). + * @throws {Error} If file writing fails. + */ +export async function exportJson( + this: DataExchangeContext, + filePath: string, + options: object = {} +): Promise { + const docs = await this.getAll(); + try { + const jsonContent = JSON.stringify(docs, null, 2); + await fs.writeFile(filePath, jsonContent, 'utf-8'); + } catch (error: any) { + logger.error(`[Data Exchange] Error exporting JSON to ${filePath}:`, error); + throw error; + } +} + +/** + * Exports all active (non-expired) documents from the collection to a CSV file. + * Uses a flattening utility to convert nested objects into a flat CSV structure. + * * @param filePath - The destination path for the CSV file. + * @throws {Error} If the flattening process or file writing fails. + */ +export async function exportCsv( + this: DataExchangeContext, + filePath: string +): Promise { + const docs = await this.getAll(); + + if (docs.length === 0) { + try { + await fs.writeFile(filePath, '', 'utf-8'); // Create an empty file + } catch (error: any) { + logger.error(`[Data Exchange] Error creating empty CSV file ${filePath}:`, error); + throw error; + } + return; + } + + try { + // Assume flattenDocToCsv is available. + // If it's not a `this` method, it needs to be imported: + // const { flattenDocToCsv } = require('./utils.js'); // or require('../utils') if it exists + // In the current structure of core.js, it is imported and used, + // so if data-exchange.js becomes part of Collection, this.flattenDocToCsv might not exist. + // It's safer to import it directly if it's not on the Collection prototype. + // Let's assume it needs to be imported: + const csvData = flattenDocToCsv(docs); + await fs.writeFile(filePath, csvData, 'utf-8'); + } catch (error: any) { + logger.error(`[Data Exchange] Error exporting CSV to ${filePath}:`, error); + throw error; + } +} + +/** + * Imports documents from a JSON file into the collection. + * Supports appending to current data or completely replacing the collection. + * * @param filePath - Path to the JSON file containing an array of documents. + * @param options - Configuration for the import mode ('append' | 'replace'). + * @throws {Error} If the file is not a valid JSON array or insertion fails. + */ +export async function importJson( + this: DataExchangeContext, + filePath: string, + options: ImportOptions = {} +): Promise { + const mode = options.mode || 'append'; + let jsonData: any; + + try { + const rawData = await fs.readFile(filePath, 'utf-8'); + jsonData = JSON.parse(rawData); + } catch (error: any) { + logger.error(`[Data Exchange] Error reading or parsing JSON file ${filePath}:`, error); + throw error; + } + + if (!Array.isArray(jsonData)) { + const error = new Error('Import file must contain a JSON array of documents.'); + logger.error(`[Data Exchange] ${error.message}`); + throw error; + } + + if (jsonData.length === 0) { + return; + } + + try { + if (mode === 'replace') { + await this.clear(); // `this.clear()` - method from crud-ops (or ops.js) + } + + // `this.insertMany()` is a method from crud-ops (or ops.js) + // insertMany handles the WAL logging and statistics internally + await this.insertMany(jsonData); + } catch (error: any) { + logger.error(`[Data Exchange] Error during import operation (mode: ${mode}):`, error); + throw error; + } +} diff --git a/src/lib/collection/events.ts b/src/lib/collection/events.ts new file mode 100644 index 0000000..960e72a --- /dev/null +++ b/src/lib/collection/events.ts @@ -0,0 +1,105 @@ +import logger from '../logger.js'; +import { CollectionEventMap } from '../types.js'; + + +/** + * EventEmitter class for local events in a Collection. + */ +export class CollectionEventEmitter> { + // We use a more specific type than 'Function' to satisfy the linter + private _listeners: { [K in keyof T]?: Array<(...args: any[]) => void | Promise> } = {}; + private _collectionName: string; + + // New: Storage for the most recent payload of each event + private _eventBuffer: { [K in keyof T]?: T[K] } = {}; + + constructor(collectionName = 'unnamed') { + this._collectionName = collectionName; + } + + /** + * Subscribe to an event. + * @param eventName + * @param listener + */ + on(eventName: K, listener: (args: T[K]) => void | Promise): void { + if (typeof listener !== 'function') { + throw new Error(`Collection (${this._collectionName}): listener must be a function.`); + } + + + if (!this._listeners[eventName]) { + this._listeners[eventName] = []; + } + + this._listeners[eventName]!.push(listener); + + // --- NEW LOGIC: REPLAY --- + // If we have a buffered event of this type, send it to the new listener immediately + if (this._eventBuffer[eventName] !== undefined) { + const bufferedArgs = this._eventBuffer[eventName]!; + setTimeout(() => { // Using timeout to ensure listener is fully registered + try { + listener(bufferedArgs); + } catch (e) { + console.error("Error replaying buffered event", e); + } + }, 0); + } + } + + /** + * Unsubscribe from an event. If listener is not specified — removes all. + * @param eventName + * @param listener + */ + off(eventName: K, listener?: (args: T[K]) => void | Promise): void { + const listeners = this._listeners[eventName]; + if (!listeners) return; + + if (!listener) { + delete this._listeners[eventName]; + } else { + this._listeners[eventName] = listeners.filter(l => l !== listener); + if (this._listeners[eventName]!.length === 0) { + delete this._listeners[eventName]; + } + } + } + + /** + * Emit an event. + * @param eventName + * @param args + */ + emit( + eventName: K, + ...args: T[K] extends void ? [] : [T[K]] + // args: T[K] + ): void { + // --- NEW LOGIC: BUFFERING --- + // Store the latest payload so future listeners can see it + (this._eventBuffer as any)[eventName] = args; + + const listeners = this._listeners[eventName]; + if (!listeners || listeners.length === 0) return; + + // Filter out undefined arguments as per original logic + const filteredArgs = args.filter(arg => arg !== undefined); + + for (const listener of listeners) { + try { + const result = listener(filteredArgs); + if (result instanceof Promise) { + result.catch(e => + logger.error(`Collection (${this._collectionName}) async event error '${String(eventName)}': ${e.message}`) + ); + } + } catch (e: any) { + logger.error(`Collection (${this._collectionName}) sync event error '${String(eventName)}': ${e.message}`); + } + } + } +} + +export default CollectionEventEmitter; diff --git a/src/lib/collection/file-lock.ts b/src/lib/collection/file-lock.ts new file mode 100644 index 0000000..cc54bc8 --- /dev/null +++ b/src/lib/collection/file-lock.ts @@ -0,0 +1,43 @@ +import lockfile from 'proper-lockfile'; +import { ReleaseLockFn } from '../types.js'; + + + +/** + * Acquires an exclusive file-system lock on a directory. + * Used to prevent race conditions during write operations across processes. + * @param dirPath - Path to the collection directory. + * @param options - Custom retry and timeout settings for the lock. + * @returns A promise resolving to a release function. + * @throws {Error} if the lock could not be obtained. + */ +export async function acquireCollectionLock( + dirPath: string, + options: lockfile.LockOptions = {} +): Promise { + return lockfile.lock(dirPath, { + retries: { + retries: 10, + factor: 1.5, + minTimeout: 100, + maxTimeout: 1000 + }, + stale: 60000, + ...options + }); +} + +/** + * Safely releases a previously acquired file-system lock. + * Prevents errors if the lock was already released or never established. + * @param releaseLock - The release function returned by acquireCollectionLock. + */ +export async function releaseCollectionLock(releaseLock: ReleaseLockFn | undefined | null): Promise { + if (releaseLock) { + try { + await releaseLock(); + } catch { + // Errors during release are ignored to prevent process interruption + } + } +} diff --git a/src/lib/collection/indexes.ts b/src/lib/collection/indexes.ts new file mode 100644 index 0000000..d0d7e50 --- /dev/null +++ b/src/lib/collection/indexes.ts @@ -0,0 +1,273 @@ +import { IndexDefinition, IndexOptions, IndexMetadata } from '../types.js'; + +/** + * Manages collection indexes. + */ +export class IndexManager { + public indexes: Map; // fieldName -> { type, data, fieldName } + private indexedFields: Set; + private collectionName: string; + private logger: any; + + /** + * @param {string} [collectionName='unknown'] - Collection name for logging. + * @param {object} [logger] - Logger instance. If not passed, a default logger will be used. + */ + constructor(collectionName = 'unknown', logger: any) { + this.collectionName = collectionName; + // +++ CHANGE: Store the passed logger or use fallback. + this.logger = logger || require('../logger'); + this.indexes = new Map(); + this.indexedFields = new Set(); + } + + /** + * Creates an index. + * @param {string} fieldName + * @param {{unique?: boolean}} [options] + */ + public createIndex(fieldName: string, options: IndexOptions = {}): void { + if (!fieldName || typeof fieldName !== 'string') { + this.logger.error( + `[IndexManager] fieldName must be a string for collection '${this.collectionName}', received: ${typeof fieldName} ('${fieldName}')`, + ); + throw new Error(`IndexManager: fieldName must be a non-empty string`); + } + + if (this.indexes.has(fieldName)) { + const existingIndex = this.indexes.get(fieldName)!; + const newIsUnique = options.unique === true; + const existingIsUnique = existingIndex.type === 'unique'; + + if (newIsUnique === existingIsUnique) { + this.logger.warn( + `[IndexManager] Index on field '${fieldName}' (type: ${existingIndex.type}) for collection '${this.collectionName}' already exists — skipping creation.`, + ); + return; + } else { + this.logger.error( + `[IndexManager] Attempted to change existing index type for field '${fieldName}' in collection '${this.collectionName}'. Existing: ${existingIndex.type}, New: ${newIsUnique ? 'unique' : 'standard'}. Delete the old index before creating a new one with a different type.`, + ); + throw new Error( + `IndexManager: index on field '${fieldName}' already exists with a different type. Delete it before recreating.`, + ); + } + } + + const isUnique = options.unique === true; + + const index: IndexDefinition = { + fieldName, + type: isUnique ? 'unique' : 'normal', + data: new Map(), // value -> ID or Set + }; + + this.indexes.set(fieldName, index); + this.indexedFields.add(fieldName); + this.logger.log( + `[IndexManager] Index on field '${fieldName}' (type: ${index.type}) for collection '${this.collectionName}' successfully created.`, + ); + } + + /** + * Deletes an index. + * @param {string} fieldName + */ + public dropIndex(fieldName: string): void { + if (!this.indexes.has(fieldName)) { + this.logger.warn( + `[IndexManager] Attempted to delete non-existent index on field '${fieldName}' for collection '${this.collectionName}'. Operation skipped.`, + ); + return; + } + this.indexes.delete(fieldName); + this.indexedFields.delete(fieldName); + this.logger.log( + `[IndexManager] Index on field '${fieldName}' for collection '${this.collectionName}' successfully deleted.`, + ); + } + + /** + * Returns meta-information about indexes. + * @returns {Array<{fieldName: string, type: string}>} + */ + public getIndexesMeta(): IndexMetadata[] { + return Array.from(this.indexes.values()).map((index) => ({ + fieldName: index.fieldName, + type: index.type, + })); + } + + /** + * Reconstructs indexes from data. + * @param {Map} documents + */ + public rebuildIndexesFromData(documents: Map): void { + for (const fieldName of this.indexedFields) { + const def = this.indexes.get(fieldName); + if (!def) { + this.logger.warn( + `[IndexManager] Index definition for field '${fieldName}' not found during rebuild in collection '${this.collectionName}'.`, + ); + continue; + } + def.data.clear(); + + for (const [id, doc] of documents.entries()) { + if (typeof doc !== 'object' || doc === null) continue; + this.insertIntoIndex(def, doc[fieldName], id, true); + } + } + } + + /** + * Updates indexes after insertion. + * @param {object} doc + */ + public afterInsert(doc: any): void { + if (typeof doc !== 'object' || doc === null) return; + for (const fieldName of this.indexedFields) { + const def = this.indexes.get(fieldName); + if (def) this.insertIntoIndex(def, doc[fieldName], doc._id); + } + } + + /** + * Updates indexes after removal. + * @param {object} doc + */ + public afterRemove(doc: any): void { + if (typeof doc !== 'object' || doc === null) return; + for (const fieldName of this.indexedFields) { + const def = this.indexes.get(fieldName); + if (def) this.removeFromIndex(def, doc[fieldName], doc._id); + } + } + + /** + * Updates indexes after an update. + * @param {object} oldDoc + * @param {object} newDoc + */ + public afterUpdate(oldDoc: any, newDoc: any): void { + if ( + typeof oldDoc !== 'object' || + oldDoc === null || + typeof newDoc !== 'object' || + newDoc === null + ) + return; + + for (const fieldName of this.indexedFields) { + const def = this.indexes.get(fieldName); + if (!def) continue; + + const oldVal = oldDoc[fieldName]; + const newVal = newDoc[fieldName]; + + if ( + oldVal !== newVal || + (Object.prototype.hasOwnProperty.call(newDoc, fieldName) && + oldDoc[fieldName] === undefined) || + (Object.prototype.hasOwnProperty.call(oldDoc, fieldName) && + newDoc[fieldName] === undefined) + ) { + // Remove old value from the index + this.removeFromIndex(def, oldVal, oldDoc._id); + + // Add new value to the index + this.insertIntoIndex(def, newVal, newDoc._id); + } + } + } + + /** + * Internal helper to handle the logic of adding a value to the index. + */ + private insertIntoIndex( + def: IndexDefinition, + value: any, + docId: string, + isRebuild = false, + ): void { + if (def.type === 'unique') { + if (value !== undefined && value !== null) { + if (def.data.has(value) && def.data.get(value) !== docId) { + const errorMsg = `[IndexManager] ${isRebuild ? 'Uniqueness violation during rebuild' : 'CRITICAL ERROR: Duplicate value'} '${value}' in unique index '${def.fieldName}' (collection '${this.collectionName}') detected for ID '${docId}'.`; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + isRebuild ? this.logger.warn(errorMsg) : this.logger.error(errorMsg); + if (!isRebuild) return; + } + def.data.set(value, docId); + } + } else { + // standard + if (!def.data.has(value)) { + def.data.set(value, new Set()); + } + def.data.get(value).add(docId); + } + } + + /** + * Internal helper to handle the logic of removing a value from the index. + */ + private removeFromIndex( + def: IndexDefinition, + value: any, + docId: string, + ): void { + if (def.type === 'unique') { + if (value !== undefined && value !== null) { + if (def.data.get(value) === docId) { + def.data.delete(value); + } + } + } else { + // standard + const set = def.data.get(value); + if (set instanceof Set) { + set.delete(docId); + if (set.size === 0) def.data.delete(value); + } + } + } + + /** + * Index lookup (unique). + * @param {string} fieldName + * @param {any} value + * @returns {string|null} - ID or null + */ + public findOneIdByIndex(fieldName: string, value: any): string | null { + const def = this.indexes.get(fieldName); + if (!def || def.type !== 'unique') { + return null; + } + return def.data.get(value) || null; + } + + /** + * Index lookup (standard). + * @param {string} fieldName + * @param {any} value + * @returns {Set} - a set of IDs (can be empty). Returns a COPY to prevent mutation. + */ + public findIdsByIndex(fieldName: string, value: any): Set { + const def = this.indexes.get(fieldName); + if (!def || def.type !== 'normal') { + return new Set(); + } + const internalSet = def.data.get(value); + return internalSet ? new Set(internalSet) : new Set(); + } + + /** + * Clears all index data. + */ + public clearAllData(): void { + for (const def of this.indexes.values()) { + def.data.clear(); + } + } +} diff --git a/src/lib/collection/query.base.ts b/src/lib/collection/query.base.ts new file mode 100644 index 0000000..246c314 --- /dev/null +++ b/src/lib/collection/query.base.ts @@ -0,0 +1,475 @@ +import { cleanupExpiredDocs, isAlive } from './ttl.js'; +import { matchFilter } from './utils.js'; +import { + Document, + Filter, + FilterQuery, + FindOneAndUpdateOptions, + Projection, + TTLDocument, + UpdateQuery, + UpdateResult, +} from '../types.js'; +import { CollectionBase } from './base.js'; +import logger from '../logger.js'; + +export abstract class CollectionQueryBase< + T extends Document, +> extends CollectionBase { + // Internal helpers assumed to exist in QueryBase + // protected abstract cleanupExpiredDocs(): void; + // protected abstract isAlive(doc: T): boolean; + // --- Internal Helpers --- + + protected applyUpdateOperators(doc: T, updateQuery: UpdateQuery): T { + let newDoc = { ...doc }; + const hasOperators = Object.keys(updateQuery).some((key) => + key.startsWith('$'), + ); + + if (!hasOperators) { + const { _id, createdAt } = newDoc as any; + newDoc = { ...(updateQuery as any), _id, createdAt }; + return newDoc; + } + + for (const op in updateQuery) { + const opArgs = (updateQuery as any)[op]; + switch (op) { + case '$set': + Object.assign(newDoc, opArgs); + break; + case '$inc': + for (const field in opArgs) { + (newDoc as any)[field] = + ((newDoc as any)[field] || 0) + opArgs[field]; + } + break; + case '$unset': + for (const field in opArgs) { + delete (newDoc as any)[field]; + } + break; + case '$push': + for (const field in opArgs) { + if (!Array.isArray((newDoc as any)[field])) + (newDoc as any)[field] = []; + if (opArgs[field] && opArgs[field].$each) { + (newDoc as any)[field].push(...opArgs[field].$each); + } else { + (newDoc as any)[field].push(opArgs[field]); + } + } + break; + case '$pull': + for (const field in opArgs) { + if (Array.isArray((newDoc as any)[field])) { + (newDoc as any)[field] = (newDoc as any)[field].filter( + (item: any) => item !== opArgs[field], + ); + } + } + break; + } + } + return newDoc; + } + + protected applyProjection(doc: T, projection: Projection): any { + if (!projection || Object.keys(projection).length === 0) return doc; + + const newDoc: any = {}; + const values = Object.values(projection); + const hasInclusion = values.some((v) => v === 1); + const hasExclusion = values.some((v) => v === 0); + + if ( + hasInclusion && + hasExclusion && + !Object.prototype.hasOwnProperty.call(projection, '_id') + ) { + throw new Error( + 'Projection cannot have a mix of inclusion and exclusion.', + ); + } + + if (hasInclusion) { + for (const key in projection) { + if ( + (projection as any)[key] === 1 && + Object.prototype.hasOwnProperty.call(doc, key) + ) { + newDoc[key] = (doc as any)[key]; + } + } + if (projection['_id'] !== 0) newDoc._id = (doc as any)._id; + } else { + const excludedKeys = new Set( + Object.keys(projection).filter((k) => (projection as any)[k] === 0), + ); + for (const key in doc) { + if (!excludedKeys.has(key)) { + newDoc[key] = (doc as any)[key]; + } + } + } + return newDoc; + } + + // --- Public Query API --- + + public async getById(id: string): Promise { + const doc = this.documents.get(id); + return doc && isAlive(doc as TTLDocument) ? doc : null; + } + + public async getAll(): Promise { + cleanupExpiredDocs( + this.documents as Map, + this._indexManager, + ); + return Array.from(this.documents.values()); + } + + public async count(query?: FilterQuery): Promise { + cleanupExpiredDocs( + this.documents as Map, + this._indexManager, + ); + if (!query || Object.keys(query).length === 0) { + return this.documents.size; + } + const results = await this.find(query); + return results.length; + } + + public async find( + query: FilterQuery, + projection: Projection = {}, + ): Promise { + if (query) + cleanupExpiredDocs( + this.documents as Map, + this._indexManager, + ); + + if (typeof query === 'function') { + const docs = Array.from(this.documents.values()) + .filter((doc) => isAlive(doc as TTLDocument)) + .filter(query); + return docs.map((doc) => this.applyProjection(doc, projection)); + } + + if (typeof query === 'object' && query !== null) { + let bestIndexField: { field: string; type: 'exact' | 'range' } | null = + null; + let initialDocIds: Set | null = null; + + for (const fieldName in query) { + const condition = query[fieldName]; + if (this._indexManager.indexes.has(fieldName)) { + if (typeof condition !== 'object') { + bestIndexField = { field: fieldName, type: 'exact' }; + break; + } + if ( + typeof condition === 'object' && + Object.keys(condition).some((op) => + ['$gt', '$gte', '$lt', '$lte'].includes(op), + ) + ) { + if (!bestIndexField) + bestIndexField = { field: fieldName, type: 'range' }; + } + } + } + + if (bestIndexField) { + initialDocIds = new Set(); + const indexDef = this._indexManager.indexes.get(bestIndexField.field)!; + const condition = query[bestIndexField.field]; + + if (bestIndexField.type === 'exact') { + const ids = + indexDef?.type === 'unique' + ? [ + this._indexManager.findOneIdByIndex( + bestIndexField.field, + condition, + ), + ].filter(Boolean) + : this._indexManager.findIdsByIndex( + bestIndexField.field, + condition, + ); + ids.forEach((id: any) => initialDocIds!.add(id)); + } else if (bestIndexField.type === 'range') { + for (const [indexedValue, idsOrId] of indexDef.data.entries()) { + const pseudoDoc = { [bestIndexField.field]: indexedValue }; + if (matchFilter(pseudoDoc, { [bestIndexField.field]: condition })) { + if (indexDef.type === 'unique') initialDocIds.add(idsOrId); + else + (idsOrId as Set).forEach((id) => + initialDocIds!.add(id), + ); + } + } + } + } + + const results: any[] = []; + const source: any = + initialDocIds !== null + ? Array.from(initialDocIds) + .map((id) => this.documents.get(id)) + .filter(Boolean) + : this.documents.values(); + + for (const doc of source) { + if (isAlive(doc as TTLDocument) && matchFilter(doc, query)) { + results.push(this.applyProjection(doc, projection)); + } + } + return results; + } + throw new Error('find: query must be a function or a filter object.'); + } + + public async findOne( + query: FilterQuery, + projection: Projection = {}, + ): Promise { + if (typeof query === 'function') { + cleanupExpiredDocs( + this.documents as Map, + this._indexManager, + ); + for (const doc of this.documents.values()) { + if (isAlive(doc as TTLDocument) && query(doc)) { + return this.applyProjection(doc, projection); + } + } + return null; + } + const results = await this.find(query, projection); + return results.length > 0 ? results[0] : null; + } + + /** + * Finds a single document and updates it based on the provided filter and operators. + * * @template T - The document schema type. + * @param {Filter} filter - The selection criteria for the update. + * @param {UpdateQuery} updateQuery - The update operations to apply (e.g., $set, $inc). + * @param {FindOneAndUpdateOptions} [options={}] - Configuration options for the operation. + * @param {boolean} [options.returnOriginal=false] - If true, returns the document before the update was applied. + * @returns {Promise} The updated or original document, or null if no document matched the filter. + */ + async findOneAndUpdate( + filter: FilterQuery, + updateQuery: UpdateQuery, + options: FindOneAndUpdateOptions = { returnOriginal: false }, + ): Promise { + const { returnOriginal = false } = options; + + // Locate the document to be updated + const docToUpdate = await this.findOne(filter as FilterQuery); + + // If no match is found, return null immediately + if (!docToUpdate) { + return null; + } + + /** + * NOTE: We are currently using this.update for simplicity. + * In the future, we should call _enqueueDataModification directly to retrieve + * both the old and new documents within a single atomic operation for better performance. + */ + + // Calculate the new state of the document by applying atomic operators ($set, $inc, etc.) + const newDocData = this.applyUpdateOperators(docToUpdate, updateQuery as UpdateQuery); + + + // Execute the update via the core CRUD operation + const updatedDoc = await this.update(docToUpdate._id, newDocData); + + // Return the state requested by the user options + return returnOriginal ? docToUpdate : updatedDoc; + } + + // They are here cos of 'filter' + public async updateOne( + filter: FilterQuery, + updateQuery: UpdateQuery, + ): Promise<{ matchedCount: number; modifiedCount: number }> { + const docToUpdate = await this.findOne(filter); + if (!docToUpdate) return { matchedCount: 0, modifiedCount: 0 }; + + const newDocData = this.applyUpdateOperators(docToUpdate, updateQuery); + const updatedDoc = await this.update(docToUpdate._id, newDocData); + return { matchedCount: 1, modifiedCount: updatedDoc ? 1 : 0 }; + } + + /** + * Updates multiple documents in the collection matching the given filter. + * * This method is polymorphic and supports two distinct modes: + * 1. **Functional Mode**: Uses a predicate function to iterate over documents. + * In this mode, `update` is treated as a partial document replacement. + * 2. **Declarative Mode**: Uses a MongoDB-style Filter object. + * In this mode, `update` supports atomic operators ($set, $inc, etc.) via the base class. + * + * @param filter - A query object {@link Filter} or a synchronous predicate function. + * @param update - An {@link UpdateQuery} containing operators or a {@link Partial} document. + * @returns If a function is provided as a filter, returns the count of updated documents (`number`). + * If a filter object is provided, returns an {@link UpdateResult} object. + * @throws {Error} If an update operation fails during the iteration. + */ + public async updateMany( + filter: Filter | ((doc: T) => boolean), + update: UpdateQuery | Partial, + ): Promise { + // --- MODE 1: Functional Predicate --- + // If the filter is a function, we perform a manual scan of the in-memory documents. + if (typeof filter === 'function' && this.isPlainObject(update)) { + const idsToUpdate: string[] = []; + + // Collect IDs of all active documents that satisfy the predicate. + for (const [id, doc] of this.documents.entries()) { + // isAlive checks for expiration or pending deletion status. + if (isAlive(doc) && filter(doc)) { + idsToUpdate.push(id); + } + } + + // Short-circuit if no matching documents are found. + if (idsToUpdate.length === 0) { + return 0; + } + + let successfullyUpdatedCount = 0; + + // Perform sequential updates. This ensures the WAL and persistence + // layers handle each change correctly through the standard update path. + for (const id of idsToUpdate) { + try { + const updatedDoc = await this.update(id, update as Partial); + if (updatedDoc) { + successfullyUpdatedCount++; + } + } catch (error: any) { + logger.error( + `[Ops] Error updating document ID '${id}' in updateMany: ${error.message}`, + ); + // Re-throwing ensures the caller is aware the batch operation was interrupted. + throw error; + } + } + return successfullyUpdatedCount; + } + + // --- MODE 2: Declarative Object Filter --- + // If the filter is not a function, we delegate to the query engine to find documents. + // This path supports index optimization and atomic update operators ($set, $inc). + const docsToUpdate = await (this as any).find(filter); + let modifiedCount = 0; + + for (const doc of docsToUpdate) { + // Use updateOne to leverage operator logic ($set/$inc) and maintain index consistency. + const result = await (this as any).updateOne({ _id: doc._id }, update); + if (result.modifiedCount > 0) { + modifiedCount++; + } + } + + return { + matchedCount: docsToUpdate.length, + modifiedCount, + }; + } + + public async deleteOne( + filter: FilterQuery, + ): Promise<{ deletedCount: number }> { + const docToRemove = await this.findOne(filter); + if (!docToRemove) return { deletedCount: 0 }; + const success = await this.remove(docToRemove._id); + return { deletedCount: success ? 1 : 0 }; + } + + public async deleteMany( + filter: FilterQuery, + ): Promise<{ deletedCount: number }> { + const docsToRemove = await this.find(filter); + const idsToRemove = docsToRemove.map((d) => d._id); + if (idsToRemove.length === 0) return { deletedCount: 0 }; + + const deletedCount = await this.removeMany((doc: any) => + idsToRemove.includes(doc._id), + ); + return { deletedCount }; + } + + /** + * [Legacy] Finds the first document matching an indexed value. + * * @param fieldName - The indexed field to search. + * @param value - The value to look for. + */ + public async findByIndexedValue(fieldName: string, value: any): Promise { + // 1. Housekeeping: ensure we aren't returning dead docs + cleanupExpiredDocs(this.documents, this._indexManager); + + const index = this._indexManager.indexes.get(fieldName); + if (!index) { + logger.warn( + `No index found for field: ${fieldName}. Falling back to manual search.`, + ); + return this.find({ [fieldName]: value } as any); + } + + let idsToFetch: Set; + if (index.type === 'unique') { + idsToFetch = new Set(); + const id = this._indexManager.findOneIdByIndex(fieldName, value); + if (id) idsToFetch.add(id); + } else { + idsToFetch = this._indexManager.findIdsByIndex(fieldName, value); + } + + const result: T[] = []; + for (const id of idsToFetch) { + const doc = this.documents.get(id); + // Double check existence and TTL status + if (doc && isAlive(doc)) { + result.push(doc); + } + } + return result; + } + + /** + * [Legacy] Directly searches an index for documents with a specific field value. + * * @param fieldName - The indexed field to search. + * @param value - The value to look for. + */ + public async findOneByIndexedValue( + fieldName: string, + value: any, + ): Promise { + const index = this._indexManager.indexes.get(fieldName); + if (!index) return this.findOne({ [fieldName]: value } as any); + + if (index.type === 'unique') { + const id = this._indexManager.findOneIdByIndex(fieldName, value); + if (id) { + const potentialDoc = this.documents.get(id); + if (potentialDoc && isAlive(potentialDoc)) { + return potentialDoc; + } + } + return null; + } else { + // For non-unique, get the set and take the first valid one + const results = await this.findByIndexedValue(fieldName, value); + return results.length > 0 ? results[0] : null; + } + } +} diff --git a/src/lib/collection/queue.ts b/src/lib/collection/queue.ts new file mode 100644 index 0000000..1590d1a --- /dev/null +++ b/src/lib/collection/queue.ts @@ -0,0 +1,68 @@ +import { LockableCollection } from "../types.js"; + +/** + * Initializes a write queue on a collection instance. + * Ensures that all data modifications are executed sequentially to maintain consistency. + * @param collection - The collection instance to attach the queue to. + */ +export function createWriteQueue(collection: LockableCollection) { + // 1. Initialize the state on the collection as per original logic + // 1. Initialize the state on the collection as per original logic + collection["_writeQueue"] = [] as Array<{ + opFn: () => Promise, + resolve: (val: any) => void, + reject: (err: any) => void + }>; + collection["_writing"] = false; + + /** + * Processes tasks in the queue one by one. + * Automatically handles locking and unlocking for the duration of each operation. + */ + /** + * Processes tasks in the queue one by one. + * Automatically handles locking and unlocking for the duration of each operation. + */ + collection["_processQueue"] = async function (): Promise { + if (collection["_writing"] || collection["_writeQueue"].length === 0) return; + + collection["_writing"] = true; + const task = collection["_writeQueue"].shift(); + + if (!task) { + collection["_writing"] = false; + return; + } + + try { + await collection._acquireLock(); + const result = await task.opFn(); + task.resolve(result); + } catch (err) { + task.reject(err); + } finally { + await collection._releaseLockIfHeld(); + collection["_writing"] = false; + // Use setImmediate to prevent potential stack overflow on extremely large queues + setImmediate(() => collection["_processQueue"]()); + } + }; + + /** + * Adds an operation to the queue. + * @param opFn - operation function returning a Promise. + */ + /** + * Adds an operation to the queue. + * @param opFn - operation function returning a Promise. + */ + collection["_enqueue"] = function (opFn: () => Promise): Promise { + return new Promise((resolve, reject) => { + collection["_writeQueue"].push({ opFn, resolve, reject }); + collection["_processQueue"](); + }); + }; + + // 2. IMPORTANT: Return the function so "this._enqueue = createWriteQueue(this)" works. + return collection["_enqueue"]; +} diff --git a/src/lib/collection/transaction-manager.ts b/src/lib/collection/transaction-manager.ts new file mode 100644 index 0000000..2fbaf91 --- /dev/null +++ b/src/lib/collection/transaction-manager.ts @@ -0,0 +1,155 @@ +import { v4 as uuidv4 } from 'uuid'; +import logger from '../logger.js'; +import { WiseJSON } from '../index.js'; +import {TransactionState, TransactionOp, Document} from '../types.js'; + +// We import the type only, and use dynamic import for the implementation to avoid cycles +import type * as WalManagerType from '../wal-manager.js'; +import type * as CollectionType from './core.js'; + + +/** + * Manages atomic operations across one or multiple collections. + * Uses a two-phase approach: log all operations to WAL first, then apply to memory. + */ +export class TransactionManager { + public txid: string; + public state: TransactionState = 'pending'; + private _db: WiseJSON; + private _ops: TransactionOp[] = []; + private _collections: Record = {}; + + constructor(db: WiseJSON) { + this._db = db; + this.txid = `txn_${uuidv4()}`; + } + + /** + * Returns a proxy interface for a collection that queues operations + * instead of executing them immediately. + */ + public collection(name: string) { + if (!this._collections[name]) { + this._collections[name] = this._createCollectionProxy(name); + } + return this._collections[name] as CollectionType.Collection; + } + + private _createCollectionProxy(name: string) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + return { + insert(doc: T) { + self._ops.push({ colName: name, type: 'insert', args: [doc], ts: new Date().toISOString() }); + return Promise.resolve(); + }, + insertMany(docs: T[]) { + self._ops.push({ colName: name, type: 'insertMany', args: [docs], ts: new Date().toISOString() }); + return Promise.resolve(); + }, + update(id: string, updates: Partial) { + self._ops.push({ colName: name, type: 'update', args: [id, updates], ts: new Date().toISOString() }); + return Promise.resolve(); + }, + remove(id: string) { + self._ops.push({ colName: name, type: 'remove', args: [id], ts: new Date().toISOString() }); + return Promise.resolve(); + }, + clear() { + self._ops.push({ colName: name, type: 'clear', args: [], ts: new Date().toISOString() }); + return Promise.resolve(); + } + } as unknown as Partial; + } + + /** + * Commits the transaction. + * 1. Writes a transactional block to each collection's WAL. + * 2. Applies operations to in-memory documents. + */ + public async commit(): Promise { + if (this.state !== 'pending') { + throw new Error(`Transaction ${this.txid} already ${this.state}`); + } + + this.state = 'committing'; + + // Use dynamic import to prevent circular dependency with Collection/WiseJSON + // @ts-expect-error just leave it bro + const walManager: typeof WalManagerType = await import('../wal-manager') //require('../wal-manager.js'); + const groupedOps = this._groupOpsByCollection(); + + // Phase 1: Persistence (WAL) + for (const [colName, opsInCollection] of Object.entries(groupedOps)) { + try { + const collectionInstance = await this._db.getCollection(colName); + // opsInCollection now contains operations, each of which has its own 'ts' field + await walManager.writeTransactionBlock(collectionInstance.walPath, this.txid, opsInCollection, logger); + } catch (err: any) { + this.state = 'aborted'; + const errMsg = `TransactionManager: WAL write failed for '${this.txid}' in "${colName}": ${err.message}`; + logger.error(errMsg, err.stack); + throw new Error(errMsg); + } + } + + // Phase 2: Memory Application + for (const op of this._ops) { + try { + const collectionInstance = await this._db.getCollection(op.colName); + // initPromise should have already resolved above for each affected collection + switch (op.type) { + case 'insert': + await (collectionInstance as any)._applyTransactionInsert(op.args[0], this.txid); + break; + case 'insertMany': + await (collectionInstance as any)._applyTransactionInsertMany(op.args[0], this.txid); + break; + case 'update': + await (collectionInstance as any)._applyTransactionUpdate(op.args[0], op.args[1], this.txid); + break; + case 'remove': + await (collectionInstance as any)._applyTransactionRemove(op.args[0], this.txid); + break; + case 'clear': + await (collectionInstance as any)._applyTransactionClear(this.txid); + break; + default: + throw new Error(`Unknown transaction operation: ${op.type}`); + } + } catch (err: any) { + logger.error(`TransactionManager: Error applying ${op.type} for txid ${this.txid}. ${err.message}`, err.stack); + } + } + + this.state = 'committed'; + } + + /** + * Aborts the transaction and clears queued operations. + */ + public async rollback(): Promise { + if (this.state !== 'pending') { + if (this.state === 'committing' || this.state === 'committed') { + throw new Error(`Transaction ${this.txid} cannot be rolled back, state is ${this.state}`); + } + // If 'aborted', then a repeated rollback does nothing; you don't need to throw an error. + // logger.warn(`[TransactionManager] Rollback attempt on transaction ${this.txid} which is already ${this.state}.`); + return; // Already aborted or in progress/completed + } + + this.state = 'aborted'; + this._ops = []; + } + + private _groupOpsByCollection(): Record { + const grouped: Record = {}; + for (const op of this._ops) { // op here already contains 'ts' + if (!grouped[op.colName]) { + grouped[op.colName] = []; + } + grouped[op.colName].push(op); + } + return grouped; + } +} diff --git a/src/lib/collection/ttl.ts b/src/lib/collection/ttl.ts new file mode 100644 index 0000000..6c385c4 --- /dev/null +++ b/src/lib/collection/ttl.ts @@ -0,0 +1,71 @@ +/** + * Interface representing a document with potential TTL fields. + */ + +import { TTLDocument } from "../types.js"; +import { IndexManager } from "./indexes.js"; + +/** + * Determines if a document is still "alive" based on TTL or expireAt fields. + * If both criteria are missing, the document is considered permanent. + * Uses Object.prototype.hasOwnProperty.call to check for property existence. + * @param doc - The document to check. + * @returns True if alive, false if expired. + */ +export function isAlive(doc: TTLDocument): boolean { + if (!doc || typeof doc !== 'object') return false; + + // Check Absolute Expiration (expireAt) + if (Object.prototype.hasOwnProperty.call(doc, 'expireAt')) { + if (doc["expireAt"] !== null && doc["expireAt"] !== undefined) { + const exp = typeof doc["expireAt"] === 'string' ? Date.parse(doc["expireAt"]) : Number(doc["expireAt"]); + if (!isNaN(exp)) return Date.now() < exp; + } + } + + // Check Relative Expiration (ttl) + if (Object.prototype.hasOwnProperty.call(doc, 'ttl')) { + if (doc.ttl !== null && doc.ttl !== undefined) { + const createdAtStr = doc.createdAt; + if (!createdAtStr) return true; + const createdAtMs = Date.parse(createdAtStr); + const ttlMs = Number(doc.ttl); + if (isNaN(createdAtMs) || isNaN(ttlMs)) return true; + return Date.now() < (createdAtMs + ttlMs); + } + } + + return true; +} + +/** + * Scans a document Map and removes all expired entries. + * Updates associated indexes if an IndexManager is provided. + * @param documents - The collection's primary data storage. + * @param indexManager - Optional manager to update after deletions. + * @returns The total number of documents removed. + */ +export function cleanupExpiredDocs( + documents: Map, + indexManager?: IndexManager +): number { + let removedCount = 0; + if (!(documents instanceof Map)) return removedCount; + + const idsToRemove: string[] = []; + for (const [id, doc] of documents.entries()) { + if (!isAlive(doc)) idsToRemove.push(id); + } + + for (const id of idsToRemove) { + const docToRemove = documents.get(id); + if (docToRemove) { + documents.delete(id); + if (indexManager && typeof indexManager.afterRemove === 'function') { + indexManager.afterRemove(docToRemove); + } + removedCount++; + } + } + return removedCount; +} diff --git a/src/lib/collection/utils.ts b/src/lib/collection/utils.ts new file mode 100644 index 0000000..0f5cdba --- /dev/null +++ b/src/lib/collection/utils.ts @@ -0,0 +1,177 @@ +import path from 'path'; +import { CollectionOptions } from '../types.js'; +import defaultLogger from '../logger.js'; + +export interface QueryOperators { + $gt?: any; + $gte?: any; + $lt?: any; + $lte?: any; + $ne?: any; + $in?: any[]; + $nin?: any[]; + $exists?: boolean; + $regex?: string | RegExp; + $options?: string; +} + +export type Filter = { + [key: string]: any; + $or?: Filter[]; + $and?: Filter[]; +}; + +/** + * Generates a unique ID (short, simple). + */ +export const defaultIdGenerator = (): string => { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); +}; + +/** + * Checks if value is a non-empty string. + */ +export const isNonEmptyString = (value: any): value is string => { + return typeof value === 'string' && value.length > 0; +}; + +/** + * Checks if value is a plain object. + */ +export const isPlainObject = (value: any): value is Record => { + return Object.prototype.toString.call(value) === '[object Object]'; +}; + +/** + * Converts path to absolute path. + */ +export const makeAbsolutePath = (p: string): string => { + return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p); +}; + +/** + * Validates and fills collection options with defaults. + */ +export const validateOptions = (opts: CollectionOptions = {}): Required => { + return { + maxSegmentSizeBytes: opts.maxSegmentSizeBytes ?? 2 * 1024 * 1024, + checkpointIntervalMs: opts.checkpointIntervalMs ?? 60000, + ttlCleanupIntervalMs: opts.ttlCleanupIntervalMs ?? 60000, + walSync: opts.walSync ?? false, + + idGenerator: opts.idGenerator ?? defaultIdGenerator, + checkpointsToKeep: opts.checkpointsToKeep ?? 5, + maxWalEntriesBeforeCheckpoint: opts.maxWalEntriesBeforeCheckpoint ?? 1000, + walReadOptions: { recover: false, strict: false, ...opts.walReadOptions }, + logger: opts.logger ?? defaultLogger, + apiClient: opts.apiClient! + }; +}; + +/** + * Converts an array of documents to a CSV string. + */ +export const flattenDocToCsv = (docs: Record[]): string => { + if (!Array.isArray(docs) || docs.length === 0) return ''; + + const fields = Array.from(new Set(docs.flatMap(doc => Object.keys(doc)))); + + const escape = (v: any): string | any => { + if (typeof v === 'string' && (v.includes(',') || v.includes('"') || v.includes('\n'))) { + return `"${v.replace(/"/g, '""')}"`; + } + return v; + }; + + const header = fields.join(','); + const rows = docs.map(doc => + fields.map(f => escape(doc[f] ?? '')).join(',') + ); + + return [header, ...rows].join('\n'); +}; + +/** + * MongoDB-style filter matching logic. + */ +export const matchFilter = (doc: Record, filter: Filter): boolean => { + if (!isPlainObject(filter) || !isPlainObject(doc)) { + return false; + } + + if (Array.isArray(filter.$or)) { + return filter.$or.some(f => matchFilter(doc, f)); + } + + if (Array.isArray(filter.$and)) { + return filter.$and.every(f => matchFilter(doc, f)); + } + + for (const key of Object.keys(filter)) { + if (key === '$or' || key === '$and') continue; + + const cond = filter[key]; + const value = doc[key]; + + if (isPlainObject(cond)) { + for (const op of Object.keys(cond)) { + const opVal = (cond as QueryOperators)[op as keyof QueryOperators]; + let match = true; + + switch (op) { + case '$gt': if (!(value > opVal)) match = false; break; + case '$gte': if (!(value >= opVal)) match = false; break; + case '$lt': if (!(value < opVal)) match = false; break; + case '$lte': if (!(value <= opVal)) match = false; break; + case '$ne': if (value === opVal) match = false; break; + case '$eq': if (value !== opVal) match = false; break; + + case '$in': { + if (!Array.isArray(opVal)) { + match = false; + } else if (Array.isArray(value)) { + match = value.some(item => (opVal as any[]).includes(item)); + } else { + match = (opVal as any[]).includes(value); + } + break; + } + case '$nin': { + if (!Array.isArray(opVal)) { + match = false; + } else if (Array.isArray(value)) { + match = !value.some(item => (opVal as any[]).includes(item)); + } else { + match = !(opVal as any[]).includes(value); + } + break; + } + case '$exists': + if ((value !== undefined) !== opVal) match = false; + break; + case '$regex': { + if (typeof value !== 'string') { + match = false; + } else { + try { + const re = new RegExp(opVal as string, (cond as any).$options || ''); + if (!re.test(value)) match = false; + } catch { + match = false; + } + } + break; + } + case '$options': break; // Handled by $regex + default: + match = false; + break; + } + if (!match) return false; + } + } else { + if (value !== cond) return false; + } + } + return true; +}; diff --git a/src/lib/collection/wal-ops.ts b/src/lib/collection/wal-ops.ts new file mode 100644 index 0000000..7eca1ab --- /dev/null +++ b/src/lib/collection/wal-ops.ts @@ -0,0 +1,181 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { isAlive } from './ttl.js'; +import { WalEntry, WalOpType } from '../types.js'; + + +/** + * Serializes a WAL entry to a JSON string with a trailing newline. + */ +export function walEntryToString(entry: WalEntry): string { + return JSON.stringify(entry) + '\n'; +} + +/** + * Reads and parses all entries from a physical WAL file. + */ +export async function readWalEntries(walFile: string): Promise { + try { + const raw = await fs.readFile(walFile, 'utf8'); + const lines = raw.trim().split('\n'); + const entries: WalEntry[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed)); + } catch { + // Ignore malformed JSON lines (potential partial writes) + } + } + return entries; + } catch (e: any) { + if (e.code === 'ENOENT') return []; + throw e; + } +} + +/** + * Resolves the WAL path from a collection object and reads its entries. + */ +export async function readWal(collection: any): Promise { + if (!collection) { + throw new Error('[readWal] Collection is undefined/null. Verify sync-manager call.'); + } + + // Attempt to resolve walPath from multiple common internal property names + const walPath = collection._walPath || + collection.walPath || + collection._wal?.path || + collection._wal?.walPath; + + if (!walPath) { + throw new Error( + `[readWal] Could not determine WAL path for collection: ${collection.name || 'unknown'}` + ); + } + + return readWalEntries(walPath); +} + +/** + * Factory for memory-application and data modification logic. + */ +export function createWalOps(context: { + documents: Map; + indexManager: any; + _emitter: any; + _updateIndexesAfterInsert?: (doc: any) => void; + _updateIndexesAfterRemove?: (doc: any) => void; + _updateIndexesAfterUpdate?: (oldDoc: any, newDoc: any) => void; + _triggerCheckpointIfRequired?: (entry: WalEntry) => void; + walPath: string; +}) { + const { documents, _emitter, walPath } = context; + + /** + * Directly updates the in-memory Map based on a WAL entry. + */ + function applyWalEntryToMemory(entry: WalEntry, emit = true): void { + switch (entry.op) { + case 'INSERT': { + const doc = entry.doc; + if (doc) { + documents.set(doc._id!, doc); + context._updateIndexesAfterInsert?.(doc); + if (emit) _emitter.emit('insert', doc); + } + break; + } + case 'BATCH_INSERT': { + const docs = Array.isArray(entry.docs) ? entry.docs : []; + for (const doc of docs) { + if (doc) { + documents.set(doc._id!, doc); + context._updateIndexesAfterInsert?.(doc); + if (emit) _emitter.emit('insert', doc); + } + } + break; + } + case 'UPDATE': { + const id = entry.id; + const prev = id ? documents.get(id) : null; + if (prev && isAlive(prev)) { + const updated = { ...prev, ...entry.data }; + documents.set(id!, updated); + context._updateIndexesAfterUpdate?.(prev, updated); + if (emit) _emitter.emit('update', updated, prev); + } + break; + } + case 'REMOVE': { + const id = entry.id; + const prev = id ? documents.get(id) : null; + if (prev) { + documents.delete(id!); + context._updateIndexesAfterRemove?.(prev); + if (emit) _emitter.emit('remove', prev); + } + break; + } + case 'CLEAR': { + const docsToRemove = Array.from(documents.values()); + documents.clear(); + if (context._updateIndexesAfterRemove) { + for (const doc of docsToRemove) context._updateIndexesAfterRemove(doc); + } + if (emit) _emitter.emit('clear'); + break; + } + } + } + + /** + * Enqueues a data modification: validates uniqueness, writes to WAL, then updates memory. + */ + async function enqueueDataModification( + entry: WalEntry, + opType: WalOpType, + getResult?: (err: Error | undefined, result: any) => T + ): Promise { + + // Uniqueness validation logic + if ((opType === 'INSERT' || opType === 'BATCH_INSERT') && context.indexManager) { + const docsToCheck = opType === 'INSERT' ? [entry.doc] : (entry.docs || []); + const uniqueIndexes = (context.indexManager.getIndexesMeta() || []).filter((m: any) => m.type === 'unique'); + + for (const doc of docsToCheck) { + for (const idx of uniqueIndexes) { + const val = doc?.[idx.fieldName]; + if (val !== undefined && val !== null) { + const indexData = context.indexManager.indexes.get(idx.fieldName)?.data; + if (indexData?.has(val) && indexData.get(val) !== doc?._id) { + throw new Error(`Duplicate value '${val}' for unique index '${idx.fieldName}'`); + } + } + } + } + } + + // Persist to disk + await fs.mkdir(path.dirname(walPath), { recursive: true }); + await fs.appendFile(walPath, walEntryToString(entry), 'utf8'); + + // Update memory + applyWalEntryToMemory(entry, true); + + // Maintenance + context._triggerCheckpointIfRequired?.(entry); + + // Resolve result + const next = opType === 'REMOVE' ? null : (entry.doc || entry.docs || documents.get(entry.id!)); + return getResult ? getResult(undefined, next) : undefined; + } + + return { + applyWalEntryToMemory, + enqueueDataModification, + }; +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..12626eb --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,59 @@ +/** + * Base class for all custom errors generated by WiseJSON DB. + * Allows catching all library errors via `catch (e) { if (e instanceof WiseJSONError) ... }`. + */ +export class WiseJSONError extends Error { + constructor(message: string) { + super(message); + // Explicitly set the prototype to ensure instanceof works correctly in TS/ES5 + Object.setPrototypeOf(this, new.target.prototype); + this.name = this.constructor.name; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +/** + * Error occurring when a unique index constraint is violated. + */ +export class UniqueConstraintError extends WiseJSONError { + public readonly fieldName: string; + public readonly value: any; + + /** + * @param fieldName - Name of the field with the unique index. + * @param value - The value that caused the conflict. + */ + constructor(fieldName: string, value: any) { + const valueStr = typeof value === 'string' ? `'${value}'` : String(value); + super(`Duplicate value ${valueStr} for unique index on field '${fieldName}'.`); + this.fieldName = fieldName; + this.value = value; + } +} + +/** + * Error occurring when a requested document is not found. + */ +export class DocumentNotFoundError extends WiseJSONError { + public readonly docId: string; + + /** + * @param docId - The ID of the document that was not found. + */ + constructor(docId: string) { + super(`Document with ID '${docId}' not found.`); + this.docId = docId; + } +} + +/** + * Error related to incorrect configuration, options, or invalid API usage. + */ +export class ConfigurationError extends WiseJSONError { + constructor(message: string) { + super(message); + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..ce4a630 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,141 @@ + +import * as fs from 'fs/promises'; +import { Collection, } from './collection/core.js'; +import { TransactionManager } from './collection/transaction-manager.js'; +import { makeAbsolutePath, validateOptions } from './collection/utils.js'; +import logger from './logger.js'; +import { ConfigurationError, DocumentNotFoundError, UniqueConstraintError, WiseJSONError } from './errors.js'; +import { CollectionOptions, Document } from './types.js'; + + +const DEFAULT_PATH = process.env['WISE_JSON_PATH'] || makeAbsolutePath('wise-json-db-data'); + +export class WiseJSON { + private static _hasGracefulShutdown = false; + public dbRootPath: string; + private options: Required; + private collections: Record> = {}; + private _activeTransactions: TransactionManager[] = []; + private _isInitialized = false; + private _initPromise: Promise | null = null; + + constructor(dbRootPath: string = DEFAULT_PATH, options: Partial = {}) { + this.dbRootPath = makeAbsolutePath(dbRootPath); + // Use the utility to fill in defaults + this.options = options as unknown as Required; + + if (!WiseJSON._hasGracefulShutdown) { + this._setupGracefulShutdown(); + WiseJSON._hasGracefulShutdown = true; + } + } + + public async init(): Promise { + if (this._initPromise) return this._initPromise; + + this._initPromise = (async () => { + try { + await fs.mkdir(this.dbRootPath, { recursive: true }); + this._isInitialized = true; + logger.log(`[WiseJSON] Database at ${this.dbRootPath} initialized.`); + } catch (err) { + logger.error(`[WiseJSON] Critical error during database initialization:`, err); + this._initPromise = null; + throw err; + } + })(); + + return this._initPromise; + } + + private async _ensureInitialized(): Promise { + if (!this._isInitialized) await this.init(); + } + + /** + * Returns a collection instance (Generic). + */ + public async collection(name: string, options: Partial = {}): Promise> { + await this._ensureInitialized(); + if (!this.collections[name]) { + // Pass the fully validated options down + this.collections[name] = new Collection(name, this.dbRootPath, { + ...this.options, + ...options + }); + } + return this.collections[name] as Collection; + } + + /** + * Returns a fully initialized collection ready for operations. + */ + public async getCollection(name: string, options?: Partial): Promise> { + const instance = await this.collection(name, options); + await instance.isReady; + return instance; + } + + public async getCollectionNames(): Promise { + await this._ensureInitialized(); + try { + const items = await fs.readdir(this.dbRootPath, { withFileTypes: true }); + return items + .filter(item => + item.isDirectory() && + !item.name.startsWith('.') && + !item.name.endsWith('.lock') && + item.name !== '_checkpoints' && + item.name !== 'node_modules' + ) + .map(item => item.name); + } catch (e: any) { + if (e.code === 'ENOENT') return []; + throw e; + } + } + + public beginTransaction(): TransactionManager { + if (!this._isInitialized) { + throw new ConfigurationError("Database not initialized. Call db.init() first."); + } + const txn = new TransactionManager(this); + this._activeTransactions.push(txn); + return txn; + } + + public async close(): Promise { + if (this._initPromise) await this._initPromise; + + const allCollections = Object.values(this.collections); + for (const col of allCollections) { + // The TS Collection.close() is now an async method that flushes data + await col.close(); + } + logger.log(`[WiseJSON] Database at ${this.dbRootPath} closed.`); + } + + private _setupGracefulShutdown(): void { + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; + let isShuttingDown = false; + + const shutdownHandler = async () => { + if (isShuttingDown) return; + isShuttingDown = true; + try { + logger.log(`\n[WiseJSON] Graceful shutdown initiated...`); + await this.close(); + } catch (e) { + logger.error('[WiseJSON] Error during shutdown:', e); + } finally { + setTimeout(() => process.exit(0), 100); + } + }; + + signals.forEach(signal => process.on(signal, shutdownHandler)); + } + + public getActiveTransactions(): TransactionManager[] { + return this._activeTransactions.filter(txn => txn.state === 'pending'); + } +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..9c53116 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,93 @@ +/** + * wise-json/logger.ts + * Strongly typed logging utility with color support and level filtering. + */ +import { LogLevelName, colorMap, colors, levels } from "./types.js"; + +// --- Configuration --- +const envLevel = process.env["LOG_LEVEL"]?.toLowerCase(); +const defaultLogLevel: LogLevelName = process.env["NODE_ENV"] === 'production' ? 'warn' : 'log'; + +let currentLevelThreshold: number; + +if (envLevel === 'none') { + currentLevelThreshold = -1; +} else { + currentLevelThreshold = envLevel && levels[envLevel as LogLevelName] !== undefined + ? levels[envLevel as LogLevelName] + : levels[defaultLogLevel]; +} + +const NO_COLOR = process.env["LOG_NO_COLOR"] === 'true'; + +/** + * Safely converts arguments of any type to a readable string. + */ +function safeArgsToString(args: any[]): string { + try { + return args.map(arg => { + if (arg instanceof Error) return arg.stack || arg.message; + if (typeof arg === 'object' && arg !== null) { + try { + return JSON.stringify(arg); + } catch (e) { + return '[Unserializable Object]'; + } + } + return String(arg); + }).join(" "); + } catch (e) { + console.error('[Logger Internal Error] Failed to process arguments for logging:', e); + return '[Error processing log arguments]'; + } +} + +/** + * Formats the log message with timestamps and ANSI colors. + */ +function format(level: LogLevelName, msg: string): string { + const ts = new Date().toISOString(); + if (NO_COLOR) { + return `[${ts}] [${level.toUpperCase()}] ${msg}`; + } + const color = colorMap[level] || colors.reset; + return `${color}[${ts}] [${level.toUpperCase()}]${colors.reset} ${msg}`; +} + + + +export const logger = { + // Check the level BEFORE calling console + error(...args: any[]): void { + if (currentLevelThreshold >= levels.error) { + console.error(format("error", safeArgsToString(args))); + } + }, + + warn(...args: any[]): void { + if (currentLevelThreshold >= levels.warn) { + console.log(format("warn", safeArgsToString(args))); + } + }, + + log(...args: any[]): void { + if (currentLevelThreshold >= levels.log) { + console.log(format("log", safeArgsToString(args))); + } + }, + + debug(...args: any[]): void { + if (currentLevelThreshold >= levels.debug) { + console.log(format("debug", safeArgsToString(args))); + } + }, + + getLevel(): LogLevelName | 'none' { + return (Object.keys(levels).find(k => levels[k as LogLevelName] === currentLevelThreshold) as LogLevelName) || 'none'; + }, + + // Use Object.freeze to ensure the levels cannot be modified at runtime + levels: Object.freeze({ ...levels }) +}; + +export default logger; diff --git a/src/lib/storage-utils.ts b/src/lib/storage-utils.ts new file mode 100644 index 0000000..dfa4b90 --- /dev/null +++ b/src/lib/storage-utils.ts @@ -0,0 +1,128 @@ +import fs from 'fs/promises'; +import logger from './logger.js'; + +/** + * Checks if a file or directory exists at the given path. + * @param filePath - The path to check. + * @returns Promise resolving to true if path is accessible. + */ +export async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch (err: any) { + if (err.code === 'ENOENT') return false; + // ASSUMPTION: For other errors (e.g. access denied) return false, but log. + logger.warn(`[StorageUtils] Path "${filePath}" not accessible: ${err.code}`); + return false; + } +} + +/** + * Ensures a directory exists by creating it recursively if necessary. + * @param dirPath - Target directory path. + */ +export async function ensureDirectoryExists(dirPath: string): Promise { + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (err: any) { + if (err.code !== 'EEXIST') { + // ASSUMPTION: Directory creation error is critical, re-throwing the error. + logger.error(`[StorageUtils] Error creating directory "${dirPath}": ${err.message}`); + throw err; + } + } +} + +/** + * Writes data to a file atomically using a temporary file and rename strategy. + * This prevents data corruption if the system crashes during a write operation. + * @param filePath - Final destination path. + * @param data - The object to serialize as JSON. + * @param jsonIndent - Number of spaces for JSON formatting. + */ +export async function writeJsonFileSafe( + filePath: string, + data: any, + jsonIndent: number | null = null +): Promise { + const tmpPath = `${filePath}.${Date.now()}-${Math.random().toString(36).substring(2, 10)}.tmp`; + + try { + const json = JSON.stringify(data, null, jsonIndent ?? undefined); + await fs.writeFile(tmpPath, json, 'utf-8'); + try { + await fs.rename(tmpPath, filePath); + } catch (err: any) { + // ASSUMPTION: If renaming fails, we try to delete the tmp file and throw the error above. + logger.error(`[StorageUtils] Rename failed: ${tmpPath} -> ${filePath}`); + await deleteFileIfExists(tmpPath); + throw err; + } + } catch (err: any) { + // If we couldn't delete the tmp file, we only log it and don't throw the error again. + logger.error(`[StorageUtils] JSON write error for "${filePath}": ${err.message}`); + await deleteFileIfExists(tmpPath); + throw err; + } +} + +/** + * Reads and parses a JSON file from disk. + * @param filePath - Path to the file. + * @returns The parsed object, or null if the file does not exist. + * @throws {Error} if JSON parsing fails. + */ +export async function readJsonFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + try { + return JSON.parse(raw) as T; + } catch (parseErr: any) { + // ASSUMPTION: A corrupted JSON file is a fatal error. + logger.error(`[StorageUtils] JSON parse error in "${filePath}": ${parseErr.message}`); + throw parseErr; + } + } catch (err: any) { + // ASSUMPTION: Read errors other than ENOENT are critical, forwarding further. + if (err.code === 'ENOENT') return null; + throw err; + } +} + +/** + * Safely copies a file (e.g., for creating a backup). + * If the destination file already exists, it will be overwritten. + * @param src - Source file path. + * @param dst - Destination file path. + * @throws {Error} If the copy operation fails. + */ +export async function copyFileSafe(src: string, dst: string): Promise { + try { + await fs.copyFile(src, dst); + } catch (err: any) { + // ASSUMPTION: Copy errors are critical, propagate to the caller. + logger.error(`[StorageUtils] Copy error from "${src}" to "${dst}": ${err.message}`); + throw err; + } +} + +/** + * Deletes a file if it exists on the filesystem. + * @param filePath - Path to the file to be removed. + * @remarks Logs a warning but does not throw an error if deletion or existence check fails. + */ +export async function deleteFileIfExists(filePath: string): Promise { + try { + // We check existence first to avoid unnecessary error noise for missing files + if (await pathExists(filePath)) { + await fs.unlink(filePath); + } + } catch (err: any) { + /** + * ASSUMPTION: Failure to delete a file or check its presence is not + * critical to the core system flow; we log the warning and continue. + */ + logger.warn(`[StorageUtils] Failed to clean up file at "${filePath}": ${err.message}`); + } +} diff --git a/src/lib/sync/api-client.ts b/src/lib/sync/api-client.ts new file mode 100644 index 0000000..b4a44fe --- /dev/null +++ b/src/lib/sync/api-client.ts @@ -0,0 +1,161 @@ +import http from 'http'; +import { OutgoingHttpHeaders } from 'http2'; +import https from 'https'; +import { URL } from 'url'; +import { ApiEndpoints } from '../types.js'; + +/** + * Custom error class for API-related failures, including the HTTP status code. + */ +export class ApiError extends Error { + public statusCode?: number; + constructor(message: string, statusCode?: number) { + super(message); + this.name = 'ApiError'; + this.statusCode = statusCode; + } +} + +/** + * Low-level client for communicating with a remote WiseJSON server. + * Uses native Node.js modules to minimize external dependencies and overhead. + */ +export class ApiClient { + private readonly baseUrl: URL; + private readonly apiKey: string; + private readonly agent: typeof http | typeof https; + public readonly endpoints: ApiEndpoints; + + /** + * @param baseUrl - The full base URL of the server (e.g., 'https://api.wisejson.io'). + * @param apiKey - Authentication token. + * @param endpoints - Optional custom paths for sync operations. + */ + constructor(baseUrl: string, apiKey: string, endpoints: Partial = {}) { + if (!baseUrl || !apiKey) { + throw new Error('ApiClient requires baseUrl and apiKey for initialization.'); + } + + try { + this.baseUrl = new URL(baseUrl); + } catch (err) { + throw new Error(`Invalid baseUrl provided: ${baseUrl}`); + } + + this.apiKey = apiKey; + this.agent = this.baseUrl.protocol === 'https:' ? https : http; + + // IMPROVEMENT: Making endpoints configurable + this.endpoints = { + snapshot: '/sync/snapshot', + pull: '/sync/pull', + push: '/sync/push', + health: '/sync/health', + ...endpoints, + }; + } + + /** + * Internal core method for executing HTTP requests. + * @param method - HTTP Verb (GET, POST, etc.). + * @param path - The specific endpoint path. + * @param body - Optional JSON payload for the request. + */ + private _request(method: string, path: string, body: any = null): Promise { + return new Promise((resolve, reject) => { + // Safely join base pathname and request path + const baseStr = this.baseUrl.pathname.endsWith('/') + ? this.baseUrl.pathname.slice(0, -1) + : this.baseUrl.pathname; + + const fullPath = `${baseStr}${path}`; + + const options: https.RequestOptions = { + hostname: this.baseUrl.hostname, + port: this.baseUrl.port || (this.baseUrl.protocol === 'https:' ? 443 : 80), + path: fullPath, + method: method.toUpperCase(), + headers: { + 'Accept': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + timeout: 15000, // 15-second timeout for requests + }; + + if (body) { + (options.headers as OutgoingHttpHeaders)['Content-Type'] = 'application/json'; + } + + const req = this.agent.request(options, (res) => { + let responseData = ''; + res.setEncoding('utf8'); + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + // Handle HTTP error statuses + if (res.statusCode && res.statusCode >= 400) { + let errorMessage: string; + try { + const errorPayload = JSON.parse(responseData); + errorMessage = errorPayload.error || `Server returned ${res.statusCode}`; + } catch { + errorMessage = `Server returned ${res.statusCode}: ${responseData.substring(0, 100)}`; + } + return reject(new ApiError(errorMessage, res.statusCode)); + } + + // Handle successful empty responses (e.g., 204 No Content) + if (res.statusCode === 204 || !responseData) { + return resolve(null as any); + } + + try { + const parsedData = JSON.parse(responseData); + resolve(parsedData); + } catch (e) { + reject(new Error(`Failed to parse JSON response. Raw: ${responseData.substring(0, 100)}`)); + } + }); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timed out after 15 seconds.')); + }); + + req.on('error', (e) => { + reject(new Error(`Network error: ${e.message}`)); + }); + + if (body) { + try { + req.write(JSON.stringify(body)); + } catch (e: any) { + return reject(new Error(`Serialization error: ${e.message}`)); + } + } + + req.end(); + }); + } + + /** + * Performs a type-safe GET request. + * @param path - The target path. + */ + public async get(path: string): Promise { + return this._request('GET', path); + } + + /** + * Performs a type-safe POST request. + * @param path - The target path. + * @param body - The object to be sent as JSON. + */ + public async post(path: string, body: any): Promise { + return this._request('POST', path, body); + } +} diff --git a/wise-json/sync/sync-manager.js b/src/lib/sync/sync-manager.ts similarity index 69% rename from wise-json/sync/sync-manager.js rename to src/lib/sync/sync-manager.ts index 5efe0bd..3359143 100644 --- a/wise-json/sync/sync-manager.js +++ b/src/lib/sync/sync-manager.ts @@ -1,15 +1,32 @@ -// wise-json/sync/sync-manager.js - -const { v4: uuidv4 } = require('uuid'); -const EventEmitter = require('events'); -const { readWal } = require('../wal-manager.js'); +import { v4 as uuidv4 } from 'uuid'; +import EventEmitter from 'events'; +import { readWal } from '../wal-manager.js'; +import { SyncApiClient, SyncManagerEventMap, SyncManagerOptions, WalEntry } from '../types.js'; /** * SyncManager - orchestrates robust, two-way synchronization with a remote server. * Implements an advanced sync strategy including LSN-based delta sync, push batching, * adaptive intervals, heartbeats, and idempotent pushes. */ -class SyncManager extends EventEmitter { +export class SyncManager extends EventEmitter { + private collection: any; + private apiClient: SyncApiClient; + private logger: any; + private pushBatchSize: number; + private minSyncIntervalMs: number; + private maxSyncIntervalMs: number; + private heartbeatIntervalMs: number; + private autoStartLoop: boolean; + + private _state: 'stopped' | 'idle' | 'syncing' | 'error' = 'stopped'; + private _isSyncing = false; + private _initialSyncComplete = false; + private _timeoutId: NodeJS.Timeout | null = null; + + private lastKnownServerLSN = 0; + private currentInterval: number; + private lastActivityTime: number = Date.now(); + /** * @param {object} params * @param {import('../collection/core')} params.collection - The collection instance to sync. @@ -21,17 +38,19 @@ class SyncManager extends EventEmitter { * @param {number} [params.pushBatchSize=100] - The maximum number of operations to push in a single batch. * @param {boolean} [params.autoStartLoop=true] - Whether to start the sync loop automatically. */ - constructor({ - collection, - apiClient, - logger, - minSyncIntervalMs = 5000, - maxSyncIntervalMs = 60000, - heartbeatIntervalMs = 30000, - pushBatchSize = 100, - autoStartLoop = true, - }) { + constructor(options: SyncManagerOptions) { super(); + const { + collection, + apiClient, + logger, + minSyncIntervalMs = 5000, + maxSyncIntervalMs = 60000, + heartbeatIntervalMs = 30000, + pushBatchSize = 100, + autoStartLoop = true, + } = options; + if (!collection || !apiClient) { throw new Error('SyncManager requires both "collection" and "apiClient" instances.'); } @@ -55,7 +74,7 @@ class SyncManager extends EventEmitter { this.lastActivityTime = Date.now(); } - start() { + start(): void { if (this._state !== 'stopped') return; this.logger.log(`[SyncManager] Starting for collection '${this.collection.name}'.`); this._state = 'idle'; @@ -64,7 +83,7 @@ class SyncManager extends EventEmitter { } } - stop() { + stop(): void { if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; @@ -83,7 +102,7 @@ class SyncManager extends EventEmitter { }; } - async runSync() { + async runSync(): Promise { if (this._isSyncing || this._state === 'stopped') { return; } @@ -92,7 +111,7 @@ class SyncManager extends EventEmitter { this._isSyncing = false; } - _runLoop() { + private _runLoop(): void { if (this._state === 'stopped') return; this.runSync().finally(() => { @@ -102,33 +121,33 @@ class SyncManager extends EventEmitter { }); } - async _doSync() { + private async _doSync(): Promise { this._state = 'syncing'; this.emit('sync:start', { lsn: this.lastKnownServerLSN }); try { - // --- ИСПРАВЛЕННАЯ ЛОГИКА НАЧАЛЬНОЙ СИНХРОНИЗАЦИИ --- + // --- FIXED INITIAL SYNC LOGIC --- - // Проверяем, есть ли у нас локальные изменения, которые нужно отправить ПЕРЕД начальной синхронизацией. - const walEntries = await readWal(this.collection.walPath, null, { recover: true, logger: this.logger }); - const localWalEntries = walEntries.filter(entry => !entry._remote); + // Check if we have local changes that need to be sent BEFORE the initial sync. + const walEntries: WalEntry[] = await readWal(this.collection.walPath, null, { recover: true, logger: this.logger }); + const localWalEntries = walEntries.filter(entry => !(entry as any)._remote); - // Если у нас нет локальных изменений и мы ни разу не синхронизировались, - // то можно безопасно выполнить начальную полную синхронизацию (snapshot), которая перезатрет локальные данные. + // If we have no local changes and have never synced before, + // we can safely perform an initial full sync (snapshot), which will overwrite local data. if (!this._initialSyncComplete && localWalEntries.length === 0) { await this._performInitialSync(); } - - // В любом случае, после этого ставим флаг, что попытка начальной синхронизации была. - // Если у клиента были локальные данные, он пропустит snapshot и сразу перейдет к PULL/PUSH. - this._initialSyncComplete = true; - // --- КОНЕЦ ИСПРАВЛЕННОЙ ЛОГИКИ --- + // In any case, after this we set the flag indicating that an initial sync attempt occurred. + // If the client had local data, they will skip the snapshot and move directly to PULL/PUSH. + this._initialSyncComplete = true; - // Теперь выполняем стандартный цикл PULL -> PUSH + // --- END OF FIXED LOGIC --- + + // Now perform the standard PULL -> PUSH cycle const pullActivity = await this._performPull(); const pushActivity = await this._performPush(); - + const activityDetected = pullActivity || pushActivity; if (!activityDetected && Date.now() - this.lastActivityTime > this.heartbeatIntervalMs) { await this._performHeartbeat(); @@ -148,7 +167,7 @@ class SyncManager extends EventEmitter { activityDetected, }); - } catch (err) { + } catch (err: any) { this._state = 'error'; this.currentInterval = Math.min(this.currentInterval * 2, this.maxSyncIntervalMs); this.emit('sync:error', { @@ -158,7 +177,7 @@ class SyncManager extends EventEmitter { } } - async _performInitialSync() { + private async _performInitialSync(): Promise { this.emit('sync:initial_start'); try { const snapshot = await this.apiClient.get('/sync/snapshot'); @@ -167,7 +186,7 @@ class SyncManager extends EventEmitter { this.emit('sync:initial_complete', { message: 'Snapshot not available or invalid. Continuing with delta sync.' }); return; } - + await this.collection._internalClear(); await this.collection._internalInsertMany(snapshot.documents); @@ -178,12 +197,12 @@ class SyncManager extends EventEmitter { documentsLoaded: snapshot.documents.length, lsn: this.lastKnownServerLSN, }); - } catch (err) { + } catch (err: any) { throw new Error(`Initial sync failed: ${err.message}`); } } - async _performPull() { + private async _performPull(): Promise { const pullUrl = `/sync/pull?since_lsn=${this.lastKnownServerLSN}`; const response = await this.apiClient.get(pullUrl); @@ -204,9 +223,9 @@ class SyncManager extends EventEmitter { return true; } - async _performPush() { - const allWalEntries = await readWal(this.collection.walPath, null, { recover: true, logger: this.logger }); - const localWalEntries = allWalEntries.filter(entry => !entry._remote); + private async _performPush(): Promise { + const allWalEntries: WalEntry[] = await readWal(this.collection.walPath, null, { recover: true, logger: this.logger }); + const localWalEntries = allWalEntries.filter(entry => !(entry as any)._remote); if (localWalEntries.length === 0) { return false; @@ -220,7 +239,7 @@ class SyncManager extends EventEmitter { try { const response = await this.apiClient.post('/sync/push', { batchId, ops: batch }); - + if (typeof response.server_lsn === 'number') { this.lastKnownServerLSN = response.server_lsn; } @@ -230,28 +249,47 @@ class SyncManager extends EventEmitter { batchId: batchId, lsn: this.lastKnownServerLSN, }); - } catch (err) { + } catch (err: any) { allBatchesPushedSuccessfully = false; throw new Error(`Push failed on batch ${batchId}: ${err.message}`); } } - + if (allBatchesPushedSuccessfully) { await this.collection.compactWalAfterPush(); } - + return true; } - - async _performHeartbeat() { + + private async _performHeartbeat(): Promise { try { await this.apiClient.get('/sync/health'); this.lastActivityTime = Date.now(); this.emit('sync:heartbeat_success'); - } catch (err) { + } catch (err: any) { throw new Error(`Heartbeat failed: ${err.message}`); } } -} -module.exports = SyncManager; \ No newline at end of file + /** + * Strictly typed emit. + * Uses a rest parameter that is empty if the payload is 'void'. + */ + override emit( + event: K, + ...args: SyncManagerEventMap[K] extends void ? [] : [SyncManagerEventMap[K]] + ): boolean { + return super.emit(event as string, ...args); + } + + /** + * Strictly typed listener. + */ + override on( + event: K, + listener: (payload: SyncManagerEventMap[K]) => void + ): this { + return super.on(event as string, listener); + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..1610eb5 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,596 @@ +// ✅ Correct (Modern ESM style) +import { ApiClient } from './sync/api-client.js'; +/** + * Configuration for the collection's behavior and persistence. + */ +export interface CollectionOptions { + /** Maximum size of a data segment in bytes before rotation (default: 2MB). */ + maxSegmentSizeBytes?: number; + /** Frequency of automatic checkpoints in milliseconds (default: 5 minutes). */ + checkpointIntervalMs?: number; + /** Frequency of TTL (Time-To-Live) expiration checks (default: 1 minute). */ + ttlCleanupIntervalMs?: number; + /** Custom function to generate unique IDs for new documents. */ + idGenerator?: () => string; + /** Number of historical checkpoint files to retain on disk. */ + checkpointsToKeep?: number; + /** Maximum number of WAL entries before forcing a checkpoint. */ + maxWalEntriesBeforeCheckpoint?: number; + /** Options for how the Write-Ahead Log is read during recovery. */ + walReadOptions?: ReadWalOptions; + /** Custom logger instance (e.g., Winston or pino). */ + logger?: any; + + walSync?: boolean; + + apiClient?: ApiClient +} + +/** + * Real-time operational statistics for the collection. + */ +export interface CollectionStats { + /** Total number of successful inserts in the current session. */ + inserts: number; + /** Total number of successful updates in the current session. */ + updates: number; + /** Total number of document removals. */ + removes: number; + /** Number of times the collection has been cleared. */ + clears: number; + /** Count of WAL entries that haven't been merged into a checkpoint yet. */ + walEntriesSinceCheckpoint: number; + /** ISO timestamp of the last successful disk checkpoint. */ + lastCheckpointTimestamp: string | null; + /** Total number of documents currently in memory. */ + count: number; +} + +/** + * Internal representation of a transaction log entry in the WAL. + */ +export interface WalTransactionEntry { + /** Transaction identifier if this entry is part of a multi-op transaction. */ + txn?: string; + /** Flag indicating if the entry was recovered from disk during initialization. */ + _txn_applied_from_wal?: boolean; + /** The type of database operation performed. */ + type: 'insert' | 'insertMany' | 'update' | 'remove' | 'clear'; + /** Arguments passed to the operation (e.g., document data or ID). */ + args: any[]; + /** Unique transaction ID for cross-collection atomicity. */ + txid?: string; + /** The primary key of the document affected. */ + id?: string; +} + +/** + * Represents a generic document stored in the database. + * Users can extend this interface for their specific data models. + */ +export interface Document { + _id?: string; + createdAt?: string; + updatedAt?: string; + [key: string]: any; // Allow arbitrary fields (Schema-less) +} + +export interface TTLDocument extends Document { + ttl?: number | null; +} + + +/** * A predicate function for custom filtering logic. + * Returns true if the document matches the criteria. + */ +export type QueryPredicate = (doc: T) => boolean; + +type Primitives = string | number | boolean | Date | null; + +export type Filter = { + [P in keyof T]?: + | T[P] + | FilterOperators ? U : T[P]>; +} & { + $or?: Filter[]; + $and?: Filter[]; +};; + +interface FilterOperators { + $eq?: T; + $ne?: T; + $gt?: T; + $gte?: T; + $lt?: T; + $lte?: T; + $in?: T[]; // This now refers to the element type 'U' if the field was an array + $nin?: T[]; + $exists?: boolean; + $regex?: string | RegExp; +} + +export interface FindOneAndUpdateOptions{ + returnOriginal: boolean +} + +// export type Filter = { +// [P in keyof T]?: T[P] | QueryOperator; +// } & { +// $or?: Filter[]; +// $and?: Filter[]; +// }; + + +/** + * Union type for queries: + * Either a type-safe object filter or a custom predicate function. + */ +export type FilterQuery = Filter | QueryPredicate; + +/** + * Supported operations for the Write-Ahead Log. + */ +export type WalOpType = 'INSERT' | 'BATCH_INSERT' | 'UPDATE' | 'REMOVE' | 'CLEAR'; + +/** + * Represents a single line/entry in the WAL file. + */ +export interface WalEntry { + id?: string; // Used for UPDATE/REMOVE + op: WalOpType; // The operation type + doc?: T; // Used for single INSERT + docs?: T[]; // Used for BATCH_INSERT + data?: Partial; // Used for UPDATE payload + _txnId?: string; // Transaction ID (if part of a txn) +} + +/** + * Internal interface for the in-memory index structure. + */ +export interface IndexMeta { + fieldName: string; + type: 'normal' | 'unique'; +} + +/** + * Configuration options passed to the WiseJSON constructor. + */ +export interface WiseOptions { + checkpointInterval?: number; // Default: 30000ms + walThreshold?: number; // Default: 1000 entries + ttlCheckInterval?: number; // Default: 10000ms + verbose?: boolean; // Enable debug logging +} + + +/** + * Represents the lifecycle stages of an atomic transaction. + * * - `pending`: The initial state where operations are being queued in memory. + * - `committing`: The transition state while data is being flushed to the WAL (Write-Ahead Log). + * - `committed`: The final successful state; changes are permanent in memory and on disk. + * - `aborted`: The failure/rollback state; all queued changes have been discarded. + */ +export type TransactionState = 'pending' | 'committing' | 'committed' | 'aborted'; + +/** + * Represents a single logged operation within a transaction. + * These operations are buffered and applied atomically only upon a successful commit. + */ +export interface TransactionOp { + /** The name of the collection this operation targets. */ + colName: string; + + /** * The type of database modification to perform. + * Includes single/batch writes, updates, deletions, or collection wipes. + */ + type: 'insert' | 'insertMany' | 'update' | 'remove' | 'clear'; + + /** * The arguments passed to the operation (e.g., the document body, + * search filters, or unique identifiers). + */ + args: any[]; + + /** ISO 8601 timestamp indicating when this specific operation was queued. */ + ts: string; +} + + +/** + * Interface for collection instances that require exclusive access during + * critical operations (e.g., WAL writes or Checkpointing). + * * Implements a locking mechanism to prevent race conditions when multiple + * asynchronous operations or processes attempt to modify the same data file. + */ +export interface LockableCollection { + /** + * Attempts to acquire an exclusive lock on the collection resources. + * Returns a Promise that resolves once the lock is successfully granted. + * Usually relies on a file-system level lock (e.g., proper-lockfile). + */ + _acquireLock: () => Promise; + + /** + * Releases the currently held lock if it exists. + * This method is safe to call even if the lock has already been released + * or was never acquired, preventing "double-release" errors. + */ + _releaseLockIfHeld: () => Promise; + + /** * Flexible indexer to allow access to standard Collection methods + * and internal properties without strict type blocking. + */ + [key: string]: any; +} + +/** + * Interface for MongoDB-style update operators. + */ +export interface UpdateQuery { + $set?: Partial; + $inc?: Partial>; + $unset?: Partial>; + $push?: Partial>; + $pull?: Partial>; + [key: string]: any; +} + + + +/** + * Interface for Field Projections (1 for inclusion, 0 for exclusion). + */ +export type Projection = Partial>; + +/** + * Interface representing the internal state and methods of a Collection + * required for data operations. + */ +export interface CollectionOps { + documents: Map; + options: any; + _stats: { + inserts: number; + updates: number; + removes: number; + clears: number; + walEntriesSinceCheckpoint: number; + }; + isPlainObject: (obj: any) => boolean; + _idGenerator: () => string; + _enqueue: (task: () => Promise) => Promise; + _enqueueDataModification: ( + walPayload: any, + opType: string, + transformFn: (prev: any, next: any) => any, + meta?: any + ) => Promise; + _acquireLock: () => Promise; + _releaseLockIfHeld: () => Promise; +} + + +/** + * Internal representation of a collection index. + * Stores the mapping between document field values and their corresponding IDs. + */ +export interface IndexDefinition { + /** The name of the document field being indexed. */ + fieldName: string; + + /** * The constraint type: + * - `unique`: Ensures no two documents share the same value for this field. + * - `normal`: Allows multiple documents to share the same value. + */ + type: 'normal' | 'unique'; + + /** * The underlying data store for the index: + * - For `unique`: `Map` + * - For `normal`: `Map>` + */ + data: Map; +} + +/** + * Lightweight metadata describing an index, used for persistence and + * high-level index management without exposing the raw data Map. + */ +export interface IndexMetadata { + /** The name of the field this index tracks. */ + fieldName: string; + + /** The index constraint (unique or non-unique). */ + type: 'normal' | 'unique'; +} + +/** + * Configuration options provided by the user when creating a new index. + */ +export interface IndexOptions { + /** * If true, the database will throw an error if an insertion or update + * results in a duplicate value for the indexed field. + */ + unique?: boolean; +} + + +/** + * Type definition for the lock release function returned by proper-lockfile. + */ +export type ReleaseLockFn = () => Promise; + + +/** + * Type representing the mapping of event names to their argument arrays. + */ +export type CollectionEventMap = DataEvents & SyncManagerEventMap & OtherEventsMap; + +export interface DataEvents { + 'initialized': void; + + // Data Mutation + 'insert': { doc: T }; + 'insertMany': { docs: T[] }; + 'update': { id: string; oldDoc: T; newDoc: T }; + 'delete': { id: string; doc: T }; + 'remove': {doc: T} + + // Maintenance + 'clear': { clearedCount: number}; + 'index:rebuild': { fieldName: string }; + 'ttl:cleanup': { expiredCount: number }; + + // Persistence + 'flush': { timestamp: number; reason: string }; + 'closed': void +} + +export interface OtherEventsMap{ + 'checkpoint': { timestamp: string | number }, + 'trnx:clear': { clearedCount: number, _txn: string } +} + +/** + * Options for the import operation. + */ +export interface ImportOptions { + /** * 'append' adds documents to current state, + * 'replace' wipes the collection before inserting. + */ + mode?: 'append' | 'replace'; +} + +/** + * Interface representing the required collection methods for data exchange. + * This allows the functions to be bound to the main Collection class. + */ +export interface DataExchangeContext extends CollectionOps { + getAll: () => Promise<(T & Document)[]>; + insertMany: (docs: T[]) => Promise<(T & Document)[]>; + clear: () => Promise; +} + +export interface UpdateResult { + matchedCount: number; + modifiedCount: number; +} + + +/** + * Configuration for WiseJSON server endpoints. + */ +export interface ApiEndpoints { + snapshot: string; + pull: string; + push: string; + health: string; + [key: string]: string; +} + + +/** + * Interface for the API Client required by SyncManager. + */ +export interface SyncApiClient { + get: (url: string) => Promise; + post: (url: string, data: any) => Promise; +} + +/** + * Configuration options for the SyncManager. + */ +export interface SyncManagerOptions { + collection: any; + apiClient: SyncApiClient; + logger?: any; + minSyncIntervalMs?: number; + maxSyncIntervalMs?: number; + heartbeatIntervalMs?: number; + pushBatchSize?: number; + autoStartLoop?: boolean; +} + + +/** + * Metadata structure stored in checkpoint meta files. + */ +export interface CheckpointMeta { + timestamp: string; + documentCount: number; + indexesMeta: any[]; +} + +/** + * The result of a successful checkpoint load. + */ +export interface CheckpointLoadResult { + documents: Map; + indexesMeta: any[]; + timestamp: string | null; +} + + +/** + * Represents the available severity levels for the logger. + * Hierarchy: error (0) < warn (1) < log (2) < debug (3) + */ +export type LogLevelName = 'error' | 'warn' | 'log' | 'debug'; + +/** + * ANSI escape codes for terminal string styling. + * Used to provide visual distinction between log levels in the console. + */ +export const colors = { + reset: "\x1b[0m", + gray: "\x1b[90m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", +} as const; + +/** + * Numeric priority for each log level. + * Higher values indicate more verbose logging. + */ +export const levels: Record = { + error: 0, + warn: 1, + log: 2, + debug: 3, +}; + +/** + * Map of log levels to their respective ANSI color codes. + * Ensures consistent visual branding for different message types. + */ +export const colorMap: Record = { + error: colors.red, + warn: colors.yellow, + log: colors.cyan, + debug: colors.gray, +}; + + +/** + * Represents a single operation within a transaction block. + */ +export interface WalOp { + colName: string; + type: string; + args: any; + ts?: string; +} + +/** + * Options for reading the WAL file, allowing for strict validation or recovery modes. + */ +export interface ReadWalOptions { + strict?: boolean; + recover?: boolean; + isInitialLoad?: boolean; + logger?: any; + onError?: (err: Error, line: string, lineNum: number) => void; +} + +/** + * Internal state tracker for multi-line transactions during WAL replay. + */ +export interface ITransactionState { + ops: any[]; + committed: boolean; + startLine: number; + timestampStr?: string; + commitLine?: number; + commitTimestampStr?: string; +} + + +/** + * Context for where the sync failure occurred + */ +export type SyncPhase = 'initial_sync' | 'pull' | 'push' | 'heartbeat' | 'cycle_logic'; + +/** + * Configuration options for enabling collection synchronization. + */ +export interface SyncOptions { + url: string; + apiKey: string; + apiClient?: any; + autoStartLoop?: boolean; + syncIntervalMs?: number; + [key: string]: any; // Allows for additional options passed to SyncManager +} + +/** + * Represents the current state and metrics of the sync process. + */ +export interface SyncStatus { + state: 'disabled' | 'active' | 'error' | 'stopped' | 'idle' | 'syncing'; + isSyncing: boolean; + lastKnownServerLSN: number; + initialSyncComplete: boolean; + lastError?: string; +} + +export interface SyncManagerEventMap { + 'sync:start': { lsn: number }; + 'sync:success': { type: string; lsn: number; activityDetected: boolean }; + 'sync:error': { message: string; originalError: any }; + 'sync:initial_start': void; // No payload + 'sync:initial_complete': { message?: string; documentsLoaded?: number; lsn?: number }; + 'sync:pull_success': { pulled: number; lsn: number }; + 'sync:push_success': { pushed: number; batchId: string; lsn: number }; + 'sync:heartbeat_success': void; // No payload + 'sync:conflict_resolved': { type: string, reason: string, docId: string }, + 'sync:quarantine': { quarantinedAt:string, operation: WalOp, error: { message: string, stack: any } } +} +// Sync Lifecycle Payloads +/** 'sync:start' */ +export interface SyncStartPayload { + lsn: number; // The LSN the client is starting from +} + +/** 'sync:success' */ +export interface SyncSuccessPayload { + type: 'full_cycle_complete'; + lsn: number; // The new server LSN reached + activityDetected: boolean; // Whether any data was actually pulled or pushed +} + +/** 'sync:initial_start' (No payload emitted in current code) */ +export type SyncInitialStartPayload = void; + +/** 'sync:initial_complete' */ +export interface SyncInitialCompletePayload { + message?: string; // Used for fallback/warning messages + documentsLoaded?: number; // Total count of docs from snapshot + lsn?: number; // The LSN established by the snapshot +} + +// Operation-Specific Payloads +/** 'sync:pull_success' */ +export interface SyncPullSuccessPayload { + pulled: number; // Number of operations received from server + lsn: number; // The LSN returned by the server +} + +/** 'sync:push_success' */ +export interface SyncPushSuccessPayload { + pushed: number; // Number of operations in the batch + batchId: string; // The unique ID of the pushed batch + lsn: number; // The LSN returned by the server after processing +} + +/** 'sync:heartbeat_success' (No payload emitted in current code) */ +export type SyncHeartbeatSuccessPayload = void; + +// Error Payload +/** 'sync:error' */ +export interface SyncErrorPayload { + message: string; // Formatted error message + phase?: SyncPhase; // Which step failed + originalError: Error; // The raw Error object + batchId?: string; // Only present if failure happened during a Push batch + lsn?: number; // The LSN at the time of failure +} + diff --git a/wise-json/wal-manager.js b/src/lib/wal-manager.ts similarity index 56% rename from wise-json/wal-manager.js rename to src/lib/wal-manager.ts index 1ca1d1b..e2e764f 100644 --- a/wise-json/wal-manager.js +++ b/src/lib/wal-manager.ts @@ -1,24 +1,33 @@ -// wise-json/wal-manager.js +import fs from 'fs/promises'; +import path from 'path'; +import { WalOp, ReadWalOptions, ITransactionState } from './types.js'; -const fs = require('fs/promises'); -const path = require('path'); -// const logger = require('./logger'); // --- УДАЛЕНО +// wise-json/wal-manager.ts -function getWalPath(collectionDirPath, collectionName) { +/** + * Returns the standard file path for a collection's WAL file. + */ +export function getWalPath(collectionDirPath: string, collectionName: string): string { return path.join(collectionDirPath, `wal_${collectionName}.log`); } -// +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ -async function initializeWal(walPath, collectionDirPath, logger) { - const log = logger || require('./logger'); // Фоллбэк для обратной совместимости +/** + * Ensures the WAL file and its parent directory exist. + * +++ CHANGE: Added `logger` parameter +++ + * @param walPath - Full path to the WAL file. + * @param collectionDirPath - Directory containing the collection. + * @param logger - Logger instance. + */ +export async function initializeWal(walPath: string, collectionDirPath: string, logger: any): Promise { + const log = logger || require('./logger'); // Fallback for backward compatibility if (typeof walPath !== 'string') { - log.error(`[WAL Critical] initializeWal: walPath не является строкой! Тип: ${typeof walPath}, Значение: ${walPath}`); - throw new TypeError('walPath должен быть строкой в initializeWal'); + log.error(`[WAL Critical] initializeWal: walPath is not a string! Type: ${typeof walPath}, Value: ${walPath}`); + throw new TypeError('walPath must be a string in initializeWal'); } await fs.mkdir(collectionDirPath, { recursive: true }); try { await fs.access(walPath); - } catch (e) { + } catch (e: any) { if (e.code === 'ENOENT') { await fs.writeFile(walPath, '', 'utf8'); } else { @@ -27,29 +36,43 @@ async function initializeWal(walPath, collectionDirPath, logger) { } } -function delay(ms) { +/** + * Internal utility for async delays during retries. + */ +function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } -// +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ -async function appendAndSyncWalRecord(walPath, text, logger, appendRetries = 5, fsyncRetries = 3, fsyncInitialDelayMs = 100) { +/** + * Appends text to the WAL and forces a physical sync to disk (fsync). + * Includes retry logic for common filesystem errors (disk full, busy, etc.). + * +++ CHANGE: Added `logger` parameter +++ + */ +async function appendAndSyncWalRecord( + walPath: string, + text: string, + logger: any, + appendRetries = 5, + fsyncRetries = 3, + fsyncInitialDelayMs = 100 +): Promise { const log = logger || require('./logger'); const lineToWrite = text + '\n'; - let lastAppendError = null; + let lastAppendError: any = null; for (let i = 0; i <= appendRetries; i++) { try { await fs.appendFile(walPath, lineToWrite, 'utf8'); lastAppendError = null; break; - } catch (err) { + } catch (err: any) { lastAppendError = err; if (i < appendRetries && ['ENOSPC', 'EBUSY', 'EIO', 'EMFILE', 'EAGAIN'].includes(err.code)) { const wait = 100 * (i + 1); await delay(wait); continue; } else { - log.error(`[WAL] Ошибка appendFile для WAL '${walPath}' (после ${i + 1} попыток): ${lastAppendError?.message}`); + log.error(`[WAL] appendFile error for WAL '${walPath}' (after ${i + 1} attempts): ${lastAppendError?.message}`); throw lastAppendError; } } @@ -59,8 +82,8 @@ async function appendAndSyncWalRecord(walPath, text, logger, appendRetries = 5, throw lastAppendError; } - let fileHandle; - let lastSyncError = null; + let fileHandle: any; + let lastSyncError: any = null; let currentFsyncDelay = fsyncInitialDelayMs; for (let j = 0; j < fsyncRetries; j++) { @@ -70,9 +93,9 @@ async function appendAndSyncWalRecord(walPath, text, logger, appendRetries = 5, await fileHandle.sync(); lastSyncError = null; break; - } catch (syncErr) { + } catch (syncErr: any) { lastSyncError = syncErr; - log.warn(`[WAL] Ошибка sync для файла ${walPath} (попытка ${j + 1}/${fsyncRetries}): ${syncErr.message}`); + log.warn(`[WAL] Sync error for file ${walPath} (attempt ${j + 1}/${fsyncRetries}): ${syncErr.message}`); if (j < fsyncRetries - 1) { await delay(currentFsyncDelay); currentFsyncDelay = Math.min(currentFsyncDelay * 2, 2000); @@ -81,21 +104,25 @@ async function appendAndSyncWalRecord(walPath, text, logger, appendRetries = 5, if (fileHandle) { try { await fileHandle.close(); - } catch (closeErr) { - log.warn(`[WAL] Ошибка закрытия fileHandle после попытки sync для ${walPath}: ${closeErr.message}`); + } catch (closeErr: any) { + log.warn(`[WAL] Error closing fileHandle after sync attempt for ${walPath}: ${closeErr.message}`); } } } } if (lastSyncError) { - log.error(`[WAL] КРИТИЧЕСКАЯ ОШИБКА: не удалось выполнить sync для ${walPath} после ${fsyncRetries} попыток. Ошибка: ${lastSyncError?.message}.`); + log.error(`[WAL] CRITICAL ERROR: failed to perform sync for ${walPath} after ${fsyncRetries} attempts. Error: ${lastSyncError?.message}.`); throw lastSyncError; } } -// +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ -async function appendWalEntry(walPath, entry, logger) { +/** + * Writes a single entry to the WAL. + * +++ CHANGE: Added `logger` parameter +++ + */ +export async function appendWalEntry(walPath: string, entry: any, logger: any): Promise { + // eslint-disable-next-line no-useless-catch try { await appendAndSyncWalRecord(walPath, JSON.stringify(entry), logger); } catch (err) { @@ -103,10 +130,19 @@ async function appendWalEntry(walPath, entry, logger) { } } -// +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ -async function writeTransactionBlock(walPath, txid, ops, logger) { +/** + * Writes a block of operations wrapped in a transaction (START/OP/COMMIT). + * This ensures "all or nothing" durability during a recovery. + * +++ CHANGE: Added `logger` parameter +++ + */ +export async function writeTransactionBlock( + walPath: string, + txid: string, + ops: WalOp[], + logger: any +): Promise { const nowISO = new Date().toISOString(); - const block = []; + const block: any[] = []; block.push({ txn: 'start', id: txid, ts: nowISO }); for (const op of ops) { block.push({ @@ -122,6 +158,7 @@ async function writeTransactionBlock(walPath, txid, ops, logger) { const fullTextBlock = block.map(e => JSON.stringify(e)).join('\n'); + // eslint-disable-next-line no-useless-catch try { await appendAndSyncWalRecord(walPath, fullTextBlock, logger); } catch (err) { @@ -129,34 +166,40 @@ async function writeTransactionBlock(walPath, txid, ops, logger) { } } - -async function readWal(walPath, sinceTimestamp = null, options = {}) { - // +++ ИЗМЕНЕНИЕ: Получаем логгер из опций или используем фоллбэк +++ +/** + * Reads and parses the WAL file, reconstructing transactions and filtering by timestamp. + */ +export async function readWal( + walPath: string, + sinceTimestamp: string | null = null, + options: ReadWalOptions = {} +): Promise { + // +++ CHANGE: Get logger from options or use fallback +++ const log = options.logger || require('./logger'); const effectiveOptions = { strict: false, recover: false, isInitialLoad: false, ...options }; - - let rawContent; + + let rawContent: string; try { rawContent = await fs.readFile(walPath, 'utf8'); - } catch (e) { + } catch (e: any) { if (e.code === 'ENOENT') return []; throw e; } const lines = rawContent.trim().split('\n'); - const recoveredEntries = []; - const transactionStates = {}; + const recoveredEntries: any[] = []; + const transactionStates: Record = {}; - let cutoffDateTime = null; + let cutoffDateTime: number | null = null; if (sinceTimestamp) { try { cutoffDateTime = Date.parse(sinceTimestamp); if (isNaN(cutoffDateTime)) { - log.warn(`[WAL] Невалидный sinceTimestamp '${sinceTimestamp}' при чтении ${walPath}. Фильтрация по времени отключена.`); + log.warn(`[WAL] Invalid sinceTimestamp '${sinceTimestamp}' while reading ${walPath}. Time filtering disabled.`); cutoffDateTime = null; } - } catch (e) { - log.warn(`[WAL] Ошибка парсинга sinceTimestamp '${sinceTimestamp}' (${e.message}) при чтении ${walPath}. Фильтрация по времени отключена.`); + } catch (e: any) { + log.warn(`[WAL] Error parsing sinceTimestamp '${sinceTimestamp}' (${e.message}) while reading ${walPath}. Time filtering disabled.`); cutoffDateTime = null; } } @@ -167,7 +210,7 @@ async function readWal(walPath, sinceTimestamp = null, options = {}) { const MAX_LINE_LEN = 20 * 1024 * 1024; if (line.length > MAX_LINE_LEN) { - const msg = `[WAL] Строка ${currentLineNumber} в ${walPath} превышает лимит длины (${line.length} > ${MAX_LINE_LEN}), пропускается.`; + const msg = `[WAL] Line ${currentLineNumber} in ${walPath} exceeds length limit (${line.length} > ${MAX_LINE_LEN}), skipping.`; if (effectiveOptions.strict) { log.error(msg + " (strict mode)"); throw new Error(msg); @@ -176,28 +219,28 @@ async function readWal(walPath, sinceTimestamp = null, options = {}) { continue; } - let entry; + let entry: any; try { entry = JSON.parse(line); - } catch (e) { - const errorContext = `Ошибка парсинга JSON на строке ${currentLineNumber} в ${walPath}: ${e.message}.`; + } catch (e: any) { + const errorContext = `JSON parse error on line ${currentLineNumber} in ${walPath}: ${e.message}.`; const linePreview = line.substring(0, 150) + (line.length > 150 ? '...' : ''); if (typeof effectiveOptions.onError === 'function') { try { effectiveOptions.onError(e, line, currentLineNumber); } - catch (userCallbackError) { log.error(`[WAL] Ошибка в пользовательском onError callback: ${userCallbackError.message}`); } + catch (userCallbackError: any) { log.error(`[WAL] Error in user onError callback: ${userCallbackError.message}`); } } if (effectiveOptions.strict) { - log.error(errorContext + ` Содержимое (начало): "${linePreview}" (strict mode).`); + log.error(errorContext + ` Content (start): "${linePreview}" (strict mode).`); throw new Error(errorContext + ` (strict mode).`); } - log.warn(errorContext + ` Содержимое (начало): "${linePreview}" (строка пропущена).`); + log.warn(errorContext + ` Content (start): "${linePreview}" (line skipped).`); continue; } if (typeof entry !== 'object' || entry === null) { - log.warn(`[WAL] Запись на строке ${currentLineNumber} в ${walPath} не является объектом. Пропущена.`); + log.warn(`[WAL] Entry on line ${currentLineNumber} in ${walPath} is not an object. Skipped.`); continue; } @@ -206,13 +249,13 @@ async function readWal(walPath, sinceTimestamp = null, options = {}) { const txId = entry.id || entry.txid; if (!txId) { - log.warn(`[WAL] Транз. запись '${entry.txn}' без ID на строке ${currentLineNumber}. Игнор.`); + log.warn(`[WAL] Transaction entry '${entry.txn}' without ID on line ${currentLineNumber}. Ignored.`); continue; } if (entry.txn === 'start') { if (transactionStates[txId]) { - log.warn(`[WAL] Повтор TXN_START '${txId}' на стр ${currentLineNumber}. Старая отменена.`); + log.warn(`[WAL] Duplicate TXN_START '${txId}' on line ${currentLineNumber}. Old one cancelled.`); } transactionStates[txId] = { ops: [], committed: false, startLine: currentLineNumber, timestampStr: txTimestampStr }; } else if (entry.txn === 'op') { @@ -236,11 +279,11 @@ async function readWal(walPath, sinceTimestamp = null, options = {}) { let entryDateTime = entryTsSource ? Date.parse(entryTsSource) : null; - if (entryTsSource && isNaN(entryDateTime)) { + if (entryTsSource && isNaN(entryDateTime as number)) { entryDateTime = null; } - - if (cutoffDateTime !== null && (entryDateTime === null || entryDateTime <= cutoffDateTime)) { + + if (cutoffDateTime !== null && (entryDateTime === null || (entryDateTime as number) <= cutoffDateTime)) { continue; } recoveredEntries.push(entry); @@ -251,21 +294,21 @@ async function readWal(walPath, sinceTimestamp = null, options = {}) { const state = transactionStates[txid]; if (state.committed) { let txCommitDateTime = state.commitTimestampStr ? Date.parse(state.commitTimestampStr) : null; - if(state.commitTimestampStr && isNaN(txCommitDateTime)) txCommitDateTime = null; + if(state.commitTimestampStr && isNaN(txCommitDateTime as number)) txCommitDateTime = null; - if (cutoffDateTime !== null && (txCommitDateTime === null || txCommitDateTime <= cutoffDateTime)) { + if (cutoffDateTime !== null && (txCommitDateTime === null || (txCommitDateTime as number) <= cutoffDateTime)) { continue; } for (const op of state.ops) { recoveredEntries.push({ ...op, _txn_applied_from_wal: true, _tx_origin_id: txid }); } } else { - log.warn(`[WAL] Транзакция ${txid} (начата на строке ${state.startLine}) в ${walPath} не завершена (нет COMMIT) и проигнорирована.`); + log.warn(`[WAL] Transaction ${txid} (started on line ${state.startLine}) in ${walPath} is incomplete (no COMMIT) and ignored.`); } } - const logMsg = `[WAL] Завершено чтение ${walPath}. Обработано строк: ${lines.length}. Записей для применения: ${recoveredEntries.length}.` + - (sinceTimestamp ? ` (Фильтр по времени: после ${sinceTimestamp})` : ``); + const logMsg = `[WAL] WAL reading complete for ${walPath}. Lines processed: ${lines.length}. Entries to apply: ${recoveredEntries.length}.` + + (sinceTimestamp ? ` (Time filter: after ${sinceTimestamp})` : ``); if (effectiveOptions.isInitialLoad) { log.log(logMsg.replace('[WAL]', '[WAL Init]')); @@ -274,30 +317,33 @@ async function readWal(walPath, sinceTimestamp = null, options = {}) { return recoveredEntries; } -// +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ -async function compactWal(walPath, checkpointTimestamp = null, logger) { +/** + * Compacts the WAL by removing entries that occur before a specific checkpoint timestamp. + * +++ CHANGE: Added `logger` parameter +++ + */ +export async function compactWal(walPath: string, checkpointTimestamp: string | null = null, logger: any): Promise { const log = logger || require('./logger'); if (!checkpointTimestamp) { return; } - let checkpointTimeNum; + let checkpointTimeNum: number; try { checkpointTimeNum = Date.parse(checkpointTimestamp); if (isNaN(checkpointTimeNum)) { - log.error(`[WAL] Невалидный checkpointTimestamp '${checkpointTimestamp}' при компакции WAL ${walPath}. ОТМЕНА.`); + log.error(`[WAL] Invalid checkpointTimestamp '${checkpointTimestamp}' during WAL compaction ${walPath}. CANCELLED.`); return; } - } catch (e) { - log.error(`[WAL] Ошибка парсинга checkpointTimestamp '${checkpointTimestamp}' (${e.message}) при компакции WAL ${walPath}. ОТМЕНА.`); + } catch (e: any) { + log.error(`[WAL] Error parsing checkpointTimestamp '${checkpointTimestamp}' (${e.message}) during WAL compaction ${walPath}. CANCELLED.`); return; } const allCurrentWalEntries = await readWal(walPath, null, { recover: true, strict: false, logger: log }); - const entriesToKeep = []; + const entriesToKeep: any[] = []; for (const entry of allCurrentWalEntries) { - let entryTime = null; + let entryTime: number | null = null; if (entry._txn_applied_from_wal && entry.ts) { entryTime = Date.parse(entry.ts); } else if (!entry.txn) { @@ -327,36 +373,27 @@ async function compactWal(walPath, checkpointTimestamp = null, logger) { while (true) { try { await fs.writeFile(walPath, newWalContent, 'utf8'); - let fileHandleCompact; + let fileHandleCompact: any; try { fileHandleCompact = await fs.open(walPath, 'r+'); await fileHandleCompact.sync(); - } catch (syncErr) { - log.warn(`[WAL] Ошибка sync после перезаписи WAL ${walPath} при компакции: ${syncErr.message}`); + } catch (syncErr: any) { + log.warn(`[WAL] Sync error after overwriting WAL ${walPath} during compaction: ${syncErr.message}`); } finally { if (fileHandleCompact !== undefined) { - await fileHandleCompact.close().catch(closeErr => log.warn(`[WAL] Ошибка закрытия fileHandle WAL ${walPath} после sync в compactWal: ${closeErr.message}`)); + await fileHandleCompact.close().catch((closeErr: any) => log.warn(`[WAL] Error closing fileHandle for WAL ${walPath} after sync in compactWal: ${closeErr.message}`)); } } - log.log(`[WAL] Компакция WAL для ${walPath} завершена. Осталось ${cleanEntriesToKeep.length} записей (было до фильтрации: ${allCurrentWalEntries.length}).`); + log.log(`[WAL] WAL compaction for ${walPath} complete. Remaining entries: ${cleanEntriesToKeep.length} (was before filtering: ${allCurrentWalEntries.length}).`); break; - } catch (err) { + } catch (err: any) { attempt++; if (attempt < maxAttempts) { await delay(100 * attempt); } else { - log.error(`[WAL] КРИТ. ОШИБКА перезаписи WAL ${walPath} при компакции (после ${maxAttempts} попыток): ${err.message}.`); + log.error(`[WAL] CRITICAL ERROR overwriting WAL ${walPath} during compaction (after ${maxAttempts} attempts): ${err.message}.`); break; } } } } - -module.exports = { - getWalPath, - initializeWal, - readWal, - compactWal, - appendWalEntry, - writeTransactionBlock -}; \ No newline at end of file diff --git a/test/cli-all-exclude.ts b/test/cli-all-exclude.ts new file mode 100644 index 0000000..7f9e51d --- /dev/null +++ b/test/cli-all-exclude.ts @@ -0,0 +1,150 @@ +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as assert from 'assert'; + +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// --- Configuration --- +const DB_PATH = path.resolve(__dirname, 'cli-unified-db'); +// We use 'tsx' to run the source TypeScript file directly for testing +const CLI_ENTRY = path.resolve(__dirname, '../cli/index.ts'); +const CLI_COMMAND = `npx tsx ${CLI_ENTRY}`; + +const TEST_COLLECTION = 'unified_users'; +const DATA_FILE_PATH = path.join(__dirname, 'cliapi-import.json'); +const EXPORT_JSON_PATH = path.join(__dirname, 'cli-unified-export.json'); + +/** + * Clean up test artifacts + */ +function cleanUp(): void { + if (fs.existsSync(DB_PATH)) { + fs.rmSync(DB_PATH, { recursive: true, force: true }); + } + [EXPORT_JSON_PATH, DATA_FILE_PATH].forEach(file => { + if (fs.existsSync(file)) fs.unlinkSync(file); + }); +} + +/** + * Execute a CLI command and return stdout + */ +function runCli(command: string, options: { shouldFail?: boolean } = {}): string { + const env = { + ...process.env, + WISE_JSON_PATH: DB_PATH, + LOG_LEVEL: 'none', + NODE_OPTIONS: '--no-warnings' // Suppress experimental warnings + }; + + const fullCommand = `${CLI_COMMAND} ${command}`; + + try { + const stdout = execSync(fullCommand, { env, stdio: 'pipe' }).toString(); + + if (options.shouldFail) { + assert.fail(`Command "${command}" should have failed but succeeded.`); + } + return stdout.trim(); + } catch (error: any) { + if (!options.shouldFail) { + const stderr = error.stderr ? error.stderr.toString() : ''; + const stdout = error.stdout ? error.stdout.toString() : ''; + console.error(`❌ Command failed unexpectedly: ${fullCommand}`); + console.error(`Stdout: ${stdout}`); + console.error(`Stderr: ${stderr}`); + throw error; + } + return error.stderr ? error.stderr.toString().trim() : ''; + } +} + +async function main() { + console.log('🚀 Starting Unified CLI Integration Tests...'); + cleanUp(); + + try { + // --- 0. Data Preparation --- + const testUsers = Array.from({ length: 10 }, (_, i) => ({ + _id: `user${i}`, + name: `User ${i}`, + age: 20 + i, + city: i % 2 === 0 ? 'New York' : 'London', + tags: [`tag${i}`] + })); + fs.writeFileSync(DATA_FILE_PATH, JSON.stringify(testUsers)); + + // --- 1. Write Protection --- + console.log(' Testing write protection (read-only by default)...'); + runCli(`create-index ${TEST_COLLECTION} name`, { shouldFail: true }); + console.log(' ✅ Write protection works.'); + + // --- 2. Basic CRUD via CLI --- + console.log(' Testing import and collection visibility...'); + runCli(`import-collection ${TEST_COLLECTION} ${DATA_FILE_PATH} --allow-write`); + + const collections = runCli(`list-collections`); + assert.ok(collections.includes(TEST_COLLECTION), 'Collection should be visible in list'); + + const docsOutput = runCli(`show-collection ${TEST_COLLECTION}`); + const docs = JSON.parse(docsOutput); + assert.strictEqual(docs.length, 10, 'Should have imported 10 documents'); + + const user3 = JSON.parse(runCli(`get-document ${TEST_COLLECTION} user3`)); + assert.strictEqual(user3.name, 'User 3'); + console.log(' ✅ Basic operations passed.'); + + // --- 3. Filtering and Constraints --- + console.log(' Testing filters and limits...'); + + const filterObj = { city: 'New York' }; + // Handle cross-platform quoting for the JSON filter string + const filterArg = os.platform() === 'win32' + ? `"${JSON.stringify(filterObj).replace(/"/g, '\\"')}"` + : `'${JSON.stringify(filterObj)}'`; + + const filteredDocs = JSON.parse(runCli(`show-collection ${TEST_COLLECTION} --filter=${filterArg}`)); + assert.strictEqual(filteredDocs.length, 5, 'Should filter correctly to 5 NYC users'); + + const limited = JSON.parse(runCli(`show-collection ${TEST_COLLECTION} --limit=3`)); + assert.strictEqual(limited.length, 3, 'Limit flag failed'); + console.log(' ✅ Filtering passed.'); + + // --- 4. Index Management --- + console.log(' Testing index lifecycle...'); + runCli(`create-index ${TEST_COLLECTION} name --unique --allow-write`); + let indexes = JSON.parse(runCli(`list-indexes ${TEST_COLLECTION}`)); + assert.ok(indexes.some((i: any) => i.fieldName === 'name'), 'Index missing'); + + runCli(`drop-index ${TEST_COLLECTION} name --allow-write`); + indexes = JSON.parse(runCli(`list-indexes ${TEST_COLLECTION}`)); + assert.strictEqual(indexes.length, 0, 'Index not dropped'); + console.log(' ✅ Indexing passed.'); + + // --- 5. Destructive Operations --- + console.log(' Testing safe vs forced drops...'); + runCli(`collection-drop ${TEST_COLLECTION} --allow-write`, { shouldFail: true }); + runCli(`collection-drop ${TEST_COLLECTION} --allow-write --force`); + + const finalCols = runCli('list-collections'); + assert.ok(!finalCols.includes(TEST_COLLECTION), 'Collection was not dropped'); + console.log(' ✅ Forced drop passed.'); + + } finally { + cleanUp(); + } + + console.log('🎉 ALL CLI TESTS PASSED SUCCESSFULLY'); +} + +main().catch(err => { + console.error('\n🔥 TEST SUITE FAILED:', err); + cleanUp(); + process.exit(1); +}); diff --git a/test/cli-all.js b/test/cli-all.js deleted file mode 100644 index eca9f6d..0000000 --- a/test/cli-all.js +++ /dev/null @@ -1,146 +0,0 @@ -// test/cli-unified-all.js - -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const assert = require('assert'); - -// 1. Определяем константы -const DB_PATH = path.resolve(__dirname, 'cli-unified-db'); -const CLI_PATH = `node ${path.resolve(__dirname, '../cli/index.js')}`; -const TEST_COLLECTION = 'unified_users'; -const DATA_FILE_PATH = path.join(__dirname, 'cliapi-import.json'); // Входные данные -const EXPORT_JSON_PATH = path.join(__dirname, 'cli-unified-export.json'); // Выходные данные - -// 2. Вспомогательная функция для очистки -function cleanUp() { - // Удаляем директорию БД - if (fs.existsSync(DB_PATH)) { - fs.rmSync(DB_PATH, { recursive: true, force: true }); - } - // Удаляем файл экспорта - if (fs.existsSync(EXPORT_JSON_PATH)) { - fs.unlinkSync(EXPORT_JSON_PATH); - } - // *** ИСПРАВЛЕНИЕ: ДОБАВЛЯЕМ УДАЛЕНИЕ ФАЙЛА ИМПОРТА *** - if (fs.existsSync(DATA_FILE_PATH)) { - fs.unlinkSync(DATA_FILE_PATH); - } -} - -// 3. Главная вспомогательная функция для запуска CLI -function runCli(command, options = {}) { - // ... (без изменений) - const env = { ...process.env, WISE_JSON_PATH: DB_PATH, LOG_LEVEL: 'none' }; - const fullCommand = `${CLI_PATH} ${command}`; - try { - const stdout = execSync(fullCommand, { env, stdio: 'pipe' }).toString(); - if (options.shouldFail) { - assert.fail(`Command "${command}" should have failed but it succeeded.`); - } - return stdout.trim(); - } catch (error) { - if (!options.shouldFail) { - const stderr = error.stderr ? error.stderr.toString() : ''; - console.error(`Command failed unexpectedly: ${fullCommand}\nStderr: ${stderr}`); - throw error; - } - return error.stderr ? error.stderr.toString().trim() : ''; - } -} - -async function main() { - console.log('=== UNIFIED CLI ALL TEST START ==='); - // Вызываем очистку в самом начале на случай, если предыдущий запуск упал - cleanUp(); - - try { - // --- Подготовка данных для тестов --- - const testUsers = Array.from({ length: 10 }, (_, i) => ({ - _id: `user${i}`, - name: `User ${i}`, - age: 20 + i, - city: i % 2 === 0 ? 'New York' : 'London', - tags: [`tag${i}`] - })); - // Создаем временный файл с данными - fs.writeFileSync(DATA_FILE_PATH, JSON.stringify(testUsers)); - - // --- Тест 1: Защита от записи --- - console.log(' --- Testing write protection ---'); - runCli(`create-index ${TEST_COLLECTION} name`, { shouldFail: true }); - console.log(' --- Write protection PASSED ---'); - - // --- Тест 2: Базовые операции записи и чтения --- - console.log(' --- Testing basic write/read operations ---'); - runCli(`import-collection ${TEST_COLLECTION} ${DATA_FILE_PATH} --allow-write`); - - const collectionsOutput = runCli(`list-collections`); - assert.ok(collectionsOutput.includes(TEST_COLLECTION), 'list-collections should show the new collection'); - - const docsOutput = runCli(`show-collection ${TEST_COLLECTION}`); - const docs = JSON.parse(docsOutput); - assert.strictEqual(docs.length, 10, 'show-collection should return 10 documents'); - - const singleDoc = JSON.parse(runCli(`get-document ${TEST_COLLECTION} user3`)); - assert.strictEqual(singleDoc.name, 'User 3', 'get-document should retrieve the correct document'); - console.log(' --- Basic write/read operations PASSED ---'); - - // --- Тест 3: Фильтрация и опции --- - console.log(' --- Testing filtering and options ---'); - - const filterObject = { city: 'New York' }; - let filterArgument; - - if (os.platform() === 'win32') { - const escapedJson = JSON.stringify(filterObject).replace(/"/g, '\\"'); - filterArgument = `"${escapedJson}"`; - } else { - filterArgument = `'${JSON.stringify(filterObject)}'`; - } - - const filteredDocsOutput = runCli(`show-collection ${TEST_COLLECTION} --filter=${filterArgument}`); - - const filteredDocs = JSON.parse(filteredDocsOutput); - assert.strictEqual(filteredDocs.length, 5, 'Filtering by city should return 5 documents'); - assert.ok(filteredDocs.every(d => d.city === 'New York'), 'All filtered docs should be from New York'); - - const limitedOutput = runCli(`show-collection ${TEST_COLLECTION} --limit=3`); - assert.strictEqual(JSON.parse(limitedOutput).length, 3, 'Limit option should work'); - console.log(' --- Filtering and options PASSED ---'); - - // --- Тест 4: Управление индексами --- - console.log(' --- Testing index management ---'); - runCli(`create-index ${TEST_COLLECTION} name --unique --allow-write`); - const indexes = JSON.parse(runCli(`list-indexes ${TEST_COLLECTION}`)); - assert.ok(indexes.some(idx => idx.fieldName === 'name' && idx.type === 'unique'), 'Index should be created'); - - runCli(`drop-index ${TEST_COLLECTION} name --allow-write`); - const indexesAfterDrop = JSON.parse(runCli(`list-indexes ${TEST_COLLECTION}`)); - assert.strictEqual(indexesAfterDrop.length, 0, 'Index should be dropped'); - console.log(' --- Index management PASSED ---'); - - // --- Тест 5: Опасные операции и флаг --force --- - console.log(' --- Testing dangerous operations ---'); - runCli(`collection-drop ${TEST_COLLECTION} --allow-write`, { shouldFail: true }); - - runCli(`collection-drop ${TEST_COLLECTION} --allow-write --force`); - const collectionsAfterDrop = runCli('list-collections'); - assert.ok(!collectionsAfterDrop.includes(TEST_COLLECTION), 'Collection should be dropped with --force'); - console.log(' --- Dangerous operations PASSED ---'); - - } finally { - // Гарантированная очистка после выполнения всех тестов, даже если они упали - cleanUp(); - } - - console.log('=== UNIFIED CLI ALL TEST PASSED SUCCESSFULLY ==='); -} - -main().catch(err => { - console.error('\n🔥 UNIFIED CLI TEST FAILED:', err); - // Гарантированная очистка в случае глобальной ошибки - cleanUp(); - process.exit(1); -}); \ No newline at end of file diff --git a/test/cli-and-api-all-exclude.ts b/test/cli-and-api-all-exclude.ts new file mode 100644 index 0000000..6910254 --- /dev/null +++ b/test/cli-and-api-all-exclude.ts @@ -0,0 +1,210 @@ +import { execSync, spawn, ChildProcess } from 'child_process'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import path from 'path'; +import http from 'http'; +import assert from 'assert'; + +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// --- Configuration --- +const DB_PATH = path.resolve(__dirname, 'cli-and-api-db'); +// Ensure we use the .ts extension for tsx +const CLI_ENTRY = path.resolve(__dirname, '../cli/index.ts'); +const CLI_COMMAND = `npx tsx ${CLI_ENTRY}`; +// If explorer/server.ts is a TS file, run it with tsx as well +const SERVER_PATH = path.resolve(__dirname, '../explorer/server.ts'); +const SERVER_COMMAND = `npx tsx ${SERVER_PATH}`; + +const BASE_URL = 'http://127.0.0.1:3101'; +const TEST_COLLECTION = 'cliapi_users'; +const DATA_FILE = path.join(__dirname, 'cliapi-import.json'); +const EXPORT_JSON = path.join(__dirname, 'cliapi-export.json'); +const EXPORT_CSV = path.join(__dirname, 'cliapi-export.csv'); +const AUTH_USER = 'apitest'; +const AUTH_PASS = 'secret'; + +const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); + +/** + * Cleanup database and temp files. + */ +async function cleanUp(): Promise { + if (fs.existsSync(DB_PATH)) { + await fsp.rm(DB_PATH, { recursive: true, force: true }); + } + const tempFiles = [DATA_FILE, EXPORT_JSON, EXPORT_CSV]; + for (const file of tempFiles) { + if (fs.existsSync(file)) { + try { + await fsp.unlink(file); + } catch (e: any) { + // Ignore errors if file is already deleted or locked + } + } + } +} + +/** + * Execute a CLI command and return stdout. + */ +function runCli(command: string, opts: { shouldFail?: boolean } = {}): string { + const env = { + ...process.env, + WISE_JSON_PATH: DB_PATH, + LOG_LEVEL: 'none', + NODE_OPTIONS: '--no-warnings' + }; + const fullCommand = `${CLI_COMMAND} ${command}`; + try { + const stdout = execSync(fullCommand, { env, stdio: 'pipe' }).toString(); + if (opts.shouldFail) assert.fail(`Command "${command}" should have failed.`); + return stdout.trim(); + } catch (error: any) { + if (!opts.shouldFail) { + const stderr = error.stderr ? error.stderr.toString() : ''; + const stdout = error.stdout ? error.stdout.toString() : ''; + console.error(`❌ CLI Error: ${fullCommand}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); + throw error; + } + return error.stderr ? error.stderr.toString().trim() : ''; + } +} + +/** + * Simple HTTP helper for testing REST endpoints. + */ +function fetchJson(url: string, options: { auth?: boolean } = {}): Promise<{ status: number; data: any }> { + return new Promise((resolve, reject) => { + const httpOpts: http.RequestOptions = { + headers: {}, + method: 'GET' + }; + if (options.auth) { + const credentials = Buffer.from(`${AUTH_USER}:${AUTH_PASS}`).toString('base64'); + (httpOpts.headers as any)!['Authorization'] = `Basic ${credentials}`; + } + + const req = http.request(url, httpOpts, res => { + let data = ''; + res.on('data', chunk => (data += chunk)); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 400) { + return reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + try { + resolve({ status: res.statusCode || 0, data: JSON.parse(data) }); + } catch (e) { resolve({ status: res.statusCode || 0, data }); } + }); + }); + req.on('error', reject); + req.end(); + }); +} + +/** + * Wait for server to become responsive. + */ +async function waitServerStart(): Promise { + for (let i = 0; i < 50; i++) { + try { + await fetchJson(`${BASE_URL}/api/collections`, { auth: true }); + return; + } catch (e) { + await sleep(200); + } + } + throw new Error('Server timeout: 127.0.0.1:3101 is not responding.'); +} + +async function main() { + console.log('🚀 Starting Hybrid CLI/API Integration Tests...'); + await cleanUp(); + + let serverProc: ChildProcess | undefined; + + try { + // --- 1. CLI OPERATIONS --- + console.log(' Testing CLI Data Setup...'); + const testUsers = Array.from({ length: 30 }, (_, i) => ({ + _id: `user${i}`, + name: `User${i}`, + age: 20 + i, + group: i % 3 + })); + await fsp.writeFile(DATA_FILE, JSON.stringify(testUsers, null, 2)); + + // CLI logic: Import -> Export JSON -> Export CSV + runCli(`import-collection ${TEST_COLLECTION} ${DATA_FILE} --mode=replace --allow-write`); + runCli(`export-collection ${TEST_COLLECTION} ${EXPORT_JSON}`); + runCli(`export-collection ${TEST_COLLECTION} ${EXPORT_CSV} --output=csv`); + + assert.ok(fs.existsSync(EXPORT_JSON), 'JSON export failed'); + assert.ok(fs.existsSync(EXPORT_CSV), 'CSV export failed'); + console.log(' ✅ CLI Setup successful.'); + + // --- 2. API SERVER TESTS --- + console.log(' Starting Explorer Server...'); + // Split SERVER_COMMAND into binary and arguments for spawn + const [cmd, ...args] = SERVER_COMMAND.split(' '); + + serverProc = spawn(cmd, args, { + stdio: 'pipe', + env: { + ...process.env, + WISE_JSON_PATH: DB_PATH, + PORT: '3101', + LOG_LEVEL: 'none', + WISEJSON_AUTH_USER: AUTH_USER, + WISEJSON_AUTH_PASS: AUTH_PASS, + } + }); + + // Pipe server logs for better debugging if the server fails + serverProc.stdout?.on('data', (d) => { if(process.env['DEBUG']) console.log(`[Server]: ${d}`); }); + serverProc.stderr?.on('data', (d) => console.error(`[Server Error]: ${d}`)); + + await waitServerStart(); + + console.log(' Testing API Endpoints...'); + + // Check Collection List + const collections = await fetchJson(`${BASE_URL}/api/collections`, { auth: true }); + assert.ok(collections.data.some((c: any) => c.name === TEST_COLLECTION), 'Collection not found in API'); + + // Check Limit + const limitedDocs = await fetchJson(`${BASE_URL}/api/collections/${TEST_COLLECTION}?limit=5`, { auth: true }); + assert.strictEqual(limitedDocs.data.length, 5, 'API Limit filter failed'); + + // Check Auth + await assert.rejects( + fetchJson(`${BASE_URL}/api/collections`, { auth: false }), + /HTTP 401/, + 'API Security Breach: Accessed without credentials' + ); + + console.log(' ✅ API checks passed.'); + + } finally { + if (serverProc) { + console.log(' Stopping server...'); + serverProc.kill('SIGTERM'); + } + await sleep(500); + await cleanUp(); + } + + console.log('🎉 ALL HYBRID TESTS PASSED.'); +} + + + +main().catch(async err => { + console.error('\n🔥 TEST FAILURE:', err.message); + if (err.stack) console.error(err.stack); + process.exit(1); +}); diff --git a/test/cli-and-api-all.js b/test/cli-and-api-all.js deleted file mode 100644 index 6fe4290..0000000 --- a/test/cli-and-api-all.js +++ /dev/null @@ -1,159 +0,0 @@ -// test/cli-and-api-all.js - -const { execSync, spawn } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const http = require('http'); -const assert = require('assert'); - -const DB_PATH = path.resolve(__dirname, 'cli-and-api-db'); -// ИЗМЕНЕНИЕ: Путь к новому CLI -const CLI_PATH = `node ${path.resolve(__dirname, '../cli/index.js')}`; -const SERVER_PATH = path.resolve(__dirname, '../explorer/server.js'); -const BASE_URL = 'http://127.0.0.1:3101'; -const TEST_COLLECTION = 'cliapi_users'; -const DATA_FILE = path.join(__dirname, 'cliapi-import.json'); -const EXPORT_JSON = path.join(__dirname, 'cliapi-export.json'); -const EXPORT_CSV = path.join(__dirname, 'cliapi-export.csv'); -const AUTH_USER = 'apitest'; -const AUTH_PASS = 'secret'; - -const sleep = (ms) => new Promise(res => setTimeout(res, ms)); - -function cleanUp() { - if (fs.existsSync(DB_PATH)) { - fs.rmSync(DB_PATH, { recursive: true, force: true }); - } - [DATA_FILE, EXPORT_JSON, EXPORT_CSV].forEach(f => { - if (fs.existsSync(f)) { - try { - fs.unlinkSync(f); - } catch (e) { - console.warn(`Could not delete temp file ${f}: ${e.message}`); - } - } - }); -} - -function runCli(command, opts = {}) { - // Устанавливаем WISE_JSON_PATH для всех вызовов - const env = { ...process.env, WISE_JSON_PATH: DB_PATH, LOG_LEVEL: 'none' }; - const fullCommand = `${CLI_PATH} ${command}`; - try { - const stdout = execSync(fullCommand, { env, stdio: 'pipe' }).toString(); - if (opts.shouldFail) assert.fail(`Command "${command}" should have failed.`); - return stdout.trim(); - } catch (error) { - if (!opts.shouldFail) { - const stderr = error.stderr ? error.stderr.toString() : ''; - console.error(`Command failed unexpectedly: ${fullCommand}\n${stderr}`); - throw error; - } - return error.stderr ? error.stderr.toString().trim() : ''; - } -} - -function fetchJson(url, { auth } = {}) { - return new Promise((resolve, reject) => { - const opts = { headers: {} }; - if (auth) { - opts.headers['Authorization'] = `Basic ${Buffer.from(`${AUTH_USER}:${AUTH_PASS}`).toString('base64')}`; - } - http.get(url, opts, res => { - let data = ''; - res.on('data', chunk => (data += chunk)); - res.on('end', () => { - if (res.statusCode >= 400) { - return reject(new Error(`HTTP ${res.statusCode}: ${data}`)); - } - try { - resolve({ status: res.statusCode, data: JSON.parse(data) }); - } catch (e) { reject(e); } - }); - }).on('error', reject); - }); -} - -async function waitServerStart() { - for (let i = 0; i < 30; i++) { - try { - await fetchJson(`${BASE_URL}/api/collections`, { auth: true }); - return; - } catch (e) { await sleep(200); } - } - throw new Error('Server did not start in time.'); -} - -async function main() { - console.log('=== CLI AND API ALL TEST START ==='); - cleanUp(); - - let serverProc; - try { - // --- CLI PART --- - console.log(' --- Running CLI setup ---'); - const testUsers = Array.from({ length: 30 }, (_, i) => ({ name: `User${i}`, age: 20 + i, group: i % 3 })); - fs.writeFileSync(DATA_FILE, JSON.stringify(testUsers, null, 2)); - - // ИЗМЕНЕНИЕ: Используем синтаксис нового CLI - runCli(`import-collection ${TEST_COLLECTION} ${DATA_FILE} --mode=replace --allow-write`); - // Экспорт - операция чтения, --allow-write не нужен - runCli(`export-collection ${TEST_COLLECTION} ${EXPORT_JSON}`); - runCli(`export-collection ${TEST_COLLECTION} ${EXPORT_CSV} --output=csv`); - - assert(fs.existsSync(EXPORT_JSON), 'JSON export file should be created'); - assert(fs.existsSync(EXPORT_CSV), 'CSV export file should be created'); - console.log(' --- CLI setup PASSED ---'); - - // --- API PART --- - console.log(' --- Running API server tests ---'); - serverProc = spawn('node', [SERVER_PATH], { - stdio: 'pipe', - env: { - ...process.env, - WISE_JSON_PATH: DB_PATH, - PORT: '3101', - LOG_LEVEL: 'none', - WISEJSON_AUTH_USER: AUTH_USER, - WISEJSON_AUTH_PASS: AUTH_PASS, - } - }); - - serverProc.stderr.on('data', (data) => console.error(`Server stderr: ${data}`)); - - await waitServerStart(); - - const collections = await fetchJson(`${BASE_URL}/api/collections`, { auth: true }); - assert(collections.data.some(c => c.name === TEST_COLLECTION), 'API: test collection should exist'); - - // ВАЖНО: API сервера использует другой синтаксис фильтрации, это нормально - const docs = await fetchJson(`${BASE_URL}/api/collections/${TEST_COLLECTION}?limit=5`, { auth: true }); - assert.strictEqual(docs.data.length, 5, 'API: limit should work'); - - const byName = await fetchJson(`${BASE_URL}/api/collections/${TEST_COLLECTION}?filter_name=User5`, { auth: true }); - assert.ok(byName.data.length === 1 && byName.data[0].name === 'User5', 'API: filter_name should work'); - - await assert.rejects( - fetchJson(`${BASE_URL}/api/collections`), - /HTTP 401/, - 'API: Request without auth should be rejected with 401' - ); - console.log(' --- API server tests PASSED ---'); - - } finally { - if (serverProc) { - serverProc.kill(); - } - await sleep(200); - cleanUp(); - } - - console.log('=== CLI AND API ALL TEST PASSED ==='); -} - -main().catch(err => { - console.error('\n🔥 TEST FAILED:', err); - if (err.stack) console.error(err.stack); - console.error(`\n❗ Директория/файлы не были удалены для отладки: ${DB_PATH}`); - process.exit(1); -}); \ No newline at end of file diff --git a/test/db-advanced-scenarios.js b/test/db-advanced-scenarios.js deleted file mode 100644 index 5eee416..0000000 --- a/test/db-advanced-scenarios.js +++ /dev/null @@ -1,353 +0,0 @@ -// test/db-advanced-scenarios.js - -const path = require('path'); -const fs = require('fs/promises'); // Используем промисы для асинхронных операций fs -const assert = require('assert'); -const WiseJSON = require('../wise-json/index.js'); -const { cleanupExpiredDocs } = require('../wise-json/collection/ttl.js'); -const { getWalPath, initializeWal, appendWalEntry, readWal } = require('../wise-json/wal-manager.js'); -// loadLatestCheckpoint, cleanupOldCheckpoints будут вызываться неявно через API коллекции - -const DB_ROOT_PATH = path.resolve(__dirname, 'db-advanced-test-data'); -const COLLECTION_NAME = 'advanced_tests_col'; - -async function cleanUpDbDirectory(dbPath) { - try { - const exists = await fs.stat(dbPath).then(() => true).catch(() => false); - if (exists) { - await fs.rm(dbPath, { recursive: true, force: true }); - // console.log(`[Test Cleanup] Directory ${dbPath} removed.`); - } - } catch (error) { - if (error.code !== 'ENOENT') { - console.error(`[Test Cleanup] Error removing directory ${dbPath}:`, error); - } - } -} - -async function sleep(ms) { - return new Promise(res => setTimeout(res, ms)); -} - -async function testTtlEdgeCases() { - console.log(' --- Running TTL Edge Cases Test ---'); - const dbPath = path.join(DB_ROOT_PATH, 'ttl_edge'); - await cleanUpDbDirectory(dbPath); - - const db = new WiseJSON(dbPath, { ttlCleanupIntervalMs: 20000 }); - await db.init(); - const col = await db.collection(COLLECTION_NAME); - await col.initPromise; - - const now = Date.now(); - const createdAtISO = new Date(now).toISOString(); - - await col.insert({ _id: 'expired_past', data: 'past', expireAt: now - 10000 }); - await col.insert({ _id: 'invalid_expire', data: 'invalid', expireAt: 'not-a-date' }); - await col.insert({ _id: 'ttl_zero', data: 'zero_ttl', ttl: 0, createdAt: new Date(now - 1).toISOString() }); - await col.insert({ _id: 'ttl_short', data: 'short_ttl', ttl: 200, createdAt: createdAtISO }); - await col.insert({ _id: 'normal_doc', data: 'normal' }); - await col.insert({ _id: 'null_expire', data: 'null_expire', expireAt: null }); - await col.insert({ _id: 'undefined_ttl', data: 'undefined_ttl', ttl: undefined, createdAt: createdAtISO }); - - assert.strictEqual(col.documents.size, 7, 'Initial raw document count in map should be 7'); - assert.strictEqual(await col.count(), 5, 'Count after first cleanup (expired_past, ttl_zero removed)'); - - let docInvalid = await col.getById('invalid_expire'); - assert.ok(docInvalid, 'Document with invalid expireAt should remain after first count'); - let docShort = await col.getById('ttl_short'); - assert.ok(docShort, 'Document with short TTL should still be there'); - let docNormal = await col.getById('normal_doc'); - assert.ok(docNormal, 'Normal document should be there'); - let docNullExpire = await col.getById('null_expire'); - assert.ok(docNullExpire, 'Document with null expireAt should remain'); - let docUndefinedTtl = await col.getById('undefined_ttl'); - assert.ok(docUndefinedTtl, 'Document with undefined ttl should remain'); - - await sleep(300); - cleanupExpiredDocs(col.documents, col._indexManager); - - assert.strictEqual(await col.count(), 4, 'Final count after short TTL expired and explicit cleanup'); - - const docPast = await col.getById('expired_past'); - assert.strictEqual(docPast, null, 'Document with past expireAt should be removed'); - docInvalid = await col.getById('invalid_expire'); - assert.ok(docInvalid, 'Document with invalid expireAt should remain'); - assert.strictEqual(docInvalid.data, 'invalid', 'Invalid expireAt data check'); - const docTtlZero = await col.getById('ttl_zero'); - assert.strictEqual(docTtlZero, null, 'Document with ttl: 0 should be removed'); - const docTtlShortAfterWait = await col.getById('ttl_short'); - assert.strictEqual(docTtlShortAfterWait, null, 'Document with short ttl should be removed after wait'); - docNormal = await col.getById('normal_doc'); - assert.ok(docNormal, 'Normal document should still be there'); - docNullExpire = await col.getById('null_expire'); - assert.ok(docNullExpire, 'Document with null expireAt should still be there after all cleanups'); - docUndefinedTtl = await col.getById('undefined_ttl'); - assert.ok(docUndefinedTtl, 'Document with undefined ttl should still be there after all cleanups'); - - await db.close(); - await cleanUpDbDirectory(dbPath); - console.log(' --- TTL Edge Cases Test PASSED ---'); -} - -async function testCorruptedWalRecovery() { - console.log(' --- Running Corrupted WAL Recovery Test ---'); - const dbPath = path.join(DB_ROOT_PATH, 'wal_corrupt'); - await cleanUpDbDirectory(dbPath); - - const colDir = path.join(dbPath, COLLECTION_NAME); - await fs.mkdir(colDir, { recursive: true }); - - const walPath = getWalPath(colDir, COLLECTION_NAME); - await initializeWal(walPath, colDir); - - await appendWalEntry(walPath, { op: 'INSERT', doc: { _id: 'doc1', name: 'Valid Doc 1', value: 10 } }); - await appendWalEntry(walPath, { op: 'INSERT', doc: { _id: 'doc2', name: 'Valid Doc 2', value: 20 } }); - await fs.appendFile(walPath, 'this is not a valid json line that will be skipped\n', 'utf8'); - await appendWalEntry(walPath, { op: 'INSERT', doc: { _id: 'doc3', name: 'Valid Doc 3 After Corrupt', value: 30 } }); - await appendWalEntry(walPath, { op: 'UPDATE', id: 'doc1', data: { name: 'Updated Doc 1', value: 15 } }); - await appendWalEntry(walPath, { op: 'REMOVE', id: 'doc2' }); - - const db = new WiseJSON(dbPath, { walReadOptions: { recover: true, strict: false } }); - await db.init(); - const col = await db.collection(COLLECTION_NAME); - await col.initPromise; - - const count = await col.count(); - assert.strictEqual(count, 2, 'Should recover 2 documents after WAL processing (doc1 updated, doc2 removed, doc3 inserted)'); - - const doc1 = await col.getById('doc1'); - assert.ok(doc1, 'doc1 should be recovered'); - assert.strictEqual(doc1.name, 'Updated Doc 1', 'doc1 should be updated'); - assert.strictEqual(doc1.value, 15, 'doc1 value should be updated'); - - const doc2 = await col.getById('doc2'); - assert.strictEqual(doc2, null, 'doc2 should be removed'); - - const doc3 = await col.getById('doc3'); - assert.ok(doc3, 'doc3 (after corruption) should be recovered'); - assert.strictEqual(doc3.name, 'Valid Doc 3 After Corrupt', 'doc3 name check'); - - await db.close(); - await cleanUpDbDirectory(dbPath); - console.log(' --- Corrupted WAL Recovery Test PASSED ---'); -} - -async function testIndexEdgeCases() { - console.log(' --- Running Index Edge Cases Test ---'); - const dbPath = path.join(DB_ROOT_PATH, 'index_edge'); - await cleanUpDbDirectory(dbPath); - - const db = new WiseJSON(dbPath); - await db.init(); - const col = await db.collection(COLLECTION_NAME); - await col.initPromise; - - // 1. Создание индекса - await col.createIndex('email', { unique: false }); // Не уникальный для начала - let indexes = await col.getIndexes(); - assert.strictEqual(indexes.length, 1, 'Index should be created'); - assert.strictEqual(indexes[0].fieldName, 'email', 'Correct index fieldName'); - assert.strictEqual(indexes[0].type, 'standard', 'Index type should be standard'); - - // 2. Попытка создать существующий идентичный индекс (НЕ должна быть ошибка, должно быть предупреждение) - let errorThrownOnDuplicateCreate = false; - try { - await col.createIndex('email', { unique: false }); // Попытка создать такой же - // Ошибки не ожидается, если createIndex идемпотентен для идентичных определений - } catch (e) { - // Этот блок не должен выполняться, если createIndex не бросает ошибку на дубликате - errorThrownOnDuplicateCreate = true; - } - // ИЗМЕНЕНИЕ В ТЕСТЕ: Проверяем, что ошибка НЕ была выброшена - assert.strictEqual(errorThrownOnDuplicateCreate, false, 'Should NOT throw error when attempting to create an existing identical index definition. A warning should be logged instead.'); - indexes = await col.getIndexes(); - assert.strictEqual(indexes.length, 1, 'Index count should remain 1 after attempting to create an existing identical index'); - - // 2.1 Попытка создать индекс с тем же именем, но другим типом (unique: true) - ДОЛЖНА быть ошибка - let errorThrownOnTypeChange = false; - try { - await col.createIndex('email', { unique: true }); // Пытаемся изменить тип существующего - } catch (e) { - // Ожидаем ошибку, так как IndexManager должен предотвращать смену типа без удаления - assert.ok(e.message.toLowerCase().includes('already exists with a different type') || e.message.toLowerCase().includes('уже существует с другим типом'), 'Error message for type change attempt should be specific'); - errorThrownOnTypeChange = true; - } - assert.ok(errorThrownOnTypeChange, 'Should throw error when attempting to change the type of an existing index without deleting it first.'); - indexes = await col.getIndexes(); // Убедимся, что старый индекс остался - assert.strictEqual(indexes.length, 1, 'Index count should remain 1 after failed type change'); - assert.strictEqual(indexes[0].type, 'standard', 'Original index type (standard) should persist after failed type change'); - - - // 3. Удаление индекса - await col.dropIndex('email'); - indexes = await col.getIndexes(); - assert.strictEqual(indexes.length, 0, 'Index should be dropped'); - - // 4. Попытка удалить несуществующий индекс (не должно быть ошибки, просто ничего не делает, но выводится warn) - let errorThrownOnDropNonExistent = false; - try { - await col.dropIndex('non_existent_field'); - } catch (e) { - errorThrownOnDropNonExistent = true; - } - assert.strictEqual(errorThrownOnDropNonExistent, false, 'Dropping non-existent index should not throw error (a warning is expected).'); - indexes = await col.getIndexes(); - assert.strictEqual(indexes.length, 0, 'Dropping non-existent index should not change index list'); - - // 5. Создание уникального индекса - await col.createIndex('username', { unique: true }); - indexes = await col.getIndexes(); - assert.strictEqual(indexes.length, 1, 'Unique index should be created'); - assert.strictEqual(indexes[0].fieldName, 'username', 'Correct unique index fieldName'); - assert.strictEqual(indexes[0].type, 'unique', 'Index type should be unique'); - - await db.close(); - await cleanUpDbDirectory(dbPath); - console.log(' --- Index Edge Cases Test PASSED ---'); -} - -async function testEmptyDbOperations() { - console.log(' --- Running Empty DB Operations Test ---'); - const dbPath = path.join(DB_ROOT_PATH, 'empty_db_ops'); - await cleanUpDbDirectory(dbPath); - - const db = new WiseJSON(dbPath); - await db.init(); - - const names = await db.getCollectionNames(); - assert.deepStrictEqual(names, [], 'getCollectionNames on empty DB directory should return empty array'); - - const col = await db.collection('non_existent_col'); - await col.initPromise; - - const colPath = path.join(dbPath, 'non_existent_col'); - const colDirExists = await fs.stat(colPath).then(stat => stat.isDirectory()).catch(() => false); - assert.ok(colDirExists, 'Directory for new collection should be created'); - - assert.strictEqual(await col.count(), 0, 'Count on new empty collection should be 0'); - const doc = await col.getById('any_id'); - assert.strictEqual(doc, null, 'getById on empty collection should return null'); - - const col2 = await db.collection('another_col'); - await col2.initPromise; - await col2.insert({_id: 'test'}); - await col2.flushToDisk(); - - const updatedNames = (await db.getCollectionNames()).sort(); - assert.deepStrictEqual(updatedNames, ['another_col', 'non_existent_col'].sort(), 'getCollectionNames should list newly created collections'); - - await db.close(); - await cleanUpDbDirectory(dbPath); - console.log(' --- Empty DB Operations Test PASSED ---'); -} - -async function testSegmentedCheckpointCleanup() { - console.log(' --- Running Segmented Checkpoint Cleanup Test ---'); - const dbPath = path.join(DB_ROOT_PATH, 'checkpoint_cleanup_seg'); - await cleanUpDbDirectory(dbPath); - - const dbOptions = { - maxSegmentSizeBytes: 50, - checkpointsToKeep: 2, - checkpointIntervalMs: 5 * 60 * 1000, - }; - const db = new WiseJSON(dbPath, dbOptions); - await db.init(); - const col = await db.collection(COLLECTION_NAME); - await col.initPromise; - - for (let i = 0; i < 5; i++) { - await col.insert({ _id: `doc_seg_${i}`, text: `Document segment content part ${i} with enough text to exceed segment size potentially.` }); - } - - await col.flushToDisk(); - await sleep(20); - await col.insert({ _id: 'extra_doc_cp2', text: 'Another doc for checkpoint 2' }); - await col.flushToDisk(); - - await sleep(20); - await col.insert({ _id: 'extra_doc_cp3', text: 'Yet another doc for checkpoint 3' }); - await col.flushToDisk(); - - await sleep(20); - await col.insert({ _id: 'extra_doc_cp4', text: 'Final doc for checkpoint 4' }); - await col.flushToDisk(); - - const checkpointsDir = path.join(dbPath, COLLECTION_NAME, '_checkpoints'); - let files = []; - try { - files = await fs.readdir(checkpointsDir); - } catch (e) { - assert.fail(`Checkpoints directory not found: ${checkpointsDir}`); - } - - const metaFiles = files.filter(f => f.startsWith(`checkpoint_meta_${COLLECTION_NAME}_`) && f.endsWith('.json')); - const dataFiles = files.filter(f => f.startsWith(`checkpoint_data_${COLLECTION_NAME}_`) && f.endsWith('.json')); - - assert.strictEqual(metaFiles.length, dbOptions.checkpointsToKeep, `Should keep ${dbOptions.checkpointsToKeep} meta checkpoint files. Found: ${metaFiles.length} (${metaFiles.join(', ')})`); - - const keptTimestamps = new Set( - metaFiles.map(f => { - const match = f.match(new RegExp(`^checkpoint_meta_${COLLECTION_NAME}_(.+)\\.json$`)); - return match ? match[1] : null; - }).filter(Boolean) - ); - assert.strictEqual(keptTimestamps.size, dbOptions.checkpointsToKeep, 'Number of unique timestamps in kept meta files should match checkpointsToKeep'); - - for (const dataFile of dataFiles) { - const match = dataFile.match(new RegExp(`^checkpoint_data_${COLLECTION_NAME}_(.+)_seg\\d+\\.json$`)); - const dataTimestamp = match ? match[1] : null; - assert.ok(dataTimestamp, `Could not parse timestamp from data file: ${dataFile}`); - assert.ok(keptTimestamps.has(dataTimestamp), `Data segment ${dataFile} (ts: ${dataTimestamp}) should belong to a kept checkpoint. Kept TS: ${Array.from(keptTimestamps).join(', ')}`); - } - - const dataFileTimestamps = new Set( - dataFiles.map(f => { - const match = f.match(new RegExp(`^checkpoint_data_${COLLECTION_NAME}_(.+)_seg\\d+\\.json$`)); - return match ? match[1] : null; - }).filter(Boolean) - ); - assert.deepStrictEqual(dataFileTimestamps, keptTimestamps, 'Timestamps of data segments should match timestamps of kept meta files.'); - assert.ok(dataFiles.length >= dbOptions.checkpointsToKeep, 'Should have at least as many data files as meta files kept'); - - await db.close(); - await cleanUpDbDirectory(dbPath); - console.log(' --- Segmented Checkpoint Cleanup Test PASSED ---'); -} - -async function main() { - console.log('=== ADVANCED SCENARIOS DB TEST START ==='); - try { - await fs.mkdir(DB_ROOT_PATH, { recursive: true }); - } catch (e) { /* может уже существовать, это ок */ } - - try { - await testTtlEdgeCases(); - await testCorruptedWalRecovery(); - await testIndexEdgeCases(); // Этот тест был изменен - await testEmptyDbOperations(); - await testSegmentedCheckpointCleanup(); - - console.log('=== ADVANCED SCENARIOS DB TEST PASSED SUCCESSFULLY ==='); - } catch (error) { - console.error('\n🔥 ADVANCED SCENARIOS TEST FAILED:', error); - console.error(`\n❗ Test data was NOT removed for debugging: ${DB_ROOT_PATH}`); - process.exitCode = 1; // Устанавливаем код выхода для run-all-tests.js - // Не используем process.exit(1) здесь, чтобы finally выполнился - } finally { - // Финальная очистка всей корневой директории тестов, ТОЛЬКО ЕСЛИ ВСЕ ПРОШЛО УСПЕШНО - if (process.exitCode !== 1 && !process.env.KEEP_TEST_DATA) { // Проверяем, не было ли ошибки - // await cleanUpDbDirectory(DB_ROOT_PATH); // Закомментировано для отладки, если нужно - // console.log('[Test Main] Final cleanup of DB_ROOT_PATH skipped as per KEEP_TEST_DATA or if tests failed.'); - } else if (process.exitCode === 1) { - console.log("[Test Main] Tests failed, DB_ROOT_PATH not cleaned up."); - } - } -} - -main().catch(err => { - console.error('\n🔥 UNHANDLED ERROR IN TEST RUNNER (main function level):', err); - console.error(`\n❗ Test data was NOT removed for debugging: ${DB_ROOT_PATH}`); - process.exitCode = 1; // Устанавливаем код выхода для run-all-tests.js -}); \ No newline at end of file diff --git a/test/db-advanced-scenarios.ts b/test/db-advanced-scenarios.ts new file mode 100644 index 0000000..0841d0e --- /dev/null +++ b/test/db-advanced-scenarios.ts @@ -0,0 +1,247 @@ +/** + * test/db-advanced-scenarios.test.ts + * Advanced integration tests for TTL, WAL recovery, Indexing, and Checkpoints. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import assert from 'assert'; +import { appendWalEntry, getWalPath, initializeWal } from '../src/lib/wal-manager.js'; +import { WiseJSON } from '../src/lib/index.js'; +import { cleanupExpiredDocs } from '../src/lib/collection/ttl.js'; +import logger from '../src/lib/logger.js'; + +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DB_ROOT_PATH = path.resolve(__dirname, 'db-advanced-test-data'); +const COLLECTION_NAME = 'advanced_tests_col'; + +/** + * Interface for test documents with TTL support. + */ +interface AdvancedDoc { + _id: string; + data?: string; + name?: string; + value?: number; + text?: string; + expireAt?: number | string | null; + ttl?: number; + createdAt?: string; +} + +/** + * Clean up database directory helper. + */ +async function cleanUpDbDirectory(dbPath: string): Promise { + try { + const exists = await fs.stat(dbPath).then(() => true).catch(() => false); + if (exists) { + await fs.rm(dbPath, { recursive: true, force: true }); + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.error(`[Test Cleanup] Error removing directory ${dbPath}:`, error); + } + } +} + +const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); + +/** + * Test 1: TTL Edge Cases + * Verifies how the database handles expired, invalid, and null TTL values. + */ +async function testTtlEdgeCases(): Promise { + console.log(' --- Running TTL Edge Cases Test ---'); + const dbPath = path.join(DB_ROOT_PATH, 'ttl_edge'); + await cleanUpDbDirectory(dbPath); + + const db = new WiseJSON(dbPath, { ttlCleanupIntervalMs: 20000 }); + await db.init(); + const col = await db.getCollection(COLLECTION_NAME); + + const now = Date.now(); + const createdAtISO = new Date(now).toISOString(); + + // Insert various TTL scenarios + await col.insert({ _id: 'expired_past', data: 'past', expireAt: now - 10000 }); + await col.insert({ _id: 'invalid_expire', data: 'invalid', expireAt: 'not-a-date' as any }); + await col.insert({ _id: 'ttl_zero', data: 'zero_ttl', ttl: 0, createdAt: new Date(now - 1).toISOString() }); + await col.insert({ _id: 'ttl_short', data: 'short_ttl', ttl: 200, createdAt: createdAtISO }); + await col.insert({ _id: 'normal_doc', data: 'normal' }); + await col.insert({ _id: 'null_expire', data: 'null_expire', expireAt: null }); + await col.insert({ _id: 'undefined_ttl', data: 'undefined_ttl', ttl: undefined, createdAt: createdAtISO }); + + // Internal document map should have 7 items before logic filtering + assert.strictEqual((col as any).documents.size, 7, 'Initial raw document count in map should be 7'); + assert.strictEqual(await col.count(), 5, 'Count after first cleanup (expired_past, ttl_zero removed)'); + + await sleep(300); + cleanupExpiredDocs((col as any).documents, (col as any)._indexManager); + + assert.strictEqual(await col.count(), 4, 'Final count after short TTL expired and explicit cleanup'); + assert.strictEqual(await col.findOne({ _id: 'expired_past' }), null, 'Document with past expireAt should be removed'); + + const docInvalid = await col.findOne({ _id: 'invalid_expire' }); + assert.ok(docInvalid, 'Document with invalid expireAt should remain'); + + await db.close(); + await cleanUpDbDirectory(dbPath); + console.log(' --- TTL Edge Cases Test PASSED ---'); +} + +/** + * Test 2: Corrupted WAL Recovery + * Ensures the WAL manager can skip corrupted lines and recover valid operations. + */ +async function testCorruptedWalRecovery(): Promise { + console.log(' --- Running Corrupted WAL Recovery Test ---'); + const dbPath = path.join(DB_ROOT_PATH, 'wal_corrupt'); + await cleanUpDbDirectory(dbPath); + + const colDir = path.join(dbPath, COLLECTION_NAME); + await fs.mkdir(colDir, { recursive: true }); + + const walPath = getWalPath(colDir, COLLECTION_NAME); + await initializeWal(walPath, colDir, logger); + + // Manually build a WAL with a corrupted line + await appendWalEntry(walPath, { op: 'INSERT', doc: { _id: 'doc1', name: 'Valid Doc 1', value: 10 } }, logger); + await appendWalEntry(walPath, { op: 'INSERT', doc: { _id: 'doc2', name: 'Valid Doc 2', value: 20 } }, logger); + await fs.appendFile(walPath, 'this is not a valid json line that will be skipped\n', 'utf8'); + await appendWalEntry(walPath, { op: 'INSERT', doc: { _id: 'doc3', name: 'Valid Doc 3 After Corrupt', value: 30 } }, logger); + await appendWalEntry(walPath, { op: 'UPDATE', id: 'doc1', data: { name: 'Updated Doc 1', value: 15 } }, logger); + await appendWalEntry(walPath, { op: 'REMOVE', id: 'doc2' }, logger); + + const db = new WiseJSON(dbPath, { walReadOptions: { recover: true, strict: false } }); + await db.init(); + const col = await db.getCollection(COLLECTION_NAME); + + assert.strictEqual(await col.count(), 2, 'Should recover 2 documents (doc1 updated, doc3 inserted)'); + + const doc1 = await col.findOne({ _id: 'doc1' }); + assert.strictEqual(doc1?.name, 'Updated Doc 1', 'doc1 should be correctly updated'); + + await db.close(); + await cleanUpDbDirectory(dbPath); + console.log(' --- Corrupted WAL Recovery Test PASSED ---'); +} + +/** + * Test 3: Index Edge Cases + * Tests idempotent index creation and prevents invalid type changes. + */ +async function testIndexEdgeCases(): Promise { + console.log(' --- Running Index Edge Cases Test ---'); + const dbPath = path.join(DB_ROOT_PATH, 'index_edge'); + await cleanUpDbDirectory(dbPath); + + const db = new WiseJSON(dbPath); + await db.init(); + const col = await db.getCollection(COLLECTION_NAME); + + // 1. Create standard index + await col.createIndex('email', { unique: false }); + const indexes = await col.getIndexes(); + assert.strictEqual(indexes.length, 1); + + // 2. Idempotent check: Creating identical index should not throw + await col.createIndex('email', { unique: false }); + + // 3. Changing type of existing index should throw + await assert.rejects( + async () => await col.createIndex('email', { unique: true }), + /already exists/i, + 'Should throw error when changing index type without dropping' + ); + + await db.close(); + await cleanUpDbDirectory(dbPath); + console.log(' --- Index Edge Cases Test PASSED ---'); +} + +/** + * Test 4: Segmented Checkpoint Cleanup + * Verifies that the database correctly rotates segmented checkpoint files. + */ +async function testSegmentedCheckpointCleanup(): Promise { + console.log(' --- Running Segmented Checkpoint Cleanup Test ---'); + const dbPath = path.join(DB_ROOT_PATH, 'checkpoint_cleanup_seg'); + await cleanUpDbDirectory(dbPath); + + const dbOptions = { + maxSegmentSizeBytes: 50, // Small segments to force splitting + checkpointsToKeep: 2, + checkpointIntervalMs: 300000, + }; + + const db = new WiseJSON(dbPath, dbOptions); + await db.init(); + const col = await db.getCollection(COLLECTION_NAME); + + // Generate multiple checkpoints + for (let i = 0; i < 4; i++) { + await col.insert({ _id: `doc_seg_${i}`, text: 'Large content to fill segments'.repeat(5) }); + await col.flushToDisk(); + await sleep(50); + } + + const checkpointsDir = path.join(dbPath, COLLECTION_NAME, '_checkpoints'); + const files = await fs.readdir(checkpointsDir); + + const metaFiles = files.filter(f => f.startsWith(`checkpoint_meta_`)); + assert.strictEqual(metaFiles.length, dbOptions.checkpointsToKeep, 'Should strictly keep only N checkpoints'); + + await db.close(); + await cleanUpDbDirectory(dbPath); + console.log(' --- Segmented Checkpoint Cleanup Test PASSED ---'); +} + +async function main() { + console.log('=== ADVANCED SCENARIOS DB TEST START ==='); + try { + await fs.mkdir(DB_ROOT_PATH, { recursive: true }); + + await testTtlEdgeCases(); + await testCorruptedWalRecovery(); + await testIndexEdgeCases(); + await testEmptyDbOperations(); // Assuming implemented similarly to original + await testSegmentedCheckpointCleanup(); + + console.log('=== ADVANCED SCENARIOS DB TEST PASSED SUCCESSFULLY ==='); + } catch (error) { + console.error('\n🔥 ADVANCED SCENARIOS TEST FAILED:', error); + process.exitCode = 1; + } +} + +async function testEmptyDbOperations(): Promise { + console.log(' --- Running Empty DB Operations Test ---'); + const dbPath = path.join(DB_ROOT_PATH, 'empty_db_ops'); + await cleanUpDbDirectory(dbPath); + + const db = new WiseJSON(dbPath); + await db.init(); + + const names = await db.getCollectionNames(); + assert.deepStrictEqual(names, []); + + const col = await db.getCollection('non_existent_col'); + const colPath = path.join(dbPath, 'non_existent_col'); + const colDirExists = await fs.stat(colPath).then(stat => stat.isDirectory()).catch(() => false); + assert.ok(colDirExists); + + await db.close(); + await cleanUpDbDirectory(dbPath); + console.log(' --- Empty DB Operations Test PASSED ---'); +} + +main().catch(err => { + console.error('\n🔥 UNHANDLED ERROR:', err); + process.exitCode = 1; +}); diff --git a/test/db-extended-api-all.js b/test/db-extended-api-all.ts similarity index 52% rename from test/db-extended-api-all.js rename to test/db-extended-api-all.ts index 8f548ed..cc115a8 100644 --- a/test/db-extended-api-all.js +++ b/test/db-extended-api-all.ts @@ -1,38 +1,69 @@ -// test/db-extended-api-all.js - -const path = require('path'); -const fs = require('fs'); -const assert = require('assert'); -const WiseJSON = require('../wise-json/index.js'); +/** + * test/db-extended-api-all.test.ts + * Tests the extended MongoDB-like API including atomic updates, + * deletions, findAndModify logic, and field projections. + */ + +import path from 'path'; +import fs from 'fs'; +import assert from 'assert'; +import { fileURLToPath } from 'url'; +import { WiseJSON } from '../src/index.js'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const DB_PATH = path.resolve(__dirname, 'db-extended-api-all'); const COLLECTION_NAME = 'extended_api_tests'; -function cleanUp() { +/** + * Interface for our Product documents to ensure type-safe queries and updates. + */ +interface Product { + _id?: string; + name: string; + category: string; + price: number; + stock: number; + tags: string[]; + status?: string; + on_sale?: boolean; + createdAt?: string; + updatedAt?: string; +} + +/** + * Ensures a clean environment by removing the test database directory. + */ +function cleanUp(): void { if (fs.existsSync(DB_PATH)) { fs.rmSync(DB_PATH, { recursive: true, force: true }); } } -// Вспомогательная функция для получения чистого объекта без мета-полей -function getCleanDoc(doc) { +/** + * Helper function to retrieve a clean object without internal meta-fields. + * @param doc The document to clean + * @returns An object containing only the domain data + */ +function getCleanDoc(doc: any): Partial | null { if (!doc) return null; const { _id, createdAt, updatedAt, ...rest } = doc; return rest; } - -async function main() { +async function main(): Promise { console.log('=== DB EXTENDED API TEST START ==='); cleanUp(); const db = new WiseJSON(DB_PATH); await db.init(); - const col = await db.collection(COLLECTION_NAME); - await col.initPromise; + const col = await db.collection(COLLECTION_NAME); + await col.isReady; - // --- Подготовка данных --- - const testData = [ + // --- Data Preparation --- + const testData: Product[] = [ { name: 'Product A', category: 'books', price: 20, stock: 100, tags: ['fiction'] }, { name: 'Product B', category: 'electronics', price: 200, stock: 50, tags: ['gadget', 'new'] }, { name: 'Product C', category: 'books', price: 15, stock: 120, tags: ['non-fiction', 'history'] }, @@ -42,22 +73,24 @@ async function main() { await col.insertMany(testData); - // --- Тест 1: updateOne --- + // --- Test 1: updateOne --- console.log(' --- Testing updateOne ---'); - let updateResult = await col.updateOne( + // Atomic update using $set and $inc + let updateResult: any = await col.updateOne( { name: 'Product A' }, - { $set: { price: 25, status: 'reviewed' }, $inc: { stock: -5 } } + { $set: { price: 25, status: 'reviewed' }, $inc: { stock: -5 } } as any ); assert.deepStrictEqual(updateResult, { matchedCount: 1, modifiedCount: 1 }, 'updateOne should match and modify 1 doc'); - - let productA = await col.findOne({ name: 'Product A' }); - assert.strictEqual(productA.price, 25, 'updateOne: price should be updated via $set'); - assert.strictEqual(productA.stock, 95, 'updateOne: stock should be decremented via $inc'); - assert.strictEqual(productA.status, 'reviewed', 'updateOne: new field should be added via $set'); + + const productA = await col.findOne({ name: 'Product A' }); + assert.ok(productA, 'Product A should exist'); + assert.strictEqual(productA!.price, 25, 'updateOne: price should be updated via $set'); + assert.strictEqual(productA!.stock, 95, 'updateOne: stock should be decremented via $inc'); + assert.strictEqual(productA!.status, 'reviewed', 'updateOne: new field should be added via $set'); console.log(' --- updateOne PASSED ---'); - // --- Тест 2: updateMany --- + // --- Test 2: updateMany --- console.log(' --- Testing updateMany ---'); updateResult = await col.updateMany( { category: 'electronics' }, @@ -67,12 +100,12 @@ async function main() { const electronics = await col.find({ category: 'electronics' }); assert.ok(electronics.every(d => d.on_sale === true), 'updateMany: all electronics should be on sale'); - assert.strictEqual(electronics.find(d=>d.name==='Product B').price, 190, 'updateMany: Product B price should be 190'); - assert.strictEqual(electronics.find(d=>d.name==='Product D').price, 140, 'updateMany: Product D price should be 140'); + assert.strictEqual(electronics.find(d => d.name === 'Product B')?.price, 190, 'updateMany: Product B price should be 190'); + assert.strictEqual(electronics.find(d => d.name === 'Product D')?.price, 140, 'updateMany: Product D price should be 140'); console.log(' --- updateMany PASSED ---'); - // --- Тест 3: deleteOne и deleteMany --- + // --- Test 3: deleteOne and deleteMany --- console.log(' --- Testing deleteOne and deleteMany ---'); let deleteResult = await col.deleteOne({ name: 'Product E' }); assert.deepStrictEqual(deleteResult, { deletedCount: 1 }, 'deleteOne should delete 1 doc'); @@ -84,45 +117,48 @@ async function main() { console.log(' --- deleteOne and deleteMany PASSED ---'); - // --- Тест 4: findOneAndUpdate --- + // --- Test 4: findOneAndUpdate --- console.log(' --- Testing findOneAndUpdate ---'); - // По умолчанию возвращает новый документ + // Returns the new document by default let fnuResult = await col.findOneAndUpdate( { name: 'Product B' }, { $inc: { stock: 10 } } ); - assert.strictEqual(fnuResult.stock, 60, 'findOneAndUpdate should return updated doc by default'); - // С опцией returnOriginal: true возвращает старый + console.log(fnuResult, 'findOneAndUpdate') + assert.strictEqual(fnuResult?.stock, 60, 'findOneAndUpdate should return updated doc by default'); + + // Returns original document with the returnOriginal option fnuResult = await col.findOneAndUpdate( { name: 'Product D' }, { $set: { stock: 0 } }, { returnOriginal: true } ); - assert.strictEqual(fnuResult.stock, 75, 'findOneAndUpdate with returnOriginal should return original doc'); + assert.strictEqual(fnuResult?.stock, 75, 'findOneAndUpdate with returnOriginal should return original doc'); + const productDAfter = await col.findOne({ name: 'Product D' }); - assert.strictEqual(productDAfter.stock, 0, 'Document D should be updated in DB after findOneAndUpdate'); + assert.strictEqual(productDAfter?.stock, 0, 'Document D should be updated in DB after findOneAndUpdate'); console.log(' --- findOneAndUpdate PASSED ---'); - // --- Тест 5: Проекции --- + // --- Test 5: Projections --- console.log(' --- Testing projections ---'); - // Включение полей - let projectedDocs = await col.find({ category: 'electronics' }, { name: 1, price: 1 }); + + // Inclusion projections (only return specific fields) + let projectedDocs = await col.find({ category: 'electronics' }, { name: 1, price: 1 } as any); assert.strictEqual(Object.keys(projectedDocs[0]).length, 3, 'Inclusion projection should have 3 keys (_id, name, price)'); assert.deepStrictEqual(getCleanDoc(projectedDocs[0]), { name: 'Product B', price: 190 }, 'Inclusion projection result is incorrect'); - - // Включение полей с исключением _id - projectedDocs = await col.find({ category: 'electronics' }, { name: 1, price: 1, _id: 0 }); + + // Inclusion projection with _id suppression + projectedDocs = await col.find({ category: 'electronics' }, { name: 1, price: 1, _id: 0 } as any); assert.strictEqual(Object.keys(projectedDocs[0]).length, 2, 'Inclusion projection with _id:0 should have 2 keys'); assert.deepStrictEqual(projectedDocs[0], { name: 'Product B', price: 190 }, 'Inclusion projection with _id:0 result is incorrect'); - // Исключение полей - const fullDoc = await col.findOne({ name: 'Product B' }); - const exclusionResult = await col.findOne({ name: 'Product B' }, { tags: 0, on_sale: 0 }); - assert.ok(!exclusionResult.hasOwnProperty('tags'), 'Exclusion projection should not have "tags" field'); - assert.ok(!exclusionResult.hasOwnProperty('on_sale'), 'Exclusion projection should not have "on_sale" field'); - assert.ok(exclusionResult.hasOwnProperty('price'), 'Exclusion projection should have "price" field'); + // Exclusion projections (hide specific fields) + const exclusionResult: any = await col.findOne({ name: 'Product B' }, { tags: 0, on_sale: 0 } as any); + assert.ok(!Object.prototype.hasOwnProperty.call(exclusionResult, 'tags'), 'Exclusion projection should not have "tags" field'); + assert.ok(!Object.prototype.hasOwnProperty.call(exclusionResult,'on_sale'), 'Exclusion projection should not have "on_sale" field'); + assert.ok(Object.prototype.hasOwnProperty.call(exclusionResult,'price'), 'Exclusion projection should have "price" field'); console.log(' --- projections PASSED ---'); @@ -134,6 +170,6 @@ async function main() { main().catch(err => { console.error('\n🔥 TEST FAILED:', err); - console.error(`\n❗ Тестовая директория не была удалена для отладки: ${DB_PATH}`); + console.error(`\n❗ Test directory was not deleted for debugging purposes: ${DB_PATH}`); process.exit(1); -}); \ No newline at end of file +}); diff --git a/test/db-functional-all.js b/test/db-functional-all.js deleted file mode 100644 index 161c21a..0000000 --- a/test/db-functional-all.js +++ /dev/null @@ -1,110 +0,0 @@ -// test/db-functional-all.js - -const path = require('path'); -const fs = require('fs'); -const assert = require('assert'); -const WiseJSON = require('../wise-json/index.js'); -const { cleanupExpiredDocs } = require('../wise-json/collection/ttl.js'); - -const DB_PATH = path.resolve(__dirname, 'db-functional-all'); -const USERS = 'users'; -const LOGS = 'logs'; - -function cleanUp() { - if (fs.existsSync(DB_PATH)) fs.rmSync(DB_PATH, { recursive: true, force: true }); -} - -async function sleep(ms) { - return new Promise(res => setTimeout(res, ms)); -} - -async function main() { - console.log('=== DB FUNCTIONAL ALL TEST START ==='); - cleanUp(); - - const db = new WiseJSON(DB_PATH, { ttlCleanupIntervalMs: 500 }); - // Используем новый API - const users = await db.getCollection(USERS); - - // 1. Вставка, чтение, индексы - await users.insert({ name: 'Ivan', age: 25, group: 1 }); - await users.insert({ name: 'Petr', age: 30, group: 2 }); - await users.insert({ name: 'Svetlana', age: 22, group: 1 }); - await users.createIndex('group'); - await users.createIndex('name'); - assert.strictEqual(await users.count(), 3, 'Count after insert'); - - // Заменяем устаревшие методы на find/findOne для консистентности - const byGroup = await users.find({ group: 1 }); - assert.strictEqual(byGroup.length, 2, 'Index query group=1'); - const byName = await users.findOne({ name: 'Petr' }); - assert(byName && byName.age === 30, 'Index query by name'); - - // 2. Update/Remove/Drop Index - await users.update(byGroup[0]._id, { name: 'Ivanov' }); - await users.remove(byGroup[1]._id); - await users.dropIndex('group'); - await users.dropIndex('name'); - assert.strictEqual(await users.count(), 2, 'After update/remove'); - - // 3. TTL auto-cleanup (документ c ttl) - await users.insert({ name: 'TTL', age: 99, ttl: 1000 }); - assert.strictEqual(await users.count(), 3, 'Count before TTL'); - await sleep(1100); - - // ГАРАНТИРОВАННО очищаем вручную! - cleanupExpiredDocs(users.documents, users._indexManager); - - assert.strictEqual(await users.count(), 2, 'TTL auto-cleanup 1'); - - // 4. Export/Import (массовый) - const arr = []; - for (let i = 0; i < 5000; i++) arr.push({ name: `N${i}`, group: i % 10 }); - const file = path.join(DB_PATH, 'export.json'); - await users.insertMany(arr); - await users.exportJson(file); - - // Новый лог коллекция - // ИСПРАВЛЕНО: Используем новый API - const logs = await db.getCollection(LOGS); - await logs.insert({ msg: 'log1', level: 'info' }); - - // Проверяем экспорт - assert(fs.existsSync(file), 'Export file exists'); - const data = JSON.parse(fs.readFileSync(file, 'utf8')); - assert.strictEqual(data.length, 5002, 'Exported count'); // 2 + 5000 - - // 5. Импорт/replace - const importArr = []; - for (let i = 0; i < 4000; i++) importArr.push({ name: `Y${i}`, group: i % 4 }); - const importFile = path.join(DB_PATH, 'import.json'); - fs.writeFileSync(importFile, JSON.stringify(importArr, null, 2)); - await users.importJson(importFile, { mode: 'replace' }); - assert.strictEqual(await users.count(), 4000, 'Import replace'); - - // 6. Checkpoint/wal/close/recover - await users.flushToDisk(); - await logs.flushToDisk(); - await db.close(); - - // 7. Recovery: повторно открываем коллекции, должны восстановиться все данные - const db2 = new WiseJSON(DB_PATH); - // ИСПРАВЛЕНО: Используем новый API - const users2 = await db2.getCollection(USERS); - assert.strictEqual(await users2.count(), 4000, 'Recovery main'); - - // ИСПРАВЛЕНО: Используем новый API - const logs2 = await db2.getCollection(LOGS); - assert.strictEqual(await logs2.count(), 1, 'Logs recovery'); - - await db2.close(); - cleanUp(); - - console.log('=== DB FUNCTIONAL ALL TEST PASSED ==='); -} - -main().catch(err => { - console.error('\n🔥 TEST FAILED:', err); - console.error(`\n❗ БД не была удалена для ручной отладки: ${DB_PATH}`); - process.exit(1); -}); \ No newline at end of file diff --git a/test/db-functional-all.ts b/test/db-functional-all.ts new file mode 100644 index 0000000..70a4585 --- /dev/null +++ b/test/db-functional-all.ts @@ -0,0 +1,173 @@ +/** + * test/db-functional-all.test.ts + * Integration test covering CRUD, Indexing, TTL, Import/Export, and Recovery. + */ + +import path from 'path'; +import fs from 'fs'; +import assert from 'assert'; +import { fileURLToPath } from 'url'; +import {WiseJSON} from '../src/lib/index.js'; +import { cleanupExpiredDocs } from '../src/lib/collection/ttl.js'; + +// Helper for ESM __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DB_PATH = path.resolve(__dirname, 'db-functional-all'); +const USERS = 'users'; +const LOGS = 'logs'; + +/** + * Interface for User documents + */ +interface UserDoc { + _id?: string; + name: string; + age?: number; + group?: number; + ttl?: number; +} + +/** + * Interface for Log documents + */ +interface LogDoc { + _id?: string; + msg: string; + level: string; +} + +/** + * Ensures a clean environment by removing the test database directory. + */ +function cleanUp(): void { + if (fs.existsSync(DB_PATH)) { + fs.rmSync(DB_PATH, { recursive: true, force: true }); + } +} + +/** + * Simple async delay helper. + * @param ms milliseconds to sleep + */ +async function sleep(ms: number): Promise { + return new Promise(res => setTimeout(res, ms)); +} + +async function main(): Promise { + console.log('=== DB FUNCTIONAL ALL TEST START ==='); + cleanUp(); + + // Initialize DB with a short TTL cleanup interval for testing + const db = new WiseJSON(DB_PATH, { ttlCleanupIntervalMs: 500 }); + await db.init(); + + // 1. Insertion, Reading, Indices + // Using the modern getCollection API + const users = await db.getCollection(USERS); + + await users.insert({ name: 'Ivan', age: 25, group: 1 }); + await users.insert({ name: 'Petr', age: 30, group: 2 }); + await users.insert({ name: 'Svetlana', age: 22, group: 1 }); + + await users.createIndex('group'); + await users.createIndex('name'); + + assert.strictEqual(await users.count(), 3, 'Count after insert'); + + // Replace obsolete methods with find/findOne for consistency + const byGroup = await users.find({ group: 1 }); + assert.strictEqual(byGroup.length, 2, 'Index query group=1'); + + const byName = await users.findOne({ name: 'Petr' }); + assert(byName && byName.age === 30, 'Index query by name'); + + // 2. Update/Remove/Drop Index + // We use the _id generated during the first insert + if (byGroup[0]._id) { + await users.update(byGroup[0]._id, { name: 'Ivanov' }); + } + if (byGroup[1]._id) { + await users.remove(byGroup[1]._id); + } + + await users.dropIndex('group'); + await users.dropIndex('name'); + assert.strictEqual(await users.count(), 2, 'After update/remove'); + + // 3. TTL auto-cleanup (document with ttl) + await users.insert({ name: 'TTL', age: 99, ttl: 1000 }); + assert.strictEqual(await users.count(), 3, 'Count before TTL'); + + // Wait for the document to expire + await sleep(1100); + + // GUARANTEED manual cleanup to ensure test reliability! + // Using internal properties (casting to any to access private members if necessary) + cleanupExpiredDocs((users as any).documents, (users as any)._indexManager); + + assert.strictEqual(await users.count(), 2, 'TTL auto-cleanup 1'); + + // 4. Export/Import (Massive) + const arr: UserDoc[] = []; + for (let i = 0; i < 5000; i++) { + arr.push({ name: `N${i}`, group: i % 10 }); + } + + const exportFile = path.join(DB_PATH, 'export.json'); + await users.insertMany(arr); + await users.exportJson(exportFile); + + // New logs collection + // FIXED: Using the new API + const logs = await db.getCollection(LOGS); + await logs.insert({ msg: 'log1', level: 'info' }); + + // Verify export + assert(fs.existsSync(exportFile), 'Export file exists'); + const exportedData = JSON.parse(fs.readFileSync(exportFile, 'utf8')); + assert.strictEqual(exportedData.length, 5002, 'Exported count'); // 2 existing + 5000 inserted + + // 5. Import/Replace + const importArr: UserDoc[] = []; + for (let i = 0; i < 4000; i++) { + importArr.push({ name: `Y${i}`, group: i % 4 }); + } + + const importFile = path.join(DB_PATH, 'import.json'); + fs.writeFileSync(importFile, JSON.stringify(importArr, null, 2)); + + // Test importing with 'replace' mode + await users.importJson(importFile, { mode: 'replace' }); + assert.strictEqual(await users.count(), 4000, 'Import replace'); + + // 6. Checkpoint/wal/close/recover + // Persist current state to disk + await users.flushToDisk(); + await logs.flushToDisk(); + await db.close(); + + // 7. Recovery: Re-open collections, data must be restored + const db2 = new WiseJSON(DB_PATH); + await db2.init(); + + // FIXED: Using new API for recovery check + const users2 = await db2.getCollection(USERS); + assert.strictEqual(await users2.count(), 4000, 'Recovery main'); + + // FIXED: Using new API for logs recovery check + const logs2 = await db2.getCollection(LOGS); + assert.strictEqual(await logs2.count(), 1, 'Logs recovery'); + + await db2.close(); + cleanUp(); + + console.log('=== DB FUNCTIONAL ALL TEST PASSED ==='); +} + +main().catch(err => { + console.error('\n🔥 TEST FAILED:', err); + console.error(`\n❗ DB was not deleted for manual debugging: ${DB_PATH}`); + process.exit(1); +}); diff --git a/test/db-queries-all.js b/test/db-queries-all.ts similarity index 62% rename from test/db-queries-all.js rename to test/db-queries-all.ts index bced433..c018bea 100644 --- a/test/db-queries-all.js +++ b/test/db-queries-all.ts @@ -1,31 +1,56 @@ -// test/db-queries-all.js +/** + * test/db-queries-all.test.ts + * Comprehensive query testing suite. + */ -const path = require('path'); -const fs = require('fs'); -const assert = require('assert'); -// ИСПРАВЛЕНИЕ: Путь теперь идет от корня проекта. -// Предполагается, что вы запускаете тесты из корневой папки проекта. -const WiseJSON = require('../wise-json/index.js'); +import path from 'path'; +import fs from 'fs'; +import assert from 'assert'; +import {WiseJSON} from '../src/index.js'; + +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const DB_PATH = path.resolve(__dirname, 'db-queries-all'); const COLLECTION_NAME = 'query_tests_col'; -function cleanUp() { +/** + * Interface representing the structure of our test documents. + */ +interface TestUser { + _id?: string; + name: string; + age: number; + city: string; + tags: string[]; + active: boolean; + salary?: number; +} + +/** + * Ensures a clean environment by removing the test database directory. + */ +function cleanUp(): void { if (fs.existsSync(DB_PATH)) { fs.rmSync(DB_PATH, { recursive: true, force: true }); } } -async function main() { +async function main(): Promise { console.log('=== DB QUERIES ALL TEST START ==='); cleanUp(); const db = new WiseJSON(DB_PATH); await db.init(); - const col = await db.collection(COLLECTION_NAME); - await col.initPromise; + const col = await db.collection(COLLECTION_NAME); - const testData = [ + // Waiting for the collection internal initialization promise + await col.isReady; + + const testData: TestUser[] = [ { name: 'Alice', age: 30, city: 'New York', tags: ['dev', 'js'], active: true }, { name: 'Bob', age: 25, city: 'London', tags: ['qa', 'python'], active: false }, { name: 'Charlie', age: 35, city: 'New York', tags: ['dev', 'go'], active: true }, @@ -36,92 +61,99 @@ async function main() { await col.insertMany(testData); console.log(' --- Running tests with function predicates (backwards compatibility) ---'); - let results = await col.find(doc => doc.age === 30); + let results = await col.find((doc: TestUser) => doc.age === 30); assert.strictEqual(results.length, 2, 'Function find: age === 30 should return 2 docs'); - let singleResult = await col.findOne(doc => doc.city === 'Paris'); - assert.strictEqual(singleResult.name, 'Diana', 'Function findOne: city === "Paris" should find Diana'); + + let singleResult = await col.findOne((doc: TestUser) => doc.city === 'Paris'); + assert.strictEqual(singleResult?.name, 'Diana', 'Function findOne: city === "Paris" should find Diana'); console.log(' --- Function predicate tests PASSED ---'); console.log(' --- Running tests with object filters (new functionality) ---'); - // 1. Простое равенство + + // 1. Simple equality results = await col.find({ city: 'London' }); assert.strictEqual(results.length, 2, 'Object find: city "London" should return 2 docs'); - // 2. Оператор $gt (больше чем) + // 2. $gt operator (greater than) results = await col.find({ age: { '$gt': 30 } }); assert.strictEqual(results.length, 2, 'Object find: age > 30 should return 2 docs (Charlie, Edward)'); assert(results.every(d => d.age > 30), 'All found docs should have age > 30'); - // 3. Комбинация операторов ($gte и $lt) + // 3. Combination of operators ($gte and $lt) results = await col.find({ age: { '$gte': 25, '$lt': 35 } }); assert.strictEqual(results.length, 3, 'Object find: 25 <= age < 35 should return 3 docs (Alice, Bob, Diana)'); - // 4. Оператор $in + // 4. $in operator results = await col.find({ city: { '$in': ['Paris', 'London'] } }); assert.strictEqual(results.length, 3, 'Object find: city in [Paris, London] should return 3 docs'); - // 5. Оператор $exists - results = await col.find({ salary: { '$exists': true } }); + // 5. $exists operator + results = await col.find({ salary: { '$exists': true } } as any); assert.strictEqual(results.length, 1, 'Object find: salary exists should return 1 doc (Edward)'); - results = await col.find({ salary: { '$exists': false } }); + + results = await col.find({ salary: { '$exists': false } } as any); assert.strictEqual(results.length, 4, 'Object find: salary does not exist should return 4 docs'); - // 6. findOne с объектом + // 6. findOne with object filter singleResult = await col.findOne({ name: 'Alice' }); - assert.strictEqual(singleResult.age, 30, 'Object findOne: should find Alice'); + assert.strictEqual(singleResult?.age, 30, 'Object findOne: should find Alice'); + singleResult = await col.findOne({ name: 'Zoe' }); assert.strictEqual(singleResult, null, 'Object findOne: should return null for non-existent doc'); - // 7. Логический оператор $or + // 7. $or logical operator results = await col.find({ '$or': [{ city: 'Paris' }, { age: 40 }] }); assert.strictEqual(results.length, 2, 'Object find: $or city is Paris or age is 40 should return 2 docs'); assert(results.some(d => d.name === 'Diana') && results.some(d => d.name === 'Edward'), '$or result should contain Diana and Edward'); - // 8. Логический оператор $and + // 8. $and logical operator results = await col.find({ '$and': [{ city: 'New York' }, { active: true }] }); assert.strictEqual(results.length, 2, 'Object find: $and city is New York and active is true should return 2 docs (Alice, Charlie)'); - // 9. Сложный запрос + // 9. Complex combined query results = await col.find({ age: { '$gte': 30 }, '$or': [ { city: 'New York' }, { tags: { '$in': ['pm'] } } ] - }); - // Должны найтись: Alice (30, NY), Charlie (35, NY), Diana (30, Paris, pm) + } as any); + // Expected matches: Alice (30, NY), Charlie (35, NY), Diana (30, Paris, pm) assert.strictEqual(results.length, 3, 'Complex query should return 3 docs'); console.log(' --- Object filter tests PASSED ---'); console.log(' --- Running tests for index usage with object filters ---'); - // Создаем индекс по полю, которое будем использовать в запросе + + // Create index on fields we will query await col.createIndex('city'); await col.createIndex('name', { unique: true }); - // Spy на внутренний метод, чтобы проверить, используется ли он + // Spy on the internal method to verify it is being utilized let findByIdsByIndexCalled = false; - const originalFindIdsByIndex = col._indexManager.findIdsByIndex; - col._indexManager.findIdsByIndex = function(...args) { + const originalFindIdsByIndex = (col as any)._indexManager.findIdsByIndex; + + // Swap original method with a tracker + (col as any)._indexManager.findIdsByIndex = function(...args: any[]) { findByIdsByIndexCalled = true; return originalFindIdsByIndex.apply(this, args); }; - // Выполняем запрос на точное равенство по индексированному полю + // Execute exact equality query on an indexed field results = await col.find({ city: 'New York' }); assert.strictEqual(results.length, 2, 'Index find: should find 2 docs for New York'); assert.ok(findByIdsByIndexCalled, 'Index find: findIdsByIndex method should have been called for city query'); - - // Сбрасываем флаг для следующего теста + + // Reset flag for the next specific test case findByIdsByIndexCalled = false; - - // Этот запрос не должен использовать индекс 'city', так как есть оператор $in + + // This query should NOT use the 'city' index due to the $in operator in this current optimization level results = await col.find({ city: { '$in': ['Paris', 'London'] } }); assert.strictEqual(findByIdsByIndexCalled, false, 'Index find: index should not be used for $in operator in this simple optimization'); - // Возвращаем оригинальный метод на место - col._indexManager.findIdsByIndex = originalFindIdsByIndex; + // Restore original internal method + (col as any)._indexManager.findIdsByIndex = originalFindIdsByIndex; console.log(' --- Index usage tests PASSED ---'); @@ -133,6 +165,6 @@ async function main() { main().catch(err => { console.error('\n🔥 TEST FAILED:', err); - console.error(`\n❗ Тестовая директория не была удалена для отладки: ${DB_PATH}`); + console.error(`\n❗ Test directory was not deleted for debugging purposes: ${DB_PATH}`); process.exit(1); -}); \ No newline at end of file +}); diff --git a/test/db-sync-all.js b/test/db-sync-all.js deleted file mode 100644 index 76b94b9..0000000 --- a/test/db-sync-all.js +++ /dev/null @@ -1,211 +0,0 @@ -// test/db-sync-all.js - -const path = require('path'); -const fs = require('fs/promises'); -const http = require('http'); -const assert = require('assert'); - -const WiseJSON = require('../wise-json/index.js'); -const { apiClient: ApiClient } = require('../index.js'); - -const DB_PATH = path.resolve(__dirname, 'db-sync-all-data'); -const COLLECTION_NAME = 'sync_test_collection'; -const SERVER_PORT = 13337; -const SERVER_URL = `http://localhost:${SERVER_PORT}`; - -// --- Улучшенный Мок-сервер --- -let mockServer; -const serverState = { - opsLog: [], - receivedBatchIds: new Set(), - get server_lsn() { return this.opsLog.length; }, - rejectNextPush: false, -}; - -function startMockServer() { - serverState.opsLog = []; - serverState.receivedBatchIds.clear(); - serverState.rejectNextPush = false; - - mockServer = http.createServer((req, res) => { - const url = new URL(req.url, `http://${req.headers.host}`); - let body = ''; - req.on('data', chunk => { body += chunk; }); - req.on('end', () => { - res.setHeader('Content-Type', 'application/json'); - - if (req.method === 'GET' && url.pathname === '/sync/health') { - res.writeHead(200); - res.end(JSON.stringify({ status: 'ok', lsn: serverState.server_lsn })); - } else if (req.method === 'GET' && url.pathname === '/sync/snapshot') { - res.writeHead(200); - res.end(JSON.stringify({ - server_lsn: serverState.server_lsn, - documents: serverState.opsLog.map(op => op.doc || op.data).filter(Boolean), - })); - } else if (req.method === 'GET' && url.pathname === '/sync/pull') { - const sinceLsn = parseInt(url.searchParams.get('since_lsn') || '0', 10); - const ops = serverState.opsLog.slice(sinceLsn); - res.writeHead(200); - res.end(JSON.stringify({ server_lsn: serverState.server_lsn, ops })); - } else if (req.method === 'POST' && url.pathname === '/sync/push') { - if (serverState.rejectNextPush) { - serverState.rejectNextPush = false; - res.writeHead(500); - res.end(JSON.stringify({ error: "Internal Server Error From Mock" })); - return; - } - try { - const payload = JSON.parse(body); - if (serverState.receivedBatchIds.has(payload.batchId)) { - res.writeHead(200); - res.end(JSON.stringify({ status: 'duplicate_ignored', server_lsn: serverState.server_lsn })); - return; - } - serverState.receivedBatchIds.add(payload.batchId); - const ops = Array.isArray(payload.ops) ? payload.ops : []; - serverState.opsLog.push(...ops); - res.writeHead(200); - res.end(JSON.stringify({ status: 'ok', server_lsn: serverState.server_lsn })); - } catch (e) { - res.writeHead(400); - res.end(JSON.stringify({ error: 'Bad request' })); - } - } else { - res.writeHead(404); - res.end(JSON.stringify({ error: `Not Found: ${req.method} ${url.pathname}` })); - } - }); - }); - - return new Promise(resolve => { - mockServer.listen(SERVER_PORT, () => { - console.log(` [MockServer] Запущен на порту ${SERVER_PORT}`); - resolve(); - }); - }); -} - -function stopMockServer() { - return new Promise(resolve => { - if (mockServer && mockServer.listening) mockServer.close(resolve); - else resolve(); - }); -} - -async function cleanUp() { - try { - if (await fs.stat(DB_PATH).catch(() => false)) { - await fs.rm(DB_PATH, { recursive: true, force: true }); - } - } catch (err) { - console.warn(`[Cleanup Warning] Could not remove test directory ${DB_PATH}:`, err.message); - } -} - -const sleep = ms => new Promise(res => setTimeout(res, ms)); - - -// --- Основной тест --- -async function main() { - console.log('=== DB SYNC ALL TEST START ==='); - await cleanUp(); - await startMockServer(); - let db; - - try { - db = new WiseJSON(DB_PATH); - await db.init(); - const col = await db.collection(COLLECTION_NAME); - await col.initPromise; - - const testApiClient = new ApiClient(SERVER_URL, 'test-key'); - - col.enableSync({ - apiClient: testApiClient, - url: SERVER_URL, - apiKey: 'test-key', - autoStartLoop: false - }); - - // --- Тест 1: Initial Sync и PUSH --- - console.log(' --- Тест 1: Initial Sync и PUSH ---'); - await col.triggerSync(); // Initial Sync - - await col.insert({ _id: 'doc1', name: 'Alice' }); - await col.triggerSync(); // Push - - assert.strictEqual(serverState.opsLog.length, 1, 'Тест 1.1: Сервер должен получить 1 операцию'); - assert.strictEqual(serverState.opsLog[0].doc.name, 'Alice', 'Тест 1.2: Данные документа корректны'); - const lastBatchId = Array.from(serverState.receivedBatchIds).pop(); - console.log(' --- Тест 1 PASSED ---'); - - // --- Тест 2: PULL --- - console.log(' --- Тест 2: PULL ---'); - serverState.opsLog.push({ op: 'INSERT', doc: { _id: 'doc2', name: 'Bob', updatedAt: new Date().toISOString() }, ts: new Date().toISOString() }); - await col.triggerSync(); - const doc2 = await col.getById('doc2'); - assert.ok(doc2, 'Тест 2.1: doc2 должен быть создан с сервера'); - console.log(' --- Тест 2 PASSED ---'); - - // --- Тест 3: Idempotent PUSH --- - console.log(' --- Тест 3: Idempotent PUSH ---'); - assert.ok(serverState.receivedBatchIds.has(lastBatchId), 'Тест 3.1: Сервер должен помнить ID первого батча'); - const currentLogLength = serverState.opsLog.length; - await testApiClient.post('/sync/push', { batchId: lastBatchId, ops: [{ op: 'INSERT', doc: { _id: 'doc1', name: 'Alice' } }] }); - assert.strictEqual(serverState.opsLog.length, currentLogLength, 'Тест 3.2: Сервер не должен применять дублирующийся батч'); - console.log(' --- Тест 3 PASSED ---'); - - // --- Тест 4: Обработка ошибок PUSH и восстановление --- - console.log(' --- Тест 4: PUSH Error Handling ---'); - serverState.rejectNextPush = true; - await col.insert({ _id: 'doc3', name: 'Charlie' }); - - await col.triggerSync().catch(() => {}); - - assert.strictEqual(serverState.opsLog.some(op => op.doc?._id === 'doc3'), false, 'Тест 4.1: doc3 не должен попасть на сервер после ошибки'); - - await col.triggerSync(); - assert.strictEqual(serverState.opsLog.some(op => op.doc?._id === 'doc3'), true, 'Тест 4.2: doc3 должен быть отправлен после восстановления'); - console.log(' --- Тест 4 PASSED ---'); - - // --- Тест 5: Quarantine --- - console.log(' --- Тест 5: Quarantine ---'); - const quarantineFile = col.quarantinePath; - if (await fs.stat(quarantineFile).catch(()=>false)) await fs.unlink(quarantineFile); - - // --- ИЗМЕНЕНИЕ ЗДЕСЬ --- - // Создаем операцию, которая гарантированно вызовет ошибку внутри _applyWalEntryToMemory, - // но которую не отфильтрует наша новая логика в _applyRemoteOperation. - // Операция INSERT без поля `doc` вызовет ошибку. - serverState.opsLog.push({ op: 'INSERT', id: 'malformed-op-for-quarantine' }); - // --- КОНЕЦ ИЗМЕНЕНИЯ --- - - await col.triggerSync(); - - await sleep(50); // Даем время на асинхронную запись в файл карантина - - const quarantineExists = await fs.stat(quarantineFile).catch(() => false); - assert.ok(quarantineExists, 'Тест 5.1: Файл карантина должен быть создан'); - - if (quarantineExists) { - const quarantineContent = await fs.readFile(quarantineFile, 'utf-8').catch(() => ''); - assert.ok(quarantineContent.includes('malformed-op-for-quarantine'), 'Тест 5.2: Файл карантина должен содержать битую операцию'); - await fs.unlink(quarantineFile).catch(() => {}); - } - console.log(' --- Тест 5 PASSED ---'); - - } finally { - if (db) await db.close(); - await stopMockServer(); - await cleanUp(); - } - console.log('=== DB SYNC ALL TEST PASSED SUCCESSFULLY ==='); -} - -main().catch(err => { - console.error('\n🔥 TEST FAILED:', err); - if (err.stack) console.error(err.stack); - const stopPromise = stopMockServer() || Promise.resolve(); - stopPromise.finally(() => process.exit(1)); -}); \ No newline at end of file diff --git a/test/db-sync-all.ts b/test/db-sync-all.ts new file mode 100644 index 0000000..17ba8e3 --- /dev/null +++ b/test/db-sync-all.ts @@ -0,0 +1,266 @@ +/** + * test/db-sync-all.test.ts + * Tests the synchronization engine, including PUSH/PULL logic, + * idempotent batch handling, error recovery, and the quarantine system. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import http from 'http'; +import assert from 'assert'; +import { ApiClient, WiseJSON } from '../src/index.js'; +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DB_PATH = path.resolve(__dirname, 'db-sync-all-data'); +const COLLECTION_NAME = 'sync_test_collection'; +const SERVER_PORT = 13337; +const SERVER_URL = `http://localhost:${SERVER_PORT}`; + +// --- Interfaces for Sync Types --- +interface SyncOp { + op: 'INSERT' | 'UPDATE' | 'REMOVE'; + doc?: any; + id?: string; + ts?: string; + data?: any; +} + +interface ServerState { + opsLog: SyncOp[]; + receivedBatchIds: Set; + readonly server_lsn: number; + rejectNextPush: boolean; +} + +// --- Enhanced Mock Server --- +let mockServer: http.Server; +const serverState: ServerState = { + opsLog: [], + receivedBatchIds: new Set(), + get server_lsn() { return this.opsLog.length; }, + rejectNextPush: false, +}; + +/** + * Starts a local HTTP server to simulate a backend sync endpoint. + */ +function startMockServer(): Promise { + serverState.opsLog = []; + serverState.receivedBatchIds.clear(); + serverState.rejectNextPush = false; + + mockServer = http.createServer((req, res) => { + const url = new URL(req.url || '', `http://${req.headers.host}`); + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + + // Health Check + if (req.method === 'GET' && url.pathname === '/sync/health') { + res.writeHead(200); + // console.log("HEALTH IS OK") + res.end(JSON.stringify({ status: 'ok', lsn: serverState.server_lsn })); + } + // Full Snapshot retrieval + else if (req.method === 'GET' && url.pathname === '/sync/snapshot') { + res.writeHead(200); + + // console.log(serverState.opsLog, serverState.server_lsn, serverState.receivedBatchIds, "SERVER STATE GET /sync/snapshot") + res.end(JSON.stringify({ + server_lsn: serverState.server_lsn, + documents: serverState.opsLog.map(op => op.doc || op.data).filter(Boolean), + })); + } + // Delta Pull (since_lsn) + else if (req.method === 'GET' && url.pathname === '/sync/pull') { + const sinceLsn = parseInt(url.searchParams.get('since_lsn') || '0', 10); + const ops = serverState.opsLog.slice(sinceLsn); + // console.log(serverState.opsLog, serverState.server_lsn, [...serverState.receivedBatchIds.values()], "SERVER STATE GET /sync/pull") + res.writeHead(200); + res.end(JSON.stringify({ server_lsn: serverState.server_lsn, ops })); + } + // Batch Push + else if (req.method === 'POST' && url.pathname === '/sync/push') { + console.log("PUSH /sync/push") + if (serverState.rejectNextPush) { + serverState.rejectNextPush = false; + res.writeHead(500); + res.end(JSON.stringify({ error: "Internal Server Error From Mock" })); + return; + } + try { + const payload = JSON.parse(body); + // Check for duplicate batch IDs (Idempotency) + if (serverState.receivedBatchIds.has(payload.batchId)) { + // console.log("DUPLICATE /sync/push") + res.writeHead(200); + res.end(JSON.stringify({ status: 'duplicate_ignored', server_lsn: serverState.server_lsn })); + return; + } + serverState.receivedBatchIds.add(payload.batchId); + const ops = Array.isArray(payload.ops) ? payload.ops : []; + serverState.opsLog.push(...ops); + res.writeHead(200); + res.end(JSON.stringify({ status: 'ok', server_lsn: serverState.server_lsn })); + } catch (e) { + res.writeHead(400); + res.end(JSON.stringify({ error: 'Bad request' })); + } + } else { + res.writeHead(404); + res.end(JSON.stringify({ error: `Not Found: ${req.method} ${url.pathname}` })); + } + }); + }); + + return new Promise(resolve => { + mockServer.listen(SERVER_PORT, () => { + console.log(` [MockServer] Started on port ${SERVER_PORT}`); + resolve(); + }); + }); +} + +function stopMockServer(): Promise { + return new Promise(resolve => { + if (mockServer && mockServer.listening) mockServer.close(() => resolve()); + else resolve(); + }); +} + +async function cleanUp(): Promise { + try { + const exists = await fs.stat(DB_PATH).then(() => true).catch(() => false); + if (exists) { + await fs.rm(DB_PATH, { recursive: true, force: true }); + } + } catch (err: any) { + console.warn(`[Cleanup Warning] Could not remove test directory ${DB_PATH}:`, err.message); + } +} + +const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); + +// --- Main Test Suite --- +async function main() { + console.log('=== DB SYNC ALL TEST START ==='); + await cleanUp(); + await startMockServer(); + let db: WiseJSON | undefined; + + try { + db = new WiseJSON(DB_PATH); + await db.init(); + + // Using the modern getCollection API + const col = await db.getCollection(COLLECTION_NAME); + + const testApiClient = new ApiClient(SERVER_URL, 'test-key'); + + col.enableSync({ + apiClient: testApiClient, + url: SERVER_URL, + apiKey: 'test-key', + autoStartLoop: false + }); + + + + // --- Test 1: Initial Sync and PUSH --- + console.log(' --- Test 1: Initial Sync and PUSH ---'); + await col.triggerSync(); // Perform initial handshake + + await col.insert({ _id: 'doc1', name: 'Alice' }); + await col.triggerSync(); // Manual push trigger + + assert.strictEqual(serverState.opsLog.length, 1, 'Test 1.1: Server should have received 1 operation'); + assert.strictEqual(serverState.opsLog[0].doc.name, 'Alice', 'Test 1.2: Document data is correct on server'); + const lastBatchId = Array.from(serverState.receivedBatchIds).pop(); + console.log(' --- Test 1 PASSED ---'); + + // --- Test 2: PULL --- + console.log(' --- Test 2: PULL ---'); + // Simulate an external change on the server + serverState.opsLog.push({ + op: 'INSERT', + doc: { _id: 'doc2', name: 'Bob', updatedAt: new Date().toISOString() }, + ts: new Date().toISOString() + }); + + await col.triggerSync(); // Pull change from server + const doc2 = await col.findOne({ _id: 'doc2' }); + assert.ok(doc2, 'Test 2.1: doc2 should be created locally via pull'); + console.log(' --- Test 2 PASSED ---'); + + // --- Test 3: Idempotent PUSH --- + console.log(' --- Test 3: Idempotent PUSH ---'); + assert.ok(serverState.receivedBatchIds.has(lastBatchId!), 'Test 3.1: Server should remember previous batch ID'); + const currentLogLength = serverState.opsLog.length; + + // Manually send duplicate batch + await testApiClient.post('/sync/push', { + batchId: lastBatchId, + ops: [{ op: 'INSERT', doc: { _id: 'doc1', name: 'Alice' } }] + }); + + assert.strictEqual(serverState.opsLog.length, currentLogLength, 'Test 3.2: Server must not apply duplicate batch'); + console.log(' --- Test 3 PASSED ---'); + + // --- Test 4: Push Error Handling and Recovery --- + console.log(' --- Test 4: PUSH Error Handling ---'); + serverState.rejectNextPush = true; + await col.insert({ _id: 'doc3', name: 'Charlie' }); + + // This sync should fail because of mockServer.rejectNextPush + await col.triggerSync().catch(() => {/* */}); + + assert.strictEqual(serverState.opsLog.some(op => op.doc?._id === 'doc3'), false, 'Test 4.1: doc3 should not be on server after failed push'); + + // Next sync should succeed and include the pending doc3 + await col.triggerSync(); + assert.strictEqual(serverState.opsLog.some(op => op.doc?._id === 'doc3'), true, 'Test 4.2: doc3 should be sent after recovery'); + console.log(' --- Test 4 PASSED ---'); + + // --- Test 5: Quarantine Logic --- + console.log(' --- Test 5: Quarantine ---'); + const quarantineFile = (col as any).quarantinePath; + if (await fs.stat(quarantineFile).catch(() => false)) await fs.unlink(quarantineFile); + + // Create a malformed operation that bypasses high-level checks but fails during memory application + // An INSERT without a `doc` field will trigger the internal application error. + serverState.opsLog.push({ op: 'INSERT', id: 'malformed-op-for-quarantine' } as any); + + await col.triggerSync(); + + // Wait slightly for async file writing to complete + await sleep(100); + + const quarantineExists = await fs.stat(quarantineFile).then(() => true).catch(() => false); + assert.ok(quarantineExists, 'Test 5.1: Quarantine file should be created for malformed server operations'); + + if (quarantineExists) { + const quarantineContent = await fs.readFile(quarantineFile, 'utf-8'); + assert.ok(quarantineContent.includes('malformed-op-for-quarantine'), 'Test 5.2: Quarantine file content should include the bad operation ID'); + await fs.unlink(quarantineFile).catch(() => {/* EMPTY */}); + } + console.log(' --- Test 5 PASSED ---'); + + } finally { + if (db) await db.close(); + await stopMockServer(); + await cleanUp(); + } + console.log('=== DB SYNC ALL TEST PASSED SUCCESSFULLY ==='); +} + +main().catch(async (err) => { + console.error('\n🔥 TEST FAILED:', err); + if (err.stack) console.error(err.stack); + await stopMockServer().catch(() => {/* */}); + process.exit(1); +}); diff --git a/test/db-ttl-all.js b/test/db-ttl-all.js deleted file mode 100644 index 59dd642..0000000 --- a/test/db-ttl-all.js +++ /dev/null @@ -1,72 +0,0 @@ -// test/db-ttl-all.js - -const path = require('path'); -const fs = require('fs'); -const assert = require('assert'); -const WiseJSON = require('../wise-json/index.js'); - -const DB_PATH = path.resolve(__dirname, 'db-ttl-all'); -const COL = 'ttl_test'; - -function cleanUp() { - if (fs.existsSync(DB_PATH)) fs.rmSync(DB_PATH, { recursive: true, force: true }); -} - -async function sleep(ms) { - return new Promise(res => setTimeout(res, ms)); -} - -async function main() { - console.log('=== DB TTL ALL TEST START ==='); - cleanUp(); - - const db = new WiseJSON(DB_PATH, { ttlCleanupIntervalMs: 500 }); - const col = await db.collection(COL); - await col.initPromise; - - // 1. Вставка с TTL = 1 секунда - await col.insert({ _id: 'a', val: 1, ttl: 1000 }); // 1 секунда - await col.insert({ _id: 'b', val: 2 }); // без TTL - - assert.strictEqual(await col.count(), 2, 'Count after insert'); - - // 2. Ожидаем auto-cleanup - await sleep(1500); - - // cleanupExpiredDocs уже вызван по таймеру - assert.strictEqual(await col.count(), 1, 'Count after TTL cleanup'); - const docB = await col.getById('b'); - assert(docB && docB.val === 2, 'Doc b should survive'); - - // 3. Массовая вставка с TTL - const batch = []; - for (let i = 0; i < 10; i++) batch.push({ _id: `t${i}`, x: i, ttl: 500 }); - await col.insertMany(batch); - - await sleep(700); - - // Проверяем, что все t0-t9 удалены, b остался - assert.strictEqual(await col.count(), 1, 'All expired batch docs gone, b survives'); - - // 4. insert без TTL, вручную вызов cleanup - await col.insert({ _id: 'c', val: 3 }); - await col.insert({ _id: 'd', val: 4, ttl: 100 }); - await sleep(150); - // cleanupExpiredDocs можно вызвать явно: - const { cleanupExpiredDocs } = require('../wise-json/collection/ttl.js'); - cleanupExpiredDocs(col.documents, col._indexManager); - - assert.strictEqual(await col.getById('d'), null, 'd expired and cleaned'); - assert(await col.getById('c'), 'c must stay'); - - await db.close(); - cleanUp(); - - console.log('=== DB TTL ALL TEST PASSED ==='); -} - -main().catch(err => { - console.error('\n🔥 TEST FAILED:', err); - console.error(`\n❗ Директория/файлы не были удалены для ручной отладки: ${DB_PATH}`); - process.exit(1); -}); diff --git a/test/db-ttl-all.ts b/test/db-ttl-all.ts new file mode 100644 index 0000000..1e12745 --- /dev/null +++ b/test/db-ttl-all.ts @@ -0,0 +1,107 @@ +/** + * test/db-ttl-all.test.ts + * Integration test for document expiration and automatic cleanup logic. + */ + +import path from 'path'; +import fs from 'fs'; +import assert from 'assert'; +import { WiseJSON } from '../src/index.js'; +import { cleanupExpiredDocs } from '../src/lib/collection/ttl.js'; + +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DB_PATH = path.resolve(__dirname, 'db-ttl-all'); +const COL_NAME = 'ttl_test'; + +/** + * Interface representing documents with optional TTL. + */ +interface TTLDoc { + _id: string; + val?: number; + x?: number; + ttl?: number; +} + +/** + * Utility to remove the test database directory. + */ +function cleanUp(): void { + if (fs.existsSync(DB_PATH)) { + fs.rmSync(DB_PATH, { recursive: true, force: true }); + } +} + +/** + * Helper to pause execution for TTL intervals. + * @param ms Milliseconds to wait. + */ +async function sleep(ms: number): Promise { + return new Promise(res => setTimeout(res, ms)); +} + +async function main(): Promise { + console.log('=== DB TTL ALL TEST START ==='); + cleanUp(); + + // Initialize with a high-frequency cleanup interval (500ms) for testing + const db = new WiseJSON(DB_PATH, { ttlCleanupIntervalMs: 500 }); + await db.init(); + + const col = await db.getCollection(COL_NAME); + + // 1. Insert documents: one with 1s TTL, one persistent + await col.insert({ _id: 'a', val: 1, ttl: 1000 }); + await col.insert({ _id: 'b', val: 2 }); + + assert.strictEqual(await col.count(), 2, 'Count after initial insert'); + + // 2. Wait for auto-cleanup cycle to trigger + await sleep(1500); + + // Document 'a' should be removed by the background timer + assert.strictEqual(await col.count(), 1, 'Count after TTL cleanup'); + const docB = await col.findOne({ _id: 'b' }); + assert(docB && docB.val === 2, 'Persistent document "b" should survive'); + + // 3. Batch insertion with short TTL (500ms) + const batch: TTLDoc[] = []; + for (let i = 0; i < 10; i++) { + batch.push({ _id: `t${i}`, x: i, ttl: 500 }); + } + await col.insertMany(batch); + + // Wait for the batch documents to expire + await sleep(700); + + // Verify all t0-t9 are gone, and b still remains + assert.strictEqual(await col.count(), 1, 'All expired batch docs should be removed'); + + // 4. Manual cleanup call verification + await col.insert({ _id: 'c', val: 3 }); + await col.insert({ _id: 'd', val: 4, ttl: 100 }); + + await sleep(150); + + // Explicitly trigger cleanup logic using internal document map and index manager + cleanupExpiredDocs((col as any).documents, (col as any)._indexManager); + + assert.strictEqual(await col.findOne({ _id: 'd' }), null, 'Document "d" should be expired and manually cleaned'); + assert(await col.findOne({ _id: 'c' }), 'Persistent document "c" must remain'); + + await db.close(); + cleanUp(); + + console.log('=== DB TTL ALL TEST PASSED ==='); +} + +main().catch(err => { + console.error('\n🔥 TEST FAILED:', err); + console.error(`\n❗ Test directory was not deleted for debugging: ${DB_PATH}`); + process.exit(1); +}); diff --git a/test/db-txn-batch-all.js b/test/db-txn-batch-all.js deleted file mode 100644 index f671185..0000000 --- a/test/db-txn-batch-all.js +++ /dev/null @@ -1,62 +0,0 @@ -// test/db-txn-batch-all.js - -const path = require('path'); -const fs = require('fs'); -const assert = require('assert'); -const WiseJSON = require('../wise-json/index.js'); - -const DB_PATH = path.resolve(__dirname, 'db-txn-batch-all'); -const COL = 'txn_test'; - -function cleanUp() { - if (fs.existsSync(DB_PATH)) fs.rmSync(DB_PATH, { recursive: true, force: true }); -} - -async function main() { - console.log('=== DB TXN BATCH ALL TEST START ==='); - cleanUp(); - - const db = new WiseJSON(DB_PATH); - const col = await db.collection(COL); - await col.initPromise; - - // Batch insert - const batch = []; - for (let i = 0; i < 100; i++) batch.push({ _id: `k${i}`, v: i }); - await col.insertMany(batch); - - // Batch update - await col.updateMany(d => d.v % 2 === 0, { even: true }); - const evens = (await col.getAll()).filter(d => d.even); - assert.strictEqual(evens.length, 50, 'Batch update'); - - // Batch remove - await col.removeMany(d => d.v < 10); - assert.strictEqual(await col.count(), 90, 'Batch remove'); - - // Транзакции: commit и rollback - const txn = db.beginTransaction(); - await txn.collection(COL).insert({ _id: 'txnX', flag: true }); - await txn.collection(COL).update('k11', { flag: true }); - await txn.commit(); - assert((await col.getById('txnX')).flag, 'Txn insert'); - assert((await col.getById('k11')).flag, 'Txn update'); - - const txn2 = db.beginTransaction(); - await txn2.collection(COL).insert({ _id: 'shouldNotExist', flag: 99 }); - await txn2.collection(COL).remove('k12'); - await txn2.rollback(); - assert(!(await col.getById('shouldNotExist')), 'Txn rollback insert'); - assert(await col.getById('k12'), 'Txn rollback remove'); - - await db.close(); - cleanUp(); - - console.log('=== DB TXN BATCH ALL TEST PASSED ==='); -} - -main().catch(err => { - console.error('\n🔥 TEST FAILED:', err); - console.error(`\n❗ Директория/файлы не были удалены для ручной отладки: ${DB_PATH}`); - process.exit(1); -}); diff --git a/test/db-txn-batch-all.ts b/test/db-txn-batch-all.ts new file mode 100644 index 0000000..165fb13 --- /dev/null +++ b/test/db-txn-batch-all.ts @@ -0,0 +1,103 @@ +/** + * test/db-txn-batch-all.test.ts + * Tests for batch operations (insertMany, updateMany, removeMany) + * and ACID-like transactions (commit/rollback). + */ + +import path from 'path'; +import fs from 'fs'; +import assert from 'assert'; +import { WiseJSON } from '../src/index.js'; + +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DB_PATH = path.resolve(__dirname, 'db-txn-batch-all'); +const COL_NAME = 'txn_test'; + +/** + * Interface for the test documents. + */ +interface TestDoc { + _id: string; + v?: number; + even?: boolean; + flag?: boolean | number; +} + +/** + * Cleanup helper for test environment. + */ +function cleanUp(): void { + if (fs.existsSync(DB_PATH)) { + fs.rmSync(DB_PATH, { recursive: true, force: true }); + } +} + +async function main(): Promise { + console.log('=== DB TXN BATCH ALL TEST START ==='); + cleanUp(); + + const db = new WiseJSON(DB_PATH); + await db.init(); + + const col = await db.getCollection(COL_NAME); + + // 1. Batch Insert + const batch: TestDoc[] = []; + for (let i = 0; i < 100; i++) { + batch.push({ _id: `k${i}`, v: i }); + } + await col.insertMany(batch); + assert.strictEqual(await col.count(), 100, 'Batch insert failed to insert 100 docs'); + + // 2. Batch Update + // Update documents where 'v' is even + await col.updateMany((d: TestDoc) => (d.v || 0) % 2 === 0, { even: true }); + const allDocs = await col.find({}); + const evens = allDocs.filter(d => d.even); + assert.strictEqual(evens.length, 50, 'Batch update should affect exactly 50 documents'); + + // 3. Batch Remove + // Remove documents where 'v' is less than 10 + await col.removeMany((d: TestDoc) => (d.v || 0) < 10); + assert.strictEqual(await col.count(), 90, 'Batch remove failed to delete 10 docs'); + + // 4. Transactions: Commit + // Changes within a transaction should be visible after commit() + const txn = db.beginTransaction(); + await txn.collection(COL_NAME).insert({ _id: 'txnX', flag: true }); + await txn.collection(COL_NAME).update('k11', { flag: true }); + await txn.commit(); + + const txnX = await col.findOne({ _id: 'txnX' }); + const k11 = await col.findOne({ _id: 'k11' }); + assert.ok(txnX?.flag, 'Transactionally inserted doc should exist after commit'); + assert.ok(k11?.flag, 'Transactionally updated doc should reflect changes after commit'); + + // 5. Transactions: Rollback + // Changes within a transaction should be discarded after rollback() + const txn2 = db.beginTransaction(); + await txn2.collection(COL_NAME).insert({ _id: 'shouldNotExist', flag: 99 }); + await txn2.collection(COL_NAME).remove('k12'); + await txn2.rollback(); + + const shouldNotExist = await col.findOne({ _id: 'shouldNotExist' }); + const k12 = await col.findOne({ _id: 'k12' }); + assert.strictEqual(shouldNotExist, null, 'Inserted doc in rolled-back txn should not exist'); + assert.ok(k12, 'Removed doc in rolled-back txn should still exist in collection'); + + await db.close(); + cleanUp(); + + console.log('=== DB TXN BATCH ALL TEST PASSED ==='); +} + +main().catch(err => { + console.error('\n🔥 TEST FAILED:', err); + console.error(`\n❗ Test directory was not deleted for debugging: ${DB_PATH}`); + process.exit(1); +}); diff --git a/test/db-unique-index-all.js b/test/db-unique-index-all.js deleted file mode 100644 index 6281f1d..0000000 --- a/test/db-unique-index-all.js +++ /dev/null @@ -1,88 +0,0 @@ -// test/db-unique-index-all.js - -const path = require('path'); -const fs = require('fs'); -const assert = require('assert'); -const WiseJSON = require('../wise-json/index.js'); -// +++ ИМПОРТ ОШИБКИ +++ -const { UniqueConstraintError } = require('../wise-json/errors.js'); - -const DB_PATH = path.resolve(__dirname, 'db-unique-index-all'); -const COL = 'uniq_test'; - -function cleanUp() { - if (fs.existsSync(DB_PATH)) fs.rmSync(DB_PATH, { recursive: true, force: true }); -} - -async function main() { - console.log('=== DB UNIQUE INDEX ALL TEST START ==='); - cleanUp(); - - const db = new WiseJSON(DB_PATH); - const col = await db.getCollection(COL); - - // 1. Создаём уникальный индекс - await col.createIndex('email', { unique: true }); - - // 2. Вставляем первый документ - await col.insert({ email: 'u1@mail.com', name: 'User1' }); - assert.strictEqual(await col.count(), 1, 'Insert first'); - - // 3. Пытаемся вставить второй с таким же email — должно быть исключение! - // ИСПРАВЛЕННЫЙ ТЕСТ: - await assert.rejects( - async () => { - await col.insert({ email: 'u1@mail.com', name: 'User2' }); - }, - UniqueConstraintError, - 'Should throw UniqueConstraintError on duplicate insert' - ); - - // 4. Batch insert с одним дубликатом — должна быть ошибка - // ИСПРАВЛЕННЫЙ ТЕСТ: - await assert.rejects( - async () => { - await col.insertMany([ - { email: 'u2@mail.com', name: 'User2' }, - { email: 'u1@mail.com', name: 'User3' } // дубликат - ]); - }, - UniqueConstraintError, - 'Should throw UniqueConstraintError on batch insert with duplicate' - ); - - // 5. Batch insert без дубликатов — проходит - await col.insertMany([ - { email: 'u2@mail.com', name: 'User2' }, - { email: 'u3@mail.com', name: 'User3' } - ]); - assert.strictEqual(await col.count(), 3, 'Batch insert OK'); - - // 6. Обновление: пытаемся обновить email на уже существующий — ошибка - const user3 = await col.findOne({ email: 'u3@mail.com' }); - // ИСПРАВЛЕННЫЙ ТЕСТ: - await assert.rejects( - async () => { - // Пытаемся установить email 'u2@mail.com', который уже занят - await col.update(user3._id, { email: 'u2@mail.com' }); - }, - UniqueConstraintError, - 'Should throw UniqueConstraintError on update with duplicate value' - ); - - // 7. Обновление без конфликта — проходит - await col.update(user3._id, { email: 'u4@mail.com' }); - const byEmail = await col.find({ email: 'u4@mail.com' }); - assert(byEmail.length === 1 && byEmail[0].name === 'User3', 'Update unique ok'); - - await db.close(); - cleanUp(); - - console.log('=== DB UNIQUE INDEX ALL TEST PASSED ==='); -} - -main().catch(err => { - console.error('\n🔥 TEST FAILED:', err); - console.error(`\n❗ Директория/файлы не были удалены для ручной отладки: ${DB_PATH}`); - process.exit(1); -}); \ No newline at end of file diff --git a/test/db-unique-index-all.ts b/test/db-unique-index-all.ts new file mode 100644 index 0000000..1970793 --- /dev/null +++ b/test/db-unique-index-all.ts @@ -0,0 +1,112 @@ +/** + * test/db-unique-index-all.test.ts + * Tests for unique index constraints and error handling. + */ + +import path from 'path'; +import fs from 'fs'; +import assert from 'assert'; +import { WiseJSON } from '../src/index.js'; +import { UniqueConstraintError } from '../src/lib/errors.js'; + +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DB_PATH = path.resolve(__dirname, 'db-unique-index-all'); +const COL_NAME = 'uniq_test'; + +/** + * Interface for User documents to ensure type-safe testing. + */ +interface User { + _id?: string; + email: string; + name: string; +} + +/** + * Cleanup helper to remove test artifacts. + */ +function cleanUp(): void { + if (fs.existsSync(DB_PATH)) { + fs.rmSync(DB_PATH, { recursive: true, force: true }); + } +} + +async function main(): Promise { + console.log('=== DB UNIQUE INDEX ALL TEST START ==='); + cleanUp(); + + const db = new WiseJSON(DB_PATH); + await db.init(); + + // Using the modern getCollection API with generic type + const col = await db.getCollection(COL_NAME); + + // 1. Create a unique index + await col.createIndex('email', { unique: true }); + + // 2. Insert the first document + await col.insert({ email: 'u1@mail.com', name: 'User1' }); + assert.strictEqual(await col.count(), 1, 'Insert first should succeed'); + + // 3. Attempt to insert a second doc with the same email — must throw! + await assert.rejects( + async () => { + await col.insert({ email: 'u1@mail.com', name: 'User2' }); + }, + UniqueConstraintError, + 'Should throw UniqueConstraintError on duplicate insert' + ); + + // 4. Batch insert with one duplicate — should fail the entire operation or throw + await assert.rejects( + async () => { + await col.insertMany([ + { email: 'u2@mail.com', name: 'User2' }, + { email: 'u1@mail.com', name: 'User3' } // Duplicate of u1 + ]); + }, + UniqueConstraintError, + 'Should throw UniqueConstraintError on batch insert with duplicate' + ); + + // 5. Batch insert without duplicates — should pass + await col.insertMany([ + { email: 'u2@mail.com', name: 'User2' }, + { email: 'u3@mail.com', name: 'User3' } + ]); + assert.strictEqual(await col.count(), 3, 'Batch insert OK'); + + // 6. Update: attempt to change email to an existing one — error + const user3 = await col.findOne({ email: 'u3@mail.com' }); + assert.ok(user3?._id, 'User3 should be found and have an _id'); + + await assert.rejects( + async () => { + // Attempting to set email 'u2@mail.com' which is already taken + await col.update(user3!._id!, { email: 'u2@mail.com' }); + }, + UniqueConstraintError, + 'Should throw UniqueConstraintError on update with duplicate value' + ); + + // 7. Update without conflict — should pass + await col.update(user3!._id!, { email: 'u4@mail.com' }); + const byEmail = await col.find({ email: 'u4@mail.com' }); + assert(byEmail.length === 1 && byEmail[0].name === 'User3', 'Update unique ok'); + + await db.close(); + cleanUp(); + + console.log('=== DB UNIQUE INDEX ALL TEST PASSED ==='); +} + +main().catch(err => { + console.error('\n🔥 TEST FAILED:', err); + console.error(`\n❗ Files were not deleted for manual debugging: ${DB_PATH}`); + process.exit(1); +}); diff --git a/test/run-all-tests.js b/test/run-all-tests.js deleted file mode 100644 index a60f8a3..0000000 --- a/test/run-all-tests.js +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node - -const { spawn } = require('child_process'); -const path = require('path'); -const fs = require('fs/promises'); - -/** - * Асинхронно запускает один тестовый файл. - * @param {string} filePath - Полный путь к тестовому файлу. - * @returns {Promise} Промис, который разрешается при успешном завершении теста - * или отклоняется при ошибке. - */ -async function runTest(filePath) { - return new Promise((resolve, reject) => { - const testName = path.basename(filePath); - console.log(`\n\n===== Running test: ${testName} =====\n`); - - // Запускаем тест в дочернем процессе - const child = spawn('node', [filePath], { - // Наследуем stdio, чтобы видеть вывод теста (включая цвета) в реальном времени - stdio: 'inherit' - }); - - // Обработчик завершения процесса - child.on('close', (code) => { - if (code !== 0) { - // Если код выхода ненулевой, значит, тест упал. - // Отклоняем промис с ошибкой. - reject(new Error(`Test failed: ${testName} (exited with code ${code})`)); - } else { - // Если все хорошо, разрешаем промис. - console.log(`\n✅ PASSED: ${testName}`); - resolve(); - } - }); - - // Обработчик ошибок самого процесса (например, если не удалось запустить node) - child.on('error', (err) => { - reject(new Error(`Failed to start test process for ${testName}: ${err.message}`)); - }); - }); -} - -/** - * Основная функция-оркестратор. - */ -async function main() { - console.log('Starting all tests...'); - - const testDir = __dirname; - const allFilesInDir = await fs.readdir(testDir); - - // Автоматически находим все нужные тестовые файлы. - // Исключаем сам этот скрипт. - const testFiles = allFilesInDir - .filter(f => - (f.endsWith('-all.js') || f.endsWith('-scenarios.js')) && f !== 'run-all-tests.js' - ) - .map(f => path.join(testDir, f)); - - if (testFiles.length === 0) { - console.warn('⚠️ No test files found to run. Check file naming convention (*-all.js, *-scenarios.js).'); - return; - } - - console.log(`Found ${testFiles.length} test files to run.`); - - // Запускаем тесты последовательно. - // Если любой из `await runTest(file)` выбросит ошибку (reject), - // цикл `for...of` прервется, и выполнение перейдет в блок `catch`. - for (const file of testFiles) { - await runTest(file); - } - - // Этот блок выполнится, только если все тесты прошли успешно. - console.log('\n\n============================'); - console.log(`✅ All ${testFiles.length} tests passed successfully!`); - console.log('============================'); -} - -// Запускаем основную логику и ловим любые ошибки. -main().catch(error => { - console.error('\n\n============================'); - console.error(`🔥 A test run failed. Aborting.`); - console.error(error.message); - console.error('============================'); - process.exit(1); // Завершаем процесс с кодом ошибки, чтобы CI тоже упал. -}); \ No newline at end of file diff --git a/test/run-all-tests.ts b/test/run-all-tests.ts new file mode 100644 index 0000000..49ad7f2 --- /dev/null +++ b/test/run-all-tests.ts @@ -0,0 +1,86 @@ +/** + * test/run-all-tests.ts + * Orchestrator to run all TypeScript integration tests in sequence. + */ + +import { spawn } from 'child_process'; +import path from 'path'; +import fs from 'fs/promises'; +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Runs a single test file using 'tsx'. + */ +async function runTest(filePath: string): Promise { + return new Promise((resolve, reject) => { + const testName = path.basename(filePath); + console.log(`\n\n🚀 ===== Running test: ${testName} =====\n`); + + // Use 'npx tsx' to ensure the TypeScript file is executed correctly + const child = spawn('npx', ['tsx', filePath], { + stdio: 'inherit', + env: { ...process.env, NODE_OPTIONS: '--no-warnings' } + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`❌ Test failed: ${testName} (exit code ${code})`)); + } else { + console.log(`\n✅ PASSED: ${testName}`); + resolve(); + } + }); + + child.on('error', (err) => { + reject(new Error(`💥 Failed to start test: ${testName}. Error: ${err.message}`)); + }); + }); +} + +async function main() { + console.log('🧪 Starting the WiseJSON test suite...'); + + const testDir = __dirname; + const allFilesInDir = await fs.readdir(testDir); + + // Find all files ending in .test.ts or -all.ts, excluding this runner + const testFiles = allFilesInDir + .filter(f => + (f.endsWith('.test.ts') || f.endsWith('-all.ts') || f.endsWith('-scenarios.ts')) + && f !== path.basename(__filename) + ) + .map(f => path.join(testDir, f)); + + if (testFiles.length === 0) { + console.warn('⚠️ No test files found. Check your naming convention (*.test.ts or *-all.ts).'); + return; + } + + console.log(`🔍 Found ${testFiles.length} test files to run.`); + + // Run tests sequentially to avoid port/database conflicts + for (const file of testFiles) { + try { + await runTest(file); + } catch (error: any) { + console.error(`\n\n============================`); + console.error(`🔥 CRITICAL FAILURE: ${error.message}`); + console.error(`Aborting remaining tests.`); + console.error(`============================`); + process.exit(1); + } + } + + console.log('\n\n=========================================='); + console.log(`🎊 SUCCESS: All ${testFiles.length} tests passed!`); + console.log('=========================================='); +} + +main().catch(error => { + console.error('Unexpected error in test runner:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/test/server-ready-api-all.js b/test/server-ready-api-all.js deleted file mode 100644 index d5d6673..0000000 --- a/test/server-ready-api-all.js +++ /dev/null @@ -1,96 +0,0 @@ -// test/server-ready-api.test.js - -const path = require('path'); -const fs = require('fs'); -const assert = require('assert'); - -// Импортируем все, что нам нужно, из корневого модуля -const { connect, UniqueConstraintError } = require('../index.js'); - -// --- Настройка тестового окружения --- -const TEST_DB_PATH = path.resolve(__dirname, 'server-api-test-db'); - -/** - * Вспомогательная функция для полной очистки тестовой директории. - */ -function cleanup() { - if (fs.existsSync(TEST_DB_PATH)) { - fs.rmSync(TEST_DB_PATH, { recursive: true, force: true }); - } -} - -// --- Основной блок теста --- -async function runServerReadyApiTest() { - console.log('=== SERVER-READY API TEST START ==='); - // Гарантированная очистка перед началом - cleanup(); - let db; - - try { - // 1. Проверяем "ленивую" инициализацию. НЕ вызываем db.init() - console.log(' [1] Инициализация DB без явного вызова .init()'); - db = connect(TEST_DB_PATH); - assert.ok(db, 'Экземпляр DB должен быть создан'); - - // 2. Используем новый метод getCollection() - console.log(' [2] Получение коллекции через getCollection()'); - const users = await db.getCollection('users'); - assert.ok(users, 'Коллекция "users" должна быть получена'); - assert.strictEqual(await users.count(), 0, 'Новая коллекция должна быть пустой'); - - // 3. Базовые операции - console.log(' [3] Тестирование базовых CRUD-операций'); - await users.insert({ _id: 'user1', name: 'Alice', email: 'alice@example.com' }); - const alice = await users.getById('user1'); - assert.strictEqual(alice.name, 'Alice', 'getById должен найти Alice'); - assert.strictEqual(await users.count(), 1, 'Количество должно быть 1'); - - // 4. Проверка кастомной ошибки UniqueConstraintError - console.log(' [4] Тестирование кастомной ошибки UniqueConstraintError'); - await users.createIndex('email', { unique: true }); - - await assert.rejects( - async () => { - await users.insert({ name: 'Alicia', email: 'alice@example.com' }); - }, - (err) => { - // Проверяем, что это ошибка нужного типа и содержит правильные данные - assert(err instanceof UniqueConstraintError, 'Ошибка должна быть типа UniqueConstraintError'); - assert.strictEqual(err.fieldName, 'email', 'Поле ошибки должно быть "email"'); - assert.strictEqual(err.value, 'alice@example.com', 'Значение ошибки должно быть "alice@example.com"'); - return true; // Если все assert внутри прошли, возвращаем true - }, - 'Должна быть выброшена ошибка UniqueConstraintError при дублировании email' - ); - console.log(' --- UniqueConstraintError успешно поймана'); - - // 5. Проверка работы с несколькими коллекциями - console.log(' [5] Работа с несколькими коллекциями'); - const logs = await db.getCollection('logs'); - await logs.insert({ event: 'user_created', userId: 'user1' }); - assert.strictEqual(await logs.count(), 1, 'Коллекция логов должна содержать 1 запись'); - - const collectionNames = await db.getCollectionNames(); - assert.deepStrictEqual(collectionNames.sort(), ['logs', 'users'].sort(), 'getCollectionNames должен вернуть правильный список'); - - } finally { - // 6. Гарантированное закрытие БД и очистка - console.log(' [6] Закрытие БД и очистка временных файлов'); - if (db) { - await db.close(); - } - cleanup(); - console.log(' --- Очистка завершена'); - } - - console.log('\n✅ === SERVER-READY API TEST PASSED SUCCESSFULLY ==='); -} - -// Запускаем тест -runServerReadyApiTest().catch(err => { - console.error('\n🔥 === TEST FAILED ==='); - console.error(err); - // Все равно пытаемся очистить файлы в случае ошибки - cleanup(); - process.exit(1); -}); \ No newline at end of file diff --git a/test/server-ready-api-all.ts b/test/server-ready-api-all.ts new file mode 100644 index 0000000..4811a21 --- /dev/null +++ b/test/server-ready-api-all.ts @@ -0,0 +1,130 @@ +/** + * test/server-ready-api.test.ts + * Tests the high-level public API, lazy initialization via connect(), + * and specialized error handling for production environments. + */ + +import path from 'path'; +import fs from 'fs'; +import assert from 'assert'; +import { connect, UniqueConstraintError, WiseJSON } from '../src/index.js'; + +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const TEST_DB_PATH = path.resolve(__dirname, 'server-api-test-db'); + +/** + * Interface for the User document to ensure type safety in the test. + */ +interface UserDoc { + _id?: string; + name: string; + email: string; +} + +/** + * Interface for Log documents. + */ +interface LogDoc { + event: string; + userId: string; +} + +/** + * Helper function for complete cleanup of the test directory. + */ +function cleanup(): void { + if (fs.existsSync(TEST_DB_PATH)) { + fs.rmSync(TEST_DB_PATH, { recursive: true, force: true }); + } +} + +async function runServerReadyApiTest(): Promise { + console.log('=== SERVER-READY API TEST START ==='); + cleanup(); + + let db: WiseJSON | undefined; + + try { + // 1. Verify "Lazy" Initialization + // We do NOT call db.init() explicitly; the first operation should trigger it. + console.log(' [1] Initializing DB without explicit .init() call'); + db = connect(TEST_DB_PATH); + assert.ok(db, 'DB instance should be successfully created'); + + // 2. Using the getCollection() API + console.log(' [2] Retrieving collection via getCollection()'); + const users = await db.getCollection('users'); + assert.ok(users, 'Collection "users" should be retrieved'); + assert.strictEqual(await users.count(), 0, 'A new collection should be empty'); + + // 3. Basic CRUD Operations + console.log(' [3] Testing basic CRUD operations'); + await users.insert({ _id: 'user1', name: 'Alice', email: 'alice@example.com' }); + + const alice = await users.findOne({ _id: 'user1' }); + assert.strictEqual(alice?.name, 'Alice', 'findOne should retrieve Alice'); + assert.strictEqual(await users.count(), 1, 'Collection count should be 1'); + + // 4. UniqueConstraintError Validation + console.log(' [4] Testing custom UniqueConstraintError handling'); + await users.createIndex('email', { unique: true }); + + await assert.rejects( + async () => { + // Attempting to insert a duplicate email + await users.insert({ name: 'Alicia', email: 'alice@example.com' }); + }, + (err: any) => { + // Verify the error is the correct class and contains the expected metadata + const isCorrectType = err instanceof UniqueConstraintError; + const isCorrectField = err.fieldName === 'email'; + const isCorrectValue = err.value === 'alice@example.com'; + + assert(isCorrectType, 'Error must be an instance of UniqueConstraintError'); + assert(isCorrectField, 'Error fieldName should be "email"'); + assert(isCorrectValue, 'Error value should be "alice@example.com"'); + + return true; + }, + 'Should throw UniqueConstraintError on duplicate email insertion' + ); + console.log(' --- UniqueConstraintError successfully caught and verified'); + + // 5. Multi-collection interaction + console.log(' [5] Testing multi-collection operations'); + const logs = await db.getCollection('logs'); + await logs.insert({ event: 'user_created', userId: 'user1' }); + assert.strictEqual(await logs.count(), 1, 'Logs collection should contain 1 entry'); + + const collectionNames = await db.getCollectionNames(); + assert.deepStrictEqual( + collectionNames.sort(), + ['logs', 'users'].sort(), + 'getCollectionNames should return the correct list of collections' + ); + + } finally { + // 6. Resource Cleanup + console.log(' [6] Closing database and cleaning up temporary files'); + if (db) { + await db.close(); + } + cleanup(); + console.log(' --- Cleanup complete'); + } + + console.log('\n✅ === SERVER-READY API TEST PASSED SUCCESSFULLY ==='); +} + +// Execute the test runner +runServerReadyApiTest().catch(err => { + console.error('\n🔥 === TEST FAILED ==='); + console.error(err); + cleanup(); + process.exit(1); +}); \ No newline at end of file diff --git a/test/test-index-proxy-all.js b/test/test-index-proxy-all.js deleted file mode 100644 index 471c903..0000000 --- a/test/test-index-proxy-all.js +++ /dev/null @@ -1,76 +0,0 @@ -// test/test-index-proxy.js - -const assert = require('assert'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -(async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wisejson-proxy-test-')); - const dbPath = path.join(tmpDir, 'db-dir'); - let db; - - // Гарантированная очистка перед тестом - if (fs.existsSync(dbPath)) { - fs.rmSync(dbPath, { recursive: true, force: true }); - } - - try { - const proxy = require('../index'); - assert.strictEqual(typeof proxy.connect, 'function', 'connect должен быть функцией'); - - db = proxy.connect(dbPath); - // Получаем коллекцию асинхронно, как и положено - const users = await db.collection('users-proxy-test'); - await users.initPromise; - - // Проверяем наличие НОВЫХ методов - const methods = [ - 'insert', 'insertMany', - 'find', 'findOne', - 'updateOne', 'updateMany', - 'deleteOne', 'deleteMany' - ]; - methods.forEach(m => { - assert.strictEqual(typeof users[m], 'function', `Метод ${m} должен существовать`); - }); - - // insert + findOne - await users.insert({ id: 1, name: 'Alice' }); - const f1 = await users.findOne({ id: 1 }); - assert.strictEqual(f1.name, 'Alice', 'findOne должен найти Alice'); - - // insertMany + find - await users.insertMany([{ id: 2, name: 'Bob' }, { id: 3, name: 'Carol' }]); - assert.strictEqual(await users.count(), 3, 'После вставки должно быть 3 документа'); - - // updateOne - await users.updateOne({ id: 3 }, { $set: { name: 'Caroline' } }); - const caroline = await users.findOne({ id: 3 }); - assert.strictEqual(caroline.name, 'Caroline', 'updateOne должен обновить имя'); - - // deleteOne - await users.deleteOne({ id: 1 }); - assert.strictEqual(await users.count(), 2, 'После deleteOne должно остаться 2 документа'); - - // deleteMany - await users.deleteMany({ id: { $in: [2, 3] } }); - assert.strictEqual(await users.count(), 0, 'После deleteMany должно остаться 0 документов'); - - if (typeof db.close === 'function') { - await db.close(); - } - - console.log('✓ test-index-proxy.js: все проверки пройдены'); - process.exit(0); - } catch (err) { - console.error('✗ test-index-proxy.js: ошибка при проверке прокладки', err); - process.exit(1); - } finally { - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch (cleanupErr) { - console.warn('Не удалось удалить временные файлы:', cleanupErr); - } - } -})(); \ No newline at end of file diff --git a/test/test-index-proxy-all.ts b/test/test-index-proxy-all.ts new file mode 100644 index 0000000..4925cb9 --- /dev/null +++ b/test/test-index-proxy-all.ts @@ -0,0 +1,108 @@ +/** + * test/test-index-proxy-all.test.ts + * Verifies that the library entry point correctly exposes the modernized + * CRUD API and that the proxying to the Collection class is working as intended. + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { WiseJSON } from '../src/lib/index.js'; + +import { fileURLToPath } from 'url'; + +// --- ESM Compatibility --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Interface for document used in proxy testing. + */ +interface ProxyTestDoc { + id: number; + name: string; +} + +(async () => { + // Create a unique temporary directory for this test run + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wisejson-proxy-test-')); + const dbPath = path.join(tmpDir, 'db-dir'); + let db: WiseJSON; + + // Ensure clean state + if (fs.existsSync(dbPath)) { + fs.rmSync(dbPath, { recursive: true, force: true }); + } + + try { + // Import the package entry point + const wise = await import('../src/index.js'); + + assert.strictEqual(typeof wise.connect, 'function', 'The connect factory must be a function'); + + // Initialize database instance using the factory + db = wise.connect(dbPath); + + // Retrieve the collection using the modern async API + const users = await db.getCollection('users-proxy-test'); + + // 1. Verify existence of the modern CRUD API methods + const methods = [ + 'insert', 'insertMany', + 'find', 'findOne', + 'updateOne', 'updateMany', + 'deleteOne', 'deleteMany' + ]; + + methods.forEach(m => { + assert.strictEqual(typeof (users as any)[m], 'function', `Method ${m} should exist on the collection`); + }); + + + + // 2. Test: insert + findOne + await users.insert({ id: 1, name: 'Alice' }); + const f1 = await users.findOne({ id: 1 }); + assert.strictEqual(f1?.name, 'Alice', 'findOne should successfully retrieve Alice'); + + // 3. Test: insertMany + find + await users.insertMany([ + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' } + ]); + assert.strictEqual(await users.count(), 3, 'Collection count should be 3 after batch insertion'); + + // 4. Test: updateOne (using MongoDB-style $set operator) + await users.updateOne({ id: 3 }, { $set: { name: 'Caroline' } }); + const caroline = await users.findOne({ id: 3 }); + assert.strictEqual(caroline?.name, 'Caroline', 'updateOne should update the name field'); + + // 5. Test: deleteOne + await users.deleteOne({ id: 1 }); + assert.strictEqual(await users.count(), 2, 'Collection count should be 2 after deleteOne'); + + // 6. Test: deleteMany (using MongoDB-style $in operator) + await users.deleteMany({ id: { $in: [2, 3] } }); + assert.strictEqual(await users.count(), 0, 'Collection should be empty after deleteMany'); + + // 7. Cleanup DB connection + if (db && typeof db.close === 'function') { + await db.close(); + } + + console.log('✓ test-index-proxy.ts: All proxy interface checks passed'); + process.exit(0); + + } catch (err) { + console.error('✗ test-index-proxy.ts: Failed to verify proxy interface', err); + process.exit(1); + } finally { + // Final cleanup of temporary files + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch (cleanupErr) { + console.warn('Could not remove temporary test files:', cleanupErr); + } + } +})(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aededa4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "declaration": true, + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*", "test"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file diff --git a/wise-json/checkpoint-manager.js b/wise-json/checkpoint-manager.js deleted file mode 100644 index 04fc5e2..0000000 --- a/wise-json/checkpoint-manager.js +++ /dev/null @@ -1,199 +0,0 @@ -// wise-json/checkpoint-manager.js - -const path = require('path'); -const fs = require('fs/promises'); -const { cleanupExpiredDocs } = require('./collection/ttl.js'); -// const logger = require('./logger'); // --- УДАЛЕНО - -// +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ -async function getCheckpointFiles(checkpointsDir, collectionName, type = 'meta', logger) { - const log = logger || require('./logger'); - let files = []; - try { - try { - await fs.access(checkpointsDir); - } catch (accessError) { - if (accessError.code === 'ENOENT') { - return []; - } - throw accessError; - } - files = await fs.readdir(checkpointsDir); - } catch (e) { - if (e.code === 'ENOENT') { - return []; - } - log.error(`[Checkpoint] Ошибка чтения директории чекпоинтов ${checkpointsDir}: ${e.message}`); - throw e; - } - return files - .filter(f => f.startsWith(`checkpoint_${type}_${collectionName}_`) && f.endsWith('.json')) - .sort(); -} - -function extractTimestampFromMetaFile(metaFileName, collectionName) { - const re = new RegExp(`^checkpoint_meta_${collectionName}_([\\dTZ-]+)\\.json$`); - const match = metaFileName.match(re); - return match ? match[1] : null; -} - -// +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ -async function loadLatestCheckpoint(checkpointsDir, collectionName, logger) { - const log = logger || require('./logger'); - const metaFiles = await getCheckpointFiles(checkpointsDir, collectionName, 'meta', log); - - if (metaFiles.length === 0) { - log.log(`[Checkpoint] Файлы meta-чекпоинтов для коллекции '${collectionName}' не найдены. Это ожидаемо при первом запуске или если коллекция была очищена/удалена.`); - return { documents: new Map(), indexesMeta: [], timestamp: null }; - } - - for (let i = metaFiles.length - 1; i >= 0; i--) { - const currentMetaFile = metaFiles[i]; - const timestampFromFile = extractTimestampFromMetaFile(currentMetaFile, collectionName); - - if (!timestampFromFile) { - log.warn(`[Checkpoint] Не удалось извлечь файловый timestamp из meta-файла '${currentMetaFile}' для коллекции '${collectionName}'. Файл будет пропущен.`); - continue; - } - - const allDataFilesRaw = await getCheckpointFiles(checkpointsDir, collectionName, 'data', log); - const dataSegmentFiles = allDataFilesRaw.filter(f => { - const segMatch = f.match(new RegExp(`^checkpoint_data_${collectionName}_${timestampFromFile}_seg\\d+\\.json$`)); - return !!segMatch; - }); - dataSegmentFiles.sort(); - - let metaContent; - try { - metaContent = JSON.parse(await fs.readFile(path.join(checkpointsDir, currentMetaFile), 'utf8')); - if (!metaContent.timestamp || typeof metaContent.timestamp !== 'string') { - log.warn(`[Checkpoint] Meta-файл '${currentMetaFile}' для коллекции '${collectionName}' не содержит валидного поля 'timestamp'. Чекпоинт пропущен.`); - continue; - } - } catch (e) { - log.warn(`[Checkpoint] ⚠ Ошибка чтения или парсинга meta-файла чекпоинта '${currentMetaFile}' для коллекции '${collectionName}': ${e.message}. Чекпоинт пропущен.`); - continue; - } - - if (metaContent.documentCount === 0 && dataSegmentFiles.length === 0) { - cleanupExpiredDocs(new Map()); - return { - documents: new Map(), - indexesMeta: metaContent.indexesMeta || [], - timestamp: metaContent.timestamp - }; - } - - if (metaContent.documentCount > 0 && dataSegmentFiles.length === 0) { - log.warn(`[Checkpoint] Meta-файл '${currentMetaFile}' (ISO ts: ${metaContent.timestamp}) коллекции '${collectionName}' указывает на ${metaContent.documentCount} документов, но не найдены соответствующие data-сегменты. Чекпоинт пропущен.`); - continue; - } - - const documents = new Map(); - let allSegmentsLoadedSuccessfully = true; - for (const segFile of dataSegmentFiles) { - try { - const segmentDocsArray = JSON.parse(await fs.readFile(path.join(checkpointsDir, segFile), 'utf8')); - if (Array.isArray(segmentDocsArray)) { - for (const doc of segmentDocsArray) { - if (doc && typeof doc._id !== 'undefined') { - documents.set(doc._id, doc); - } else { - log.warn(`[Checkpoint] Обнаружен документ без _id или некорректный документ в сегменте '${segFile}' (коллекция '${collectionName}'). Документ пропущен.`); - } - } - } else { - log.warn(`[Checkpoint] Data-сегмент '${segFile}' (коллекция '${collectionName}') не содержит массив. Сегмент пропущен.`); - allSegmentsLoadedSuccessfully = false; - break; - } - } catch (e) { - log.warn(`[Checkpoint] ⚠ Ошибка чтения или парсинга data-сегмента '${segFile}' (коллекция '${collectionName}'): ${e.message}. Сегмент пропущен.`); - allSegmentsLoadedSuccessfully = false; - break; - } - } - - if (!allSegmentsLoadedSuccessfully) { - log.warn(`[Checkpoint] Не все data-сегменты для файлового timestamp '${timestampFromFile}' (ISO ts: ${metaContent.timestamp}, коллекция '${collectionName}') были успешно загружены. Этот чекпоинт будет пропущен.`); - continue; - } - - const removedByTtl = cleanupExpiredDocs(documents); - if (removedByTtl > 0) { - log.log(`[Checkpoint] [TTL] При загрузке чекпоинта для коллекции '${collectionName}' (ISO ts: ${metaContent.timestamp}) удалено ${removedByTtl} истекших документов.`); - } - - return { - documents, - indexesMeta: metaContent.indexesMeta || [], - timestamp: metaContent.timestamp - }; - } - - log.warn(`[Checkpoint] Не удалось загрузить ни один валидный чекпоинт для коллекции '${collectionName}'. Коллекция будет инициализирована как пустая (или только из WAL).`); - return { documents: new Map(), indexesMeta: [], timestamp: null }; -} - -// +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ -async function cleanupOldCheckpoints(checkpointsDir, collectionName, keep = 5, logger) { - const log = logger || require('./logger'); - if (keep <= 0) { - log.warn(`[Checkpoint] cleanupOldCheckpoints вызван с keep <= 0 (${keep}) для коллекции '${collectionName}'. Очистка не будет выполнена.`); - return; - } - - const metaFiles = await getCheckpointFiles(checkpointsDir, collectionName, 'meta', log); - const allDataFiles = await getCheckpointFiles(checkpointsDir, collectionName, 'data', log); - - const metaFilesToRemove = metaFiles.length > keep ? metaFiles.slice(0, metaFiles.length - keep) : []; - - const timestampsToKeep = new Set( - metaFiles.slice(-keep).map(f => extractTimestampFromMetaFile(f, collectionName)).filter(Boolean) - ); - - const unlinkWithRetry = async (filePath, fileNameForLog) => { - let retries = 10; - let currentDelay = 500; - - while (retries > 0) { - try { - await fs.unlink(filePath); - return true; - } catch (err) { - if (err.code === 'ENOENT') { - return true; - } - retries--; - if (retries === 0) { - log.warn(`[Checkpoint] Не удалось удалить файл '${fileNameForLog}' (коллекция: ${collectionName}) после нескольких попыток: ${err.code} - ${err.message}`); - return false; - } - await new Promise(resolve => setTimeout(resolve, currentDelay)); - currentDelay = Math.min(currentDelay + 500, 3000); - } - } - return false; - }; - - for (const metaFileToRemove of metaFilesToRemove) { - const filePath = path.join(checkpointsDir, metaFileToRemove); - await unlinkWithRetry(filePath, metaFileToRemove); - } - - const dataFilesToRemove = allDataFiles.filter(dataFile => { - const match = dataFile.match(new RegExp(`^checkpoint_data_${collectionName}_([\\dTZ-]+)_seg\\d+\\.json$`)); - const dataTimestamp = match ? match[1] : null; - return dataTimestamp && !timestampsToKeep.has(dataTimestamp); - }); - - for (const dataFileToRemove of dataFilesToRemove) { - const filePath = path.join(checkpointsDir, dataFileToRemove); - await unlinkWithRetry(filePath, dataFileToRemove); - } -} - -module.exports = { - loadLatestCheckpoint, - cleanupOldCheckpoints, -}; \ No newline at end of file diff --git a/wise-json/collection/checkpoints.js b/wise-json/collection/checkpoints.js deleted file mode 100644 index f2517f7..0000000 --- a/wise-json/collection/checkpoints.js +++ /dev/null @@ -1,124 +0,0 @@ -// wise-json/collection/checkpoints.js - -const path = require('path'); -const fs = require('fs/promises'); -const { cleanupExpiredDocs } = require('./ttl.js'); -const logger = require('../logger'); - -/** - * Генерирует имя файла чекпоинта. - * Использует timestamp, безопасный для имен файлов (с '-' вместо ':' и '.'). - * @param {string} collectionName - * @param {string} type - 'meta' или 'data' - * @param {string} timestampForFileName - Timestamp в формате YYYY-MM-DDTHH-mm-ss-SSSZ - * @param {number|undefined} segment - * @returns {string} - */ -function getCheckpointFileName(collectionName, type, timestampForFileName, segment) { - if (segment !== undefined) { - return `checkpoint_${type}_${collectionName}_${timestampForFileName}_seg${segment}.json`; - } - return `checkpoint_${type}_${collectionName}_${timestampForFileName}.json`; -} - -/** - * Контроллер чекпоинтов для коллекции. - */ -function createCheckpointController({ collectionName, collectionDirPath, documents, options, getIndexesMeta }) { - let checkpointTimer = null; - const checkpointsDir = path.join(collectionDirPath, '_checkpoints'); - - async function saveCheckpoint() { - await fs.mkdir(checkpointsDir, { recursive: true }); - - if (typeof cleanupExpiredDocs === 'function') { - cleanupExpiredDocs(documents); - } else { - logger.error("[Checkpoints] Функция cleanupExpiredDocs не найдена или не является функцией. Очистка TTL перед чекпоинтом может не произойти."); - } - - const originalIsoTimestamp = new Date().toISOString(); // Формат: YYYY-MM-DDTHH:MM:SS.sssZ - const timestampForFileName = originalIsoTimestamp.replace(/[:.]/g, '-'); // Формат: YYYY-MM-DDTHH-mm-ss-SSSZ - - const meta = { - collectionName, - timestamp: originalIsoTimestamp, // Оригинальный ISO для хранения в meta - documentCount: documents.size, - indexesMeta: getIndexesMeta ? getIndexesMeta() : [], - type: 'meta' - }; - - const metaFile = getCheckpointFileName(collectionName, 'meta', timestampForFileName); - - await fs.writeFile(path.join(checkpointsDir, metaFile), JSON.stringify(meta, null, 2), 'utf8'); - - const aliveDocs = Array.from(documents.values()); - const maxSegmentSize = options?.maxSegmentSizeBytes || 2 * 1024 * 1024; - let segmentIndex = 0; - let currentSegment = []; - let currentSize = 2; // Учитываем символы "[]" для пустого массива JSON - let segmentFiles = []; - - for (const doc of aliveDocs) { - // Для подсчета размера используем строку без форматирования для скорости - const docJsonString = JSON.stringify(doc); - const docSize = Buffer.byteLength(docJsonString, 'utf8') + (currentSegment.length > 0 ? 1 : 0); // +1 за запятую, если не первый элемент - - if (currentSize + docSize > maxSegmentSize && currentSegment.length > 0) { - const dataFile = getCheckpointFileName(collectionName, 'data', timestampForFileName, segmentIndex); - await fs.writeFile( - path.join(checkpointsDir, dataFile), - JSON.stringify(currentSegment, null, 2), - 'utf8' - ); - segmentFiles.push(dataFile); - segmentIndex++; - currentSegment = []; - currentSize = 2; // Сбрасываем размер для нового сегмента - } - currentSegment.push(doc); - currentSize += docSize + (currentSegment.length > 1 ? 1 : 0); // +1 за запятую после предыдущего элемента - } - - if (currentSegment.length > 0) { - const dataFile = getCheckpointFileName(collectionName, 'data', timestampForFileName, segmentIndex); - await fs.writeFile( - path.join(checkpointsDir, dataFile), - JSON.stringify(currentSegment, null, 2), - 'utf8' - ); - segmentFiles.push(dataFile); - } - - return { metaFile, segmentFiles, meta }; - } - - function startCheckpointTimer(intervalMs) { // Убрал значение по умолчанию, оно должно приходить из опций коллекции - stopCheckpointTimer(); - if (intervalMs > 0) { - checkpointTimer = setInterval(async () => { - try { - // logger.debug(`[Checkpoints] Auto-checkpoint for ${collectionName} triggered by timer.`); - await saveCheckpoint(); - } catch (e) { - logger.error(`[Checkpoints] Error during auto-checkpoint for ${collectionName}: ${e.message}`, e.stack); - } - }, intervalMs); - } - } - - function stopCheckpointTimer() { - if (checkpointTimer) { - clearInterval(checkpointTimer); - checkpointTimer = null; - } - } - - return { - saveCheckpoint, - startCheckpointTimer, - stopCheckpointTimer - }; -} - -module.exports = createCheckpointController; \ No newline at end of file diff --git a/wise-json/collection/core.js b/wise-json/collection/core.js deleted file mode 100644 index 130efb6..0000000 --- a/wise-json/collection/core.js +++ /dev/null @@ -1,718 +0,0 @@ -// wise-json/collection/core.js - -const path = require('path'); -const fs = require('fs/promises'); -const { v4: uuidv4 } = require('uuid'); -const CollectionEventEmitter = require('./events.js'); -const IndexManager = require('./indexes.js'); -const SyncManager = require('../sync/sync-manager.js'); -const { UniqueConstraintError } = require('../errors.js'); -const { - defaultIdGenerator, - isNonEmptyString, - isPlainObject, - makeAbsolutePath, -} = require('./utils.js'); -const { - initializeWal, - readWal, - getWalPath, - compactWal, - appendWalEntry, -} = require('../wal-manager.js'); -const { - loadLatestCheckpoint, - cleanupOldCheckpoints -} = require('../checkpoint-manager.js'); -const { - cleanupExpiredDocs, - isAlive -} = require('./ttl.js'); -const { - acquireCollectionLock, - releaseCollectionLock -} = require('./file-lock.js'); -const { createWriteQueue } = require('./queue.js'); -const { writeJsonFileSafe } = require('../storage-utils.js'); - -const crudOps = require('./ops.js'); -const queryOps = require('./query-ops.js'); -const dataExchangeOps = require('./data-exchange.js'); - -function validateCollectionOptions(opts = {}) { - const defaults = { - maxSegmentSizeBytes: 2 * 1024 * 1024, - checkpointIntervalMs: 5 * 60 * 1000, - ttlCleanupIntervalMs: 60 * 1000, - idGenerator: defaultIdGenerator, - checkpointsToKeep: 5, - maxWalEntriesBeforeCheckpoint: 1000, - walReadOptions: { recover: false, strict: false } - }; - const options = { ...defaults, ...opts }; - - if (typeof options.maxSegmentSizeBytes !== 'number' || options.maxSegmentSizeBytes <= 0) options.maxSegmentSizeBytes = defaults.maxSegmentSizeBytes; - if (typeof options.checkpointIntervalMs !== 'number' || options.checkpointIntervalMs < 0) options.checkpointIntervalMs = defaults.checkpointIntervalMs; - if (typeof options.ttlCleanupIntervalMs !== 'number' || options.ttlCleanupIntervalMs <= 0) options.ttlCleanupIntervalMs = defaults.ttlCleanupIntervalMs; - if (typeof options.idGenerator !== 'function') options.idGenerator = defaults.idGenerator; - if (typeof options.checkpointsToKeep !== 'number' || options.checkpointsToKeep < 1) options.checkpointsToKeep = defaults.checkpointsToKeep; - if (typeof options.maxWalEntriesBeforeCheckpoint !== 'number' || options.maxWalEntriesBeforeCheckpoint < 0) options.maxWalEntriesBeforeCheckpoint = defaults.maxWalEntriesBeforeCheckpoint; - - if (typeof options.walReadOptions !== 'object' || options.walReadOptions === null) { - options.walReadOptions = { ...defaults.walReadOptions }; - } else { - options.walReadOptions = { ...defaults.walReadOptions, ...options.walReadOptions }; - } - return options; -} - - -class Collection { - constructor(name, dbRootPath, options = {}) { - if (!isNonEmptyString(name)) { - throw new Error('Collection: collection name must be a non-empty string.'); - } - - this.name = name; - this.dbRootPath = makeAbsolutePath(dbRootPath); - this.options = validateCollectionOptions(options); - - this.logger = this.options.logger || require('../logger'); - - this.collectionDirPath = path.resolve(this.dbRootPath, this.name); - this.checkpointsDir = path.join(this.collectionDirPath, '_checkpoints'); - this.walPath = getWalPath(this.collectionDirPath, this.name); - this.quarantinePath = path.join(this.collectionDirPath, `quarantine_${this.name}.log`); - - this.documents = new Map(); - this._idGenerator = this.options.idGenerator; - this.isPlainObject = isPlainObject; - - this._emitter = new CollectionEventEmitter(this.name); - this._indexManager = new IndexManager(this.name, this.logger); - - this._stats = { - inserts: 0, - updates: 0, - removes: 0, - clears: 0, - walEntriesSinceCheckpoint: 0, - lastCheckpointTimestamp: null - }; - - this._checkpointTimerId = null; - this._ttlCleanupTimer = null; - this._releaseLock = null; - this.syncManager = null; - - createWriteQueue(this); - this._bindApiMethods(); - this.initPromise = this._initialize(); - } - - _bindApiMethods() { - this.insert = crudOps.insert.bind(this); - this.insertMany = crudOps.insertMany.bind(this); - this.update = crudOps.update.bind(this); - this.remove = crudOps.remove.bind(this); - this.removeMany = crudOps.removeMany.bind(this); - this.clear = crudOps.clear.bind(this); - this.getById = queryOps.getById.bind(this); - this.getAll = queryOps.getAll.bind(this); - this.count = queryOps.count.bind(this); - this.find = queryOps.find.bind(this); - this.findOne = queryOps.findOne.bind(this); - this.updateOne = queryOps.updateOne.bind(this); - this.updateMany = queryOps.updateMany.bind(this); - this.findOneAndUpdate = queryOps.findOneAndUpdate.bind(this); - this.deleteOne = queryOps.deleteOne.bind(this); - this.deleteMany = queryOps.deleteMany.bind(this); - this.findByIndexedValue = queryOps.findByIndexedValue.bind(this); - this.findOneByIndexedValue = queryOps.findOneByIndexedValue.bind(this); - this.exportJson = dataExchangeOps.exportJson.bind(this); - this.exportCsv = dataExchangeOps.exportCsv.bind(this); - this.importJson = dataExchangeOps.importJson.bind(this); - } - - async _initialize() { - await fs.mkdir(this.collectionDirPath, { recursive: true }); - await fs.mkdir(this.checkpointsDir, { recursive: true }); - - await initializeWal(this.walPath, this.collectionDirPath, this.logger); - const loadedCheckpoint = await loadLatestCheckpoint(this.checkpointsDir, this.name, this.logger); - - this.documents = loadedCheckpoint.documents; - this._stats.lastCheckpointTimestamp = loadedCheckpoint.timestamp || null; - - for (const indexMeta of loadedCheckpoint.indexesMeta || []) { - try { - this._indexManager.createIndex(indexMeta.fieldName, { unique: indexMeta.type === 'unique' }); - } catch (e) { /* ignore */ } - } - this._indexManager.rebuildIndexesFromData(this.documents); - - const walReadOpts = { ...this.options.walReadOptions, isInitialLoad: true, logger: this.logger }; - const walEntries = await readWal(this.walPath, this._stats.lastCheckpointTimestamp, walReadOpts); - - const isInitialLoad = true; - for (const entry of walEntries) { - if (entry.txn === 'op' && entry._txn_applied_from_wal) { - await this._applyTransactionWalOp(entry, isInitialLoad); - } else if (!entry.txn) { - this._applyWalEntryToMemory(entry, false, isInitialLoad); - } - } - - this._stats.walEntriesSinceCheckpoint = walEntries.length; - this._indexManager.rebuildIndexesFromData(this.documents); - - this._startCheckpointTimer(); - this._startTtlCleanupTimer(); - - this._emitter.emit('initialized'); - return true; - } - - _applyWalEntryToMemory(entry, emitEvents = true, isInitialLoad = false) { - switch (entry.op) { - case 'INSERT': { - const doc = entry.doc; - if (!doc || !doc._id) throw new Error('Cannot apply INSERT: document or _id is missing.'); - if (!isInitialLoad && this.documents.has(doc._id)) { - if (!entry._remote) { - throw new Error(`Cannot apply INSERT: document with _id ${doc._id} already exists.`); - } - } - this.documents.set(doc._id, doc); - this._indexManager.afterInsert(doc); - if (emitEvents) this._emitter.emit('insert', doc); - break; - } - case 'BATCH_INSERT': { - const docs = Array.isArray(entry.docs) ? entry.docs : []; - if (!isInitialLoad) { - for (const doc of docs) { - if (!doc || !doc._id) throw new Error('Cannot apply BATCH_INSERT: a document or its _id is missing.'); - if (this.documents.has(doc._id) && !entry._remote) { - throw new Error(`Cannot apply BATCH_INSERT: document with _id ${doc._id} already exists.`); - } - } - } - for (const doc of docs) { - this.documents.set(doc._id, doc); - this._indexManager.afterInsert(doc); - if (emitEvents) this._emitter.emit('insert', doc); - } - break; - } - case 'UPDATE': { - const id = entry.id; - const dataToUpdate = entry.data; - if (!id) throw new Error('Cannot apply UPDATE: id is missing.'); - const prevDoc = this.documents.get(id); - - if (!prevDoc) { - if (isInitialLoad && dataToUpdate) { - const newDoc = { _id: id, createdAt: new Date().toISOString(), ...dataToUpdate, updatedAt: dataToUpdate.updatedAt || new Date().toISOString() }; - this.documents.set(id, newDoc); - this._indexManager.afterInsert(newDoc); - if(emitEvents) this._emitter.emit('insert', newDoc); - return; - } - if (!entry._remote) { - throw new Error(`Cannot apply UPDATE: document with id ${id} not found.`); - } - return; - } - - if (!isAlive(prevDoc)) throw new Error(`Cannot apply UPDATE: document with id ${id} has expired.`); - const updatedDoc = { ...prevDoc, ...dataToUpdate }; - this.documents.set(id, updatedDoc); - this._indexManager.afterUpdate(prevDoc, updatedDoc); - if (emitEvents) this._emitter.emit('update', updatedDoc, prevDoc); - break; - } - case 'REMOVE': { - const id = entry.id; - if (!id) throw new Error('Cannot apply REMOVE: id is missing.'); - const prevDoc = this.documents.get(id); - if (!prevDoc) return; - - this.documents.delete(id); - this._indexManager.afterRemove(prevDoc); - if (emitEvents) this._emitter.emit('remove', prevDoc); - break; - } - case 'CLEAR': { - const allDocs = Array.from(this.documents.values()); - this.documents.clear(); - this._indexManager.clearAllData(); - if (emitEvents) this._emitter.emit('clear', { clearedCount: allDocs.length }); - break; - } - default: - throw new Error(`Unknown operation type: ${entry.op}`); - } - } - - async _enqueueDataModification(entry, opType, getResultFn) { - if (this._indexManager) { - if (opType === 'INSERT') { - const docToInsert = entry.doc; - if (docToInsert) { - for (const idxMeta of this._indexManager.getIndexesMeta()) { - if (idxMeta.type === 'unique') { - const value = docToInsert[idxMeta.fieldName]; - if (value !== undefined && value !== null && this._indexManager.findOneIdByIndex(idxMeta.fieldName, value)) { - throw new UniqueConstraintError(idxMeta.fieldName, value); - } - } - } - } - } else if (opType === 'BATCH_INSERT') { - const docs = entry.docs || []; - if (docs.length > 0) { - for (const idxMeta of this._indexManager.getIndexesMeta()) { - if (idxMeta.type === 'unique') { - const seenValues = new Set(); - for (const doc of docs) { - const value = doc[idxMeta.fieldName]; - if (value !== undefined && value !== null) { - if (seenValues.has(value) || this._indexManager.findOneIdByIndex(idxMeta.fieldName, value)) { - throw new UniqueConstraintError(idxMeta.fieldName, value); - } - seenValues.add(value); - } - } - } - } - } - } else if (opType === 'UPDATE') { - const { id, data } = entry; - const originalDoc = this.documents.get(id); - if (originalDoc && data) { - for (const idxMeta of this._indexManager.getIndexesMeta()) { - if (idxMeta.type === 'unique' && data.hasOwnProperty(idxMeta.fieldName)) { - const newValue = data[idxMeta.fieldName]; - if (newValue !== undefined && newValue !== null) { - const existingId = this._indexManager.findOneIdByIndex(idxMeta.fieldName, newValue); - if (existingId && existingId !== id) { - throw new UniqueConstraintError(idxMeta.fieldName, newValue); - } - } - } - } - } - } - } - - const entryWithOpId = { ...entry, opId: uuidv4() }; - await appendWalEntry(this.walPath, entryWithOpId, this.logger); - - this._applyWalEntryToMemory(entry, true); - this._handlePotentialCheckpointTrigger(); - - let nextResult; - if (opType === 'INSERT') nextResult = entry.doc; - else if (opType === 'BATCH_INSERT') nextResult = entry.docs; - else if (opType === 'UPDATE') nextResult = this.documents.get(entry.id); - - return getResultFn ? getResultFn(undefined, nextResult) : undefined; - } - - async _acquireLock() { - if (this._releaseLock) return; - this._releaseLock = await acquireCollectionLock(this.collectionDirPath); - } - - async _releaseLockIfHeld() { - if (this._releaseLock) { - await releaseCollectionLock(this._releaseLock); - this._releaseLock = null; - } - } - - _startCheckpointTimer() { - this.stopCheckpointTimer(); - if (this.options.checkpointIntervalMs > 0) { - this._checkpointTimerId = setInterval(async () => { - try { - await this.flushToDisk(); - } catch (e) { - this.logger.error(`[Collection] Auto-checkpoint error for ${this.name}: ${e.message}`, e.stack); - } - }, this.options.checkpointIntervalMs); - if (this._checkpointTimerId && typeof this._checkpointTimerId.unref === 'function') { - this._checkpointTimerId.unref(); - } - } - } - - stopCheckpointTimer() { - if (this._checkpointTimerId) { - clearInterval(this._checkpointTimerId); - this._checkpointTimerId = null; - } - } - - _startTtlCleanupTimer() { - this._stopTtlCleanupTimer(); - if (this.options.ttlCleanupIntervalMs > 0) { - this._ttlCleanupTimer = setInterval(() => { - try { - const removedCount = cleanupExpiredDocs(this.documents, this._indexManager); - if (removedCount > 0) { - this.logger.debug(`[Collection] [TTL] Auto-cleanup removed ${removedCount} docs from ${this.name}.`); - } - } catch (e) { - this.logger.error(`[Collection] [TTL] Auto-cleanup error for ${this.name}: ${e.message}`, e.stack); - } - }, this.options.ttlCleanupIntervalMs); - if (this._ttlCleanupTimer && typeof this._ttlCleanupTimer.unref === 'function') { - this._ttlCleanupTimer.unref(); - } - } - } - - _stopTtlCleanupTimer() { - if (this._ttlCleanupTimer) { - clearInterval(this._ttlCleanupTimer); - this._ttlCleanupTimer = null; - } - } - - _handlePotentialCheckpointTrigger() { - this._stats.walEntriesSinceCheckpoint++; - if (this.options.maxWalEntriesBeforeCheckpoint > 0 && - this._stats.walEntriesSinceCheckpoint >= this.options.maxWalEntriesBeforeCheckpoint) { - this.flushToDisk().catch(e => { - this.logger.error(`[Collection] Auto-checkpoint (by WAL count) error for ${this.name}: ${e.message}`, e.stack); - }); - } - } - - async flushToDisk() { - return this._enqueue(async () => { - cleanupExpiredDocs(this.documents, this._indexManager); - - const timestamp = new Date().toISOString(); - const meta = { - collectionName: this.name, - timestamp, - documentCount: this.documents.size, - indexesMeta: this._indexManager.getIndexesMeta() || [] - }; - - const timestampForFile = timestamp.replace(/[:.]/g, '-'); - const metaPath = path.join(this.checkpointsDir, `checkpoint_meta_${this.name}_${timestampForFile}.json`); - - await writeJsonFileSafe(metaPath, meta, null, this.logger); - - const aliveDocs = Array.from(this.documents.values()); - const maxSegmentSize = this.options.maxSegmentSizeBytes; - let segmentIndex = 0; - let currentSegment = []; - let currentSize = 2; - - for (const doc of aliveDocs) { - const docStr = JSON.stringify(doc); - const docSize = Buffer.byteLength(docStr, 'utf8') + (currentSegment.length > 0 ? 1 : 0); - if (currentSize + docSize > maxSegmentSize && currentSegment.length > 0) { - const dataPath = path.join(this.checkpointsDir, `checkpoint_data_${this.name}_${timestampForFile}_seg${segmentIndex++}.json`); - await writeJsonFileSafe(dataPath, currentSegment, null, this.logger); - currentSegment = []; - currentSize = 2; - } - currentSegment.push(doc); - currentSize += docSize; - } - - if (currentSegment.length > 0) { - const dataPath = path.join(this.checkpointsDir, `checkpoint_data_${this.name}_${timestampForFile}_seg${segmentIndex++}.json`); - await writeJsonFileSafe(dataPath, currentSegment, null, this.logger); - } - - this._stats.lastCheckpointTimestamp = timestamp; - this._stats.walEntriesSinceCheckpoint = 0; - - await compactWal(this.walPath, this._stats.lastCheckpointTimestamp, this.logger); - - if (this.options.checkpointsToKeep > 0) { - await cleanupOldCheckpoints(this.checkpointsDir, this.name, this.options.checkpointsToKeep, this.logger); - } - - this._emitter.emit('checkpoint', { timestamp }); - }); - } - - async close() { - this.disableSync(); - this.stopCheckpointTimer(); - this._stopTtlCleanupTimer(); - await this.flushToDisk(); - await this._releaseLockIfHeld(); - this._emitter.emit('closed'); - } - - stats() { - cleanupExpiredDocs(this.documents, this._indexManager); - return { - ...this._stats, - count: this.documents.size, - }; - } - - async createIndex(fieldName, options = {}) { - return this._enqueue(async () => { - this._indexManager.createIndex(fieldName, options); - this._indexManager.rebuildIndexesFromData(this.documents); - }); - } - - async dropIndex(fieldName) { - return this._enqueue(async () => { - this._indexManager.dropIndex(fieldName); - }); - } - - async getIndexes() { - return this._indexManager.getIndexesMeta(); - } - - on(eventName, listener) { - this._emitter.on(eventName, listener); - } - - off(eventName, listener) { - this._emitter.off(eventName, listener); - } - - async compactWalAfterPush() { - this.logger.log(`[Collection] Compacting local state for '${this.name}' after successful sync push by flushing to disk.`); - return this.flushToDisk(); - } - - enableSync(syncOptions) { - if (this.syncManager) { - this.logger.warn(`[Sync] Sync for collection '${this.name}' is already enabled.`); - return; - } - const { url, apiKey, ...restOptions } = syncOptions; - if (!url || !apiKey) { - throw new Error('Sync requires `url` and `apiKey`.'); - } - this.syncManager = new SyncManager({ - collection: this, - logger: this.logger, - ...syncOptions - }); - - const eventsToForward = [ - 'sync:start', 'sync:success', 'sync:error', 'sync:push_success', - 'sync:pull_success', 'sync:initial_start', 'sync:initial_complete', - 'sync:conflict_resolved', 'sync:quarantine', 'sync:heartbeat_success' - ]; - eventsToForward.forEach(eventName => { - this.syncManager.on(eventName, (...args) => this._emitter.emit(eventName, ...args)); - }); - - this.syncManager.start(); - } - - disableSync() { - if (this.syncManager) { - this.syncManager.stop(); - this.syncManager = null; - this.logger.log(`[Sync] Sync for collection '${this.name}' stopped.`); - } - } - - async triggerSync() { - if (!this.syncManager) { - this.logger.warn(`[Sync] Cannot trigger sync for '${this.name}', sync is not enabled.`); - return Promise.resolve(); - } - return this.syncManager.runSync(); - } - - getSyncStatus() { - if (!this.syncManager) { - return { state: 'disabled', isSyncing: false, lastKnownServerLSN: 0, initialSyncComplete: false }; - } - return this.syncManager.getStatus(); - } - - async _internalClear() { - return this._enqueue(async () => { - const clearedCount = this.documents.size; - this.documents.clear(); - this._indexManager.clearAllData(); - this._emitter.emit('clear', { clearedCount }); - }); - } - - async _internalInsertMany(docs) { - return this._enqueue(async () => { - for (const doc of docs) { - if (!doc._id) doc._id = this._idGenerator(); - if (!doc.createdAt) doc.createdAt = new Date().toISOString(); - if (!doc.updatedAt) doc.updatedAt = doc.createdAt; - this.documents.set(doc._id, doc); - } - this._indexManager.rebuildIndexesFromData(this.documents); - this._emitter.emit('import', { count: docs.length }); - }); - } - - // --- ИСПРАВЛЕНИЕ НАЧИНАЕТСЯ ЗДЕСЬ --- - async _applyRemoteOperation(remoteOp) { - if (!remoteOp || !remoteOp.op) { - this.logger.warn(`[Sync] Received invalid remote operation:`, remoteOp); - return; - } - - // Ставим всю логику применения удаленной операции в очередь, - // чтобы избежать гонок данных при одновременных PUSH-запросах на сервере. - return this._enqueue(async () => { - const docId = remoteOp.id || remoteOp.doc?._id; - const localDoc = docId ? this.documents.get(docId) : null; - - if (remoteOp.op === 'INSERT' && localDoc) { - this.logger.debug(`[Sync] Ignored remote INSERT for existing document ID: ${docId}`); - return; - } - - if (remoteOp.op === 'UPDATE' && !localDoc) { - this.logger.warn(`[Sync] Ignored remote UPDATE for non-existent document ID: ${docId}`); - return; - } - - const remoteTimestampStr = remoteOp.ts || remoteOp.doc?.updatedAt || remoteOp.data?.updatedAt; - if (localDoc && remoteTimestampStr) { - try { - const remoteTimestamp = new Date(remoteTimestampStr).getTime(); - const localTimestamp = new Date(localDoc.updatedAt).getTime(); - if (localTimestamp > remoteTimestamp) { - this._emitter.emit('sync:conflict_resolved', { - type: 'ignored_remote', reason: 'local_is_newer', docId, - localTimestamp: localDoc.updatedAt, remoteTimestamp: remoteTimestampStr - }); - this.logger.log(`[Sync] Ignored remote op for doc ${docId} because local version is newer.`); - return; - } - } catch (e) { - this.logger.warn(`[Sync] Could not parse timestamp for conflict resolution. Error: ${e.message}`); - } - } - - try { - // Теперь вызов происходит внутри очереди с захваченной блокировкой. - this._applyWalEntryToMemory(remoteOp, true, false); - const entry = { ...remoteOp, _remote: true }; - // Записываем в WAL, чтобы пережить перезапуск. - await appendWalEntry(this.walPath, entry, this.logger); - } catch (err) { - this.logger.error(`[Sync] Failed to apply remote op. Quarantining. Op: ${JSON.stringify(remoteOp)}`, err.message); - await this._quarantineOperation(remoteOp, err); - } - }); - } - // --- ИСПРАВЛЕНИЕ ЗАКАНЧИВАЕТСЯ ЗДЕСЬ --- - - async _quarantineOperation(op, error) { - const quarantineEntry = { - quarantinedAt: new Date().toISOString(), - operation: op, - error: { - message: error.message, - stack: error.stack, - }, - }; - try { - await fs.appendFile(this.quarantinePath, JSON.stringify(quarantineEntry) + '\n', 'utf8'); - this._emitter.emit('sync:quarantine', quarantineEntry); - } catch (qErr) { - this.logger.error(`[Sync] CRITICAL: Failed to write to quarantine log file at ${this.quarantinePath}`, qErr); - } - } - - async _applyTransactionWalOp(entry, isInitialLoad = false) { - const txidForLog = entry.txid || entry.id || 'unknown_txid'; - switch (entry.type) { - case 'insert': await this._applyTransactionInsert(entry.args[0], txidForLog, isInitialLoad); break; - case 'insertMany': await this._applyTransactionInsertMany(entry.args[0], txidForLog, isInitialLoad); break; - case 'update': await this._applyTransactionUpdate(entry.args[0], entry.args[1], txidForLog, isInitialLoad); break; - case 'remove': await this._applyTransactionRemove(entry.args[0], txidForLog, isInitialLoad); break; - case 'clear': await this._applyTransactionClear(txidForLog, isInitialLoad); break; - default: this.logger.warn(`[Collection] Unknown transactional WAL op type '${entry.type}' for ${this.name}, txid: ${txidForLog}`); - } - } - async _applyTransactionInsert(docData, txid, isInitialLoad = false) { - const _id = docData._id || this._idGenerator(); - if (!isInitialLoad && this.documents.has(_id)) { - throw new Error(`Cannot apply transaction insert: document with _id ${_id} already exists.`); - } - const now = new Date().toISOString(); - const finalDoc = { ...docData, _id, createdAt: docData.createdAt || now, updatedAt: docData.updatedAt || now, _txn: txid }; - this.documents.set(_id, finalDoc); - this._indexManager.afterInsert(finalDoc); - this._stats.inserts++; - this._emitter.emit('insert', finalDoc); - return finalDoc; - } - async _applyTransactionInsertMany(docsData, txid, isInitialLoad = false) { - if (!isInitialLoad) { - for(const docData of docsData) { - const _id = docData._id || this._idGenerator(); - if(this.documents.has(_id)) throw new Error(`Cannot apply transaction insertMany: document with _id ${_id} already exists.`); - } - } - const now = new Date().toISOString(); - const insertedDocs = []; - for (const docData of docsData) { - const _id = docData._id || this._idGenerator(); - const finalDoc = { ...docData, _id, createdAt: docData.createdAt || now, updatedAt: docData.updatedAt || now, _txn: txid }; - this.documents.set(_id, finalDoc); - this._indexManager.afterInsert(finalDoc); - this._stats.inserts++; - this._emitter.emit('insert', finalDoc); - insertedDocs.push(finalDoc); - } - return insertedDocs; - } - async _applyTransactionUpdate(id, updates, txid, isInitialLoad = false) { - const oldDoc = this.documents.get(id); - if (!oldDoc && !isInitialLoad) throw new Error(`Cannot apply transaction update: document with id ${id} not found.`); - if (!oldDoc) return null; - - const { _id, createdAt, ...restOfUpdates } = updates; - const now = new Date().toISOString(); - const newDoc = { ...oldDoc, ...restOfUpdates, updatedAt: updates.updatedAt || now, _txn: txid }; - this.documents.set(id, newDoc); - this._indexManager.afterUpdate(oldDoc, newDoc); - this._stats.updates++; - this._emitter.emit('update', newDoc, oldDoc); - return newDoc; - } - async _applyTransactionRemove(id, txid, isInitialLoad = false) { - const doc = this.documents.get(id); - if (!doc) return false; - this.documents.delete(id); - this._indexManager.afterRemove(doc); - this._stats.removes++; - this._emitter.emit('remove', doc); - return true; - } - async _applyTransactionClear(txid, isInitialLoad = false) { - const clearedCount = this.documents.size; - this.documents.clear(); - this._indexManager.clearAllData(); - this._stats.clears++; - this._stats.inserts = 0; this._stats.updates = 0; this._stats.removes = 0; - this._stats.walEntriesSinceCheckpoint = 0; - this._emitter.emit('clear', { clearedCount, _txn: txid }); - return true; - } -} - -module.exports = Collection; \ No newline at end of file diff --git a/wise-json/collection/data-exchange.js b/wise-json/collection/data-exchange.js deleted file mode 100644 index a910198..0000000 --- a/wise-json/collection/data-exchange.js +++ /dev/null @@ -1,123 +0,0 @@ -// wise-json/collection/data-exchange.js - -const fs = require('fs/promises'); // Для асинхронной работы с файлами -// Утилита flattenDocToCsv теперь находится в collection/utils.js основного модуля -// Мы предполагаем, что она будет доступна через `this.flattenDocToCsv` -// или импортирована здесь, если она полностью независима. -// Для текущей структуры, где методы "подмешиваются" в Collection, -// и utils.js является частью основного модуля, мы можем ожидать, что -// flattenDocToCsv будет доступна как this.flattenDocToCsv, если она добавлена в прототип Collection, -// или мы можем импортировать ее здесь явно, если она экспортируется из utils.js. -// Давайте предположим, что она будет импортирована, если не является методом Collection. -// Однако, она используется в Collection.exportCsv в core.js, так что может быть уже там. -// Если нет, нужно будет ее импортировать: -// const { flattenDocToCsv } = require('./utils.js'); // Или из '../utils.js' если структура изменится -const logger = require('../logger'); -/** - * Экспортирует все "живые" документы коллекции в JSON-файл. - * @param {string} filePath - Путь к файлу для экспорта. - * @param {object} [options] - Опции (в данный момент не используются, но зарезервированы). - * @returns {Promise} - * @throws {Error} если произошла ошибка записи файла. - */ -async function exportJson(filePath, options = {}) { - // `this.getAll()` уже вызывает cleanupExpiredDocs и возвращает "живые" документы. - const docs = await this.getAll(); - try { - await fs.writeFile(filePath, JSON.stringify(docs, null, 2), 'utf8'); - // logger.log(`[Data Exchange] Exported ${docs.length} documents to ${filePath}`); - } catch (error) { - logger.error(`[Data Exchange] Error exporting JSON to ${filePath}:`, error); - throw error; // Пробрасываем ошибку дальше - } -} - -/** - * Экспортирует все "живые" документы коллекции в CSV-файл. - * @param {string} filePath - Путь к файлу для экспорта. - * @returns {Promise} - * @throws {Error} если произошла ошибка записи файла. - */ -async function exportCsv(filePath) { - const docs = await this.getAll(); // Получаем "живые" документы - - if (docs.length === 0) { - try { - await fs.writeFile(filePath, '', 'utf8'); // Создаем пустой файл - // logger.log(`[Data Exchange] No documents to export. Created empty CSV file: ${filePath}`); - } catch (error) { - logger.error(`[Data Exchange] Error creating empty CSV file ${filePath}:`, error); - throw error; - } - return; - } - - // Предполагаем, что flattenDocToCsv доступна. - // Если она не метод `this`, ее нужно импортировать: - // const { flattenDocToCsv } = require('./utils.js'); // или require('../utils') если она там - // В текущей структуре core.js она импортируется и используется, - // так что если data-exchange.js станет частью Collection, this.flattenDocToCsv может не существовать. - // Безопаснее импортировать напрямую, если она не в прототипе Collection. - // Давайте предположим, что она должна быть импортирована: - const { flattenDocToCsv } = require('./utils.js'); // Путь может потребовать корректировки - - try { - const csvData = flattenDocToCsv(docs); - await fs.writeFile(filePath, csvData, 'utf8'); - // logger.log(`[Data Exchange] Exported ${docs.length} documents to ${filePath} (CSV)`); - } catch (error) { - logger.error(`[Data Exchange] Error exporting CSV to ${filePath}:`, error); - throw error; - } -} - -/** - * Импортирует документы из JSON-файла в коллекцию. - * @param {string} filePath - Путь к JSON-файлу (должен содержать массив документов). - * @param {object} [options] - Опции импорта. - * @param {string} [options.mode='append'] - Режим импорта: 'append' (добавить) или 'replace' (заменить все). - * @returns {Promise} - * @throws {Error} если файл не найден, невалидный JSON, или произошла ошибка вставки. - */ -async function importJson(filePath, options = {}) { - const mode = options.mode || 'append'; // 'append' или 'replace' - let jsonData; - - try { - const rawData = await fs.readFile(filePath, 'utf8'); - jsonData = JSON.parse(rawData); - } catch (error) { - logger.error(`[Data Exchange] Error reading or parsing JSON file ${filePath}:`, error); - throw error; - } - - if (!Array.isArray(jsonData)) { - const error = new Error('Import file must contain a JSON array of documents.'); - logger.error(`[Data Exchange] ${error.message}`); - throw error; - } - - if (jsonData.length === 0) { - // logger.log('[Data Exchange] Import file is empty. No documents to import.'); - return; // Ничего не делаем, если массив пуст - } - - try { - if (mode === 'replace') { - await this.clear(); // `this.clear()` - метод из crud-ops (или ops.js) - } - // `this.insertMany()` - метод из crud-ops (или ops.js) - // Он должен корректно обработать пакетную вставку, включая проверки уникальности, если есть. - const insertedDocs = await this.insertMany(jsonData); - // logger.log(`[Data Exchange] Imported ${insertedDocs.length} documents from ${filePath} (mode: ${mode})`); - } catch (error) { - logger.error(`[Data Exchange] Error during import operation (mode: ${mode}):`, error); - throw error; - } -} - -module.exports = { - exportJson, - exportCsv, - importJson, -}; \ No newline at end of file diff --git a/wise-json/collection/events.js b/wise-json/collection/events.js deleted file mode 100644 index e9492da..0000000 --- a/wise-json/collection/events.js +++ /dev/null @@ -1,71 +0,0 @@ -// collection/events.js -const logger = require('../logger'); -/** - * Класс EventEmitter для локальных событий в Collection. - */ -class CollectionEventEmitter { - constructor(collectionName) { - this._listeners = {}; - this._collectionName = collectionName || 'unnamed'; - } - - /** - * Подписка на событие. - * @param {string} eventName - * @param {Function} listener - */ - on(eventName, listener) { - if (typeof listener !== 'function') { - throw new Error(`Collection (${this._collectionName}): listener должен быть функцией.`); - } - if (!this._listeners[eventName]) { - this._listeners[eventName] = []; - } - this._listeners[eventName].push(listener); - } - - /** - * Отписка от события. Если listener не указан — удаляет всех. - * @param {string} eventName - * @param {Function} [listener] - */ - off(eventName, listener) { - if (!this._listeners[eventName]) return; - - if (!listener) { - delete this._listeners[eventName]; - } else { - this._listeners[eventName] = this._listeners[eventName].filter(l => l !== listener); - if (this._listeners[eventName].length === 0) { - delete this._listeners[eventName]; - } - } - } - - /** - * Вызов события. - * @param {string} eventName - * @param {...any} args - */ - emit(eventName, ...args) { - const listeners = this._listeners[eventName]; - if (!listeners || listeners.length === 0) return; - - const filteredArgs = args.filter(arg => arg !== undefined); - - for (const listener of listeners) { - try { - const result = listener(...filteredArgs); - if (result instanceof Promise) { - result.catch(e => - logger.error(`Collection (${this._collectionName}) async event error '${eventName}': ${e.message}`) - ); - } - } catch (e) { - logger.error(`Collection (${this._collectionName}) sync event error '${eventName}': ${e.message}`); - } - } - } -} - -module.exports = CollectionEventEmitter; diff --git a/wise-json/collection/file-lock.js b/wise-json/collection/file-lock.js deleted file mode 100644 index 11057a7..0000000 --- a/wise-json/collection/file-lock.js +++ /dev/null @@ -1,42 +0,0 @@ -// wise-json/collection/file-lock.js - -const lockfile = require('proper-lockfile'); - -/** - * Захватывает file-lock на указанную директорию. - * @param {string} dirPath - * @param {object} [options] - * @returns {Promise} releaseLock функция для снятия lock - * @throws {Error} если lock не удалось получить - */ -async function acquireCollectionLock(dirPath, options = {}) { - return lockfile.lock(dirPath, { - retries: { - retries: 10, - factor: 1.5, - minTimeout: 100, - maxTimeout: 1000 - }, - stale: 60000, - ...options - }); -} - -/** - * Снимает file-lock. - * @param {function} releaseLock - */ -async function releaseCollectionLock(releaseLock) { - if (releaseLock) { - try { - await releaseLock(); - } catch { - // ignore - } - } -} - -module.exports = { - acquireCollectionLock, - releaseCollectionLock, -}; diff --git a/wise-json/collection/indexes.js b/wise-json/collection/indexes.js deleted file mode 100644 index 5e6ac5c..0000000 --- a/wise-json/collection/indexes.js +++ /dev/null @@ -1,259 +0,0 @@ -// wise-json/collection/indexes.js - -// const logger = require('../logger'); // --- УДАЛЕНО: Глобальный импорт больше не нужен. - -/** - * Управляет индексами коллекции. - */ -class IndexManager { - /** - * @param {string} [collectionName='unknown'] - Имя коллекции для логирования. - * @param {object} [logger] - Экземпляр логгера. Если не передан, будет использован логгер по умолчанию. - */ - constructor(collectionName = 'unknown', logger) { - this.collectionName = collectionName; - // +++ ИЗМЕНЕНИЕ: Сохраняем переданный логгер или используем фоллбэк. - this.logger = logger || require('../logger'); - this.indexes = new Map(); // fieldName -> { type, data, fieldName } - this.indexedFields = new Set(); - } - - /** - * Создаёт индекс. - * @param {string} fieldName - * @param {{unique?: boolean}} [options] - */ - createIndex(fieldName, options = {}) { - if (!fieldName || typeof fieldName !== 'string') { - this.logger.error(`[IndexManager] fieldName должен быть строкой для коллекции '${this.collectionName}', получено: ${typeof fieldName} ('${fieldName}')`); - throw new Error(`IndexManager: fieldName должен быть непустой строкой`); - } - - if (this.indexes.has(fieldName)) { - const existingIndex = this.indexes.get(fieldName); - const newIsUnique = options.unique === true; - const existingIsUnique = existingIndex.type === 'unique'; - - if (newIsUnique === existingIsUnique) { - this.logger.warn(`[IndexManager] Индекс по полю '${fieldName}' (type: ${existingIndex.type}) для коллекции '${this.collectionName}' уже существует — создание пропускается.`); - return; - } else { - this.logger.error(`[IndexManager] Попытка изменить тип существующего индекса для поля '${fieldName}' в коллекции '${this.collectionName}'. Существующий: ${existingIndex.type}, Новый: ${newIsUnique ? 'unique' : 'standard'}. Удалите старый индекс перед созданием нового с другим типом.`); - throw new Error(`IndexManager: индекс по полю '${fieldName}' уже существует с другим типом. Удалите его перед повторным созданием.`); - } - } - - const isUnique = options.unique === true; - - const index = { - type: isUnique ? 'unique' : 'standard', - data: isUnique ? new Map() : new Map(), // value -> ID или Set - fieldName, - }; - - this.indexes.set(fieldName, index); - this.indexedFields.add(fieldName); - this.logger.log(`[IndexManager] Индекс по полю '${fieldName}' (type: ${index.type}) для коллекции '${this.collectionName}' успешно создан.`); - } - - /** - * Удаляет индекс. - * @param {string} fieldName - */ - dropIndex(fieldName) { - if (!this.indexes.has(fieldName)) { - this.logger.warn(`[IndexManager] Попытка удалить несуществующий индекс по полю '${fieldName}' для коллекции '${this.collectionName}'. Операция пропущена.`); - return; - } - this.indexes.delete(fieldName); - this.indexedFields.delete(fieldName); - this.logger.log(`[IndexManager] Индекс по полю '${fieldName}' для коллекции '${this.collectionName}' успешно удален.`); - } - - /** - * Возвращает мета-информацию об индексах. - * @returns {Array<{fieldName: string, type: string}>} - */ - getIndexesMeta() { - return Array.from(this.indexes.values()).map(index => ({ - fieldName: index.fieldName, - type: index.type, - })); - } - - /** - * Восстанавливает индексы из данных. - * @param {Map} documents - */ - rebuildIndexesFromData(documents) { - for (const fieldName of this.indexedFields) { - const def = this.indexes.get(fieldName); - if (!def) { - this.logger.warn(`[IndexManager] Определение индекса для поля '${fieldName}' не найдено при перестроении в коллекции '${this.collectionName}'.`); - continue; - } - def.data.clear(); - - for (const [id, doc] of documents.entries()) { - if (typeof doc !== 'object' || doc === null) continue; - - const value = doc[fieldName]; - - if (def.type === 'unique') { - if (value !== undefined && value !== null) { - if (def.data.has(value)) { - this.logger.warn(`[IndexManager] Нарушение уникальности при перестроении индекса '${fieldName}' в коллекции '${this.collectionName}'. Значение '${value}' уже привязано к ID '${def.data.get(value)}', новый ID '${id}' будет проигнорирован для этого значения.`); - } else { - def.data.set(value, id); - } - } - } else { // standard - if (!def.data.has(value)) { - def.data.set(value, new Set()); - } - def.data.get(value).add(id); - } - } - } - } - - /** - * Обновляет индексы после вставки. - * @param {object} doc - */ - afterInsert(doc) { - if (typeof doc !== 'object' || doc === null) return; - for (const fieldName of this.indexedFields) { - const def = this.indexes.get(fieldName); - if (!def) continue; - const value = doc[fieldName]; - - if (def.type === 'unique') { - if (value !== undefined && value !== null) { - if (def.data.has(value) && def.data.get(value) !== doc._id) { - this.logger.error(`[IndexManager] КРИТИЧЕСКАЯ ОШИБКА: Дубликат значения '${value}' в уникальном индексе '${fieldName}' (коллекция '${this.collectionName}') обнаружен ПОСЛЕ вставки документа ID '${doc._id}'. Этого не должно было произойти.`); - } - def.data.set(value, doc._id); - } - } else { // standard - if (!def.data.has(value)) def.data.set(value, new Set()); - def.data.get(value).add(doc._id); - } - } - } - - /** - * Обновляет индексы после удаления. - * @param {object} doc - */ - afterRemove(doc) { - if (typeof doc !== 'object' || doc === null) return; - for (const fieldName of this.indexedFields) { - const def = this.indexes.get(fieldName); - if (!def) continue; - const value = doc[fieldName]; - - if (def.type === 'unique') { - if (value !== undefined && value !== null) { - if (def.data.get(value) === doc._id) { - def.data.delete(value); - } - } - } else { // standard - const set = def.data.get(value); - if (set) { - set.delete(doc._id); - if (set.size === 0) def.data.delete(value); - } - } - } - } - - /** - * Обновляет индексы после обновления. - * @param {object} oldDoc - * @param {object} newDoc - */ - afterUpdate(oldDoc, newDoc) { - if (typeof oldDoc !== 'object' || oldDoc === null || typeof newDoc !== 'object' || newDoc === null) return; - - for (const fieldName of this.indexedFields) { - const def = this.indexes.get(fieldName); - if (!def) continue; - - const oldVal = oldDoc[fieldName]; - const newVal = newDoc[fieldName]; - - if (oldVal !== newVal || (newDoc.hasOwnProperty(fieldName) && oldDoc[fieldName] === undefined) || (oldDoc.hasOwnProperty(fieldName) && newDoc[fieldName] === undefined)) { - // Удаляем старое значение из индекса - if (def.type === 'unique') { - if (oldVal !== undefined && oldVal !== null) { - if (def.data.get(oldVal) === oldDoc._id) { - def.data.delete(oldVal); - } - } - } else { // standard - const oldSet = def.data.get(oldVal); - if (oldSet) { - oldSet.delete(oldDoc._id); - if (oldSet.size === 0) def.data.delete(oldVal); - } - } - - // Добавляем новое значение в индекс - if (def.type === 'unique') { - if (newVal !== undefined && newVal !== null) { - if (def.data.has(newVal) && def.data.get(newVal) !== newDoc._id) { - this.logger.error(`[IndexManager] КРИТИЧЕСКАЯ ОШИБКА: Дубликат значения '${newVal}' в уникальном индексе '${fieldName}' (коллекция '${this.collectionName}') обнаружен ПОСЛЕ обновления документа ID '${newDoc._id}'.`); - } - def.data.set(newVal, newDoc._id); - } - } else { // standard - if (newVal !== undefined || newVal === null) { - if (!def.data.has(newVal)) def.data.set(newVal, new Set()); - def.data.get(newVal).add(newDoc._id); - } - } - } - } - } - - /** - * Поиск по индексу (уникальному). - * @param {string} fieldName - * @param {any} value - * @returns {string|null} - ID или null - */ - findOneIdByIndex(fieldName, value) { - const def = this.indexes.get(fieldName); - if (!def || def.type !== 'unique') { - return null; - } - return def.data.get(value) || null; - } - - /** - * Поиск по индексу (стандартному). - * @param {string} fieldName - * @param {any} value - * @returns {Set} - множество ID (может быть пустым) - */ - findIdsByIndex(fieldName, value) { - const def = this.indexes.get(fieldName); - if (!def || def.type !== 'standard') { - return new Set(); - } - return def.data.get(value) || new Set(); - } - - /** - * Очистка всех данных индексов. - */ - clearAllData() { - for (const def of this.indexes.values()) { - def.data.clear(); - } - } -} - -module.exports = IndexManager; \ No newline at end of file diff --git a/wise-json/collection/ops.js b/wise-json/collection/ops.js deleted file mode 100644 index 3cf6638..0000000 --- a/wise-json/collection/ops.js +++ /dev/null @@ -1,261 +0,0 @@ -// wise-json/collection/ops.js - -const { isAlive } = require('./ttl.js'); -const logger = require('../logger'); // Убедитесь, что logger импортирован - -async function insert(doc) { - if (!this.isPlainObject(doc)) { - throw new Error('insert: аргумент должен быть объектом.'); - } - return this._enqueue(async () => { - const _id = doc._id || this._idGenerator(); - const now = new Date().toISOString(); - const finalDoc = { - ...doc, - _id, - createdAt: doc.createdAt || now, - updatedAt: now, - }; - const result = await this._enqueueDataModification( - { op: 'INSERT', doc: finalDoc }, - 'INSERT', - (_prev, insertedDoc) => insertedDoc - ); - this._stats.inserts++; - return result; - }); -} - -async function insertMany(docs) { - if (!Array.isArray(docs)) { - throw new Error('insertMany: аргумент должен быть массивом.'); - } - if (docs.length === 0) { - return []; - } - - // Максимальное количество документов в одной WAL-записи BATCH_INSERT. - // Можно сделать настраиваемым через this.options, если необходимо. - const MAX_DOCS_PER_BATCH_WAL_ENTRY = this.options?.maxDocsPerBatchWalEntry || 1000; - // Если в this.options нет maxDocsPerBatchWalEntry, используем 1000 по умолчанию. - - // Вся операция insertMany (включая все чанки) должна быть атомарной - // с точки зрения блокировки коллекции, поэтому оборачиваем все в один _enqueue. - return this._enqueue(async () => { - await this._acquireLock(); // Захватываем блокировку в начале - const allInsertedDocs = []; - let totalProcessed = 0; - - try { - for (let i = 0; i < docs.length; i += MAX_DOCS_PER_BATCH_WAL_ENTRY) { - const chunk = docs.slice(i, i + MAX_DOCS_PER_BATCH_WAL_ENTRY); - // logger.debug(`[Ops] insertMany: обрабатываем чанк ${i / MAX_DOCS_PER_BATCH_WAL_ENTRY + 1} из ${Math.ceil(docs.length / MAX_DOCS_PER_BATCH_WAL_ENTRY)}, размер: ${chunk.length}`); - - const now = new Date().toISOString(); - const preparedChunk = chunk.map(doc => ({ - ...doc, - _id: doc._id || this._idGenerator(), - createdAt: doc.createdAt || now, // Используем один 'now' для всего чанка - updatedAt: now, - })); - - // Каждая порция (chunk) записывается как отдельная BATCH_INSERT операция в WAL - // _enqueueDataModification выполняет запись в WAL и применение в памяти. - // Важно: _enqueueDataModification сам по себе не должен вызывать _acquireLock/_releaseLock, - // так как мы уже под общей блокировкой. - const insertedChunk = await this._enqueueDataModification( // Предполагается, что этот метод не вызывает _acquireLock - { op: 'BATCH_INSERT', docs: preparedChunk }, - 'BATCH_INSERT', - (_prev, inserted) => inserted - ); - - if (Array.isArray(insertedChunk)) { // Убедимся, что результат - массив - allInsertedDocs.push(...insertedChunk); - this._stats.inserts += insertedChunk.length; - totalProcessed += insertedChunk.length; - } else { - // Это не должно произойти, если _enqueueDataModification для BATCH_INSERT возвращает массив - logger.warn(`[Ops] insertMany: _enqueueDataModification для чанка не вернул массив. Чанк пропущен или обработан некорректно.`); - } - } - // logger.debug(`[Ops] insertMany: успешно обработано ${totalProcessed} документов из ${docs.length}.`); - return allInsertedDocs; - } catch (error) { - // Если произошла ошибка при обработке любого из чанков (например, нарушение уникальности - // которое было проверено внутри _enqueueDataModification, или ошибка записи WAL для чанка), - // то вся операция insertMany откатывается (т.к. мы под одним _enqueue). - // В текущей реализации _enqueueDataModification сам бросит ошибку, и она будет поймана - // обработчиком ошибок в _processQueue, который вызовет task.reject(err). - // Поэтому здесь мы просто пробрасываем ошибку дальше. - logger.error(`[Ops] insertMany: ошибка во время обработки чанков: ${error.message}. Обработано до ошибки: ${totalProcessed} документов.`); - throw error; - } finally { - await this._releaseLockIfHeld(); // Освобождаем блокировку в конце - } - }); -} - - -async function update(id, updates) { - if (typeof id !== 'string' || id.length === 0) { - throw new Error('update: id должен быть непустой строкой.'); - } - if (!this.isPlainObject(updates)) { - throw new Error('update: обновления должны быть объектом.'); - } - - return this._enqueue(async () => { - if (!this.documents.has(id)) { - return null; - } - const now = new Date().toISOString(); - const result = await this._enqueueDataModification( - { op: 'UPDATE', id, data: { ...updates, updatedAt: now } }, - 'UPDATE', - (_prev, updatedDoc) => updatedDoc, - { idToUpdate: id } - ); - if (result) { - this._stats.updates++; - } - return result; - }); -} - -async function updateMany(queryFn, updates) { - if (typeof queryFn !== 'function') { - throw new Error('updateMany: queryFn должен быть функцией.'); - } - if (!this.isPlainObject(updates)) { - throw new Error('updateMany: обновления должны быть объектом.'); - } - - // Собираем ID ДО постановки в очередь, чтобы не итерировать по изменяемой коллекции. - const idsToUpdate = []; - // Эта часть выполняется вне _enqueue, читая текущее состояние this.documents. - // Это нормально, так как фактические изменения будут в _enqueue. - for (const [id, doc] of this.documents.entries()) { - if (isAlive(doc) && queryFn(doc)) { - idsToUpdate.push(id); - } - } - - if (idsToUpdate.length === 0) { - return 0; - } - - // Все обновления для updateMany выполняются в рамках одного _enqueue вызова - // для обеспечения атомарности на уровне всей операции updateMany, если это возможно. - // Однако, this.update внутри цикла сам вызывает _enqueue. - // Чтобы сделать updateMany по-настоящему атомарным (все или ничего для всех найденных документов), - // потребовалась бы другая архитектура для _enqueueDataModification, принимающая массив обновлений. - // Текущая реализация делает каждую отдельную операцию update атомарной, но не весь updateMany. - - // Оставляем текущую реализацию, где каждое обновление - отдельная операция в очереди. - // Это проще, но менее атомарно для всего набора. - let successfullyUpdatedCount = 0; - for (const id of idsToUpdate) { // Этот цикл выполнится вне _enqueue - try { - // Каждый this.update будет поставлен в очередь и выполнен последовательно. - const updatedDoc = await this.update(id, updates); - if (updatedDoc) { - successfullyUpdatedCount++; - } - } catch (error) { - // Если один из update падает (например, нарушение уникальности), - // то updateMany прерывается здесь, и предыдущие успешные обновления остаются. - logger.error(`[Ops] Ошибка при обновлении документа ID '${id}' в updateMany. Прерывание. Ошибка: ${error.message}`); - throw error; - } - } - return successfullyUpdatedCount; -} - -async function remove(id) { - if (typeof id !== 'string' || id.length === 0) { - throw new Error('remove: id должен быть непустой строкой.'); - } - - if (!this.documents.has(id)) { - return false; - } - - return this._enqueue(async () => { - if (!this.documents.has(id)) { - return false; - } - - const success = await this._enqueueDataModification( - { op: 'REMOVE', id }, - 'REMOVE', - (_prev, _next) => true, - { idToRemove: id } - ); - - if (success) { - this._stats.removes++; - } - return success; - }); -} - -async function removeMany(predicate) { - if (typeof predicate !== 'function') { - throw new Error('removeMany: predicate должен быть функцией.'); - } - - const idsToRemove = []; - for (const [id, doc] of this.documents.entries()) { - if (isAlive(doc) && predicate(doc)) { - idsToRemove.push(id); - } - } - - if (idsToRemove.length === 0) { - return 0; - } - - let removedCount = 0; - for (const id of idsToRemove) { // Аналогично updateMany, цикл вне _enqueue - try { - const success = await this.remove(id); // Каждый remove ставится в очередь - if (success) { - removedCount++; - } - } catch (error) { - logger.error(`[Ops] Ошибка при удалении документа ID '${id}' в removeMany. Прерывание. Ошибка: ${error.message}`); - throw error; - } - } - return removedCount; -} - - -async function clear() { - return this._enqueue(async () => { - const success = await this._enqueueDataModification( - { op: 'CLEAR' }, - 'CLEAR', - () => true - ); - - if (success) { - this._stats.clears++; - this._stats.inserts = 0; - this._stats.updates = 0; - this._stats.removes = 0; - this._stats.walEntriesSinceCheckpoint = 0; - } - return success; - }); -} - -module.exports = { - insert, - insertMany, - update, - updateMany, - remove, - removeMany, - clear, -}; \ No newline at end of file diff --git a/wise-json/collection/query-ops.js b/wise-json/collection/query-ops.js deleted file mode 100644 index 9d0cd82..0000000 --- a/wise-json/collection/query-ops.js +++ /dev/null @@ -1,349 +0,0 @@ -// wise-json/collection/query-ops.js - -const { cleanupExpiredDocs, isAlive } = require('./ttl.js'); -const { matchFilter } = require('./utils.js'); -const logger = require('../logger'); - -// --- Вспомогательные функции для операций обновления --- - -function applyUpdateOperators(doc, updateQuery) { - let newDoc = { ...doc }; - let hasOperators = false; - for (const op in updateQuery) { - if (op.startsWith('$')) { - hasOperators = true; - break; - } - } - - if (!hasOperators) { - // Полная замена документа (кроме _id и createdAt) - const _id = newDoc._id; - const createdAt = newDoc.createdAt; - newDoc = { ...updateQuery, _id, createdAt }; - return newDoc; - } - - for (const op in updateQuery) { - const opArgs = updateQuery[op]; - switch (op) { - case '$set': - Object.assign(newDoc, opArgs); - break; - case '$inc': - for (const field in opArgs) { - newDoc[field] = (newDoc[field] || 0) + opArgs[field]; - } - break; - case '$unset': - for (const field in opArgs) { - delete newDoc[field]; - } - break; - case '$push': - for (const field in opArgs) { - if (!Array.isArray(newDoc[field])) newDoc[field] = []; - if (opArgs[field] && opArgs[field].$each) { - newDoc[field].push(...opArgs[field].$each); - } else { - newDoc[field].push(opArgs[field]); - } - } - break; - case '$pull': - for (const field in opArgs) { - if (Array.isArray(newDoc[field])) { - newDoc[field] = newDoc[field].filter(item => item !== opArgs[field]); - } - } - break; - } - } - return newDoc; -} - -function applyProjection(doc, projection) { - if (!projection || Object.keys(projection).length === 0) { - return doc; - } - - const newDoc = {}; - const hasInclusion = Object.values(projection).some(v => v === 1); - const hasExclusion = Object.values(projection).some(v => v === 0); - - if (hasInclusion && hasExclusion && !projection.hasOwnProperty('_id')) { - throw new Error('Projection cannot have a mix of inclusion and exclusion.'); - } - - if (hasInclusion) { - for (const key in projection) { - if (projection[key] === 1 && doc.hasOwnProperty(key)) { - newDoc[key] = doc[key]; - } - } - if (projection._id !== 0) { - newDoc._id = doc._id; - } - } else { // Режим исключения - const excludedKeys = new Set(Object.keys(projection).filter(k => projection[k] === 0)); - for (const key in doc) { - if (!excludedKeys.has(key)) { - newDoc[key] = doc[key]; - } - } - } - - return newDoc; -} - - -// --- Основные методы API --- - -async function getById(id) { - const doc = this.documents.get(id); - return doc && isAlive(doc) ? doc : null; -} - -async function getAll() { - cleanupExpiredDocs(this.documents, this._indexManager); - return Array.from(this.documents.values()); -} - -async function count(query) { - cleanupExpiredDocs(this.documents, this._indexManager); - if (!query || Object.keys(query).length === 0) { - return this.documents.size; - } - const results = await this.find(query); - return results.length; -} - -async function find(query, projection = {}) { - if (typeof query === 'function') { // Обратная совместимость - cleanupExpiredDocs(this.documents, this._indexManager); - const docs = Array.from(this.documents.values()).filter(doc => isAlive(doc)).filter(query); - return docs.map(doc => applyProjection(doc, projection)); - } - - if (typeof query === 'object' && query !== null) { - cleanupExpiredDocs(this.documents, this._indexManager); - - let initialDocIds = null; - let bestIndexField = null; - - for (const fieldName in query) { - const condition = query[fieldName]; - if (this._indexManager.indexes.has(fieldName)) { - if (typeof condition !== 'object') { - bestIndexField = { field: fieldName, type: 'exact' }; - break; - } - if (typeof condition === 'object' && Object.keys(condition).some(op => ['$gt', '$gte', '$lt', '$lte'].includes(op))) { - if (!bestIndexField) { - bestIndexField = { field: fieldName, type: 'range' }; - } - } - } - } - - if (bestIndexField) { - initialDocIds = new Set(); - const index = this._indexManager.indexes.get(bestIndexField.field); - const condition = query[bestIndexField.field]; - - if (bestIndexField.type === 'exact') { - const ids = index.type === 'unique' - ? [this._indexManager.findOneIdByIndex(bestIndexField.field, condition)].filter(Boolean) - : this._indexManager.findIdsByIndex(bestIndexField.field, condition); - ids.forEach(id => initialDocIds.add(id)); - } else if (bestIndexField.type === 'range') { - for (const [indexedValue, idsOrId] of index.data.entries()) { - const pseudoDoc = { [bestIndexField.field]: indexedValue }; - if (matchFilter(pseudoDoc, { [bestIndexField.field]: condition })) { - if (index.type === 'unique') initialDocIds.add(idsOrId); - else idsOrId.forEach(id => initialDocIds.add(id)); - } - } - } - } - - const results = []; - const source = initialDocIds !== null - ? Array.from(initialDocIds).map(id => this.documents.get(id)).filter(Boolean) - : this.documents.values(); - - for (const doc of source) { - if (isAlive(doc) && matchFilter(doc, query)) { - results.push(applyProjection(doc, projection)); - } - } - return results; - } - - throw new Error('find: query must be a function or a filter object.'); -} - -async function findOne(query, projection = {}) { - if (typeof query === 'function') { // Обратная совместимость - cleanupExpiredDocs(this.documents, this._indexManager); - for (const doc of this.documents.values()) { - if (isAlive(doc) && query(doc)) { - return applyProjection(doc, projection); - } - } - return null; - } - - if (typeof query === 'object' && query !== null) { - const results = await this.find(query, projection); - return results.length > 0 ? results[0] : null; - } - - throw new Error('findOne: query must be a function or a filter object.'); -} - -async function updateOne(filter, updateQuery) { - const docToUpdate = await this.findOne(filter); - if (!docToUpdate) { - return { matchedCount: 0, modifiedCount: 0 }; - } - - const newDocData = applyUpdateOperators(docToUpdate, updateQuery); - - const updatedDoc = await this.update(docToUpdate._id, newDocData); - - return { matchedCount: 1, modifiedCount: updatedDoc ? 1 : 0 }; -} - -async function updateMany(filter, updateQuery) { - // ВАЖНО: `updateMany` из ops.js вызывает `this.update` для каждого документа. - // Нам нужно, чтобы `update` мог принимать не только полную замену, но и операторы. - // Поэтому мы делегируем логику нашему внутреннему `updateOne`, который знает про операторы. - const docsToUpdate = await this.find(filter); - if (docsToUpdate.length === 0) { - return { matchedCount: 0, modifiedCount: 0 }; - } - - let modifiedCount = 0; - for (const doc of docsToUpdate) { - // Мы не можем просто вызвать updateMany из ops.js, так как он не знает про операторы. - // Поэтому мы итерируем здесь и обновляем по одному. - const result = await this.updateOne({ _id: doc._id }, updateQuery); - if (result.modifiedCount > 0) { - modifiedCount++; - } - } - - return { matchedCount: docsToUpdate.length, modifiedCount }; -} - - -async function findOneAndUpdate(filter, updateQuery, options = {}) { - const { returnOriginal = false } = options; - const docToUpdate = await this.findOne(filter); - if (!docToUpdate) return null; - - // Здесь мы не используем this.update, а напрямую вызываем _enqueueDataModification - // чтобы получить и старый, и новый документ в одной атомарной операции. - // Но для простоты пока оставим вызов this.update - const newDocData = applyUpdateOperators(docToUpdate, updateQuery); - const updatedDoc = await this.update(docToUpdate._id, newDocData); - - return returnOriginal ? docToUpdate : updatedDoc; -} - -async function deleteOne(filter) { - const docToRemove = await this.findOne(filter); - if (!docToRemove) { - return { deletedCount: 0 }; - } - const success = await this.remove(docToRemove._id); - return { deletedCount: success ? 1 : 0 }; -} - -async function deleteMany(filter) { - const docsToRemove = await this.find(filter); - const idsToRemove = docsToRemove.map(d => d._id); - if (idsToRemove.length === 0) { - return { deletedCount: 0 }; - } - - // Используем removeMany из ops.js, который итерирует и удаляет по одному - const deletedCount = await this.removeMany(doc => idsToRemove.includes(doc._id)); - return { deletedCount }; -} - - -// --- СТАРЫЕ МЕТОДЫ ДЛЯ ОБРАТНОЙ СОВМЕСТИМОСТИ --- - -async function findByIndexedValue(fieldName, value) { - cleanupExpiredDocs(this.documents, this._indexManager); - - const index = this._indexManager.indexes.get(fieldName); - if (!index) { - return []; - } - - let idsToFetch = new Set(); - if (index.type === 'unique') { - const id = this._indexManager.findOneIdByIndex(fieldName, value); - if (id) { - idsToFetch.add(id); - } - } else { - idsToFetch = this._indexManager.findIdsByIndex(fieldName, value); - } - - const result = []; - for (const id of idsToFetch) { - const doc = this.documents.get(id); - if (doc && isAlive(doc)) { - result.push(doc); - } - } - return result; -} - -async function findOneByIndexedValue(fieldName, value) { - const index = this._indexManager.indexes.get(fieldName); - if (!index) { - return null; - } - - let doc = null; - if (index.type === 'unique') { - const id = this._indexManager.findOneIdByIndex(fieldName, value); - if (id) { - const potentialDoc = this.documents.get(id); - if (potentialDoc && isAlive(potentialDoc)) { - doc = potentialDoc; - } - } - } else { - const results = await this.findByIndexedValue(fieldName, value); - if (results.length > 0) { - doc = results[0]; - } - } - return doc; -} - -module.exports = { - // Основные - getById, - getAll, - count, - find, - findOne, - - // Расширенные (в стиле MongoDB) - updateOne, - updateMany, - findOneAndUpdate, - deleteOne, - deleteMany, - - // Старые методы для обратной совместимости - findByIndexedValue, - findOneByIndexedValue, -}; \ No newline at end of file diff --git a/wise-json/collection/queue.js b/wise-json/collection/queue.js deleted file mode 100644 index 2146a9c..0000000 --- a/wise-json/collection/queue.js +++ /dev/null @@ -1,49 +0,0 @@ -// wise-json/collection/queue.js - -/** - * Создаёт очередь записи с поддержкой file-lock для коллекции. - * Все операции выполняются последовательно с гарантией эксклюзивного блокирования. - * @param {object} collection - экземпляр Collection - */ -function createWriteQueue(collection) { - collection._writeQueue = []; - collection._writing = false; - - /** - * Добавляет операцию в очередь. - * @param {Function} opFn - функция-операция, возвращающая Promise - * @returns {Promise} - */ - collection._enqueue = function (opFn) { - return new Promise((resolve, reject) => { - collection._writeQueue.push({ opFn, resolve, reject }); - collection._processQueue(); - }); - }; - - /** - * Обрабатывает операции по одной, последовательно, - * с удержанием file-lock на время выполнения операции. - */ - collection._processQueue = async function () { - if (collection._writing || collection._writeQueue.length === 0) return; - - collection._writing = true; - const task = collection._writeQueue.shift(); - try { - await collection._acquireLock(); - const result = await task.opFn(); - task.resolve(result); - } catch (err) { - task.reject(err); - } finally { - await collection._releaseLockIfHeld(); - collection._writing = false; - collection._processQueue(); - } - }; -} - -module.exports = { - createWriteQueue, -}; diff --git a/wise-json/collection/transaction-manager.js b/wise-json/collection/transaction-manager.js deleted file mode 100644 index cd9dbcf..0000000 --- a/wise-json/collection/transaction-manager.js +++ /dev/null @@ -1,133 +0,0 @@ -// wise-json/collection/transaction-manager.js - -const { v4: uuidv4 } = require('uuid'); -const logger = require('../logger'); - -class TransactionManager { - constructor(db) { - this.db = db; - this.txid = `txn_${uuidv4()}`; - this.state = 'pending'; - this._ops = []; // Теперь будет содержать { colName, type, args, ts } - this._collections = {}; - } - - collection(name) { - if (!this._collections[name]) { - this._collections[name] = this._createCollectionProxy(name); - } - return this._collections[name]; - } - - _createCollectionProxy(name) { - const self = this; - return { - insert(doc) { - self._ops.push({ colName: name, type: 'insert', args: [doc], ts: new Date().toISOString() }); - return Promise.resolve(); - }, - insertMany(docs) { - self._ops.push({ colName: name, type: 'insertMany', args: [docs], ts: new Date().toISOString() }); - return Promise.resolve(); - }, - update(id, updates) { - self._ops.push({ colName: name, type: 'update', args: [id, updates], ts: new Date().toISOString() }); - return Promise.resolve(); - }, - remove(id) { - self._ops.push({ colName: name, type: 'remove', args: [id], ts: new Date().toISOString() }); - return Promise.resolve(); - }, - clear() { - self._ops.push({ colName: name, type: 'clear', args: [], ts: new Date().toISOString() }); - return Promise.resolve(); - } - }; - } - - async commit() { - if (this.state !== 'pending') { - throw new Error(`Transaction ${this.txid} already ${this.state}`); - } - this.state = 'committing'; - // logger.debug(`[TransactionManager] Committing transaction ${this.txid} with ${this._ops.length} operations.`); - - const walManager = require('../wal-manager.js'); // Динамический require, чтобы избежать циклических зависимостей при инициализации - const groupedOps = this._groupOpsByCollection(); - - for (const [colName, opsInCollection] of Object.entries(groupedOps)) { - let collectionInstance; - try { - collectionInstance = await this.db.collection(colName); - await collectionInstance.initPromise; - // opsInCollection теперь содержит операции, каждая из которых имеет свое поле 'ts' - await walManager.writeTransactionBlock(collectionInstance.walPath, this.txid, opsInCollection); - } catch (err) { - this.state = 'aborted'; - const errMsg = `TransactionManager: WAL write failed for transaction ID '${this.txid}' in collection "${colName}": ${err.message}`; - logger.error(errMsg, err.stack); // Добавим stack для большей информации - throw new Error(errMsg); - } - } - - for (const op of this._ops) { // this._ops содержит операции с индивидуальными 'ts' - let collectionInstance; - try { - collectionInstance = await this.db.collection(op.colName); - // initPromise уже должен был разрешиться выше для каждой затронутой коллекции - - switch (op.type) { - case 'insert': - await collectionInstance._applyTransactionInsert(op.args[0], this.txid); - break; - case 'insertMany': - await collectionInstance._applyTransactionInsertMany(op.args[0], this.txid); - break; - case 'update': - await collectionInstance._applyTransactionUpdate(op.args[0], op.args[1], this.txid); - break; - case 'remove': - await collectionInstance._applyTransactionRemove(op.args[0], this.txid); - break; - case 'clear': - await collectionInstance._applyTransactionClear(this.txid); - break; - default: - logger.error(`[TransactionManager] Unknown transaction operation type: ${op.type} for txid ${this.txid}`); - throw new Error('Unknown transaction operation: ' + op.type); - } - } catch (err) { - logger.error(`TransactionManager: Error applying operation (type: ${op.type}) for transaction ID '${this.txid}' in collection "${op.colName}". Error: ${err.message}`, err.stack); - } - } - this.state = 'committed'; - // logger.debug(`[TransactionManager] Transaction ${this.txid} committed successfully.`); - } - - async rollback() { - if (this.state !== 'pending') { - if (this.state === 'committing' || this.state === 'committed') { - throw new Error(`Transaction ${this.txid} cannot be rolled back, state is ${this.state}`); - } - // Если 'aborted', то повторный rollback ничего не делает, можно не бросать ошибку - // logger.warn(`[TransactionManager] Rollback attempt on transaction ${this.txid} which is already ${this.state}.`); - return; // Уже прервана или в процессе/завершена - } - // logger.debug(`[TransactionManager] Rolling back transaction ${this.txid}.`); - this.state = 'aborted'; - this._ops = []; - } - - _groupOpsByCollection() { - const grouped = {}; - for (const op of this._ops) { // op здесь уже содержит 'ts' - if (!grouped[op.colName]) { - grouped[op.colName] = []; - } - grouped[op.colName].push(op); - } - return grouped; - } -} - -module.exports = TransactionManager; \ No newline at end of file diff --git a/wise-json/collection/ttl.js b/wise-json/collection/ttl.js deleted file mode 100644 index 64a896d..0000000 --- a/wise-json/collection/ttl.js +++ /dev/null @@ -1,106 +0,0 @@ -// wise-json/collection/ttl.js -const logger = require('../logger'); -/** - * Проверяет, жив ли документ (учитывая expireAt или ttl). - * ttl — это время жизни в ms с момента createdAt. - * expireAt — абсолютная дата (ms или ISO string). - */ -function isAlive(doc) { - if (!doc || typeof doc !== 'object') { - return false; - } - - // Абсолютный срок жизни (expireAt) - if (doc.hasOwnProperty('expireAt')) { // Используем hasOwnProperty для явного указания на поле - if (doc.expireAt === null || doc.expireAt === undefined) { - // Если expireAt явно null или undefined, считаем его бессрочным по этому критерию - // (или можно интерпретировать как ошибку, но для isAlive лучше вернуть true) - } else { - const exp = typeof doc.expireAt === 'string' - ? Date.parse(doc.expireAt) - : Number(doc.expireAt); - - if (isNaN(exp)) { - // Если expireAt не может быть преобразован в валидную дату (например, "not-a-date"), - // считаем такой документ "живым", чтобы избежать случайного удаления из-за неверных данных. - // Администратор должен будет исправить такие данные вручную. - return true; - } - // Если exp - валидное число, проверяем, не истек ли срок - return Date.now() < exp; - } - } - - // TTL — относительный срок жизни (ms от createdAt) - if (doc.hasOwnProperty('ttl')) { // Используем hasOwnProperty - if (doc.ttl === null || doc.ttl === undefined) { - // Если ttl явно null или undefined, этот критерий не применяется - } else { - const createdAtStr = doc.createdAt; - if (!createdAtStr) { - // Если нет createdAt, а ttl задан, невозможно рассчитать срок жизни. - // Считаем "живым", чтобы не удалить по ошибке. - return true; - } - - const createdAtMs = Date.parse(createdAtStr); - if (isNaN(createdAtMs)) { - // Если createdAt невалиден, невозможно рассчитать срок жизни. - // Считаем "живым". - return true; - } - - const ttlMs = Number(doc.ttl); - if (isNaN(ttlMs)) { - // Если ttl не число, невозможно рассчитать. Считаем "живым". - return true; - } - - // Date.now() должен быть строго меньше, чем createdAtMs + ttlMs - // Если ttlMs равен 0, то Date.now() < createdAtMs будет false (или true, если часы сильно рассинхронизированы), - // что корректно сделает документ "неживым" практически сразу. - return Date.now() < (createdAtMs + ttlMs); - } - } - - // Если нет ни expireAt, ни ttl, документ считается бессрочным (живым) - return true; -} -exports.isAlive = isAlive; - -/** - * Удаляет все expired-документы из Map документов и обновляет индексы, если indexManager предоставлен. - * Возвращает количество удалённых документов. - * @param {Map} documents - Карта документов коллекции. - * @param {object} [indexManager] - Опциональный менеджер индексов для обновления. - * @returns {number} - Количество удаленных документов. - */ -function cleanupExpiredDocs(documents, indexManager) { - let removedCount = 0; - if (!(documents instanceof Map)) { - // logger.warn('[TTL Cleanup] Provided documents is not a Map. Skipping cleanup.'); - return removedCount; // Защита, если передан не Map - } - - const idsToRemove = []; - for (const [id, doc] of documents.entries()) { - if (!isAlive(doc)) { - idsToRemove.push(id); - } - } - - if (idsToRemove.length > 0) { - for (const id of idsToRemove) { - const docToRemove = documents.get(id); // Получаем документ перед удалением для indexManager - if (docToRemove) { // Убедимся, что он все еще там (маловероятно, но для безопасности) - documents.delete(id); - if (indexManager && typeof indexManager.afterRemove === 'function') { - indexManager.afterRemove(docToRemove); - } - removedCount++; - } - } - } - return removedCount; -} -exports.cleanupExpiredDocs = cleanupExpiredDocs; \ No newline at end of file diff --git a/wise-json/collection/utils.js b/wise-json/collection/utils.js deleted file mode 100644 index e131e1c..0000000 --- a/wise-json/collection/utils.js +++ /dev/null @@ -1,170 +0,0 @@ -/** - * wise-json/collection/utils.js - * Утилиты для работы с коллекциями WiseJSON (id, типы, сериализация и др.) - */ - -/** - * Генерирует уникальный id (короткий, простой). - * @returns {string} - */ -function defaultIdGenerator() { - return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); -} - -/** - * Проверяет, является ли значение непустой строкой. - * @param {any} value - * @returns {boolean} - */ -function isNonEmptyString(value) { - return typeof value === 'string' && value.length > 0; -} - -/** - * Проверяет, является ли значение plain-объектом (без прототипа). - * @param {any} value - * @returns {boolean} - */ -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]'; -} - -/** - * Делает абсолютный путь (удобно для базы) - * @param {string} p - * @returns {string} - */ -function makeAbsolutePath(p) { - return require('path').isAbsolute(p) ? p : require('path').resolve(process.cwd(), p); -} - -/** - * Валидирует/дополняет опции коллекции. - * @param {object} [opts] - * @returns {object} - */ -function validateOptions(opts = {}) { - return Object.assign({ - maxSegmentSizeBytes: 2 * 1024 * 1024, - checkpointIntervalMs: 60000, - ttlCleanupIntervalMs: 60000, - walSync: false - }, opts || {}); -} - -/** - * Преобразует массив документов в CSV-строку. - * @param {Array} docs - * @returns {string} - */ -function flattenDocToCsv(docs) { - if (!Array.isArray(docs) || docs.length === 0) return ''; - const fields = Array.from(new Set(docs.flatMap(doc => Object.keys(doc)))); - const escape = v => (typeof v === 'string' && (v.includes(',') || v.includes('"') || v.includes('\n'))) - ? `"${String(v).replace(/"/g, '""')}"` - : v; - const csv = [ - fields.join(','), - ...docs.map(doc => fields.map(f => escape(doc[f] ?? '')).join(',')) - ]; - return csv.join('\n'); -} - -/** - * Проверяет, соответствует ли документ декларативному фильтру (в стиле MongoDB). - * @param {object} doc - Документ для проверки. - * @param {object} filter - Объект фильтра. - * @returns {boolean} - */ -function matchFilter(doc, filter) { - if (typeof filter !== 'object' || filter == null || doc === null || typeof doc !== 'object') { - return false; - } - - if (Array.isArray(filter.$or)) { - return filter.$or.some(f => matchFilter(doc, f)); - } - if (Array.isArray(filter.$and)) { - return filter.$and.every(f => matchFilter(doc, f)); - } - - for (const key of Object.keys(filter)) { - if (key === '$or' || key === '$and') continue; - - const cond = filter[key]; - const value = doc[key]; - - if (typeof cond === 'object' && cond !== null && !Array.isArray(cond)) { - for (const op of Object.keys(cond)) { - const opVal = cond[op]; - let match = true; - switch (op) { - case '$gt': if (!(value > opVal)) match = false; break; - case '$gte': if (!(value >= opVal)) match = false; break; - case '$lt': if (!(value < opVal)) match = false; break; - case '$lte': if (!(value <= opVal)) match = false; break; - case '$ne': if (value === opVal) match = false; break; - - // --- ИЗМЕНЕНИЕ ЗДЕСЬ --- - case '$in': { - if (!Array.isArray(opVal)) { - match = false; - } else if (Array.isArray(value)) { - // Если поле в документе - массив, проверяем пересечение - match = value.some(item => opVal.includes(item)); - } else { - // Если поле в документе - простое значение - match = opVal.includes(value); - } - break; - } - case '$nin': { - if (!Array.isArray(opVal)) { - match = false; - } else if (Array.isArray(value)) { - // Если поле в документе - массив, проверяем отсутствие пересечений - match = !value.some(item => opVal.includes(item)); - } else { - // Если поле в документе - простое значение - match = !opVal.includes(value); - } - break; - } - // --- КОНЕЦ ИЗМЕНЕНИЯ --- - - case '$exists': if ((value !== undefined) !== opVal) match = false; break; - case '$regex': { - if (typeof value !== 'string') { - match = false; - } else { - try { - const re = new RegExp(opVal, cond.$options || ''); - if (!re.test(value)) match = false; - } catch (e) { - match = false; - } - } - break; - } - default: - match = false; - break; - } - if (!match) return false; - } - } else { - if (value !== cond) return false; - } - } - return true; -} - -module.exports = { - defaultIdGenerator, - isNonEmptyString, - isPlainObject, - makeAbsolutePath, - validateOptions, - flattenDocToCsv, - matchFilter, -}; \ No newline at end of file diff --git a/wise-json/collection/wal-ops.js b/wise-json/collection/wal-ops.js deleted file mode 100644 index a5be21f..0000000 --- a/wise-json/collection/wal-ops.js +++ /dev/null @@ -1,198 +0,0 @@ -// wise-json/collection/wal-ops.js - -const fs = require('fs/promises'); -const path = require('path'); -const { isAlive } = require('./ttl.js'); - -function walEntryToString(entry) { - return JSON.stringify(entry) + '\n'; -} - -async function readWalEntries(walFile, sinceTimestamp = null) { - try { - const raw = await fs.readFile(walFile, 'utf8'); - const lines = raw.trim().split('\n'); - const entries = []; - for (const line of lines) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - entries.push(entry); - } catch { - // игнорируем ошибочные строки - } - } - return entries; - } catch (e) { - if (e.code === 'ENOENT') return []; - throw e; - } -} - -async function readWal(collection) { - if (!collection) { - throw new Error('[readWal] В функцию передан undefined/null вместо коллекции. Проверь вызов readWal(this.collection) внутри sync-manager.'); - } - let walPath = null; - if (collection._walPath) walPath = collection._walPath; - else if (collection.walPath) walPath = collection.walPath; - else if (collection._wal && collection._wal.path) walPath = collection._wal.path; - else if (collection._wal && collection._wal.walPath) walPath = collection._wal.walPath; - - if (!walPath) { - throw new Error( - '[readWal] Не удалось определить путь к WAL-файлу коллекции. ' + - 'Проверь свойства collection._walPath, collection.walPath, collection._wal.path. ' + - `Объект collection: ${JSON.stringify(collection, null, 2)}` - ); - } - return readWalEntries(walPath, null); -} - -function createWalOps({ - documents, - indexManager, - _performCheckpoint, - _emitter, - _updateIndexesAfterInsert, - _updateIndexesAfterRemove, - _updateIndexesAfterUpdate, - _triggerCheckpointIfRequired, - options, - walPath, -}) { - function applyWalEntryToMemory(entry, emit = true) { - if (entry.op === 'INSERT') { - const doc = entry.doc; - if (doc) { - documents.set(doc._id, doc); - _updateIndexesAfterInsert && _updateIndexesAfterInsert(doc); - if (emit) _emitter.emit('insert', doc); - } - } else if (entry.op === 'BATCH_INSERT') { - const docs = Array.isArray(entry.docs) ? entry.docs : []; - for (const doc of docs) { - if (doc) { - documents.set(doc._id, doc); - _updateIndexesAfterInsert && _updateIndexesAfterInsert(doc); - if (emit) _emitter.emit('insert', doc); - } - } - } else if (entry.op === 'UPDATE') { - const id = entry.id; - const prev = documents.get(id); - if (prev && isAlive(prev)) { - const updated = { ...prev, ...entry.data }; - documents.set(id, updated); - _updateIndexesAfterUpdate && _updateIndexesAfterUpdate(prev, updated); - if (emit) _emitter.emit('update', updated, prev); - } else if (!prev && entry.data && entry.data._id === id) { - // Логика для случая, если документ не найден (например, upsert не поддерживается) - } - } else if (entry.op === 'REMOVE') { - const id = entry.id; - const prev = documents.get(id); - if (prev) { - documents.delete(id); - _updateIndexesAfterRemove && _updateIndexesAfterRemove(prev); - if (emit) _emitter.emit('remove', prev); - } - } else if (entry.op === 'CLEAR') { - const docsToRemove = Array.from(documents.values()); - documents.clear(); - if (_updateIndexesAfterRemove) { - for (const doc of docsToRemove) { - _updateIndexesAfterRemove(doc); - } - } - if (emit) _emitter.emit('clear'); - } - } - - async function enqueueDataModification(entry, opType, getResult, extra = {}) { - if (opType === 'INSERT') { - const docToInsert = entry.doc; - if (docToInsert && indexManager) { - const uniqueIndexesMeta = (indexManager.getIndexesMeta() || []).filter(m => m.type === 'unique'); - for (const idxMeta of uniqueIndexesMeta) { - const fieldName = idxMeta.fieldName; - const valueToInsert = docToInsert[fieldName]; - if (valueToInsert !== undefined && valueToInsert !== null) { - const index = indexManager.indexes.get(fieldName); - if (index && index.data && index.data.has(valueToInsert)) { - if (index.data.get(valueToInsert) !== docToInsert._id) { - throw new Error( - `Duplicate value '${valueToInsert}' for unique index '${fieldName}' in insert operation` - ); - } - } - } - } - } - } - else if (opType === 'BATCH_INSERT') { - const docs = entry.docs || []; - if (docs.length > 0 && indexManager) { - const uniqueIndexesMeta = (indexManager.getIndexesMeta() || []) - .filter(meta => meta.type === 'unique') - .map(meta => meta.fieldName); - - for (const field of uniqueIndexesMeta) { - const batchValues = new Set(); - const existingValuesFromMemory = new Set(); - for (const doc of documents.values()) { - if (doc[field] !== undefined && doc[field] !== null) { - existingValuesFromMemory.add(doc[field]); - } - } - - for (const doc of docs) { - if (doc[field] !== undefined && doc[field] !== null) { - if (batchValues.has(doc[field]) || existingValuesFromMemory.has(doc[field])) { - throw new Error( - `Duplicate value '${doc[field]}' for unique index '${field}' in batch insert` - ); - } - batchValues.add(doc[field]); - } - } - } - } - } - - await fs.mkdir(path.dirname(walPath), { recursive: true }); - await fs.appendFile(walPath, walEntryToString(entry), 'utf8'); - - applyWalEntryToMemory(entry, true); - - if (typeof _triggerCheckpointIfRequired === 'function') { - _triggerCheckpointIfRequired(entry); - } - - let prev = null, next = null; - if (opType === 'INSERT') { - next = entry.doc; - } else if (opType === 'BATCH_INSERT') { - next = entry.docs; - } else if (opType === 'UPDATE') { - if (documents.has(entry.id)) { - const originalDoc = documents.get(entry.id); - next = originalDoc; - } else { - next = null; - } - } else if (opType === 'REMOVE') { - // prev для эмиттера событий берется внутри applyWalEntryToMemory - } - - return getResult ? getResult(undefined, next) : undefined; - } - - return { - applyWalEntryToMemory, - enqueueDataModification, - }; -} - -module.exports = createWalOps; -module.exports.readWal = readWal; diff --git a/wise-json/errors.js b/wise-json/errors.js deleted file mode 100644 index 37dc2e9..0000000 --- a/wise-json/errors.js +++ /dev/null @@ -1,71 +0,0 @@ -// wise-json/errors.js - -/** - * Базовый класс для всех кастомных ошибок, генерируемых WiseJSON DB. - * Позволяет ловить все ошибки библиотеки через `catch (e if e instanceof WiseJSONError)`. - */ -class WiseJSONError extends Error { - /** - * @param {string} message - Сообщение об ошибке. - */ - constructor(message) { - super(message); - // Устанавливаем имя конструктора как имя ошибки для легкой идентификации. - this.name = this.constructor.name; - // Сохраняем стек вызовов (полезно для отладки). - Error.captureStackTrace(this, this.constructor); - } -} - -/** - * Ошибка, возникающая при попытке нарушить ограничение уникальности индекса. - * Например, при вставке документа со значением, которое уже существует в уникальном индексе. - */ -class UniqueConstraintError extends WiseJSONError { - /** - * @param {string} fieldName - Имя поля с уникальным индексом. - * @param {*} value - Значение, которое вызвало конфликт. - */ - constructor(fieldName, value) { - const valueStr = typeof value === 'string' ? `'${value}'` : value; - super(`Duplicate value ${valueStr} for unique index on field '${fieldName}'.`); - this.fieldName = fieldName; - this.value = value; - } -} - -/** - * Ошибка, возникающая, когда операция не может быть выполнена, - * так как ожидаемый документ не был найден по указанному ID. - * (Примечание: getById просто возвращает null, а вот update или remove могут бросать эту ошибку, если это требуется логикой). - */ -class DocumentNotFoundError extends WiseJSONError { - /** - * @param {string} docId - ID документа, который не был найден. - */ - constructor(docId) { - super(`Document with ID '${docId}' not found.`); - this.docId = docId; - } -} - -/** - * Ошибка, связанная с некорректной конфигурацией, опциями или неверным использованием API. - * Например, передача невалидных аргументов в метод. - */ -class ConfigurationError extends WiseJSONError { - /** - * @param {string} message - Описание ошибки конфигурации. - */ - constructor(message) { - super(message); - } -} - - -module.exports = { - WiseJSONError, - UniqueConstraintError, - DocumentNotFoundError, - ConfigurationError, -}; \ No newline at end of file diff --git a/wise-json/index.js b/wise-json/index.js deleted file mode 100644 index 9a2d616..0000000 --- a/wise-json/index.js +++ /dev/null @@ -1,189 +0,0 @@ -// wise-json/index.js - -const path = require('path'); -const fs = require('fs/promises'); -const Collection = require('./collection/core.js'); -const TransactionManager = require('./collection/transaction-manager.js'); -const { makeAbsolutePath, validateOptions } = require('./collection/utils.js'); -const logger = require('./logger'); -const { ConfigurationError } = require('./errors.js'); - -const DEFAULT_PATH = process.env.WISE_JSON_PATH || makeAbsolutePath('wise-json-db-data'); - -/** - * Основной класс для управления базой данных WiseJSON. - * Является точкой входа для работы с коллекциями и транзакциями. - */ -class WiseJSON { - /** - * Создает экземпляр базы данных. - * @param {string} [dbRootPath=./wise-json-db-data] - Путь к корневой директории, где будут храниться все данные. - * @param {object} [options={}] - Объект с опциями конфигурации для базы данных. - */ - constructor(dbRootPath = DEFAULT_PATH, options = {}) { - this.dbRootPath = makeAbsolutePath(dbRootPath); - this.options = validateOptions(options); - this.collections = {}; // Кэш для экземпляров коллекций - this._activeTransactions = []; - - this._isInitialized = false; - this._initPromise = null; - - if (!WiseJSON._hasGracefulShutdown) { - this._setupGracefulShutdown(); - WiseJSON._hasGracefulShutdown = true; - } - } - - /** - * Асинхронно и потокобезопасно инициализирует базу данных. - * Если вызов init() уже в процессе, новый вызов вернет тот же самый промис. - * Если БД уже инициализирована, промис разрешится немедленно. - * @returns {Promise} - */ - init() { - if (this._initPromise) { - return this._initPromise; - } - - this._initPromise = (async () => { - try { - await fs.mkdir(this.dbRootPath, { recursive: true }); - this._isInitialized = true; - logger.log(`[WiseJSON] Database at ${this.dbRootPath} initialized.`); - } catch (err) { - logger.error(`[WiseJSON] Critical error during database initialization at ${this.dbRootPath}:`, err); - this._initPromise = null; - throw err; - } - })(); - - return this._initPromise; - } - - /** - * Внутренний метод для гарантии, что БД инициализирована перед операцией. - * @private - */ - async _ensureInitialized() { - if (!this._isInitialized) { - await this.init(); - } - } - - /** - * Получает или создает экземпляр коллекции, но не дожидается ее полной инициализации. - * @param {string} name - Имя коллекции. - * @returns {Promise} Промис, который разрешается экземпляром коллекции. - */ - async collection(name) { - await this._ensureInitialized(); - if (!this.collections[name]) { - this.collections[name] = new Collection(name, this.dbRootPath, this.options); - } - return this.collections[name]; - } - - /** - * Асинхронно получает или создает коллекцию и дожидается ее полной инициализации. - * @param {string} name - Имя коллекции. - * @returns {Promise} Готовый к работе экземпляр коллекции. - */ - async getCollection(name) { - const collectionInstance = await this.collection(name); - await collectionInstance.initPromise; - return collectionInstance; - } - - /** - * Возвращает имена всех существующих коллекций в базе данных. - * @returns {Promise} Массив с именами коллекций. - */ - async getCollectionNames() { - await this._ensureInitialized(); - try { - const items = await fs.readdir(this.dbRootPath, { withFileTypes: true }); - return items - .filter(item => - item.isDirectory() && - !item.name.startsWith('.') && // Игнорируем скрытые папки (например, .DS_Store) - !item.name.endsWith('.lock') && // +++ ИСПРАВЛЕНИЕ: Игнорируем lock-директории - item.name !== '_checkpoints' && // Игнорируем общую папку чекпоинтов, если она есть - item.name !== 'node_modules' // На всякий случай - ) - .map(item => item.name); - } catch (e) { - if (e.code === 'ENOENT') { - return []; - } - throw e; - } - } - - /** - * Корректно закрывает все открытые коллекции, сохраняя все несохраненные данные на диск. - * @returns {Promise} - */ - async close() { - // Дожидаемся завершения инициализации, если она еще идет, перед закрытием - if (this._initPromise) { - await this._initPromise; - } - const allCollections = Object.values(this.collections); - for (const col of allCollections) { - if (col && typeof col.close === 'function') { - await col.close(); - } - } - } - - /** - * Устанавливает обработчики системных сигналов для корректного завершения. - * @private - */ - _setupGracefulShutdown() { - const signals = ['SIGINT', 'SIGTERM']; - let isShuttingDown = false; - const shutdownHandler = async () => { - if (isShuttingDown) return; - isShuttingDown = true; - try { - logger.log(`\n[WiseJSON] Graceful shutdown initiated, saving all collections...`); - await this.close(); - logger.log('[WiseJSON] All data saved. Shutting down.'); - } catch (e) { - logger.error('[WiseJSON] Error during graceful shutdown:', e); - } finally { - // Даем логам время записаться и выходим - setTimeout(() => process.exit(0), 100); - } - }; - signals.forEach(signal => { - process.on(signal, shutdownHandler); - }); - } - - /** - * Начинает новую транзакцию, которая может затрагивать несколько коллекций. - * @returns {TransactionManager} Объект менеджера транзакций. - * @throws {ConfigurationError} если база данных еще не инициализирована. - */ - beginTransaction() { - if (!this._isInitialized) { - throw new ConfigurationError("Cannot begin transaction: database is not initialized. Call db.init() or an async method like getCollection() first."); - } - const txn = new TransactionManager(this); - this._activeTransactions.push(txn); - return txn; - } - - /** - * Возвращает массив активных (незавершенных) транзакций. - * @returns {TransactionManager[]} - */ - getActiveTransactions() { - return this._activeTransactions.filter(txn => txn.state === 'pending'); - } -} - -module.exports = WiseJSON; \ No newline at end of file diff --git a/wise-json/logger.js b/wise-json/logger.js deleted file mode 100644 index f2661a5..0000000 --- a/wise-json/logger.js +++ /dev/null @@ -1,95 +0,0 @@ -// wise-json/logger.js - -const colors = { - reset: "\x1b[0m", - gray: "\x1b[90m", - red: "\x1b[31m", - yellow: "\x1b[33m", - cyan: "\x1b[36m", -}; - -const levels = { - error: 0, - warn: 1, - log: 2, - debug: 3, -}; - -const colorMap = { - error: colors.red, - warn: colors.yellow, - log: colors.cyan, - debug: colors.gray, -}; - -// --- Конфигурация --- -const envLevel = process.env.LOG_LEVEL ? String(process.env.LOG_LEVEL).toLowerCase() : null; -const defaultLogLevel = process.env.NODE_ENV === 'production' ? 'warn' : 'log'; -let currentLevel; - -if (envLevel === 'none') { - currentLevel = -1; // Уровень, который отключит все логи -} else { - currentLevel = levels[envLevel] !== undefined ? levels[envLevel] : levels[defaultLogLevel]; -} - -const NO_COLOR = process.env.LOG_NO_COLOR === 'true'; - -function safeArgsToString(args) { - try { - return args.map(arg => { - if (arg instanceof Error) return arg.stack || arg.message; - if (typeof arg === 'object' && arg !== null) { - try { return JSON.stringify(arg); } catch (e) { return '[Unserializable Object]'; } - } - return String(arg); - }).join(" "); - } catch (e) { - console.error('[Logger Internal Error] Failed to process arguments for logging:', e); - return '[Error processing log arguments]'; - } -} - -function format(level, msg) { - const ts = new Date().toISOString(); - if (NO_COLOR) { - return `[${ts}] [${level.toUpperCase()}] ${msg}`; - } - const color = colorMap[level] || colors.reset; - return `${color}[${ts}] [${level.toUpperCase()}]${colors.reset} ${msg}`; -} - -const logger = { - error(...args) { - if (currentLevel >= levels.error) { // Проверка уровня ДО вызова console - console.error(format("error", safeArgsToString(args))); - } - }, - - warn(...args) { - if (currentLevel >= levels.warn) { // Проверка уровня ДО вызова console - // Используем console.log для warn, чтобы не загрязнять stderr в тестах, если не нужно - console.log(format("warn", safeArgsToString(args))); - } - }, - - log(...args) { - if (currentLevel >= levels.log) { // Проверка уровня ДО вызова console - console.log(format("log", safeArgsToString(args))); - } - }, - - debug(...args) { - if (currentLevel >= levels.debug) { // Проверка уровня ДО вызова console - console.log(format("debug", safeArgsToString(args))); - } - }, - - getLevel() { - return Object.keys(levels).find(k => levels[k] === currentLevel) || 'none'; - }, - - levels: { ...levels } -}; - -module.exports = logger; \ No newline at end of file diff --git a/wise-json/storage-utils.js b/wise-json/storage-utils.js deleted file mode 100644 index efe38a5..0000000 --- a/wise-json/storage-utils.js +++ /dev/null @@ -1,155 +0,0 @@ -// storage-utils.js -const fs = require('fs/promises'); -const path = require('path'); -const logger = require('./logger'); - -/** - * Проверяет, существует ли указанный путь (файл или директория). - * @param {string} filePath - * @returns {Promise} - */ -async function pathExists(filePath) { - try { - await fs.access(filePath); - return true; - } catch (err) { - if (err.code === 'ENOENT') return false; - // ASSUMPTION: Для других ошибок (например, отказано в доступе) возвращаем false, но логируем. - logger.warn(`[StorageUtils] Предупреждение: путь "${filePath}" не доступен (${err.code}).`); - return false; - } -} - -/** - * Создаёт директорию, если она не существует. - * @param {string} dirPath - * @returns {Promise} - */ -async function ensureDirectoryExists(dirPath) { - try { - await fs.mkdir(dirPath, { recursive: true }); - } catch (err) { - if (err.code !== 'EEXIST') { - // ASSUMPTION: Ошибка создания директории критична, пробрасываем ошибку. - logger.error(`[StorageUtils] Ошибка создания директории "${dirPath}": ${err.message}`); - throw err; - } - } -} - -/** - * Безопасно записывает JSON в файл. - * Пишет сначала во временный `.tmp` файл, затем переименовывает. - * Это защищает от порчи данных при сбое. - * @param {string} filePath - путь к финальному JSON-файлу - * @param {any} data - данные для записи - * @param {number|null} [jsonIndent=null] - отступ в JSON или null - * @returns {Promise} - * @throws {Error} если запись или переименование не удались - */ -async function writeJsonFileSafe(filePath, data, jsonIndent = null) { - const tmpName = `${Date.now()}-${Math.random().toString(36).substring(2, 10)}.tmp`; - const tmpPath = `${filePath}.${tmpName}`; - - try { - const json = JSON.stringify(data, null, jsonIndent); - await fs.writeFile(tmpPath, json, 'utf-8'); - try { - await fs.rename(tmpPath, filePath); - } catch (err) { - // ASSUMPTION: Если переименование не удалось — пробуем удалить tmp-файл, бросаем ошибку выше. - logger.error(`[StorageUtils] Ошибка переименования tmp-файла "${tmpPath}" -> "${filePath}": ${err.message}`); - try { - await fs.unlink(tmpPath); - } catch (unlinkErr) { - // Если не смогли удалить tmp-файл — только логируем, не бросаем ошибку повторно. - logger.warn(`[StorageUtils] Не удалось удалить tmp-файл после сбоя rename "${tmpPath}": ${unlinkErr.message}`); - } - throw err; - } - } catch (err) { - // ASSUMPTION: Любая ошибка на любом этапе считается критичной, пробрасываем наружу. - logger.error(`[StorageUtils] Ошибка записи JSON в "${filePath}": ${err.message}`); - if (await pathExists(tmpPath)) { - try { - await fs.unlink(tmpPath); - } catch (unlinkErr) { - logger.warn(`[StorageUtils] Не удалось удалить tmp-файл "${tmpPath}": ${unlinkErr.message}`); - } - } - throw err; - } -} - -/** - * Читает JSON-файл с диска и парсит его. - * @param {string} filePath - * @returns {Promise} - * @throws {Error} если файл есть, но повреждён (некорректный JSON) - */ -async function readJsonFile(filePath) { - try { - const raw = await fs.readFile(filePath, 'utf-8'); - try { - return JSON.parse(raw); - } catch (parseErr) { - // ASSUMPTION: Повреждённый JSON-файл — это критическая ошибка. - logger.error(`[StorageUtils] Ошибка парсинга JSON-файла "${filePath}": ${parseErr.message}`); - throw parseErr; - } - } catch (err) { - if (err.code === 'ENOENT') return null; - // ASSUMPTION: Ошибки чтения кроме ENOENT критичны, пробрасываем дальше. - logger.error(`[StorageUtils] Ошибка чтения JSON-файла "${filePath}": ${err.message}`); - throw err; - } -} - -/** - * Копирует файл (например, для создания резервной копии). - * Если dst уже существует — перезаписывает. - * @param {string} src - * @param {string} dst - * @returns {Promise} - * @throws {Error} если копирование не удалось - */ -async function copyFileSafe(src, dst) { - try { - await fs.copyFile(src, dst); - } catch (err) { - // ASSUMPTION: Ошибка копирования критична, пробрасываем наружу. - logger.error(`[StorageUtils] Ошибка копирования из "${src}" в "${dst}": ${err.message}`); - throw err; - } -} - -/** - * Удаляет файл, если он существует. - * @param {string} filePath - * @returns {Promise} - * @remarks Логирует, но не бросает ошибку, если удаление не удалось. - */ -async function deleteFileIfExists(filePath) { - try { - if (await pathExists(filePath)) { - try { - await fs.unlink(filePath); - } catch (err) { - // ASSUMPTION: Неудачное удаление файла не критично для работы системы, только логируем. - logger.warn(`[StorageUtils] Не удалось удалить файл "${filePath}": ${err.message}`); - } - } - } catch (err) { - // ASSUMPTION: Ошибка при проверке существования файла не критична, только логируем. - logger.warn(`[StorageUtils] Не удалось проверить наличие файла "${filePath}": ${err.message}`); - } -} - -module.exports = { - pathExists, - ensureDirectoryExists, - writeJsonFileSafe, - readJsonFile, - copyFileSafe, - deleteFileIfExists, -}; diff --git a/wise-json/sync/api-client.js b/wise-json/sync/api-client.js deleted file mode 100644 index f1f8f25..0000000 --- a/wise-json/sync/api-client.js +++ /dev/null @@ -1,138 +0,0 @@ -// wise-json/sync/api-client.js - -const http = require('http'); -const https = require('https'); -const { URL } = require('url'); - -/** - * ApiClient - a low-level client for interacting with a remote WiseJSON server. - * It is responsible for creating and sending HTTP requests and handling responses. - */ -class ApiClient { - /** - * @param {string} baseUrl - The full base URL of the server, e.g., 'https://api.example.com'. - * @param {string} apiKey - The API key for authentication. - * @param {object} [endpoints={}] - Optional custom endpoint paths. - */ - constructor(baseUrl, apiKey, endpoints = {}) { - if (!baseUrl || !apiKey) { - throw new Error('ApiClient requires baseUrl and apiKey for initialization.'); - } - this.baseUrl = new URL(baseUrl); - this.apiKey = apiKey; - this.agent = this.baseUrl.protocol === 'https:' ? https : http; - - // УЛУЧШЕНИЕ: Делаем эндпоинты настраиваемыми - this.endpoints = { - snapshot: '/sync/snapshot', - pull: '/sync/pull', - push: '/sync/push', - health: '/sync/health', - ...endpoints, - }; - } - - /** - * The core method for making requests. - * @private - * @param {string} method - The HTTP method ('GET', 'POST', etc.). - * @param {string} path - The request path (e.g., '/sync/pull'). - * @param {object|null} body - The request body for POST/PUT methods. - * @returns {Promise} A promise that resolves with the parsed JSON response. - */ - _request(method, path, body = null) { - return new Promise((resolve, reject) => { - const requestPath = this.baseUrl.pathname.endsWith('/') - ? `${this.baseUrl.pathname.slice(0, -1)}${path}` - : `${this.baseUrl.pathname}${path}`; - - const options = { - hostname: this.baseUrl.hostname, - port: this.baseUrl.port || (this.baseUrl.protocol === 'https:' ? 443 : 80), - path: requestPath, - method: method.toUpperCase(), - headers: { - 'Accept': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, - }, - timeout: 15000, // 15-секундный таймаут для запросов - }; - - if (body) { - options.headers['Content-Type'] = 'application/json'; - } - - const req = this.agent.request(options, (res) => { - let responseData = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { - responseData += chunk; - }); - res.on('end', () => { - if (res.statusCode >= 400) { - let error; - try { - const errorPayload = JSON.parse(responseData); - error = new Error(errorPayload.error || `Server returned error ${res.statusCode}`); - } catch (e) { - error = new Error(`Server returned error ${res.statusCode} with non-JSON body: ${responseData}`); - } - error.statusCode = res.statusCode; - return reject(error); - } - - if (res.statusCode === 204 || responseData.length === 0) { - return resolve(null); // No Content - } - - try { - const parsedData = JSON.parse(responseData); - resolve(parsedData); - } catch (e) { - reject(new Error(`Failed to parse JSON response from server. Raw response: ${responseData}`)); - } - }); - }); - - req.on('timeout', () => { - req.destroy(); - reject(new Error('Request timed out after 15 seconds.')); - }); - - req.on('error', (e) => { - reject(new Error(`Network error during request: ${e.message}`)); - }); - - if (body) { - try { - req.write(JSON.stringify(body)); - } catch (e) { - return reject(new Error(`Failed to serialize request body: ${e.message}`)); - } - } - - req.end(); - }); - } - - /** - * Performs a GET request. - * @param {string} path - The request path. - * @returns {Promise} - */ - get(path) { - return this._request('GET', path); - } - - /** - * Performs a POST request. - * @param {string} path - The request path. - * @param {object} body - The request body. - * @returns {Promise} - */ - post(path, body) { - return this._request('POST', path, body); - } -} - -module.exports = ApiClient; \ No newline at end of file