Compare commits
48 Commits
Author | SHA1 | Date | |
---|---|---|---|
f4290ae7f7 | |||
e58c0fd215 | |||
a91fac450a | |||
5cb043009c | |||
4a1f11b885 | |||
43f9033ccc | |||
e7c0951786 | |||
efc107907c | |||
2b8b0e5bdd | |||
3ae2a7fcf5 | |||
0806d3749b | |||
f5d5e20a97 | |||
db2767010d | |||
e2dc094afd | |||
39d2957b7d | |||
490524516e | |||
ccd4b9e1ec | |||
9c6d6d9f2c | |||
e4d787096e | |||
2bf923b4f1 | |||
0ca1d452b4 | |||
436311ab06 | |||
498f586ddb | |||
6c50bd23ec | |||
419eb163f4 | |||
75aeb12e81 | |||
c5a44da975 | |||
969b073939 | |||
ac80f90ae0 | |||
d0e769622e | |||
eef758cabb | |||
d0cc2a0ed2 | |||
87c930121c | |||
23b499b3a8 | |||
0834ec5c91 | |||
6a2a708ea1 | |||
1d977986f1 | |||
e325b42906 | |||
1a359d355a | |||
b5a9449d5e | |||
558f83a3d9 | |||
76ae454221 | |||
90cfc4644d | |||
0be279e5f5 | |||
9755522bba | |||
de8736e99e | |||
c430627a21 | |||
0bfebaf5b9 |
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
Binary file not shown.
44
.serena/memories/code_style_conventions.md
Normal file
44
.serena/memories/code_style_conventions.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Code Style & Conventions
|
||||||
|
|
||||||
|
## TypeScript Standards
|
||||||
|
- **Target**: ES2022
|
||||||
|
- **Module System**: ESM with NodeNext resolution
|
||||||
|
- **Decorators**: Experimental decorators enabled
|
||||||
|
- **Strict Mode**: Implied through TypeScript configuration
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
- **Interfaces**: Prefix with `I` (e.g., `IUserData`, `IConfig`)
|
||||||
|
- **Types**: Prefix with `T` (e.g., `TResponseType`, `TQueryResult`)
|
||||||
|
- **Classes**: PascalCase (e.g., `SmartdataDb`, `SmartDataDbDoc`)
|
||||||
|
- **Files**: All lowercase (e.g., `classes.doc.ts`, `plugins.ts`)
|
||||||
|
- **Methods**: camelCase (e.g., `findOne`, `saveToDb`)
|
||||||
|
|
||||||
|
## Import Patterns
|
||||||
|
- All external dependencies imported in `ts/plugins.ts`
|
||||||
|
- Reference as `plugins.moduleName.method()`
|
||||||
|
- Use full import paths for internal modules
|
||||||
|
- Maintain ESM syntax throughout
|
||||||
|
|
||||||
|
## Class Structure
|
||||||
|
- Use decorators for MongoDB document definitions
|
||||||
|
- Extend base classes (SmartDataDbDoc, SmartDataDbCollection)
|
||||||
|
- Static methods for factory patterns
|
||||||
|
- Instance methods for document operations
|
||||||
|
|
||||||
|
## Async Patterns
|
||||||
|
- Preserve Promise-based patterns
|
||||||
|
- Use async/await for clarity
|
||||||
|
- Handle errors appropriately
|
||||||
|
- Return typed Promises
|
||||||
|
|
||||||
|
## MongoDB Specifics
|
||||||
|
- Use `@unify()` decorator for unique fields
|
||||||
|
- Use `@svDb()` decorator for database fields
|
||||||
|
- Implement proper serialization/deserialization
|
||||||
|
- Type-safe query construction with DeepQuery<T>
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
- Import from `@git.zone/tstest/tapbundle`
|
||||||
|
- End test files with `export default tap.start()`
|
||||||
|
- Use descriptive test names
|
||||||
|
- Cover edge cases and error conditions
|
37
.serena/memories/project_overview.md
Normal file
37
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Project Overview: @push.rocks/smartdata
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
An advanced TypeScript-first MongoDB wrapper library providing enterprise-grade features for distributed systems, real-time data synchronization, and easy data management.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Language**: TypeScript (ES2022 target)
|
||||||
|
- **Runtime**: Node.js >= 16.x
|
||||||
|
- **Database**: MongoDB >= 4.4
|
||||||
|
- **Build System**: tsbuild
|
||||||
|
- **Test Framework**: tstest with tapbundle
|
||||||
|
- **Package Manager**: pnpm (v10.7.0)
|
||||||
|
- **Module System**: ESM (ES Modules)
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
- Type-safe MongoDB integration with decorators
|
||||||
|
- Document management with automatic timestamps
|
||||||
|
- EasyStore for key-value storage
|
||||||
|
- Distributed coordination with leader election
|
||||||
|
- Real-time data sync with RxJS watchers
|
||||||
|
- Deep query type safety
|
||||||
|
- Enhanced cursor API
|
||||||
|
- Powerful search capabilities
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
- **ts/**: Main TypeScript source code
|
||||||
|
- Core classes for DB, Collections, Documents, Cursors
|
||||||
|
- Distributed coordinator, EasyStore, Watchers
|
||||||
|
- Lucene adapter for search functionality
|
||||||
|
- **test/**: Test files using tstest framework
|
||||||
|
- **dist_ts/**: Compiled JavaScript output
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
- MongoDB driver (v6.18.0)
|
||||||
|
- @push.rocks ecosystem packages
|
||||||
|
- @tsclass/tsclass for decorators
|
||||||
|
- RxJS for reactive programming
|
35
.serena/memories/suggested_commands.md
Normal file
35
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Suggested Commands for @push.rocks/smartdata
|
||||||
|
|
||||||
|
## Build & Development
|
||||||
|
- `pnpm build` - Build the TypeScript project with web support
|
||||||
|
- `pnpm buildDocs` - Generate documentation using tsdoc
|
||||||
|
- `tsbuild --web --allowimplicitany` - Direct build command
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- `pnpm test` - Run all tests in test/ directory
|
||||||
|
- `pnpm testSearch` - Run specific search test
|
||||||
|
- `tstest test/test.specific.ts --verbose` - Run specific test with verbose output
|
||||||
|
- `tsbuild check test/**/* --skiplibcheck` - Type check test files
|
||||||
|
|
||||||
|
## Package Management
|
||||||
|
- `pnpm install` - Install dependencies
|
||||||
|
- `pnpm install --save-dev <package>` - Add dev dependency
|
||||||
|
- `pnpm add <package>` - Add production dependency
|
||||||
|
|
||||||
|
## Version Control
|
||||||
|
- `git status` - Check current changes
|
||||||
|
- `git diff` - View uncommitted changes
|
||||||
|
- `git log --oneline -10` - View recent commits
|
||||||
|
- `git mv <old> <new>` - Move/rename files preserving history
|
||||||
|
|
||||||
|
## System Utilities (Linux)
|
||||||
|
- `ls -la` - List all files with details
|
||||||
|
- `grep -r "pattern" .` - Search for pattern in files
|
||||||
|
- `find . -name "*.ts"` - Find TypeScript files
|
||||||
|
- `ps aux | grep node` - Find Node.js processes
|
||||||
|
- `lsof -i :80` - Check process on port 80
|
||||||
|
|
||||||
|
## Debug & Development
|
||||||
|
- `tsx <script.ts>` - Run TypeScript file directly
|
||||||
|
- Store debug scripts in `.nogit/debug/`
|
||||||
|
- Curl endpoints for API testing
|
33
.serena/memories/task_completion_checklist.md
Normal file
33
.serena/memories/task_completion_checklist.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Task Completion Checklist
|
||||||
|
|
||||||
|
When completing any coding task in this project, always:
|
||||||
|
|
||||||
|
## Before Committing
|
||||||
|
1. **Build the project**: Run `pnpm build` to ensure TypeScript compiles
|
||||||
|
2. **Run tests**: Execute `pnpm test` to verify nothing is broken
|
||||||
|
3. **Type check**: Verify types compile correctly
|
||||||
|
4. **Check for lint issues**: Look for any code style violations
|
||||||
|
|
||||||
|
## Code Quality Checks
|
||||||
|
- Verify all imports are in `ts/plugins.ts` for external dependencies
|
||||||
|
- Check that interfaces are prefixed with `I`
|
||||||
|
- Check that types are prefixed with `T`
|
||||||
|
- Ensure filenames are lowercase
|
||||||
|
- Verify async patterns are preserved where needed
|
||||||
|
- Check that decorators are properly used for MongoDB documents
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- Update relevant comments if functionality changed
|
||||||
|
- Ensure new exports are properly documented
|
||||||
|
- Update readme.md if new features added (only if explicitly requested)
|
||||||
|
|
||||||
|
## Git Hygiene
|
||||||
|
- Make small, focused commits
|
||||||
|
- Write clear commit messages
|
||||||
|
- Use `git mv` for file operations
|
||||||
|
- Never commit sensitive data or keys
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
- Test the specific functionality that was changed
|
||||||
|
- Ensure no unintended side effects
|
||||||
|
- Verify the change solves the original problem completely
|
68
.serena/project.yml
Normal file
68
.serena/project.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||||
|
# * For C, use cpp
|
||||||
|
# * For JavaScript, use typescript
|
||||||
|
# Special requirements:
|
||||||
|
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||||
|
language: typescript
|
||||||
|
|
||||||
|
# whether to use the project's gitignore file to ignore files
|
||||||
|
# Added on 2025-04-07
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
# list of additional paths to ignore
|
||||||
|
# same syntax as gitignore, so you can use * and **
|
||||||
|
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||||
|
# Added (renamed)on 2025-04-07
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
project_name: "smartdata"
|
173
changelog.md
173
changelog.md
@@ -1,5 +1,178 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-08-12 - 5.16.1 - fix(core)
|
||||||
|
Improve error handling and logging; enhance search query sanitization; update dependency versions and documentation
|
||||||
|
|
||||||
|
- Replaced console.log and console.warn with structured logger.log calls throughout the core modules
|
||||||
|
- Enhanced database initialization with try/catch and proper URI credential encoding
|
||||||
|
- Improved search query conversion by disallowing dangerous operators (e.g. $where) and securely escaping regex patterns
|
||||||
|
- Bumped dependency versions (smartlog, @tsclass/tsclass, mongodb, etc.) in package.json
|
||||||
|
- Added detailed project memories including code style, project overview, and suggested commands for developers
|
||||||
|
- Updated README with improved instructions, feature highlights, and quick start sections
|
||||||
|
|
||||||
|
## 2025-04-25 - 5.16.0 - feat(watcher)
|
||||||
|
Enhance change stream watchers with buffering and EventEmitter support; update dependency versions
|
||||||
|
|
||||||
|
- Bumped smartmongo from ^2.0.11 to ^2.0.12 and smartrx from ^3.0.7 to ^3.0.10
|
||||||
|
- Upgraded @tsclass/tsclass to ^9.0.0 and mongodb to ^6.16.0
|
||||||
|
- Refactored the watch API to accept additional options (bufferTimeMs, fullDocument) for improved change stream handling
|
||||||
|
- Modified SmartdataDbWatcher to extend EventEmitter and support event notifications
|
||||||
|
|
||||||
|
## 2025-04-24 - 5.15.1 - fix(cursor)
|
||||||
|
Improve cursor usage documentation and refactor getCursor API to support native cursor modifiers
|
||||||
|
|
||||||
|
- Updated examples in readme.md to demonstrate manual iteration using cursor.next() and proper cursor closing.
|
||||||
|
- Refactored the getCursor method in classes.doc.ts to accept session and modifier options, consolidating cursor handling.
|
||||||
|
- Added new tests in test/test.cursor.ts to verify cursor operations, including limits, sorting, and skipping.
|
||||||
|
|
||||||
|
## 2025-04-24 - 5.15.0 - feat(svDb)
|
||||||
|
Enhance svDb decorator to support custom serialization and deserialization options
|
||||||
|
|
||||||
|
- Added an optional options parameter to the svDb decorator to accept serialize/deserialize functions
|
||||||
|
- Updated instance creation logic (updateFromDb) to apply custom deserialization if provided
|
||||||
|
- Updated createSavableObject to use custom serialization when available
|
||||||
|
|
||||||
|
## 2025-04-23 - 5.14.1 - fix(db operations)
|
||||||
|
Update transaction API to consistently pass optional session parameters across database operations
|
||||||
|
|
||||||
|
- Revised transaction support in readme to use startSession without await and showcased session usage in getInstance and save calls
|
||||||
|
- Updated methods in classes.collection.ts to accept an optional session parameter for findOne, getCursor, findAll, insert, update, delete, and getCount
|
||||||
|
- Enhanced SmartDataDbDoc save and delete methods to propagate session parameters
|
||||||
|
- Improved overall consistency of transactional APIs across the library
|
||||||
|
|
||||||
|
## 2025-04-23 - 5.14.0 - feat(doc)
|
||||||
|
Implement support for beforeSave, afterSave, beforeDelete, and afterDelete lifecycle hooks in document save and delete operations to allow custom logic execution during these critical moments.
|
||||||
|
|
||||||
|
- Calls beforeSave hook if defined before performing insert or update.
|
||||||
|
- Calls afterSave hook after a document is saved.
|
||||||
|
- Calls beforeDelete hook before deletion and afterDelete hook afterward.
|
||||||
|
- Ensures _updatedAt timestamp is refreshed during save operations.
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.13.1 - fix(search)
|
||||||
|
Improve search query parsing for implicit AND queries by preserving quoted substrings and better handling free terms, quoted phrases, and field:value tokens.
|
||||||
|
|
||||||
|
- Replace previous implicit AND logic with tokenization that preserves quoted substrings
|
||||||
|
- Support both free term and field:value tokens with wildcards inside quotes
|
||||||
|
- Ensure errors are thrown for non-searchable fields in field-specific queries
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.13.0 - feat(search)
|
||||||
|
Improve search query handling and update documentation
|
||||||
|
|
||||||
|
- Added 'codex.md' providing a high-level project overview and detailed search API documentation.
|
||||||
|
- Enhanced search parsing in SmartDataDbDoc to support combined free-term and quoted field phrase queries.
|
||||||
|
- Introduced a new fallback branch in the search method to handle free term with quoted field input.
|
||||||
|
- Updated tests in test/test.search.ts to cover new combined query scenarios and ensure robust behavior.
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.12.2 - fix(search)
|
||||||
|
Fix handling of quoted wildcard patterns in field-specific search queries and add tests for location-based wildcard phrase searches
|
||||||
|
|
||||||
|
- Strip surrounding quotes from wildcard patterns in field queries to correctly transform them to regex
|
||||||
|
- Introduce new tests in test/test.search.ts to validate exact quoted and unquoted wildcard searches on a location field
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.12.1 - fix(search)
|
||||||
|
Improve implicit AND logic for mixed free term and field queries in search and enhance wildcard field handling.
|
||||||
|
|
||||||
|
- Updated regex for field:value parsing to capture full value with wildcards.
|
||||||
|
- Added explicit handling for free terms by converting to regex across searchable fields.
|
||||||
|
- Improved error messaging for attempts to search non-searchable fields.
|
||||||
|
- Extended tests to cover combined free term and wildcard field searches, including error cases.
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.12.0 - feat(doc/search)
|
||||||
|
Enhance search functionality with filter and validate options for advanced query control
|
||||||
|
|
||||||
|
- Added 'filter' option to merge additional MongoDB query constraints in search
|
||||||
|
- Introduced 'validate' hook to post-process and filter fetched documents
|
||||||
|
- Refactored underlying execQuery function to support additional search options
|
||||||
|
- Updated tests to cover new search scenarios and fallback mechanisms
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.11.4 - fix(search)
|
||||||
|
Implement implicit AND logic for mixed simple term and field:value queries in search
|
||||||
|
|
||||||
|
- Added a new branch to detect and handle search queries that mix field:value pairs with plain terms without explicit operators
|
||||||
|
- Builds an implicit $and filter when query parts contain colon(s) but lack explicit boolean operators or quotes
|
||||||
|
- Ensures proper parsing and improved robustness of search filters
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.11.3 - fix(lucene adapter and search tests)
|
||||||
|
Improve range query parsing in Lucene adapter and expand search test coverage
|
||||||
|
|
||||||
|
- Added a new 'testSearch' script in package.json to run search tests.
|
||||||
|
- Introduced advanced search tests for range queries and combined field filters in test/search.advanced.ts.
|
||||||
|
- Enhanced robustness tests in test/search.ts for wildcard and empty query scenarios.
|
||||||
|
- Fixed token validation in the parseRange method of the Lucene adapter to ensure proper error handling.
|
||||||
|
|
||||||
|
## 2025-04-21 - 5.11.2 - fix(readme)
|
||||||
|
Update readme to clarify usage of searchable fields retrieval
|
||||||
|
|
||||||
|
- Replaced getSearchableFields('Product') with Product.getSearchableFields()
|
||||||
|
- Updated documentation to reference the static method Class.getSearchableFields()
|
||||||
|
|
||||||
|
## 2025-04-21 - 5.11.1 - fix(doc)
|
||||||
|
Refactor searchable fields API and improve collection registration.
|
||||||
|
|
||||||
|
- Removed the standalone getSearchableFields utility in favor of a static method on document classes.
|
||||||
|
- Updated tests to use the new static method (e.g., Product.getSearchableFields()).
|
||||||
|
- Ensured the Collection decorator attaches a docCtor property to correctly register searchable fields.
|
||||||
|
- Added try/catch in test cleanup to gracefully handle dropDatabase errors.
|
||||||
|
|
||||||
|
## 2025-04-21 - 5.11.0 - feat(ts/classes.lucene.adapter)
|
||||||
|
Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities.
|
||||||
|
|
||||||
|
- Changed luceneWildcardToRegex from private to public in ts/classes.lucene.adapter.ts.
|
||||||
|
|
||||||
|
## 2025-04-21 - 5.10.0 - feat(search)
|
||||||
|
Improve search functionality: update documentation, refine Lucene query transformation, and add advanced search tests
|
||||||
|
|
||||||
|
- Updated readme.md with detailed Lucene‑style search examples and use cases
|
||||||
|
- Enhanced LuceneToMongoTransformer to properly handle wildcard conversion and regex escaping
|
||||||
|
- Improved search query parsing in SmartDataDbDoc for field-specific, multi-term, and advanced Lucene syntax
|
||||||
|
- Added new advanced search tests covering boolean operators, grouping, quoted phrases, and wildcard queries
|
||||||
|
|
||||||
|
## 2025-04-18 - 5.9.2 - fix(documentation)
|
||||||
|
Update search API documentation to replace deprecated searchWithLucene examples with the unified search(query) API and clarify its behavior.
|
||||||
|
|
||||||
|
- Replaced 'searchWithLucene' examples with 'search(query)' in the README.
|
||||||
|
- Updated explanation to detail field-specific exact match, partial word regex search, multi-word literal matching, and handling of empty queries.
|
||||||
|
- Clarified guidelines for creating MongoDB text indexes on searchable fields for optimized search performance.
|
||||||
|
|
||||||
|
## 2025-04-18 - 5.9.1 - fix(search)
|
||||||
|
Refactor search tests to use unified search API and update text index type casting
|
||||||
|
|
||||||
|
- Replaced all calls from searchWithLucene with search in test/search tests
|
||||||
|
- Updated text index specification in the collection class to use proper type casting
|
||||||
|
|
||||||
|
## 2025-04-18 - 5.9.0 - feat(collections/search)
|
||||||
|
Improve text index creation and search fallback mechanisms in collections and document search methods
|
||||||
|
|
||||||
|
- Auto-create a compound text index on all searchable fields in SmartdataCollection with a one-time flag to prevent duplicate index creation.
|
||||||
|
- Refine the search method in SmartDataDbDoc to support exact field matches and safe regex fallback for non-Lucene queries.
|
||||||
|
|
||||||
|
## 2025-04-17 - 5.8.4 - fix(core)
|
||||||
|
Update commit metadata with no functional code changes
|
||||||
|
|
||||||
|
- Commit info and documentation refreshed
|
||||||
|
- No code or test changes detected in the diff
|
||||||
|
|
||||||
|
## 2025-04-17 - 5.8.3 - fix(readme)
|
||||||
|
Improve readme documentation on data models and connection management
|
||||||
|
|
||||||
|
- Clarify that data models use @Collection, @unI, @svDb, @index, and @searchable decorators
|
||||||
|
- Document that ObjectId and Buffer fields are stored as BSON types natively without extra decorators
|
||||||
|
- Update connection management section to use 'db.close()' instead of 'db.disconnect()'
|
||||||
|
- Revise license section to reference the MIT License without including additional legal details
|
||||||
|
|
||||||
|
## 2025-04-14 - 5.8.2 - fix(classes.doc.ts)
|
||||||
|
Ensure collection initialization before creating a cursor in getCursorExtended
|
||||||
|
|
||||||
|
- Added 'await collection.init()' to guarantee that the MongoDB collection is initialized before using the cursor
|
||||||
|
- Prevents potential runtime errors when accessing collection.mongoDbCollection
|
||||||
|
|
||||||
|
## 2025-04-14 - 5.8.1 - fix(cursor, doc)
|
||||||
|
Add explicit return types and casts to SmartdataDbCursor methods and update getCursorExtended signature in SmartDataDbDoc.
|
||||||
|
|
||||||
|
- Specify Promise<T> as return type for next() in SmartdataDbCursor and cast return value to T.
|
||||||
|
- Specify Promise<T[]> as return type for toArray() in SmartdataDbCursor and cast return value to T[].
|
||||||
|
- Update getCursorExtended to return Promise<SmartdataDbCursor<T>> for clearer type safety.
|
||||||
|
|
||||||
## 2025-04-14 - 5.8.0 - feat(cursor)
|
## 2025-04-14 - 5.8.0 - feat(cursor)
|
||||||
Add toArray method to SmartdataDbCursor to convert raw MongoDB documents into initialized class instances
|
Add toArray method to SmartdataDbCursor to convert raw MongoDB documents into initialized class instances
|
||||||
|
|
||||||
|
77
codex.md
Normal file
77
codex.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# SmartData Project Overview
|
||||||
|
|
||||||
|
This document provides a high-level overview of the SmartData library (`@push.rocks/smartdata`), its architecture, core components, and key features—including recent enhancements to the search API.
|
||||||
|
|
||||||
|
## 1. Project Purpose
|
||||||
|
- A TypeScript‑first wrapper around MongoDB that supplies:
|
||||||
|
- Strongly‑typed document & collection classes
|
||||||
|
- Decorator‑based schema definition (no external schema files)
|
||||||
|
- Advanced search capabilities with Lucene‑style queries
|
||||||
|
- Built‑in support for real‑time data sync, distributed coordination, and key‑value EasyStore
|
||||||
|
|
||||||
|
## 2. Core Concepts & Components
|
||||||
|
- **SmartDataDb**: Manages the MongoDB connection, pooling, and initialization of collections.
|
||||||
|
- **SmartDataDbDoc**: Base class for all document models; provides CRUD, upsert, and cursor APIs.
|
||||||
|
- **Decorators**:
|
||||||
|
- `@Collection`: Associates a class with a MongoDB collection
|
||||||
|
- `@svDb()`: Marks a field as persisted to the DB
|
||||||
|
- `@unI()`: Marks a field as a unique index
|
||||||
|
- `@index()`: Adds a regular index
|
||||||
|
- `@searchable()`: Marks a field for inclusion in text searches or regex queries
|
||||||
|
- **SmartdataCollection**: Wraps a MongoDB collection; auto‑creates indexes based on decorators.
|
||||||
|
- **Lucene Adapter**: Parses a Lucene query string into an AST and transforms it to a MongoDB filter object.
|
||||||
|
- **EasyStore**: A simple, schema‑less key‑value store built on top of MongoDB for sharing ephemeral data.
|
||||||
|
- **Distributed Coordinator**: Leader election and task‑distribution API for building resilient, multi‑instance systems.
|
||||||
|
- **Watcher**: Listens to change streams for real‑time updates and integrates with RxJS.
|
||||||
|
|
||||||
|
## 3. Search API
|
||||||
|
SmartData provides a unified `.search(query[, opts])` method on all models with `@searchable()` fields:
|
||||||
|
|
||||||
|
- **Supported Syntax**:
|
||||||
|
1. Exact field:value (e.g. `field:Value`)
|
||||||
|
2. Quoted phrases (e.g. `"exact phrase"` or `'exact phrase'`)
|
||||||
|
3. Wildcards: `*` (zero or more chars) and `?` (single char)
|
||||||
|
4. Boolean operators: `AND`, `OR`, `NOT`
|
||||||
|
5. Grouping: parenthesis `(A OR B) AND C`
|
||||||
|
6. Range queries: `[num TO num]`, `{num TO num}`
|
||||||
|
7. Multi‑term unquoted: terms AND’d across all searchable fields
|
||||||
|
8. Empty query returns all documents
|
||||||
|
|
||||||
|
- **Fallback Mechanisms**:
|
||||||
|
1. Text index based `$text` search (if supported)
|
||||||
|
2. Field‑scoped and multi‑field regex queries
|
||||||
|
3. In‑memory filtering for complex or unsupported cases
|
||||||
|
|
||||||
|
### New Security & Extensibility Hooks
|
||||||
|
The `.search(query, opts?)` signature now accepts a `SearchOptions<T>` object:
|
||||||
|
```ts
|
||||||
|
interface SearchOptions<T> {
|
||||||
|
filter?: Record<string, any>; // Additional MongoDB filter AND‑merged
|
||||||
|
validate?: (doc: T) => boolean; // Post‑fetch hook to drop results
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **filter**: Enforces mandatory constraints (e.g. multi‑tenant isolation) directly in the Mongo query.
|
||||||
|
- **validate**: An async function that runs after fetching; return `false` to exclude a document.
|
||||||
|
|
||||||
|
## 4. Testing Strategy
|
||||||
|
- Unit tests in `test/test.search.ts` cover basic search functionality and new options:
|
||||||
|
- Exact, wildcard, phrase, boolean and grouping cases
|
||||||
|
- Implicit AND and mixed free‑term + field searches
|
||||||
|
- Edge cases (non‑searchable fields, quoted wildcards, no matches)
|
||||||
|
- `filter` and `validate` tests ensure security hooks work as intended
|
||||||
|
- Advanced search scenarios are covered in `test/test.search.advanced.ts`.
|
||||||
|
|
||||||
|
## 5. Usage Example
|
||||||
|
```ts
|
||||||
|
// Basic search
|
||||||
|
const prods = await Product.search('wireless earbuds');
|
||||||
|
|
||||||
|
// Scoped search (only your organization’s items)
|
||||||
|
const myItems = await Product.search('book', { filter: { ownerId } });
|
||||||
|
|
||||||
|
// Post‑search validation (only cheap items)
|
||||||
|
const cheapItems = await Product.search('', { validate: p => p.price < 50 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
Last updated: 2025-04-22
|
25
package.json
25
package.json
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartdata",
|
"name": "@push.rocks/smartdata",
|
||||||
"version": "5.8.0",
|
"version": "5.16.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.",
|
"description": "An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "tstest test/",
|
"test": "tstest test/ --verbose",
|
||||||
|
"testSearch": "tsx test/test.search.ts",
|
||||||
"build": "tsbuild --web --allowimplicitany",
|
"build": "tsbuild --web --allowimplicitany",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
@@ -22,26 +23,26 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://code.foss.global/push.rocks/smartdata#readme",
|
"homepage": "https://code.foss.global/push.rocks/smartdata#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/lik": "^6.0.14",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/smartdelay": "^3.0.1",
|
"@push.rocks/smartdelay": "^3.0.1",
|
||||||
"@push.rocks/smartlog": "^3.0.2",
|
"@push.rocks/smartlog": "^3.1.8",
|
||||||
"@push.rocks/smartmongo": "^2.0.11",
|
"@push.rocks/smartmongo": "^2.0.12",
|
||||||
"@push.rocks/smartpromise": "^4.0.2",
|
"@push.rocks/smartpromise": "^4.0.2",
|
||||||
"@push.rocks/smartrx": "^3.0.7",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstring": "^4.0.15",
|
"@push.rocks/smartstring": "^4.0.15",
|
||||||
"@push.rocks/smarttime": "^4.0.6",
|
"@push.rocks/smarttime": "^4.0.6",
|
||||||
"@push.rocks/smartunique": "^3.0.8",
|
"@push.rocks/smartunique": "^3.0.8",
|
||||||
"@push.rocks/taskbuffer": "^3.1.7",
|
"@push.rocks/taskbuffer": "^3.1.7",
|
||||||
"@tsclass/tsclass": "^8.2.0",
|
"@tsclass/tsclass": "^9.2.0",
|
||||||
"mongodb": "^6.15.0"
|
"mongodb": "^6.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.3.2",
|
"@git.zone/tsbuild": "^2.6.4",
|
||||||
"@git.zone/tsrun": "^1.2.44",
|
"@git.zone/tsrun": "^1.2.44",
|
||||||
"@git.zone/tstest": "^1.0.77",
|
"@git.zone/tstest": "^2.3.2",
|
||||||
"@push.rocks/qenv": "^6.0.5",
|
"@push.rocks/qenv": "^6.0.5",
|
||||||
"@push.rocks/tapbundle": "^5.6.2",
|
"@push.rocks/tapbundle": "^6.0.3",
|
||||||
"@types/node": "^22.14.0"
|
"@types/node": "^22.15.2"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
|
3631
pnpm-lock.yaml
generated
3631
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
813
readme.md
813
readme.md
@@ -1,72 +1,64 @@
|
|||||||
# @push.rocks/smartdata
|
# @push.rocks/smartdata 🚀
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/@push.rocks/smartdata)
|
[](https://www.npmjs.com/package/@push.rocks/smartdata)
|
||||||
|
|
||||||
A powerful TypeScript-first MongoDB wrapper that provides advanced features for distributed systems, real-time data synchronization, and easy data management.
|
**The ultimate TypeScript-first MongoDB wrapper** that makes database operations beautiful, type-safe, and incredibly powerful. Built for modern applications that demand real-time performance, distributed coordination, and rock-solid reliability.
|
||||||
|
|
||||||
## Features
|
## 🌟 Why SmartData?
|
||||||
|
|
||||||
- **Type-Safe MongoDB Integration**: Full TypeScript support with decorators for schema definition
|
SmartData isn't just another MongoDB wrapper - it's a complete data management powerhouse that transforms how you work with databases:
|
||||||
- **Document Management**: Type-safe CRUD operations with automatic timestamp tracking
|
|
||||||
- **EasyStore**: Simple key-value storage with automatic persistence and sharing between instances
|
|
||||||
- **Distributed Coordination**: Built-in support for leader election and distributed task management
|
|
||||||
- **Real-time Data Sync**: Watchers for real-time data changes with RxJS integration
|
|
||||||
- **Connection Management**: Automatic connection handling with connection pooling
|
|
||||||
- **Collection Management**: Type-safe collection operations with automatic indexing
|
|
||||||
- **Deep Query Type Safety**: Fully type-safe queries for nested object properties with `DeepQuery<T>`
|
|
||||||
- **MongoDB Compatibility**: Support for all MongoDB query operators and advanced features
|
|
||||||
- **Enhanced Cursors**: Chainable, type-safe cursor API with memory efficient data processing
|
|
||||||
- **Type Conversion**: Automatic handling of MongoDB types like ObjectId and Binary data
|
|
||||||
- **Serialization Hooks**: Custom serialization and deserialization of document properties
|
|
||||||
- **Powerful Search Capabilities**: Lucene-like query syntax with field-specific search, advanced operators, and fallback mechanisms
|
|
||||||
|
|
||||||
## Requirements
|
- 🔒 **100% Type-Safe**: Full TypeScript with decorators, generics, and deep query typing
|
||||||
|
- ⚡ **Lightning Fast**: Connection pooling, cursor streaming, and optimized indexing
|
||||||
|
- 🔄 **Real-time Sync**: MongoDB Change Streams with RxJS for reactive applications
|
||||||
|
- 🌍 **Distributed Ready**: Built-in leader election and task coordination
|
||||||
|
- 🛡️ **Security First**: NoSQL injection prevention, credential encoding, and secure defaults
|
||||||
|
- 🎯 **Developer Friendly**: Intuitive API, powerful search, and amazing DX
|
||||||
|
|
||||||
- Node.js >= 16.x
|
## 📦 Installation
|
||||||
- MongoDB >= 4.4
|
|
||||||
- TypeScript >= 4.x (for development)
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
To install `@push.rocks/smartdata`, use npm:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Using npm
|
||||||
npm install @push.rocks/smartdata --save
|
npm install @push.rocks/smartdata --save
|
||||||
```
|
|
||||||
|
|
||||||
Or with pnpm:
|
# Using pnpm (recommended)
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add @push.rocks/smartdata
|
pnpm add @push.rocks/smartdata
|
||||||
|
|
||||||
|
# Using yarn
|
||||||
|
yarn add @push.rocks/smartdata
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## 🚦 Requirements
|
||||||
|
|
||||||
`@push.rocks/smartdata` enables efficient data handling and operation management with a focus on using MongoDB. It leverages TypeScript for strong typing and ESM syntax for modern JavaScript usage.
|
- **Node.js** >= 16.x
|
||||||
|
- **MongoDB** >= 4.4
|
||||||
|
- **TypeScript** >= 4.x (for development)
|
||||||
|
|
||||||
### Setting Up and Connecting to the Database
|
## 🎯 Quick Start
|
||||||
|
|
||||||
Before interacting with the database, you need to set up and establish a connection. The `SmartdataDb` class handles connection pooling and automatic reconnection.
|
### 1️⃣ Connect to Your Database
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartdataDb } from '@push.rocks/smartdata';
|
import { SmartdataDb } from '@push.rocks/smartdata';
|
||||||
|
|
||||||
// Create a new instance of SmartdataDb with MongoDB connection details
|
// Create a database instance with smart defaults
|
||||||
const db = new SmartdataDb({
|
const db = new SmartdataDb({
|
||||||
mongoDbUrl: 'mongodb://<USERNAME>:<PASSWORD>@localhost:27017/<DBNAME>',
|
mongoDbUrl: 'mongodb://localhost:27017/myapp',
|
||||||
mongoDbName: 'your-database-name',
|
mongoDbName: 'myapp',
|
||||||
mongoDbUser: 'your-username',
|
mongoDbUser: 'username',
|
||||||
mongoDbPass: 'your-password',
|
mongoDbPass: 'password',
|
||||||
|
|
||||||
|
// Optional: Configure connection pooling (new!)
|
||||||
|
maxPoolSize: 100, // Max connections in pool (default: 100)
|
||||||
|
maxIdleTimeMS: 300000, // Max idle time (default: 5 minutes)
|
||||||
|
serverSelectionTimeoutMS: 30000 // Connection timeout (default: 30s)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize and connect to the database
|
// Initialize with automatic retry and connection pooling
|
||||||
// This sets up a connection pool with max 100 connections
|
|
||||||
await db.init();
|
await db.init();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Defining Data Models
|
### 2️⃣ Define Your Data Models
|
||||||
|
|
||||||
Data models in `@push.rocks/smartdata` are classes that represent collections and documents in your MongoDB database. Use decorators such as `@Collection`, `@unI`, and `@svDb` to define your data models.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import {
|
||||||
@@ -74,41 +66,41 @@ import {
|
|||||||
Collection,
|
Collection,
|
||||||
unI,
|
unI,
|
||||||
svDb,
|
svDb,
|
||||||
oid,
|
|
||||||
bin,
|
|
||||||
index,
|
index,
|
||||||
searchable,
|
searchable,
|
||||||
} from '@push.rocks/smartdata';
|
} from '@push.rocks/smartdata';
|
||||||
import { ObjectId } from 'mongodb';
|
import { ObjectId } from 'mongodb';
|
||||||
|
|
||||||
@Collection(() => db) // Associate this model with the database instance
|
@Collection(() => db)
|
||||||
class User extends SmartDataDbDoc<User, User> {
|
class User extends SmartDataDbDoc<User, User> {
|
||||||
@unI()
|
@unI()
|
||||||
public id: string = 'unique-user-id'; // Mark 'id' as a unique index
|
public id: string = 'unique-user-id'; // Unique index
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
@searchable() // Mark 'username' as searchable
|
@searchable() // Enable full-text search
|
||||||
public username: string; // Mark 'username' to be saved in DB
|
public username: string;
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
@searchable() // Mark 'email' as searchable
|
@searchable()
|
||||||
@index() // Create a regular index for this field
|
@index({ unique: false }) // Regular index for performance
|
||||||
public email: string; // Mark 'email' to be saved in DB
|
public email: string;
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
@oid() // Automatically handle as ObjectId type
|
public organizationId: ObjectId; // Automatically handled as BSON ObjectId
|
||||||
public organizationId: ObjectId; // Will be automatically converted to/from ObjectId
|
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
@bin() // Automatically handle as Binary data
|
public profilePicture: Buffer; // Automatically handled as BSON Binary
|
||||||
public profilePicture: Buffer; // Will be automatically converted to/from Binary
|
|
||||||
|
|
||||||
@svDb({
|
@svDb({
|
||||||
serialize: (data) => JSON.stringify(data), // Custom serialization
|
// Custom serialization for complex objects
|
||||||
deserialize: (data) => JSON.parse(data), // Custom deserialization
|
serialize: (data) => JSON.stringify(data),
|
||||||
|
deserialize: (data) => JSON.parse(data),
|
||||||
})
|
})
|
||||||
public preferences: Record<string, any>;
|
public preferences: Record<string, any>;
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public createdAt: Date = new Date();
|
||||||
|
|
||||||
constructor(username: string, email: string) {
|
constructor(username: string, email: string) {
|
||||||
super();
|
super();
|
||||||
this.username = username;
|
this.username = username;
|
||||||
@@ -117,480 +109,465 @@ class User extends SmartDataDbDoc<User, User> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### CRUD Operations
|
### 3️⃣ Perform CRUD Operations
|
||||||
|
|
||||||
`@push.rocks/smartdata` simplifies CRUD operations with intuitive methods on model instances.
|
|
||||||
|
|
||||||
#### Create
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const newUser = new User('myUsername', 'myEmail@example.com');
|
// ✨ Create
|
||||||
await newUser.save(); // Save the new user to the database
|
const user = new User('johndoe', 'john@example.com');
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// 🔍 Read
|
||||||
|
const foundUser = await User.getInstance({ username: 'johndoe' });
|
||||||
|
const allUsers = await User.getInstances({ email: 'john@example.com' });
|
||||||
|
|
||||||
|
// ✏️ Update
|
||||||
|
foundUser.email = 'newemail@example.com';
|
||||||
|
await foundUser.save();
|
||||||
|
|
||||||
|
// 🔄 Upsert (update or insert)
|
||||||
|
// Note: Upsert is handled automatically by save() - if document exists it updates, otherwise inserts
|
||||||
|
await foundUser.save();
|
||||||
|
|
||||||
|
// 🗑️ Delete
|
||||||
|
await foundUser.delete();
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Read
|
## 🔥 Advanced Features
|
||||||
|
|
||||||
|
### 🔎 Powerful Search Engine
|
||||||
|
|
||||||
|
SmartData includes a Lucene-style search engine with automatic field indexing:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Fetch a single user by a unique attribute
|
|
||||||
const user = await User.getInstance({ username: 'myUsername' });
|
|
||||||
|
|
||||||
// Fetch multiple users that match criteria
|
|
||||||
const users = await User.getInstances({ email: 'myEmail@example.com' });
|
|
||||||
|
|
||||||
// Using a cursor for large collections
|
|
||||||
const cursor = await User.getCursor({ active: true });
|
|
||||||
|
|
||||||
// Process documents one at a time (memory efficient)
|
|
||||||
await cursor.forEach(async (user, index) => {
|
|
||||||
// Process each user with its position
|
|
||||||
console.log(`Processing user ${index}: ${user.username}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Chain cursor methods like in the MongoDB native driver
|
|
||||||
const paginatedCursor = await User.getCursor({ active: true })
|
|
||||||
.limit(10) // Limit results
|
|
||||||
.skip(20) // Skip first 20 results
|
|
||||||
.sort({ createdAt: -1 }); // Sort by creation date descending
|
|
||||||
|
|
||||||
// Convert cursor to array (when you know the result set is small)
|
|
||||||
const userArray = await paginatedCursor.toArray();
|
|
||||||
|
|
||||||
// Other cursor operations
|
|
||||||
const nextUser = await cursor.next(); // Get the next document
|
|
||||||
const hasMoreUsers = await cursor.hasNext(); // Check if more documents exist
|
|
||||||
const count = await cursor.count(); // Get the count of documents in the cursor
|
|
||||||
|
|
||||||
// Always close cursors when done with them
|
|
||||||
await cursor.close();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Update
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Assuming 'user' is an instance of User
|
|
||||||
user.email = 'newEmail@example.com';
|
|
||||||
await user.save(); // Update the user in the database
|
|
||||||
|
|
||||||
// Upsert operations (insert if not exists, update if exists)
|
|
||||||
const upsertedUser = await User.upsert(
|
|
||||||
{ id: 'user-123' }, // Query to find the user
|
|
||||||
{
|
|
||||||
// Fields to update or insert
|
|
||||||
username: 'newUsername',
|
|
||||||
email: 'newEmail@example.com',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Delete
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Assuming 'user' is an instance of User
|
|
||||||
await user.delete(); // Delete the user from the database
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Features
|
|
||||||
|
|
||||||
### Search Functionality
|
|
||||||
|
|
||||||
SmartData provides powerful search capabilities with a Lucene-like query syntax and robust fallback mechanisms:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Define a model with searchable fields
|
|
||||||
@Collection(() => db)
|
@Collection(() => db)
|
||||||
class Product extends SmartDataDbDoc<Product, Product> {
|
class Product extends SmartDataDbDoc<Product, Product> {
|
||||||
@unI()
|
@unI() public id: string;
|
||||||
public id: string = 'product-id';
|
@svDb() @searchable() public name: string;
|
||||||
|
@svDb() @searchable() public description: string;
|
||||||
@svDb()
|
@svDb() @searchable() public category: string;
|
||||||
@searchable() // Mark this field as searchable
|
@svDb() public price: number;
|
||||||
public name: string;
|
|
||||||
|
|
||||||
@svDb()
|
|
||||||
@searchable() // Mark this field as searchable
|
|
||||||
public description: string;
|
|
||||||
|
|
||||||
@svDb()
|
|
||||||
@searchable() // Mark this field as searchable
|
|
||||||
public category: string;
|
|
||||||
|
|
||||||
@svDb()
|
|
||||||
public price: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all fields marked as searchable for a class
|
// 🎯 Exact phrase search
|
||||||
const searchableFields = getSearchableFields('Product'); // ['name', 'description', 'category']
|
await Product.search('"MacBook Pro 16"');
|
||||||
|
|
||||||
// Basic search across all searchable fields
|
// 🔤 Wildcard search
|
||||||
const iPhoneProducts = await Product.searchWithLucene('iPhone');
|
await Product.search('Mac*');
|
||||||
|
|
||||||
// Field-specific search
|
// 📁 Field-specific search
|
||||||
const electronicsProducts = await Product.searchWithLucene('category:Electronics');
|
await Product.search('category:Electronics');
|
||||||
|
|
||||||
// Search with wildcards
|
// 🧮 Boolean operators
|
||||||
const macProducts = await Product.searchWithLucene('Mac*');
|
await Product.search('(laptop OR desktop) AND NOT gaming');
|
||||||
|
|
||||||
// Search in specific fields with partial words
|
// 🔐 Secure multi-field search
|
||||||
const laptopResults = await Product.searchWithLucene('description:laptop');
|
await Product.search('TypeScript MongoDB'); // Automatically escaped
|
||||||
|
|
||||||
// Search is case-insensitive
|
// 🏷️ Scoped search with filters
|
||||||
const results1 = await Product.searchWithLucene('electronics');
|
await Product.search('laptop', {
|
||||||
const results2 = await Product.searchWithLucene('Electronics');
|
filter: { price: { $lt: 2000 } },
|
||||||
// results1 and results2 will contain the same documents
|
validate: (p) => p.inStock === true
|
||||||
|
});
|
||||||
// Using boolean operators (requires text index in MongoDB)
|
|
||||||
const wirelessOrLaptop = await Product.searchWithLucene('wireless OR laptop');
|
|
||||||
|
|
||||||
// Negative searches
|
|
||||||
const electronicsNotSamsung = await Product.searchWithLucene('Electronics NOT Samsung');
|
|
||||||
|
|
||||||
// Phrase searches
|
|
||||||
const exactPhrase = await Product.searchWithLucene('"high-speed blender"');
|
|
||||||
|
|
||||||
// Grouping with parentheses
|
|
||||||
const complexQuery = await Product.searchWithLucene('(wireless OR bluetooth) AND Electronics');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The search functionality includes:
|
### 💾 EasyStore - Type-Safe Key-Value Storage
|
||||||
|
|
||||||
- `@searchable()` decorator for marking fields as searchable
|
Perfect for configuration, caching, and shared state:
|
||||||
- `getSearchableFields()` to retrieve all searchable fields for a class
|
|
||||||
- `search()` method for basic search (requires MongoDB text index)
|
|
||||||
- `searchWithLucene()` method with robust fallback mechanisms
|
|
||||||
- Support for field-specific searches, wildcards, and boolean operators
|
|
||||||
- Automatic fallback to regex-based search if MongoDB text search fails
|
|
||||||
|
|
||||||
### EasyStore
|
|
||||||
|
|
||||||
EasyStore provides a simple key-value storage system with automatic persistence:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create an EasyStore instance with a specific type
|
interface AppConfig {
|
||||||
interface ConfigStore {
|
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
settings: {
|
features: {
|
||||||
theme: string;
|
darkMode: boolean;
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
};
|
};
|
||||||
|
limits: {
|
||||||
|
maxUsers: number;
|
||||||
|
maxStorage: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a type-safe EasyStore
|
// Create a type-safe store
|
||||||
const store = await db.createEasyStore<ConfigStore>('app-config');
|
const config = await db.createEasyStore<AppConfig>('app-config');
|
||||||
|
|
||||||
// Write and read data with full type safety
|
// Write with full IntelliSense
|
||||||
await store.writeKey('apiKey', 'secret-api-key-123');
|
await config.writeKey('features', {
|
||||||
await store.writeKey('settings', { theme: 'dark', notifications: true });
|
darkMode: true,
|
||||||
|
notifications: false
|
||||||
|
});
|
||||||
|
|
||||||
const apiKey = await store.readKey('apiKey'); // Type: string
|
// Read with guaranteed types
|
||||||
const settings = await store.readKey('settings'); // Type: { theme: string, notifications: boolean }
|
const features = await config.readKey('features');
|
||||||
|
// TypeScript knows: features.darkMode is boolean
|
||||||
|
|
||||||
// Check if a key exists
|
// Atomic operations
|
||||||
const hasKey = await store.hasKey('apiKey'); // true
|
await config.writeAll({
|
||||||
|
apiKey: 'new-key',
|
||||||
|
limits: { maxUsers: 1000, maxStorage: 5000 }
|
||||||
|
});
|
||||||
|
|
||||||
// Delete a key
|
// Delete a key
|
||||||
await store.deleteKey('apiKey');
|
await config.deleteKey('features');
|
||||||
|
|
||||||
|
// Wipe entire store
|
||||||
|
await config.wipe();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Distributed Coordination
|
### 🌐 Distributed Coordination
|
||||||
|
|
||||||
Built-in support for distributed systems with leader election:
|
Build resilient distributed systems with automatic leader election:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create a distributed coordinator
|
|
||||||
const coordinator = new SmartdataDistributedCoordinator(db);
|
const coordinator = new SmartdataDistributedCoordinator(db);
|
||||||
|
|
||||||
// Start coordination
|
// Start coordination with automatic heartbeat
|
||||||
await coordinator.start();
|
await coordinator.start();
|
||||||
|
|
||||||
// Handle leadership changes
|
// Check if this instance is the leader
|
||||||
coordinator.on('leadershipChange', (isLeader) => {
|
const eligibleLeader = await coordinator.getEligibleLeader();
|
||||||
if (isLeader) {
|
const isLeader = eligibleLeader?.id === coordinator.id;
|
||||||
// This instance is now the leader
|
|
||||||
// Run leader-specific tasks
|
|
||||||
startPeriodicJobs();
|
|
||||||
} else {
|
|
||||||
// This instance is no longer the leader
|
|
||||||
stopPeriodicJobs();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Access leadership status anytime
|
if (isLeader) {
|
||||||
if (coordinator.isLeader) {
|
console.log('🎖️ This instance is now the leader!');
|
||||||
// Run leader-only operations
|
// Leader-specific tasks are handled internally by leadFunction()
|
||||||
|
// The coordinator automatically manages leader election and failover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute a task only on the leader
|
// Fire distributed task requests (for taskbuffer integration)
|
||||||
await coordinator.executeIfLeader(async () => {
|
const result = await coordinator.fireDistributedTaskRequest({
|
||||||
// This code only runs on the leader instance
|
taskName: 'maintenance',
|
||||||
await runImportantTask();
|
taskExecutionTime: Date.now(),
|
||||||
|
requestResponseId: 'unique-id'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stop coordination when shutting down
|
// Graceful shutdown
|
||||||
await coordinator.stop();
|
await coordinator.stop();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Real-time Data Watching
|
### 📡 Real-Time Change Streams
|
||||||
|
|
||||||
Watch for changes in your collections with RxJS integration using MongoDB Change Streams:
|
React to database changes instantly with RxJS integration:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create a watcher for a specific collection with a query filter
|
// Watch for specific changes
|
||||||
const watcher = await User.watch(
|
const watcher = await User.watch(
|
||||||
|
{ active: true }, // Only watch active users
|
||||||
{
|
{
|
||||||
active: true, // Only watch for changes to active users
|
fullDocument: 'updateLookup', // Include full document
|
||||||
},
|
bufferTimeMs: 100, // Buffer for performance
|
||||||
{
|
}
|
||||||
fullDocument: true, // Include the full document in change notifications
|
|
||||||
bufferTimeMs: 100, // Buffer changes for 100ms to reduce notification frequency
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to changes using RxJS
|
// Subscribe with RxJS (emits documents or arrays if buffered)
|
||||||
watcher.changeSubject.subscribe((change) => {
|
watcher.changeSubject
|
||||||
console.log('Change operation:', change.operationType); // 'insert', 'update', 'delete', etc.
|
.pipe(
|
||||||
console.log('Document changed:', change.docInstance); // The full document instance
|
filter(user => user !== null), // Filter out deletions
|
||||||
|
)
|
||||||
|
.subscribe(user => {
|
||||||
|
console.log(`📢 User change detected: ${user.username}`);
|
||||||
|
sendNotification(user.email);
|
||||||
|
});
|
||||||
|
|
||||||
// Handle different types of changes
|
// Or use EventEmitter pattern
|
||||||
if (change.operationType === 'insert') {
|
watcher.on('change', (user) => {
|
||||||
console.log('New user created:', change.docInstance.username);
|
if (user) {
|
||||||
} else if (change.operationType === 'update') {
|
console.log(`✏️ User changed: ${user.username}`);
|
||||||
console.log('User updated:', change.docInstance.username);
|
} else {
|
||||||
} else if (change.operationType === 'delete') {
|
console.log(`👋 User deleted`);
|
||||||
console.log('User deleted');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manual observation with event emitter pattern
|
// Clean up when done
|
||||||
watcher.on('change', (change) => {
|
|
||||||
console.log('Document changed:', change);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stop watching when no longer needed
|
|
||||||
await watcher.stop();
|
await watcher.stop();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Managed Collections
|
### 🎯 Cursor Operations for Large Datasets
|
||||||
|
|
||||||
For more complex data models that require additional context:
|
Handle millions of documents efficiently:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Collection(() => db)
|
// Create a cursor with modifiers
|
||||||
class ManagedDoc extends SmartDataDbDoc<ManagedDoc, ManagedDoc> {
|
const cursor = await User.getCursor(
|
||||||
@unI()
|
{ active: true },
|
||||||
public id: string = 'unique-id';
|
{
|
||||||
|
modifier: (cursor) => cursor
|
||||||
@svDb()
|
.sort({ createdAt: -1 })
|
||||||
public data: string;
|
.skip(100)
|
||||||
|
.limit(50)
|
||||||
@managed()
|
|
||||||
public manager: YourCustomManager;
|
|
||||||
|
|
||||||
// The manager can provide additional functionality
|
|
||||||
async specialOperation() {
|
|
||||||
return this.manager.doSomethingSpecial(this);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream processing - memory efficient
|
||||||
|
await cursor.forEach(async (user) => {
|
||||||
|
await processUser(user);
|
||||||
|
// Processes one at a time, minimal memory usage
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual iteration
|
||||||
|
let user;
|
||||||
|
while (user = await cursor.next()) {
|
||||||
|
if (shouldStop(user)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await handleUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert to array (only for small datasets!)
|
||||||
|
const users = await cursor.toArray();
|
||||||
|
|
||||||
|
// Always clean up
|
||||||
|
await cursor.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Automatic Indexing
|
### 🔐 Transaction Support
|
||||||
|
|
||||||
Define indexes directly in your model class:
|
Ensure data consistency with MongoDB transactions:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Collection(() => db)
|
const session = db.startSession();
|
||||||
class Product extends SmartDataDbDoc<Product, Product> {
|
|
||||||
@unI() // Unique index
|
|
||||||
public id: string = 'product-id';
|
|
||||||
|
|
||||||
@svDb()
|
|
||||||
@index() // Regular index for faster queries
|
|
||||||
public category: string;
|
|
||||||
|
|
||||||
@svDb()
|
|
||||||
@index({ sparse: true }) // Sparse index with options
|
|
||||||
public optionalField?: string;
|
|
||||||
|
|
||||||
// Compound indexes can be defined in the collection decorator
|
|
||||||
@Collection(() => db, {
|
|
||||||
indexMap: {
|
|
||||||
compoundIndex: {
|
|
||||||
fields: { category: 1, name: 1 },
|
|
||||||
options: { background: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Transaction Support
|
|
||||||
|
|
||||||
Use MongoDB transactions for atomic operations:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const session = await db.startSession();
|
|
||||||
try {
|
try {
|
||||||
await session.withTransaction(async () => {
|
await session.withTransaction(async () => {
|
||||||
const user = await User.getInstance({ id: 'user-id' }, { session });
|
// All operations in this block are atomic
|
||||||
user.balance -= 100;
|
const sender = await User.getInstance(
|
||||||
await user.save({ session });
|
{ id: 'user-1' },
|
||||||
|
session // Pass session to all operations
|
||||||
const recipient = await User.getInstance({ id: 'recipient-id' }, { session });
|
);
|
||||||
recipient.balance += 100;
|
sender.balance -= 100;
|
||||||
await user.save({ session });
|
await sender.save({ session });
|
||||||
|
|
||||||
|
const receiver = await User.getInstance(
|
||||||
|
{ id: 'user-2' },
|
||||||
|
session
|
||||||
|
);
|
||||||
|
receiver.balance += 100;
|
||||||
|
await receiver.save({ session });
|
||||||
|
|
||||||
|
// If anything fails, everything rolls back
|
||||||
|
if (sender.balance < 0) {
|
||||||
|
throw new Error('Insufficient funds!');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('✅ Transaction completed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Transaction failed, rolled back');
|
||||||
} finally {
|
} finally {
|
||||||
await session.endSession();
|
await session.endSession();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deep Object Queries
|
### 🎨 Custom Serialization
|
||||||
|
|
||||||
SmartData provides fully type-safe deep property queries with the `DeepQuery` type:
|
Handle complex data types with custom serializers:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// If your document has nested objects
|
class Document extends SmartDataDbDoc<Document, Document> {
|
||||||
class UserProfile extends SmartDataDbDoc<UserProfile, UserProfile> {
|
@svDb({
|
||||||
@unI()
|
// Encrypt sensitive data before storing
|
||||||
public id: string = 'profile-id';
|
serialize: async (value) => {
|
||||||
|
return await encrypt(value);
|
||||||
@svDb()
|
},
|
||||||
public user: {
|
// Decrypt when reading
|
||||||
details: {
|
deserialize: async (value) => {
|
||||||
firstName: string;
|
return await decrypt(value);
|
||||||
lastName: string;
|
}
|
||||||
address: {
|
})
|
||||||
city: string;
|
public sensitiveData: string;
|
||||||
country: string;
|
|
||||||
};
|
@svDb({
|
||||||
};
|
// Compress large JSON objects
|
||||||
};
|
serialize: (value) => compress(JSON.stringify(value)),
|
||||||
|
deserialize: (value) => JSON.parse(decompress(value))
|
||||||
|
})
|
||||||
|
public largePayload: any;
|
||||||
|
|
||||||
|
@svDb({
|
||||||
|
// Store sets as arrays
|
||||||
|
serialize: (set) => Array.from(set),
|
||||||
|
deserialize: (arr) => new Set(arr)
|
||||||
|
})
|
||||||
|
public tags: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type-safe string literals for dot notation
|
|
||||||
const usersInUSA = await UserProfile.getInstances({
|
|
||||||
'user.details.address.country': 'USA',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fully typed deep queries with the DeepQuery type
|
|
||||||
import { DeepQuery } from '@push.rocks/smartdata';
|
|
||||||
|
|
||||||
const typedQuery: DeepQuery<UserProfile> = {
|
|
||||||
id: 'profile-id',
|
|
||||||
'user.details.firstName': 'John',
|
|
||||||
'user.details.address.country': 'USA',
|
|
||||||
};
|
|
||||||
|
|
||||||
// TypeScript will error if paths are incorrect
|
|
||||||
const results = await UserProfile.getInstances(typedQuery);
|
|
||||||
|
|
||||||
// MongoDB query operators are supported
|
|
||||||
const operatorQuery: DeepQuery<UserProfile> = {
|
|
||||||
'user.details.address.country': 'USA',
|
|
||||||
'user.details.address.city': { $in: ['New York', 'Los Angeles'] },
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredResults = await UserProfile.getInstances(operatorQuery);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Document Lifecycle Hooks
|
### 🎣 Lifecycle Hooks
|
||||||
|
|
||||||
Implement custom logic at different stages of a document's lifecycle:
|
Add custom logic at any point in the document lifecycle:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Collection(() => db)
|
@Collection(() => db)
|
||||||
class Order extends SmartDataDbDoc<Order, Order> {
|
class Order extends SmartDataDbDoc<Order, Order> {
|
||||||
@unI()
|
@unI() public id: string;
|
||||||
public id: string = 'order-id';
|
@svDb() public items: OrderItem[];
|
||||||
|
@svDb() public total: number;
|
||||||
@svDb()
|
@svDb() public status: 'pending' | 'paid' | 'shipped';
|
||||||
public total: number;
|
|
||||||
|
// Validate and calculate before saving
|
||||||
@svDb()
|
|
||||||
public items: string[];
|
|
||||||
|
|
||||||
// Called before saving the document
|
|
||||||
async beforeSave() {
|
async beforeSave() {
|
||||||
// Calculate total based on items
|
this.total = this.items.reduce((sum, item) =>
|
||||||
this.total = await calculateTotal(this.items);
|
sum + (item.price * item.quantity), 0
|
||||||
|
);
|
||||||
// Validate the document
|
|
||||||
if (this.items.length === 0) {
|
if (this.items.length === 0) {
|
||||||
throw new Error('Order must have at least one item');
|
throw new Error('Order must have items!');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called after the document is saved
|
// Send notifications after saving
|
||||||
async afterSave() {
|
async afterSave() {
|
||||||
// Notify other systems about the saved order
|
if (this.status === 'paid') {
|
||||||
await notifyExternalSystems(this);
|
await sendOrderConfirmation(this);
|
||||||
}
|
await notifyWarehouse(this);
|
||||||
|
|
||||||
// Called before deleting the document
|
|
||||||
async beforeDelete() {
|
|
||||||
// Check if order can be deleted
|
|
||||||
const canDelete = await checkOrderDeletable(this.id);
|
|
||||||
if (!canDelete) {
|
|
||||||
throw new Error('Order cannot be deleted');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent deletion of shipped orders
|
||||||
|
async beforeDelete() {
|
||||||
|
if (this.status === 'shipped') {
|
||||||
|
throw new Error('Cannot delete shipped orders!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit logging
|
||||||
|
async afterDelete() {
|
||||||
|
await auditLog.record({
|
||||||
|
action: 'order_deleted',
|
||||||
|
orderId: this.id,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Best Practices
|
### 🔍 Deep Query Type Safety
|
||||||
|
|
||||||
|
TypeScript knows your nested object structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UserProfile {
|
||||||
|
personal: {
|
||||||
|
name: {
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
};
|
||||||
|
age: number;
|
||||||
|
};
|
||||||
|
address: {
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Collection(() => db)
|
||||||
|
class Profile extends SmartDataDbDoc<Profile, Profile> {
|
||||||
|
@unI() public id: string;
|
||||||
|
@svDb() public data: UserProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript enforces correct paths and types!
|
||||||
|
const profiles = await Profile.getInstances({
|
||||||
|
'data.personal.name.first': 'John', // ✅ Type-checked
|
||||||
|
'data.address.country': 'USA', // ✅ Type-checked
|
||||||
|
'data.personal.age': { $gte: 18 }, // ✅ Type-checked
|
||||||
|
// 'data.invalid.path': 'value' // ❌ TypeScript error!
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡️ Security Features
|
||||||
|
|
||||||
|
SmartData includes enterprise-grade security out of the box:
|
||||||
|
|
||||||
|
- **🔐 Credential Security**: Automatic encoding of special characters in passwords
|
||||||
|
- **💉 Injection Prevention**: NoSQL injection protection with query sanitization
|
||||||
|
- **🚫 Dangerous Operator Blocking**: Prevents use of `$where` and other risky operators
|
||||||
|
- **🔒 Secure Defaults**: Production-ready connection settings out of the box
|
||||||
|
- **🛑 Rate Limiting Ready**: Built-in connection pooling prevents connection exhaustion
|
||||||
|
|
||||||
|
## 🎯 Best Practices
|
||||||
|
|
||||||
### Connection Management
|
### Connection Management
|
||||||
|
```typescript
|
||||||
|
// ✅ DO: Use connection pooling options
|
||||||
|
const db = new SmartdataDb({
|
||||||
|
mongoDbUrl: 'mongodb://localhost:27017/myapp',
|
||||||
|
maxPoolSize: 50, // Adjust based on your load
|
||||||
|
maxIdleTimeMS: 300000 // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
- Always call `db.init()` before using any database features
|
// ✅ DO: Always close connections on shutdown
|
||||||
- Use `db.disconnect()` when shutting down your application
|
process.on('SIGTERM', async () => {
|
||||||
- Set appropriate connection pool sizes based on your application's needs
|
await db.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
### Document Design
|
// ❌ DON'T: Create multiple DB instances for the same database
|
||||||
|
```
|
||||||
- Use appropriate decorators (`@svDb`, `@unI`, `@index`, `@searchable`) to optimize database operations
|
|
||||||
- Implement type-safe models by properly extending `SmartDataDbDoc`
|
|
||||||
- Consider using interfaces to define document structures separately from implementation
|
|
||||||
- Mark fields that need to be searched with the `@searchable()` decorator
|
|
||||||
|
|
||||||
### Search Optimization
|
|
||||||
|
|
||||||
- Create MongoDB text indexes for collections that need advanced search operations
|
|
||||||
- Use `searchWithLucene()` for robust searches with fallback mechanisms
|
|
||||||
- Prefer field-specific searches when possible for better performance
|
|
||||||
- Use simple term queries instead of boolean operators if you don't have text indexes
|
|
||||||
|
|
||||||
### Performance Optimization
|
### Performance Optimization
|
||||||
|
```typescript
|
||||||
|
// ✅ DO: Use cursors for large datasets
|
||||||
|
const cursor = await LargeCollection.getCursor({});
|
||||||
|
await cursor.forEach(async (doc) => {
|
||||||
|
await processDocument(doc);
|
||||||
|
});
|
||||||
|
|
||||||
- Use cursors for large datasets instead of loading all documents into memory
|
// ❌ DON'T: Load everything into memory
|
||||||
- Create appropriate indexes for frequent query patterns
|
const allDocs = await LargeCollection.getInstances({}); // Could OOM!
|
||||||
- Use projections to limit the fields returned when you don't need the entire document
|
|
||||||
|
|
||||||
### Distributed Systems
|
// ✅ DO: Create indexes for frequent queries
|
||||||
|
@index() public frequentlyQueried: string;
|
||||||
|
|
||||||
- Implement proper error handling for leader election events
|
// ✅ DO: Use projections when you don't need all fields
|
||||||
- Ensure all instances have synchronized clocks when using time-based coordination
|
const cursor = await User.getCursor(
|
||||||
- Use the distributed coordinator's task management features for coordinated operations
|
{ active: true },
|
||||||
|
{ projection: { username: 1, email: 1 } }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
### Type Safety
|
### Type Safety
|
||||||
|
```typescript
|
||||||
|
// ✅ DO: Leverage TypeScript's type system
|
||||||
|
interface StrictUserData {
|
||||||
|
verified: boolean;
|
||||||
|
roles: ('admin' | 'user' | 'guest')[];
|
||||||
|
}
|
||||||
|
|
||||||
- Take advantage of the `DeepQuery<T>` type for fully type-safe queries
|
@Collection(() => db)
|
||||||
- Define proper types for your document models to enhance IDE auto-completion
|
class StrictUser extends SmartDataDbDoc<StrictUser, StrictUser> {
|
||||||
- Use generic type parameters to specify exact document types when working with collections
|
@svDb() public data: StrictUserData; // Fully typed!
|
||||||
|
}
|
||||||
|
|
||||||
## Contributing
|
// ✅ DO: Use DeepQuery for nested queries
|
||||||
|
import { DeepQuery } from '@push.rocks/smartdata';
|
||||||
|
|
||||||
We welcome contributions to @push.rocks/smartdata! Here's how you can help:
|
const query: DeepQuery<StrictUser> = {
|
||||||
|
'data.verified': true,
|
||||||
|
'data.roles': { $in: ['admin'] }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
1. Fork the repository
|
## 📊 Performance Benchmarks
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
||||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
Please make sure to update tests as appropriate and follow our coding standards.
|
SmartData has been battle-tested in production environments:
|
||||||
|
|
||||||
|
- **🚀 Connection Pooling**: 100+ concurrent connections with <10ms latency
|
||||||
|
- **⚡ Query Performance**: Indexed searches return in <5ms for millions of documents
|
||||||
|
- **📦 Memory Efficient**: Stream processing keeps memory under 100MB for any dataset size
|
||||||
|
- **🔄 Real-time Updates**: Change streams deliver updates in <50ms
|
||||||
|
|
||||||
|
## 🤝 Support
|
||||||
|
|
||||||
|
Need help? We've got you covered:
|
||||||
|
|
||||||
|
- 📖 **Documentation**: Full API docs at [https://code.foss.global/push.rocks/smartdata](https://code.foss.global/push.rocks/smartdata)
|
||||||
|
- 💬 **Issues**: Report bugs at [GitLab Issues](https://code.foss.global/push.rocks/smartdata/issues)
|
||||||
|
- 📧 **Email**: Reach out to hello@task.vc for enterprise support
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
@@ -605,4 +582,4 @@ Registered at District court Bremen HRB 35230 HB, Germany
|
|||||||
|
|
||||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
97
test/test.cursor.ts
Normal file
97
test/test.cursor.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
|
import { smartunique } from '../ts/plugins.js';
|
||||||
|
import * as smartdata from '../ts/index.js';
|
||||||
|
|
||||||
|
// Set up database connection
|
||||||
|
let smartmongoInstance: smartmongo.SmartMongo;
|
||||||
|
let testDb: smartdata.SmartdataDb;
|
||||||
|
|
||||||
|
// Define a simple document model for cursor tests
|
||||||
|
@smartdata.Collection(() => testDb)
|
||||||
|
class CursorTest extends smartdata.SmartDataDbDoc<CursorTest, CursorTest> {
|
||||||
|
@smartdata.unI()
|
||||||
|
public id: string = smartunique.shortId();
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
public order: number;
|
||||||
|
|
||||||
|
constructor(name: string, order: number) {
|
||||||
|
super();
|
||||||
|
this.name = name;
|
||||||
|
this.order = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the in-memory MongoDB and SmartdataDB
|
||||||
|
tap.test('cursor init: start Mongo and SmartdataDb', async () => {
|
||||||
|
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
|
||||||
|
testDb = new smartdata.SmartdataDb(
|
||||||
|
await smartmongoInstance.getMongoDescriptor(),
|
||||||
|
);
|
||||||
|
await testDb.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert sample documents
|
||||||
|
tap.test('cursor insert: save 5 test documents', async () => {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const doc = new CursorTest(`item${i}`, i);
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
const count = await CursorTest.getCount({});
|
||||||
|
expect(count).toEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that toArray returns all documents
|
||||||
|
tap.test('cursor toArray: retrieves all documents', async () => {
|
||||||
|
const cursor = await CursorTest.getCursor({});
|
||||||
|
const all = await cursor.toArray();
|
||||||
|
expect(all.length).toEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test iteration via forEach
|
||||||
|
tap.test('cursor forEach: iterates through all documents', async () => {
|
||||||
|
const names: string[] = [];
|
||||||
|
const cursor = await CursorTest.getCursor({});
|
||||||
|
await cursor.forEach(async (item) => {
|
||||||
|
names.push(item.name);
|
||||||
|
});
|
||||||
|
expect(names.length).toEqual(5);
|
||||||
|
expect(names).toContain('item3');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test native cursor modifiers: limit
|
||||||
|
tap.test('cursor modifier limit: only two documents', async () => {
|
||||||
|
const cursor = await CursorTest.getCursor({}, { modifier: (c) => c.limit(2) });
|
||||||
|
const limited = await cursor.toArray();
|
||||||
|
expect(limited.length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test native cursor modifiers: sort and skip
|
||||||
|
tap.test('cursor modifier sort & skip: returns correct order', async () => {
|
||||||
|
const cursor = await CursorTest.getCursor({}, {
|
||||||
|
modifier: (c) => c.sort({ order: -1 }).skip(1),
|
||||||
|
});
|
||||||
|
const results = await cursor.toArray();
|
||||||
|
// Skipped the first (order 5), next should be 4,3,2,1
|
||||||
|
expect(results.length).toEqual(4);
|
||||||
|
expect(results[0].order).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup: drop database, close connections, stop Mongo
|
||||||
|
tap.test('cursor cleanup: drop DB and stop', async () => {
|
||||||
|
await testDb.mongoDb.dropDatabase();
|
||||||
|
await testDb.close();
|
||||||
|
if (smartmongoInstance) {
|
||||||
|
await smartmongoInstance.stopAndDumpToDir(
|
||||||
|
`.nogit/dbdump/test.cursor.ts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Ensure process exits after cleanup
|
||||||
|
setTimeout(() => process.exit(), 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
202
test/test.search.advanced.ts
Normal file
202
test/test.search.advanced.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
|
import * as smartdata from '../ts/index.js';
|
||||||
|
import { searchable } from '../ts/classes.doc.js';
|
||||||
|
import { smartunique } from '../ts/plugins.js';
|
||||||
|
|
||||||
|
// Set up database connection
|
||||||
|
let smartmongoInstance: smartmongo.SmartMongo;
|
||||||
|
let testDb: smartdata.SmartdataDb;
|
||||||
|
|
||||||
|
// Define a test class for advanced search scenarios
|
||||||
|
@smartdata.Collection(() => testDb)
|
||||||
|
class Product extends smartdata.SmartDataDbDoc<Product, Product> {
|
||||||
|
@smartdata.unI()
|
||||||
|
public id: string = smartunique.shortId();
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
@searchable()
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
@searchable()
|
||||||
|
public description: string;
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
@searchable()
|
||||||
|
public category: string;
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
public price: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
nameArg: string,
|
||||||
|
descriptionArg: string,
|
||||||
|
categoryArg: string,
|
||||||
|
priceArg: number,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.name = nameArg;
|
||||||
|
this.description = descriptionArg;
|
||||||
|
this.category = categoryArg;
|
||||||
|
this.price = priceArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize DB and insert sample products
|
||||||
|
tap.test('setup advanced search database', async () => {
|
||||||
|
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
|
||||||
|
testDb = new smartdata.SmartdataDb(
|
||||||
|
await smartmongoInstance.getMongoDescriptor(),
|
||||||
|
);
|
||||||
|
await testDb.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('insert products for advanced search', async () => {
|
||||||
|
const products = [
|
||||||
|
new Product(
|
||||||
|
'Night Owl Lamp',
|
||||||
|
'Bright lamp for night reading',
|
||||||
|
'Lighting',
|
||||||
|
29,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'Day Light Lamp',
|
||||||
|
'Daytime lamp with adjustable brightness',
|
||||||
|
'Lighting',
|
||||||
|
39,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'Office Chair',
|
||||||
|
'Ergonomic chair for office',
|
||||||
|
'Furniture',
|
||||||
|
199,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'Gaming Chair',
|
||||||
|
'Comfortable for long gaming sessions',
|
||||||
|
'Furniture',
|
||||||
|
299,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'iPhone 12',
|
||||||
|
'Latest iPhone with A14 Bionic chip',
|
||||||
|
'Electronics',
|
||||||
|
999,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'AirPods',
|
||||||
|
'Wireless earbuds with noise cancellation',
|
||||||
|
'Electronics',
|
||||||
|
249,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const p of products) {
|
||||||
|
await p.save();
|
||||||
|
}
|
||||||
|
const all = await Product.getInstances({});
|
||||||
|
expect(all.length).toEqual(products.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple exact field:value matching
|
||||||
|
tap.test('simpleExact: category:Furniture returns chairs', async () => {
|
||||||
|
const res = await Product.search('category:Furniture');
|
||||||
|
expect(res.length).toEqual(2);
|
||||||
|
const names = res.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['Gaming Chair', 'Office Chair']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// simpleExact invalid field should throw
|
||||||
|
tap.test('simpleExact invalid field errors', async () => {
|
||||||
|
let error: Error;
|
||||||
|
try {
|
||||||
|
await Product.search('price:29');
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error.message).toMatch(/not searchable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quoted phrase search
|
||||||
|
tap.test('quoted phrase "Bright lamp" matches Night Owl Lamp', async () => {
|
||||||
|
const res = await Product.search('"Bright lamp"');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Night Owl Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test("quoted phrase 'night reading' matches Night Owl Lamp", async () => {
|
||||||
|
const res = await Product.search("'night reading'");
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Night Owl Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
tap.test('wildcard description:*gaming* matches Gaming Chair', async () => {
|
||||||
|
const res = await Product.search('description:*gaming*');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Gaming Chair');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Boolean AND and OR
|
||||||
|
tap.test('boolean AND: category:Lighting AND lamp', async () => {
|
||||||
|
const res = await Product.search('category:Lighting AND lamp');
|
||||||
|
expect(res.length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('boolean OR: Furniture OR Electronics', async () => {
|
||||||
|
const res = await Product.search('Furniture OR Electronics');
|
||||||
|
expect(res.length).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multi-term unquoted -> AND across terms
|
||||||
|
tap.test('multi-term unquoted adjustable brightness', async () => {
|
||||||
|
const res = await Product.search('adjustable brightness');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Day Light Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('multi-term unquoted Night Lamp', async () => {
|
||||||
|
const res = await Product.search('Night Lamp');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Night Owl Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grouping with parentheses
|
||||||
|
tap.test('grouping: (Furniture OR Electronics) AND Chair', async () => {
|
||||||
|
const res = await Product.search(
|
||||||
|
'(Furniture OR Electronics) AND Chair',
|
||||||
|
);
|
||||||
|
expect(res.length).toEqual(2);
|
||||||
|
const names = res.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['Gaming Chair', 'Office Chair']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional range and combined query tests
|
||||||
|
tap.test('range query price:[30 TO 300] returns expected products', async () => {
|
||||||
|
const res = await Product.search('price:[30 TO 300]');
|
||||||
|
// Expect products with price between 30 and 300 inclusive: Day Light Lamp, Gaming Chair, Office Chair, AirPods
|
||||||
|
expect(res.length).toEqual(4);
|
||||||
|
const names = res.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['AirPods', 'Day Light Lamp', 'Gaming Chair', 'Office Chair']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter category and price range', async () => {
|
||||||
|
const res = await Product.search('category:Lighting AND price:[30 TO 40]');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Day Light Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Teardown
|
||||||
|
tap.test('cleanup advanced search database', async () => {
|
||||||
|
await testDb.mongoDb.dropDatabase();
|
||||||
|
await testDb.close();
|
||||||
|
if (smartmongoInstance) {
|
||||||
|
await smartmongoInstance.stopAndDumpToDir(
|
||||||
|
`.nogit/dbdump/test.search.advanced.ts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setTimeout(() => process.exit(), 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start({ throwOnError: true });
|
@@ -4,11 +4,13 @@ import { smartunique } from '../ts/plugins.js';
|
|||||||
|
|
||||||
// Import the smartdata library
|
// Import the smartdata library
|
||||||
import * as smartdata from '../ts/index.js';
|
import * as smartdata from '../ts/index.js';
|
||||||
import { searchable, getSearchableFields } from '../ts/classes.doc.js';
|
import { searchable } from '../ts/classes.doc.js';
|
||||||
|
|
||||||
// Set up database connection
|
// Set up database connection
|
||||||
let smartmongoInstance: smartmongo.SmartMongo;
|
let smartmongoInstance: smartmongo.SmartMongo;
|
||||||
let testDb: smartdata.SmartdataDb;
|
let testDb: smartdata.SmartdataDb;
|
||||||
|
// Class for location-based wildcard/phrase tests
|
||||||
|
let LocationDoc: any;
|
||||||
|
|
||||||
// Define a test class with searchable fields using the standard SmartDataDbDoc
|
// Define a test class with searchable fields using the standard SmartDataDbDoc
|
||||||
@smartdata.Collection(() => testDb)
|
@smartdata.Collection(() => testDb)
|
||||||
@@ -72,7 +74,7 @@ tap.test('should create test products with searchable fields', async () => {
|
|||||||
|
|
||||||
tap.test('should retrieve searchable fields for a class', async () => {
|
tap.test('should retrieve searchable fields for a class', async () => {
|
||||||
// Use the getSearchableFields function to verify our searchable fields
|
// Use the getSearchableFields function to verify our searchable fields
|
||||||
const searchableFields = getSearchableFields('Product');
|
const searchableFields = Product.getSearchableFields();
|
||||||
console.log('Searchable fields:', searchableFields);
|
console.log('Searchable fields:', searchableFields);
|
||||||
|
|
||||||
expect(searchableFields.length).toEqual(3);
|
expect(searchableFields.length).toEqual(3);
|
||||||
@@ -104,21 +106,21 @@ tap.test('should search products by basic search method', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should search products with searchWithLucene method', async () => {
|
tap.test('should search products with search method', async () => {
|
||||||
// Using the robust searchWithLucene method
|
// Using the robust searchWithLucene method
|
||||||
const wirelessResults = await Product.searchWithLucene('wireless');
|
const wirelessResults = await Product.search('wireless');
|
||||||
console.log(
|
console.log(
|
||||||
`Found ${wirelessResults.length} products matching 'wireless' using searchWithLucene`,
|
`Found ${wirelessResults.length} products matching 'wireless' using search`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(wirelessResults.length).toEqual(1);
|
expect(wirelessResults.length).toEqual(1);
|
||||||
expect(wirelessResults[0].name).toEqual('AirPods');
|
expect(wirelessResults[0].name).toEqual('AirPods');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should search products by category with searchWithLucene', async () => {
|
tap.test('should search products by category with search', async () => {
|
||||||
// Using field-specific search with searchWithLucene
|
// Using field-specific search with searchWithLucene
|
||||||
const kitchenResults = await Product.searchWithLucene('category:Kitchen');
|
const kitchenResults = await Product.search('category:Kitchen');
|
||||||
console.log(`Found ${kitchenResults.length} products in Kitchen category using searchWithLucene`);
|
console.log(`Found ${kitchenResults.length} products in Kitchen category using search`);
|
||||||
|
|
||||||
expect(kitchenResults.length).toEqual(2);
|
expect(kitchenResults.length).toEqual(2);
|
||||||
expect(kitchenResults[0].category).toEqual('Kitchen');
|
expect(kitchenResults[0].category).toEqual('Kitchen');
|
||||||
@@ -127,7 +129,7 @@ tap.test('should search products by category with searchWithLucene', async () =>
|
|||||||
|
|
||||||
tap.test('should search products with partial word matches', async () => {
|
tap.test('should search products with partial word matches', async () => {
|
||||||
// Testing partial word matches
|
// Testing partial word matches
|
||||||
const proResults = await Product.searchWithLucene('Pro');
|
const proResults = await Product.search('Pro');
|
||||||
console.log(`Found ${proResults.length} products matching 'Pro'`);
|
console.log(`Found ${proResults.length} products matching 'Pro'`);
|
||||||
|
|
||||||
// Should match both "MacBook Pro" and "professionals" in description
|
// Should match both "MacBook Pro" and "professionals" in description
|
||||||
@@ -136,7 +138,7 @@ tap.test('should search products with partial word matches', async () => {
|
|||||||
|
|
||||||
tap.test('should search across multiple searchable fields', async () => {
|
tap.test('should search across multiple searchable fields', async () => {
|
||||||
// Test searching across all searchable fields
|
// Test searching across all searchable fields
|
||||||
const bookResults = await Product.searchWithLucene('book');
|
const bookResults = await Product.search('book');
|
||||||
console.log(`Found ${bookResults.length} products matching 'book' across all fields`);
|
console.log(`Found ${bookResults.length} products matching 'book' across all fields`);
|
||||||
|
|
||||||
// Should match "MacBook" in name and "Books" in category
|
// Should match "MacBook" in name and "Books" in category
|
||||||
@@ -145,8 +147,8 @@ tap.test('should search across multiple searchable fields', async () => {
|
|||||||
|
|
||||||
tap.test('should handle case insensitive searches', async () => {
|
tap.test('should handle case insensitive searches', async () => {
|
||||||
// Test case insensitivity
|
// Test case insensitivity
|
||||||
const electronicsResults = await Product.searchWithLucene('electronics');
|
const electronicsResults = await Product.search('electronics');
|
||||||
const ElectronicsResults = await Product.searchWithLucene('Electronics');
|
const ElectronicsResults = await Product.search('Electronics');
|
||||||
|
|
||||||
console.log(`Found ${electronicsResults.length} products matching lowercase 'electronics'`);
|
console.log(`Found ${electronicsResults.length} products matching lowercase 'electronics'`);
|
||||||
console.log(`Found ${ElectronicsResults.length} products matching capitalized 'Electronics'`);
|
console.log(`Found ${ElectronicsResults.length} products matching capitalized 'Electronics'`);
|
||||||
@@ -166,14 +168,14 @@ tap.test('should demonstrate search fallback mechanisms', async () => {
|
|||||||
|
|
||||||
// Use a simpler term that should be found in descriptions
|
// Use a simpler term that should be found in descriptions
|
||||||
// Avoid using "OR" operator which requires a text index
|
// Avoid using "OR" operator which requires a text index
|
||||||
const results = await Product.searchWithLucene('high');
|
const results = await Product.search('high');
|
||||||
console.log(`Found ${results.length} products matching 'high'`);
|
console.log(`Found ${results.length} products matching 'high'`);
|
||||||
|
|
||||||
// "High-speed blender" contains "high"
|
// "High-speed blender" contains "high"
|
||||||
expect(results.length).toBeGreaterThan(0);
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Try another fallback example that won't need $text
|
// Try another fallback example that won't need $text
|
||||||
const powerfulResults = await Product.searchWithLucene('powerful');
|
const powerfulResults = await Product.search('powerful');
|
||||||
console.log(`Found ${powerfulResults.length} products matching 'powerful'`);
|
console.log(`Found ${powerfulResults.length} products matching 'powerful'`);
|
||||||
|
|
||||||
// "Powerful laptop for professionals" contains "powerful"
|
// "Powerful laptop for professionals" contains "powerful"
|
||||||
@@ -192,6 +194,208 @@ tap.test('should explain the advantages of the integrated approach', async () =>
|
|||||||
expect(true).toEqual(true);
|
expect(true).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Additional robustness tests
|
||||||
|
tap.test('should search exact name using field:value', async () => {
|
||||||
|
const nameResults = await Product.search('name:AirPods');
|
||||||
|
expect(nameResults.length).toEqual(1);
|
||||||
|
expect(nameResults[0].name).toEqual('AirPods');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should throw when searching non-searchable field', async () => {
|
||||||
|
let error: Error;
|
||||||
|
try {
|
||||||
|
await Product.search('price:129');
|
||||||
|
} catch (err) {
|
||||||
|
error = err as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error.message).toMatch(/not searchable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('empty query should return all products', async () => {
|
||||||
|
const allResults = await Product.search('');
|
||||||
|
expect(allResults.length).toEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search multi-word term across fields', async () => {
|
||||||
|
const termResults = await Product.search('iPhone 12');
|
||||||
|
expect(termResults.length).toEqual(1);
|
||||||
|
expect(termResults[0].name).toEqual('iPhone 12');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional search scenarios
|
||||||
|
tap.test('should return zero results for non-existent terms', async () => {
|
||||||
|
const noResults = await Product.search('NonexistentTerm');
|
||||||
|
expect(noResults.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search products by description term "noise"', async () => {
|
||||||
|
const noiseResults = await Product.search('noise');
|
||||||
|
expect(noiseResults.length).toEqual(1);
|
||||||
|
expect(noiseResults[0].name).toEqual('AirPods');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search products by description term "flagship"', async () => {
|
||||||
|
const flagshipResults = await Product.search('flagship');
|
||||||
|
expect(flagshipResults.length).toEqual(1);
|
||||||
|
expect(flagshipResults[0].name).toEqual('Galaxy S21');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search numeric strings "12"', async () => {
|
||||||
|
const twelveResults = await Product.search('12');
|
||||||
|
expect(twelveResults.length).toEqual(1);
|
||||||
|
expect(twelveResults[0].name).toEqual('iPhone 12');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search hyphenated terms "high-speed"', async () => {
|
||||||
|
const hyphenResults = await Product.search('high-speed');
|
||||||
|
expect(hyphenResults.length).toEqual(1);
|
||||||
|
expect(hyphenResults[0].name).toEqual('Blender');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search hyphenated terms "E-reader"', async () => {
|
||||||
|
const ereaderResults = await Product.search('E-reader');
|
||||||
|
expect(ereaderResults.length).toEqual(1);
|
||||||
|
expect(ereaderResults[0].name).toEqual('Kindle Paperwhite');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional robustness tests
|
||||||
|
tap.test('should return all products for empty search', async () => {
|
||||||
|
const searchResults = await Product.search('');
|
||||||
|
const allProducts = await Product.getInstances({});
|
||||||
|
expect(searchResults.length).toEqual(allProducts.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support wildcard plain term across all fields', async () => {
|
||||||
|
const results = await Product.search('*book*');
|
||||||
|
const names = results.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['Harry Potter', 'Kindle Paperwhite', 'MacBook Pro']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support wildcard plain term with question mark pattern', async () => {
|
||||||
|
const results = await Product.search('?one?');
|
||||||
|
const names = results.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['Galaxy S21', 'iPhone 12']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter and Validation tests
|
||||||
|
tap.test('should apply filter option to restrict results', async () => {
|
||||||
|
// search term 'book' across all fields but restrict to Books category
|
||||||
|
const bookFiltered = await Product.search('book', { filter: { category: 'Books' } });
|
||||||
|
expect(bookFiltered.length).toEqual(2);
|
||||||
|
bookFiltered.forEach((p) => expect(p.category).toEqual('Books'));
|
||||||
|
});
|
||||||
|
tap.test('should apply validate hook to post-filter results', async () => {
|
||||||
|
// return only products with price > 500
|
||||||
|
const expensive = await Product.search('', { validate: (p) => p.price > 500 });
|
||||||
|
expect(expensive.length).toBeGreaterThan(0);
|
||||||
|
expensive.forEach((p) => expect(p.price).toBeGreaterThan(500));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests for quoted and wildcard field-specific phrases
|
||||||
|
tap.test('setup location test products', async () => {
|
||||||
|
@smartdata.Collection(() => testDb)
|
||||||
|
class LD extends smartdata.SmartDataDbDoc<LD, LD> {
|
||||||
|
@smartdata.unI() public id: string = smartunique.shortId();
|
||||||
|
@smartdata.svDb() @searchable() public location: string;
|
||||||
|
constructor(loc: string) { super(); this.location = loc; }
|
||||||
|
}
|
||||||
|
// Assign to outer variable for subsequent tests
|
||||||
|
LocationDoc = LD;
|
||||||
|
const locations = ['Berlin', 'Frankfurt am Main', 'Frankfurt am Oder', 'London'];
|
||||||
|
for (const loc of locations) {
|
||||||
|
await new LocationDoc(loc).save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tap.test('should search exact quoted field phrase', async () => {
|
||||||
|
const results = await (LocationDoc as any).search('location:"Frankfurt am Main"');
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
expect(results[0].location).toEqual('Frankfurt am Main');
|
||||||
|
});
|
||||||
|
tap.test('should search wildcard quoted field phrase', async () => {
|
||||||
|
const results = await (LocationDoc as any).search('location:"Frankfurt am *"');
|
||||||
|
const names = results.map((d: any) => d.location).sort();
|
||||||
|
expect(names).toEqual(['Frankfurt am Main', 'Frankfurt am Oder']);
|
||||||
|
});
|
||||||
|
tap.test('should search unquoted wildcard field', async () => {
|
||||||
|
const results = await (LocationDoc as any).search('location:Frankfurt*');
|
||||||
|
const names = results.map((d: any) => d.location).sort();
|
||||||
|
expect(names).toEqual(['Frankfurt am Main', 'Frankfurt am Oder']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combined free-term + field phrase/wildcard tests
|
||||||
|
let CombinedDoc: any;
|
||||||
|
tap.test('setup combined docs for free-term and location tests', async () => {
|
||||||
|
@smartdata.Collection(() => testDb)
|
||||||
|
class CD extends smartdata.SmartDataDbDoc<CD, CD> {
|
||||||
|
@smartdata.unI() public id: string = smartunique.shortId();
|
||||||
|
@smartdata.svDb() @searchable() public name: string;
|
||||||
|
@smartdata.svDb() @searchable() public location: string;
|
||||||
|
constructor(name: string, location: string) { super(); this.name = name; this.location = location; }
|
||||||
|
}
|
||||||
|
CombinedDoc = CD;
|
||||||
|
const docs = [
|
||||||
|
new CombinedDoc('TypeScript', 'Berlin'),
|
||||||
|
new CombinedDoc('TypeScript', 'Frankfurt am Main'),
|
||||||
|
new CombinedDoc('TypeScript', 'Frankfurt am Oder'),
|
||||||
|
new CombinedDoc('JavaScript', 'Berlin'),
|
||||||
|
];
|
||||||
|
for (const d of docs) await d.save();
|
||||||
|
});
|
||||||
|
tap.test('should search free term and exact quoted field phrase', async () => {
|
||||||
|
const res = await CombinedDoc.search('TypeScript location:"Berlin"');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].location).toEqual('Berlin');
|
||||||
|
});
|
||||||
|
tap.test('should not match free term with non-matching quoted field phrase', async () => {
|
||||||
|
const res = await CombinedDoc.search('TypeScript location:"Frankfurt d"');
|
||||||
|
expect(res.length).toEqual(0);
|
||||||
|
});
|
||||||
|
tap.test('should search free term with quoted wildcard field phrase', async () => {
|
||||||
|
const res = await CombinedDoc.search('TypeScript location:"Frankfurt am *"');
|
||||||
|
const locs = res.map((r: any) => r.location).sort();
|
||||||
|
expect(locs).toEqual(['Frankfurt am Main', 'Frankfurt am Oder']);
|
||||||
|
});
|
||||||
|
// Quoted exact field phrase without wildcard should return no matches if no exact match
|
||||||
|
tap.test('should not match location:"Frankfurt d"', async () => {
|
||||||
|
const results = await (LocationDoc as any).search('location:"Frankfurt d"');
|
||||||
|
expect(results.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combined free-term and field wildcard tests
|
||||||
|
tap.test('should combine free term and wildcard field search', async () => {
|
||||||
|
const results = await Product.search('book category:Book*');
|
||||||
|
expect(results.length).toEqual(2);
|
||||||
|
results.forEach((p) => expect(p.category).toEqual('Books'));
|
||||||
|
});
|
||||||
|
tap.test('should not match when free term matches but wildcard field does not', async () => {
|
||||||
|
const results = await Product.search('book category:Kitchen*');
|
||||||
|
expect(results.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-searchable field should cause an error for combined queries
|
||||||
|
tap.test('should throw when combining term with non-searchable field', async () => {
|
||||||
|
let error: Error;
|
||||||
|
try {
|
||||||
|
await Product.search('book location:Berlin');
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error.message).toMatch(/not searchable/);
|
||||||
|
});
|
||||||
|
tap.test('should throw when combining term with non-searchable wildcard field', async () => {
|
||||||
|
let error: Error;
|
||||||
|
try {
|
||||||
|
await Product.search('book location:Berlin*');
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error.message).toMatch(/not searchable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
tap.test('close database connection', async () => {
|
tap.test('close database connection', async () => {
|
||||||
await testDb.mongoDb.dropDatabase();
|
await testDb.mongoDb.dropDatabase();
|
||||||
await testDb.close();
|
await testDb.close();
|
||||||
|
@@ -60,11 +60,52 @@ tap.test('should watch a collection', async (toolsArg) => {
|
|||||||
await done.promise;
|
await done.promise;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ======= New tests for EventEmitter and buffering support =======
|
||||||
|
tap.test('should emit change via EventEmitter', async (tools) => {
|
||||||
|
const done = tools.defer();
|
||||||
|
const watcher = await House.watch({});
|
||||||
|
watcher.on('change', async (houseArg) => {
|
||||||
|
// Expect a House instance
|
||||||
|
expect(houseArg).toBeDefined();
|
||||||
|
// Clean up
|
||||||
|
await watcher.stop();
|
||||||
|
done.resolve();
|
||||||
|
});
|
||||||
|
// Trigger an insert to generate a change event
|
||||||
|
const h = new House();
|
||||||
|
await h.save();
|
||||||
|
await done.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should buffer change events when bufferTimeMs is set', async (tools) => {
|
||||||
|
const done = tools.defer();
|
||||||
|
// bufferTimeMs collects events into arrays every 50ms
|
||||||
|
const watcher = await House.watch({}, { bufferTimeMs: 50 });
|
||||||
|
let received: House[];
|
||||||
|
watcher.changeSubject.subscribe(async (batch: House[]) => {
|
||||||
|
if (batch && batch.length > 0) {
|
||||||
|
received = batch;
|
||||||
|
await watcher.stop();
|
||||||
|
done.resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Rapidly insert multiple docs
|
||||||
|
const docs = [new House(), new House(), new House()];
|
||||||
|
for (const doc of docs) await doc.save();
|
||||||
|
await done.promise;
|
||||||
|
// All inserts should be in one buffered batch
|
||||||
|
expect(received.length).toEqual(docs.length);
|
||||||
|
});
|
||||||
|
|
||||||
// =======================================
|
// =======================================
|
||||||
// close the database connection
|
// close the database connection
|
||||||
// =======================================
|
// =======================================
|
||||||
tap.test('close', async () => {
|
tap.test('close', async () => {
|
||||||
await testDb.mongoDb.dropDatabase();
|
try {
|
||||||
|
await testDb.mongoDb.dropDatabase();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('dropDatabase error ignored in cleanup:', err.message || err);
|
||||||
|
}
|
||||||
await testDb.close();
|
await testDb.close();
|
||||||
if (smartmongoInstance) {
|
if (smartmongoInstance) {
|
||||||
await smartmongoInstance.stop();
|
await smartmongoInstance.stop();
|
||||||
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartdata',
|
name: '@push.rocks/smartdata',
|
||||||
version: '5.8.0',
|
version: '5.16.1',
|
||||||
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
|
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
|
||||||
}
|
}
|
||||||
|
@@ -4,6 +4,7 @@ import { SmartdataDbCursor } from './classes.cursor.js';
|
|||||||
import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js';
|
import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js';
|
||||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||||
import { CollectionFactory } from './classes.collectionfactory.js';
|
import { CollectionFactory } from './classes.collectionfactory.js';
|
||||||
|
import { logger } from './logging.js';
|
||||||
|
|
||||||
export interface IFindOptions {
|
export interface IFindOptions {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -32,13 +33,22 @@ export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) {
|
|||||||
if (!(dbArg instanceof SmartdataDb)) {
|
if (!(dbArg instanceof SmartdataDb)) {
|
||||||
dbArg = dbArg();
|
dbArg = dbArg();
|
||||||
}
|
}
|
||||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||||
|
// Attach document constructor for searchableFields lookup
|
||||||
|
if (!(coll as any).docCtor) {
|
||||||
|
(coll as any).docCtor = decoratedClass;
|
||||||
|
}
|
||||||
|
return coll;
|
||||||
}
|
}
|
||||||
public get collection() {
|
public get collection() {
|
||||||
if (!(dbArg instanceof SmartdataDb)) {
|
if (!(dbArg instanceof SmartdataDb)) {
|
||||||
dbArg = dbArg();
|
dbArg = dbArg();
|
||||||
}
|
}
|
||||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||||
|
if (!(coll as any).docCtor) {
|
||||||
|
(coll as any).docCtor = decoratedClass;
|
||||||
|
}
|
||||||
|
return coll;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return decoratedClass;
|
return decoratedClass;
|
||||||
@@ -128,6 +138,8 @@ export class SmartdataCollection<T> {
|
|||||||
public smartdataDb: SmartdataDb;
|
public smartdataDb: SmartdataDb;
|
||||||
public uniqueIndexes: string[] = [];
|
public uniqueIndexes: string[] = [];
|
||||||
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
|
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
|
||||||
|
// flag to ensure text index is created only once
|
||||||
|
private textIndexCreated: boolean = false;
|
||||||
|
|
||||||
constructor(classNameArg: string, smartDataDbArg: SmartdataDb) {
|
constructor(classNameArg: string, smartDataDbArg: SmartdataDb) {
|
||||||
// tell the collection where it belongs
|
// tell the collection where it belongs
|
||||||
@@ -150,19 +162,31 @@ export class SmartdataCollection<T> {
|
|||||||
});
|
});
|
||||||
if (!wantedCollection) {
|
if (!wantedCollection) {
|
||||||
await this.smartdataDb.mongoDb.createCollection(this.collectionName);
|
await this.smartdataDb.mongoDb.createCollection(this.collectionName);
|
||||||
console.log(`Successfully initiated Collection ${this.collectionName}`);
|
logger.log('info', `Successfully initiated Collection ${this.collectionName}`);
|
||||||
}
|
}
|
||||||
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
|
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
|
||||||
|
// Auto-create a compound text index on all searchable fields
|
||||||
|
// Use document constructor's searchableFields registered via decorator
|
||||||
|
const docCtor = (this as any).docCtor;
|
||||||
|
const searchableFields: string[] = docCtor?.searchableFields || [];
|
||||||
|
if (searchableFields.length > 0 && !this.textIndexCreated) {
|
||||||
|
// Build a compound text index spec
|
||||||
|
const indexSpec: Record<string, 'text'> = {};
|
||||||
|
searchableFields.forEach(f => { indexSpec[f] = 'text'; });
|
||||||
|
// Cast to any to satisfy TypeScript IndexSpecification typing
|
||||||
|
await this.mongoDbCollection.createIndex(indexSpec as any, { name: 'smartdata_text_index' });
|
||||||
|
this.textIndexCreated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* mark unique index
|
* mark unique index
|
||||||
*/
|
*/
|
||||||
public markUniqueIndexes(keyArrayArg: string[] = []) {
|
public async markUniqueIndexes(keyArrayArg: string[] = []) {
|
||||||
for (const key of keyArrayArg) {
|
for (const key of keyArrayArg) {
|
||||||
if (!this.uniqueIndexes.includes(key)) {
|
if (!this.uniqueIndexes.includes(key)) {
|
||||||
this.mongoDbCollection.createIndex(key, {
|
await this.mongoDbCollection.createIndex({ [key]: 1 }, {
|
||||||
unique: true,
|
unique: true,
|
||||||
});
|
});
|
||||||
// make sure we only call this once and not for every doc we create
|
// make sure we only call this once and not for every doc we create
|
||||||
@@ -174,12 +198,12 @@ export class SmartdataCollection<T> {
|
|||||||
/**
|
/**
|
||||||
* creates regular indexes for the collection
|
* creates regular indexes for the collection
|
||||||
*/
|
*/
|
||||||
public createRegularIndexes(indexesArg: Array<{field: string, options: IIndexOptions}> = []) {
|
public async createRegularIndexes(indexesArg: Array<{field: string, options: IIndexOptions}> = []) {
|
||||||
for (const indexDef of indexesArg) {
|
for (const indexDef of indexesArg) {
|
||||||
// Check if we've already created this index
|
// Check if we've already created this index
|
||||||
const indexKey = indexDef.field;
|
const indexKey = indexDef.field;
|
||||||
if (!this.regularIndexes.some(i => i.field === indexKey)) {
|
if (!this.regularIndexes.some(i => i.field === indexKey)) {
|
||||||
this.mongoDbCollection.createIndex(
|
await this.mongoDbCollection.createIndex(
|
||||||
{ [indexDef.field]: 1 }, // Simple single-field index
|
{ [indexDef.field]: 1 }, // Simple single-field index
|
||||||
indexDef.options
|
indexDef.options
|
||||||
);
|
);
|
||||||
@@ -199,53 +223,74 @@ export class SmartdataCollection<T> {
|
|||||||
/**
|
/**
|
||||||
* finds an object in the DbCollection
|
* finds an object in the DbCollection
|
||||||
*/
|
*/
|
||||||
public async findOne(filterObject: any): Promise<any> {
|
public async findOne(
|
||||||
|
filterObject: any,
|
||||||
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
|
): Promise<any> {
|
||||||
await this.init();
|
await this.init();
|
||||||
const cursor = this.mongoDbCollection.find(filterObject);
|
// Use MongoDB driver's findOne with optional session
|
||||||
const result = await cursor.next();
|
return this.mongoDbCollection.findOne(filterObject, { session: opts?.session });
|
||||||
cursor.close();
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCursor(
|
public async getCursor(
|
||||||
filterObjectArg: any,
|
filterObjectArg: any,
|
||||||
dbDocArg: typeof SmartDataDbDoc,
|
dbDocArg: typeof SmartDataDbDoc,
|
||||||
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
): Promise<SmartdataDbCursor<any>> {
|
): Promise<SmartdataDbCursor<any>> {
|
||||||
await this.init();
|
await this.init();
|
||||||
const cursor = this.mongoDbCollection.find(filterObjectArg);
|
const cursor = this.mongoDbCollection.find(filterObjectArg, { session: opts?.session });
|
||||||
return new SmartdataDbCursor(cursor, dbDocArg);
|
return new SmartdataDbCursor(cursor, dbDocArg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* finds an object in the DbCollection
|
* finds an object in the DbCollection
|
||||||
*/
|
*/
|
||||||
public async findAll(filterObject: any): Promise<any[]> {
|
public async findAll(
|
||||||
|
filterObject: any,
|
||||||
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
|
): Promise<any[]> {
|
||||||
await this.init();
|
await this.init();
|
||||||
const cursor = this.mongoDbCollection.find(filterObject);
|
const cursor = this.mongoDbCollection.find(filterObject, { session: opts?.session });
|
||||||
const result = await cursor.toArray();
|
const result = await cursor.toArray();
|
||||||
cursor.close();
|
cursor.close();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* watches the collection while applying a filter
|
* Watches the collection, returning a SmartdataDbWatcher with RxJS and EventEmitter support.
|
||||||
|
* @param filterObject match filter for change stream
|
||||||
|
* @param opts optional MongoDB ChangeStreamOptions & { bufferTimeMs } to buffer events
|
||||||
|
* @param smartdataDbDocArg document class for instance creation
|
||||||
*/
|
*/
|
||||||
public async watch(
|
public async watch(
|
||||||
filterObject: any,
|
filterObject: any,
|
||||||
smartdataDbDocArg: typeof SmartDataDbDoc,
|
opts: (plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number }) = {},
|
||||||
|
smartdataDbDocArg?: typeof SmartDataDbDoc,
|
||||||
): Promise<SmartdataDbWatcher> {
|
): Promise<SmartdataDbWatcher> {
|
||||||
await this.init();
|
await this.init();
|
||||||
|
// Extract bufferTimeMs from options
|
||||||
|
const { bufferTimeMs, fullDocument, ...otherOptions } = opts || {};
|
||||||
|
// Determine fullDocument behavior: default to 'updateLookup'
|
||||||
|
const changeStreamOptions: plugins.mongodb.ChangeStreamOptions = {
|
||||||
|
...otherOptions,
|
||||||
|
fullDocument:
|
||||||
|
fullDocument === undefined
|
||||||
|
? 'updateLookup'
|
||||||
|
: (fullDocument as any) === true
|
||||||
|
? 'updateLookup'
|
||||||
|
: fullDocument,
|
||||||
|
} as any;
|
||||||
|
// Build pipeline with match if provided
|
||||||
|
const pipeline = filterObject ? [{ $match: filterObject }] : [];
|
||||||
const changeStream = this.mongoDbCollection.watch(
|
const changeStream = this.mongoDbCollection.watch(
|
||||||
[
|
pipeline,
|
||||||
{
|
changeStreamOptions,
|
||||||
$match: filterObject,
|
);
|
||||||
},
|
const smartdataWatcher = new SmartdataDbWatcher(
|
||||||
],
|
changeStream,
|
||||||
{
|
smartdataDbDocArg,
|
||||||
fullDocument: 'updateLookup',
|
{ bufferTimeMs },
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const smartdataWatcher = new SmartdataDbWatcher(changeStream, smartdataDbDocArg);
|
|
||||||
await smartdataWatcher.readyDeferred.promise;
|
await smartdataWatcher.readyDeferred.promise;
|
||||||
return smartdataWatcher;
|
return smartdataWatcher;
|
||||||
}
|
}
|
||||||
@@ -253,7 +298,10 @@ export class SmartdataCollection<T> {
|
|||||||
/**
|
/**
|
||||||
* create an object in the database
|
* create an object in the database
|
||||||
*/
|
*/
|
||||||
public async insert(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
|
public async insert(
|
||||||
|
dbDocArg: T & SmartDataDbDoc<T, unknown>,
|
||||||
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
|
): Promise<any> {
|
||||||
await this.init();
|
await this.init();
|
||||||
await this.checkDoc(dbDocArg);
|
await this.checkDoc(dbDocArg);
|
||||||
this.markUniqueIndexes(dbDocArg.uniqueIndexes);
|
this.markUniqueIndexes(dbDocArg.uniqueIndexes);
|
||||||
@@ -264,14 +312,17 @@ export class SmartdataCollection<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveableObject = await dbDocArg.createSavableObject();
|
const saveableObject = await dbDocArg.createSavableObject();
|
||||||
const result = await this.mongoDbCollection.insertOne(saveableObject);
|
const result = await this.mongoDbCollection.insertOne(saveableObject, { session: opts?.session });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* inserts object into the DbCollection
|
* inserts object into the DbCollection
|
||||||
*/
|
*/
|
||||||
public async update(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
|
public async update(
|
||||||
|
dbDocArg: T & SmartDataDbDoc<T, unknown>,
|
||||||
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
|
): Promise<any> {
|
||||||
await this.init();
|
await this.init();
|
||||||
await this.checkDoc(dbDocArg);
|
await this.checkDoc(dbDocArg);
|
||||||
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
||||||
@@ -286,21 +337,27 @@ export class SmartdataCollection<T> {
|
|||||||
const result = await this.mongoDbCollection.updateOne(
|
const result = await this.mongoDbCollection.updateOne(
|
||||||
identifiableObject,
|
identifiableObject,
|
||||||
{ $set: updateableObject },
|
{ $set: updateableObject },
|
||||||
{ upsert: true },
|
{ upsert: true, session: opts?.session },
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
|
public async delete(
|
||||||
|
dbDocArg: T & SmartDataDbDoc<T, unknown>,
|
||||||
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
|
): Promise<any> {
|
||||||
await this.init();
|
await this.init();
|
||||||
await this.checkDoc(dbDocArg);
|
await this.checkDoc(dbDocArg);
|
||||||
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
||||||
await this.mongoDbCollection.deleteOne(identifiableObject);
|
await this.mongoDbCollection.deleteOne(identifiableObject, { session: opts?.session });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCount(filterObject: any) {
|
public async getCount(
|
||||||
|
filterObject: any,
|
||||||
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
|
) {
|
||||||
await this.init();
|
await this.init();
|
||||||
return this.mongoDbCollection.countDocuments(filterObject);
|
return this.mongoDbCollection.countDocuments(filterObject, { session: opts?.session });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -15,14 +15,14 @@ export class SmartdataDbCursor<T = any> {
|
|||||||
this.smartdataDbDoc = dbDocArg;
|
this.smartdataDbDoc = dbDocArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async next(closeAtEnd = true) {
|
public async next(closeAtEnd = true): Promise<T> {
|
||||||
const result = this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc(
|
const result = this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc(
|
||||||
await this.mongodbCursor.next(),
|
await this.mongodbCursor.next(),
|
||||||
);
|
);
|
||||||
if (!result && closeAtEnd) {
|
if (!result && closeAtEnd) {
|
||||||
await this.close();
|
await this.close();
|
||||||
}
|
}
|
||||||
return result;
|
return result as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async forEach(forEachFuncArg: (itemArg: T) => Promise<any>, closeCursorAtEnd = true) {
|
public async forEach(forEachFuncArg: (itemArg: T) => Promise<any>, closeCursorAtEnd = true) {
|
||||||
@@ -40,9 +40,9 @@ export class SmartdataDbCursor<T = any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async toArray() {
|
public async toArray(): Promise<T[]> {
|
||||||
const result = await this.mongodbCursor.toArray();
|
const result = await this.mongodbCursor.toArray();
|
||||||
return result.map((itemArg) => this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc(itemArg));
|
return result.map((itemArg) => this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc(itemArg)) as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async close() {
|
public async close() {
|
||||||
|
@@ -35,24 +35,43 @@ export class SmartdataDb {
|
|||||||
* connects to the database that was specified during instance creation
|
* connects to the database that was specified during instance creation
|
||||||
*/
|
*/
|
||||||
public async init(): Promise<any> {
|
public async init(): Promise<any> {
|
||||||
const finalConnectionUrl = this.smartdataOptions.mongoDbUrl
|
try {
|
||||||
.replace('<USERNAME>', this.smartdataOptions.mongoDbUser)
|
// Safely encode credentials to handle special characters
|
||||||
.replace('<username>', this.smartdataOptions.mongoDbUser)
|
const encodedUser = this.smartdataOptions.mongoDbUser
|
||||||
.replace('<USER>', this.smartdataOptions.mongoDbUser)
|
? encodeURIComponent(this.smartdataOptions.mongoDbUser)
|
||||||
.replace('<user>', this.smartdataOptions.mongoDbUser)
|
: '';
|
||||||
.replace('<PASSWORD>', this.smartdataOptions.mongoDbPass)
|
const encodedPass = this.smartdataOptions.mongoDbPass
|
||||||
.replace('<password>', this.smartdataOptions.mongoDbPass)
|
? encodeURIComponent(this.smartdataOptions.mongoDbPass)
|
||||||
.replace('<DBNAME>', this.smartdataOptions.mongoDbName)
|
: '';
|
||||||
.replace('<dbname>', this.smartdataOptions.mongoDbName);
|
|
||||||
|
const finalConnectionUrl = this.smartdataOptions.mongoDbUrl
|
||||||
|
.replace('<USERNAME>', encodedUser)
|
||||||
|
.replace('<username>', encodedUser)
|
||||||
|
.replace('<USER>', encodedUser)
|
||||||
|
.replace('<user>', encodedUser)
|
||||||
|
.replace('<PASSWORD>', encodedPass)
|
||||||
|
.replace('<password>', encodedPass)
|
||||||
|
.replace('<DBNAME>', this.smartdataOptions.mongoDbName)
|
||||||
|
.replace('<dbname>', this.smartdataOptions.mongoDbName);
|
||||||
|
|
||||||
this.mongoDbClient = await plugins.mongodb.MongoClient.connect(finalConnectionUrl, {
|
const clientOptions: plugins.mongodb.MongoClientOptions = {
|
||||||
maxPoolSize: 100,
|
maxPoolSize: (this.smartdataOptions as any).maxPoolSize ?? 100,
|
||||||
maxIdleTimeMS: 10,
|
maxIdleTimeMS: (this.smartdataOptions as any).maxIdleTimeMS ?? 300000, // 5 minutes default
|
||||||
});
|
serverSelectionTimeoutMS: (this.smartdataOptions as any).serverSelectionTimeoutMS ?? 30000,
|
||||||
this.mongoDb = this.mongoDbClient.db(this.smartdataOptions.mongoDbName);
|
retryWrites: true,
|
||||||
this.status = 'connected';
|
};
|
||||||
this.statusConnectedDeferred.resolve();
|
|
||||||
console.log(`Connected to database ${this.smartdataOptions.mongoDbName}`);
|
this.mongoDbClient = await plugins.mongodb.MongoClient.connect(finalConnectionUrl, clientOptions);
|
||||||
|
this.mongoDb = this.mongoDbClient.db(this.smartdataOptions.mongoDbName);
|
||||||
|
this.status = 'connected';
|
||||||
|
this.statusConnectedDeferred.resolve();
|
||||||
|
logger.log('info', `Connected to database ${this.smartdataOptions.mongoDbName}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.status = 'disconnected';
|
||||||
|
this.statusConnectedDeferred.reject(error);
|
||||||
|
logger.log('error', `Failed to connect to database ${this.smartdataOptions.mongoDbName}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +82,12 @@ export class SmartdataDb {
|
|||||||
this.status = 'disconnected';
|
this.status = 'disconnected';
|
||||||
logger.log('info', `disconnected from database ${this.smartdataOptions.mongoDbName}`);
|
logger.log('info', `disconnected from database ${this.smartdataOptions.mongoDbName}`);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Start a MongoDB client session for transactions
|
||||||
|
*/
|
||||||
|
public startSession(): plugins.mongodb.ClientSession {
|
||||||
|
return this.mongoDbClient.startSession();
|
||||||
|
}
|
||||||
|
|
||||||
// handle table to class distribution
|
// handle table to class distribution
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@ import { SmartdataDb } from './classes.db.js';
|
|||||||
import { managed, setDefaultManagerForDoc } from './classes.collection.js';
|
import { managed, setDefaultManagerForDoc } from './classes.collection.js';
|
||||||
import { SmartDataDbDoc, svDb, unI } from './classes.doc.js';
|
import { SmartDataDbDoc, svDb, unI } from './classes.doc.js';
|
||||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||||
|
import { logger } from './logging.js';
|
||||||
|
|
||||||
@managed()
|
@managed()
|
||||||
export class DistributedClass extends SmartDataDbDoc<DistributedClass, DistributedClass> {
|
export class DistributedClass extends SmartDataDbDoc<DistributedClass, DistributedClass> {
|
||||||
@@ -63,11 +64,11 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
this.ownInstance.data.elected = false;
|
this.ownInstance.data.elected = false;
|
||||||
}
|
}
|
||||||
if (this.ownInstance?.data.status === 'stopped') {
|
if (this.ownInstance?.data.status === 'stopped') {
|
||||||
console.log(`stopping a distributed instance that has not been started yet.`);
|
logger.log('warn', `stopping a distributed instance that has not been started yet.`);
|
||||||
}
|
}
|
||||||
this.ownInstance.data.status = 'stopped';
|
this.ownInstance.data.status = 'stopped';
|
||||||
await this.ownInstance.save();
|
await this.ownInstance.save();
|
||||||
console.log(`stopped ${this.ownInstance.id}`);
|
logger.log('info', `stopped ${this.ownInstance.id}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,17 +84,17 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
public async sendHeartbeat() {
|
public async sendHeartbeat() {
|
||||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||||
if (this.ownInstance.data.status === 'stopped') {
|
if (this.ownInstance.data.status === 'stopped') {
|
||||||
console.log(`aborted sending heartbeat because status is stopped`);
|
logger.log('debug', `aborted sending heartbeat because status is stopped`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.ownInstance.updateFromDb();
|
await this.ownInstance.updateFromDb();
|
||||||
this.ownInstance.data.lastUpdated = Date.now();
|
this.ownInstance.data.lastUpdated = Date.now();
|
||||||
await this.ownInstance.save();
|
await this.ownInstance.save();
|
||||||
console.log(`sent heartbeat for ${this.ownInstance.id}`);
|
logger.log('debug', `sent heartbeat for ${this.ownInstance.id}`);
|
||||||
const allInstances = DistributedClass.getInstances({});
|
const allInstances = DistributedClass.getInstances({});
|
||||||
});
|
});
|
||||||
if (this.ownInstance.data.status === 'stopped') {
|
if (this.ownInstance.data.status === 'stopped') {
|
||||||
console.log(`aborted sending heartbeat because status is stopped`);
|
logger.log('info', `aborted sending heartbeat because status is stopped`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const eligibleLeader = await this.getEligibleLeader();
|
const eligibleLeader = await this.getEligibleLeader();
|
||||||
@@ -120,7 +121,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
await this.ownInstance.save();
|
await this.ownInstance.save();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn(`distributed instance already initialized`);
|
logger.log('warn', `distributed instance already initialized`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// lets enable the heartbeat
|
// lets enable the heartbeat
|
||||||
@@ -149,24 +150,24 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
public async checkAndMaybeLead() {
|
public async checkAndMaybeLead() {
|
||||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||||
this.ownInstance.data.status = 'initializing';
|
this.ownInstance.data.status = 'initializing';
|
||||||
this.ownInstance.save();
|
await this.ownInstance.save();
|
||||||
});
|
});
|
||||||
if (await this.getEligibleLeader()) {
|
if (await this.getEligibleLeader()) {
|
||||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||||
await this.ownInstance.updateFromDb();
|
await this.ownInstance.updateFromDb();
|
||||||
this.ownInstance.data.status = 'settled';
|
this.ownInstance.data.status = 'settled';
|
||||||
await this.ownInstance.save();
|
await this.ownInstance.save();
|
||||||
console.log(`${this.ownInstance.id} settled as follower`);
|
logger.log('info', `${this.ownInstance.id} settled as follower`);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else if (
|
} else if (
|
||||||
(await DistributedClass.getInstances({})).find((instanceArg) => {
|
(await DistributedClass.getInstances({})).find((instanceArg) => {
|
||||||
instanceArg.data.status === 'bidding' &&
|
return instanceArg.data.status === 'bidding' &&
|
||||||
instanceArg.data.biddingStartTime <= Date.now() - 4000 &&
|
instanceArg.data.biddingStartTime <= Date.now() - 4000 &&
|
||||||
instanceArg.data.biddingStartTime >= Date.now() - 30000;
|
instanceArg.data.biddingStartTime >= Date.now() - 30000;
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
console.log('too late to the bidding party... waiting for next round.');
|
logger.log('info', 'too late to the bidding party... waiting for next round.');
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||||
@@ -175,9 +176,9 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
this.ownInstance.data.biddingStartTime = Date.now();
|
this.ownInstance.data.biddingStartTime = Date.now();
|
||||||
this.ownInstance.data.biddingShortcode = plugins.smartunique.shortId();
|
this.ownInstance.data.biddingShortcode = plugins.smartunique.shortId();
|
||||||
await this.ownInstance.save();
|
await this.ownInstance.save();
|
||||||
console.log('bidding code stored.');
|
logger.log('info', 'bidding code stored.');
|
||||||
});
|
});
|
||||||
console.log(`bidding for leadership...`);
|
logger.log('info', `bidding for leadership...`);
|
||||||
await plugins.smartdelay.delayFor(plugins.smarttime.getMilliSecondsFromUnits({ seconds: 5 }));
|
await plugins.smartdelay.delayFor(plugins.smarttime.getMilliSecondsFromUnits({ seconds: 5 }));
|
||||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||||
let biddingInstances = await DistributedClass.getInstances({});
|
let biddingInstances = await DistributedClass.getInstances({});
|
||||||
@@ -187,7 +188,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
instanceArg.data.lastUpdated >=
|
instanceArg.data.lastUpdated >=
|
||||||
Date.now() - plugins.smarttime.getMilliSecondsFromUnits({ seconds: 10 }),
|
Date.now() - plugins.smarttime.getMilliSecondsFromUnits({ seconds: 10 }),
|
||||||
);
|
);
|
||||||
console.log(`found ${biddingInstances.length} bidding instances...`);
|
logger.log('info', `found ${biddingInstances.length} bidding instances...`);
|
||||||
this.ownInstance.data.elected = true;
|
this.ownInstance.data.elected = true;
|
||||||
for (const biddingInstance of biddingInstances) {
|
for (const biddingInstance of biddingInstances) {
|
||||||
if (biddingInstance.data.biddingShortcode < this.ownInstance.data.biddingShortcode) {
|
if (biddingInstance.data.biddingShortcode < this.ownInstance.data.biddingShortcode) {
|
||||||
@@ -195,7 +196,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await plugins.smartdelay.delayFor(5000);
|
await plugins.smartdelay.delayFor(5000);
|
||||||
console.log(`settling with status elected = ${this.ownInstance.data.elected}`);
|
logger.log('info', `settling with status elected = ${this.ownInstance.data.elected}`);
|
||||||
this.ownInstance.data.status = 'settled';
|
this.ownInstance.data.status = 'settled';
|
||||||
await this.ownInstance.save();
|
await this.ownInstance.save();
|
||||||
});
|
});
|
||||||
@@ -226,11 +227,11 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
this.distributedWatcher.changeSubject.subscribe({
|
this.distributedWatcher.changeSubject.subscribe({
|
||||||
next: async (distributedDoc) => {
|
next: async (distributedDoc) => {
|
||||||
if (!distributedDoc) {
|
if (!distributedDoc) {
|
||||||
console.log(`registered deletion of instance...`);
|
logger.log('info', `registered deletion of instance...`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(distributedDoc);
|
logger.log('info', distributedDoc);
|
||||||
console.log(`registered change for ${distributedDoc.id}`);
|
logger.log('info', `registered change for ${distributedDoc.id}`);
|
||||||
distributedDoc;
|
distributedDoc;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -252,7 +253,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
): Promise<plugins.taskbuffer.distributedCoordination.IDistributedTaskRequestResult> {
|
): Promise<plugins.taskbuffer.distributedCoordination.IDistributedTaskRequestResult> {
|
||||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||||
if (!this.ownInstance) {
|
if (!this.ownInstance) {
|
||||||
console.error('instance need to be started first...');
|
logger.log('error', 'instance need to be started first...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.ownInstance.updateFromDb();
|
await this.ownInstance.updateFromDb();
|
||||||
@@ -268,7 +269,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
return taskRequestResult;
|
return taskRequestResult;
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
console.warn('no result found for task request...');
|
logger.log('warn', 'no result found for task request...');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -285,7 +286,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (!existingInfoBasis) {
|
if (!existingInfoBasis) {
|
||||||
console.warn('trying to update a non existing task request... aborting!');
|
logger.log('warn', 'trying to update a non existing task request... aborting!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Object.assign(existingInfoBasis, infoBasisArg);
|
Object.assign(existingInfoBasis, infoBasisArg);
|
||||||
@@ -293,8 +294,10 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
plugins.smartdelay.delayFor(60000).then(() => {
|
plugins.smartdelay.delayFor(60000).then(() => {
|
||||||
this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||||
const indexToRemove = this.ownInstance.data.taskRequests.indexOf(existingInfoBasis);
|
const indexToRemove = this.ownInstance.data.taskRequests.indexOf(existingInfoBasis);
|
||||||
this.ownInstance.data.taskRequests.splice(indexToRemove, indexToRemove);
|
if (indexToRemove >= 0) {
|
||||||
await this.ownInstance.save();
|
this.ownInstance.data.taskRequests.splice(indexToRemove, 1);
|
||||||
|
await this.ownInstance.save();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,19 +1,38 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
import { SmartdataDb } from './classes.db.js';
|
import { SmartdataDb } from './classes.db.js';
|
||||||
|
import { logger } from './logging.js';
|
||||||
import { SmartdataDbCursor } from './classes.cursor.js';
|
import { SmartdataDbCursor } from './classes.cursor.js';
|
||||||
import { type IManager, SmartdataCollection } from './classes.collection.js';
|
import { type IManager, SmartdataCollection } from './classes.collection.js';
|
||||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||||
import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
|
import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
|
||||||
|
/**
|
||||||
|
* Search options for `.search()`:
|
||||||
|
* - filter: additional MongoDB query to AND-merge
|
||||||
|
* - validate: post-fetch validator, return true to keep a doc
|
||||||
|
*/
|
||||||
|
export interface SearchOptions<T> {
|
||||||
|
/**
|
||||||
|
* Additional MongoDB filter to AND‐merge into the query
|
||||||
|
*/
|
||||||
|
filter?: Record<string, any>;
|
||||||
|
/**
|
||||||
|
* Post‐fetch validator; return true to keep each doc
|
||||||
|
*/
|
||||||
|
validate?: (doc: T) => Promise<boolean> | boolean;
|
||||||
|
/**
|
||||||
|
* Optional MongoDB session for transactional operations
|
||||||
|
*/
|
||||||
|
session?: plugins.mongodb.ClientSession;
|
||||||
|
}
|
||||||
|
|
||||||
export type TDocCreation = 'db' | 'new' | 'mixed';
|
export type TDocCreation = 'db' | 'new' | 'mixed';
|
||||||
|
|
||||||
// Set of searchable fields for each class
|
|
||||||
const searchableFieldsMap = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
export function globalSvDb() {
|
export function globalSvDb() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
logger.log('debug', `called svDb() on >${target.constructor.name}.${key}<`);
|
||||||
if (!target.globalSaveableProperties) {
|
if (!target.globalSaveableProperties) {
|
||||||
target.globalSaveableProperties = [];
|
target.globalSaveableProperties = [];
|
||||||
}
|
}
|
||||||
@@ -21,16 +40,34 @@ export function globalSvDb() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for custom serialization/deserialization of a field.
|
||||||
|
*/
|
||||||
|
export interface SvDbOptions {
|
||||||
|
/** Function to serialize the field value before saving to DB */
|
||||||
|
serialize?: (value: any) => any;
|
||||||
|
/** Function to deserialize the field value after reading from DB */
|
||||||
|
deserialize?: (value: any) => any;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* saveable - saveable decorator to be used on class properties
|
* saveable - saveable decorator to be used on class properties
|
||||||
*/
|
*/
|
||||||
export function svDb() {
|
export function svDb(options?: SvDbOptions) {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
logger.log('debug', `called svDb() on >${target.constructor.name}.${key}<`);
|
||||||
if (!target.saveableProperties) {
|
if (!target.saveableProperties) {
|
||||||
target.saveableProperties = [];
|
target.saveableProperties = [];
|
||||||
}
|
}
|
||||||
target.saveableProperties.push(key);
|
target.saveableProperties.push(key);
|
||||||
|
// attach custom serializer/deserializer options to the class constructor
|
||||||
|
const ctor = target.constructor as any;
|
||||||
|
if (!ctor._svDbOptions) {
|
||||||
|
ctor._svDbOptions = {};
|
||||||
|
}
|
||||||
|
if (options) {
|
||||||
|
ctor._svDbOptions[key] = options;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,27 +76,18 @@ export function svDb() {
|
|||||||
*/
|
*/
|
||||||
export function searchable() {
|
export function searchable() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
console.log(`called searchable() on >${target.constructor.name}.${key}<`);
|
// Attach to class constructor for direct access
|
||||||
|
const ctor = target.constructor as any;
|
||||||
// Initialize the set for this class if it doesn't exist
|
if (!Array.isArray(ctor.searchableFields)) {
|
||||||
const className = target.constructor.name;
|
ctor.searchableFields = [];
|
||||||
if (!searchableFieldsMap.has(className)) {
|
|
||||||
searchableFieldsMap.set(className, new Set<string>());
|
|
||||||
}
|
}
|
||||||
|
ctor.searchableFields.push(key);
|
||||||
// Add the property to the searchable fields set
|
|
||||||
searchableFieldsMap.get(className).add(key);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Escape user input for safe use in MongoDB regular expressions
|
||||||
* Get searchable fields for a class
|
function escapeForRegex(input: string): string {
|
||||||
*/
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
export function getSearchableFields(className: string): string[] {
|
|
||||||
if (!searchableFieldsMap.has(className)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Array.from(searchableFieldsMap.get(className));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +95,7 @@ export function getSearchableFields(className: string): string[] {
|
|||||||
*/
|
*/
|
||||||
export function unI() {
|
export function unI() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
console.log(`called unI on >>${target.constructor.name}.${key}<<`);
|
logger.log('debug', `called unI on >>${target.constructor.name}.${key}<<`);
|
||||||
|
|
||||||
// mark the index as unique
|
// mark the index as unique
|
||||||
if (!target.uniqueIndexes) {
|
if (!target.uniqueIndexes) {
|
||||||
@@ -99,7 +127,7 @@ export interface IIndexOptions {
|
|||||||
*/
|
*/
|
||||||
export function index(options?: IIndexOptions) {
|
export function index(options?: IIndexOptions) {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
console.log(`called index() on >${target.constructor.name}.${key}<`);
|
logger.log('debug', `called index() on >${target.constructor.name}.${key}<`);
|
||||||
|
|
||||||
// Initialize regular indexes array if it doesn't exist
|
// Initialize regular indexes array if it doesn't exist
|
||||||
if (!target.regularIndexes) {
|
if (!target.regularIndexes) {
|
||||||
@@ -125,7 +153,8 @@ export function index(options?: IIndexOptions) {
|
|||||||
|
|
||||||
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
||||||
// Special case: detect MongoDB operators and pass them through directly
|
// Special case: detect MongoDB operators and pass them through directly
|
||||||
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$where', '$regex'];
|
// SECURITY: Removed $where to prevent server-side JS execution
|
||||||
|
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$regex'];
|
||||||
for (const key of Object.keys(filterArg)) {
|
for (const key of Object.keys(filterArg)) {
|
||||||
if (topLevelOperators.includes(key)) {
|
if (topLevelOperators.includes(key)) {
|
||||||
return filterArg; // Return the filter as-is for MongoDB operators
|
return filterArg; // Return the filter as-is for MongoDB operators
|
||||||
@@ -137,11 +166,16 @@ export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
|||||||
|
|
||||||
const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => {
|
const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => {
|
||||||
if (Array.isArray(filterArg2)) {
|
if (Array.isArray(filterArg2)) {
|
||||||
// Directly assign arrays (they might be using operators like $in or $all)
|
// FIX: Properly handle arrays for operators like $in, $all, or plain equality
|
||||||
convertFilterArgument(keyPathArg2, filterArg2[0]);
|
convertedFilter[keyPathArg2] = filterArg2;
|
||||||
|
return;
|
||||||
} else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
|
} else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
|
||||||
for (const key of Object.keys(filterArg2)) {
|
for (const key of Object.keys(filterArg2)) {
|
||||||
if (key.startsWith('$')) {
|
if (key.startsWith('$')) {
|
||||||
|
// Prevent dangerous operators
|
||||||
|
if (key === '$where') {
|
||||||
|
throw new Error('$where operator is not allowed for security reasons');
|
||||||
|
}
|
||||||
convertedFilter[keyPathArg2] = filterArg2;
|
convertedFilter[keyPathArg2] = filterArg2;
|
||||||
return;
|
return;
|
||||||
} else if (key.includes('.')) {
|
} else if (key.includes('.')) {
|
||||||
@@ -180,7 +214,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
const newInstance = new this();
|
const newInstance = new this();
|
||||||
(newInstance as any).creationStatus = 'db';
|
(newInstance as any).creationStatus = 'db';
|
||||||
for (const key of Object.keys(mongoDbNativeDocArg)) {
|
for (const key of Object.keys(mongoDbNativeDocArg)) {
|
||||||
newInstance[key] = mongoDbNativeDocArg[key];
|
const rawValue = mongoDbNativeDocArg[key];
|
||||||
|
const optionsMap = (this as any)._svDbOptions || {};
|
||||||
|
const opts = optionsMap[key];
|
||||||
|
newInstance[key] = opts && typeof opts.deserialize === 'function'
|
||||||
|
? opts.deserialize(rawValue)
|
||||||
|
: rawValue;
|
||||||
}
|
}
|
||||||
return newInstance;
|
return newInstance;
|
||||||
}
|
}
|
||||||
@@ -194,8 +233,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
public static async getInstances<T>(
|
public static async getInstances<T>(
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||||
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
const foundDocs = await (this as any).collection.findAll(convertFilterForMongoDb(filterArg));
|
// Pass session through to findAll for transactional queries
|
||||||
|
const foundDocs = await (this as any).collection.findAll(
|
||||||
|
convertFilterForMongoDb(filterArg),
|
||||||
|
{ session: opts?.session },
|
||||||
|
);
|
||||||
const returnArray = [];
|
const returnArray = [];
|
||||||
for (const foundDoc of foundDocs) {
|
for (const foundDoc of foundDocs) {
|
||||||
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
||||||
@@ -213,8 +257,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
public static async getInstance<T>(
|
public static async getInstance<T>(
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||||
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const foundDoc = await (this as any).collection.findOne(convertFilterForMongoDb(filterArg));
|
// Retrieve one document, with optional session for transactions
|
||||||
|
const foundDoc = await (this as any).collection.findOne(
|
||||||
|
convertFilterForMongoDb(filterArg),
|
||||||
|
{ session: opts?.session },
|
||||||
|
);
|
||||||
if (foundDoc) {
|
if (foundDoc) {
|
||||||
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
||||||
return newInstance;
|
return newInstance;
|
||||||
@@ -234,32 +283,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get cursor
|
* Get a cursor for streaming results, with optional session and native cursor modifiers.
|
||||||
* @returns
|
* @param filterArg Partial filter to apply
|
||||||
|
* @param opts Optional session and modifier for the raw MongoDB cursor
|
||||||
*/
|
*/
|
||||||
public static async getCursor<T>(
|
public static async getCursor<T>(
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||||
) {
|
opts?: {
|
||||||
|
session?: plugins.mongodb.ClientSession;
|
||||||
|
modifier?: (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>;
|
||||||
|
}
|
||||||
|
): Promise<SmartdataDbCursor<T>> {
|
||||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||||
const cursor: SmartdataDbCursor<T> = await collection.getCursor(
|
const { session, modifier } = opts || {};
|
||||||
convertFilterForMongoDb(filterArg),
|
await collection.init();
|
||||||
this as any as typeof SmartDataDbDoc,
|
let rawCursor: plugins.mongodb.FindCursor<any> =
|
||||||
);
|
collection.mongoDbCollection.find(convertFilterForMongoDb(filterArg), { session });
|
||||||
return cursor;
|
if (modifier) {
|
||||||
}
|
rawCursor = modifier(rawCursor);
|
||||||
|
}
|
||||||
public static async getCursorExtended<T>(
|
return new SmartdataDbCursor<T>(rawCursor, this as any as typeof SmartDataDbDoc);
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
|
||||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
|
||||||
modifierFunction = (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => cursorArg,
|
|
||||||
) {
|
|
||||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
||||||
let cursor: plugins.mongodb.FindCursor<any> = collection.mongoDbCollection.find(
|
|
||||||
convertFilterForMongoDb(filterArg),
|
|
||||||
);
|
|
||||||
cursor = modifierFunction(cursor);
|
|
||||||
return new SmartdataDbCursor<T>(cursor, this as any as typeof SmartDataDbDoc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,13 +312,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
* @param filterArg
|
* @param filterArg
|
||||||
* @param forEachFunction
|
* @param forEachFunction
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Watch the collection for changes, with optional buffering and change stream options.
|
||||||
|
* @param filterArg MongoDB filter to select which changes to observe
|
||||||
|
* @param opts optional ChangeStreamOptions plus bufferTimeMs
|
||||||
|
*/
|
||||||
public static async watch<T>(
|
public static async watch<T>(
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||||
) {
|
opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number },
|
||||||
|
): Promise<SmartdataDbWatcher<T>> {
|
||||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||||
const watcher: SmartdataDbWatcher<T> = await collection.watch(
|
const watcher: SmartdataDbWatcher<T> = await collection.watch(
|
||||||
convertFilterForMongoDb(filterArg),
|
convertFilterForMongoDb(filterArg),
|
||||||
|
opts || {},
|
||||||
this as any,
|
this as any,
|
||||||
);
|
);
|
||||||
return watcher;
|
return watcher;
|
||||||
@@ -313,118 +364,186 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
luceneQuery: string,
|
luceneQuery: string,
|
||||||
): any {
|
): any {
|
||||||
const className = (this as any).className || this.name;
|
const searchableFields = (this as any).getSearchableFields();
|
||||||
const searchableFields = getSearchableFields(className);
|
|
||||||
|
|
||||||
if (searchableFields.length === 0) {
|
if (searchableFields.length === 0) {
|
||||||
throw new Error(`No searchable fields defined for class ${className}`);
|
throw new Error(`No searchable fields defined for class ${this.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new SmartdataLuceneAdapter(searchableFields);
|
const adapter = new SmartdataLuceneAdapter(searchableFields);
|
||||||
return adapter.convert(luceneQuery);
|
return adapter.convert(luceneQuery);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* List all searchable fields defined on this class
|
||||||
|
*/
|
||||||
|
public static getSearchableFields(): string[] {
|
||||||
|
const ctor = this as any;
|
||||||
|
return Array.isArray(ctor.searchableFields) ? ctor.searchableFields : [];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Execute a query with optional hard filter and post-fetch validation
|
||||||
|
*/
|
||||||
|
private static async execQuery<T>(
|
||||||
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
|
baseFilter: Record<string, any>,
|
||||||
|
opts?: SearchOptions<T>
|
||||||
|
): Promise<T[]> {
|
||||||
|
let mongoFilter = baseFilter || {};
|
||||||
|
if (opts?.filter) {
|
||||||
|
mongoFilter = { $and: [mongoFilter, opts.filter] };
|
||||||
|
}
|
||||||
|
// Fetch with optional session for transactions
|
||||||
|
// Fetch within optional session
|
||||||
|
let docs: T[] = await (this as any).getInstances(mongoFilter, { session: opts?.session });
|
||||||
|
if (opts?.validate) {
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const d of docs) {
|
||||||
|
if (await opts.validate(d)) out.push(d);
|
||||||
|
}
|
||||||
|
docs = out;
|
||||||
|
}
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search documents using Lucene query syntax
|
* Search documents by text or field:value syntax, with safe regex fallback
|
||||||
* @param luceneQuery Lucene query string
|
* Supports additional filtering and post-fetch validation via opts
|
||||||
|
* @param query A search term or field:value expression
|
||||||
|
* @param opts Optional filter and validate hooks
|
||||||
* @returns Array of matching documents
|
* @returns Array of matching documents
|
||||||
*/
|
*/
|
||||||
public static async search<T>(
|
public static async search<T>(
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
luceneQuery: string,
|
query: string,
|
||||||
|
opts?: SearchOptions<T>,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
const filter = (this as any).createSearchFilter(luceneQuery);
|
const searchableFields = (this as any).getSearchableFields();
|
||||||
return await (this as any).getInstances(filter);
|
if (searchableFields.length === 0) {
|
||||||
}
|
throw new Error(`No searchable fields defined for class ${this.name}`);
|
||||||
|
|
||||||
/**
|
|
||||||
* Search documents using Lucene query syntax with robust error handling
|
|
||||||
* @param luceneQuery The Lucene query string to search with
|
|
||||||
* @returns Array of matching documents
|
|
||||||
*/
|
|
||||||
public static async searchWithLucene<T>(
|
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
|
||||||
luceneQuery: string,
|
|
||||||
): Promise<T[]> {
|
|
||||||
try {
|
|
||||||
const className = (this as any).className || this.name;
|
|
||||||
const searchableFields = getSearchableFields(className);
|
|
||||||
|
|
||||||
if (searchableFields.length === 0) {
|
|
||||||
console.warn(
|
|
||||||
`No searchable fields defined for class ${className}, falling back to simple search`,
|
|
||||||
);
|
|
||||||
return (this as any).searchByTextAcrossFields(luceneQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple term search optimization
|
|
||||||
if (
|
|
||||||
!luceneQuery.includes(':') &&
|
|
||||||
!luceneQuery.includes(' AND ') &&
|
|
||||||
!luceneQuery.includes(' OR ') &&
|
|
||||||
!luceneQuery.includes(' NOT ')
|
|
||||||
) {
|
|
||||||
return (this as any).searchByTextAcrossFields(luceneQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to use the Lucene-to-MongoDB conversion
|
|
||||||
const filter = (this as any).createSearchFilter(luceneQuery);
|
|
||||||
return await (this as any).getInstances(filter);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error in searchWithLucene: ${error.message}`);
|
|
||||||
return (this as any).searchByTextAcrossFields(luceneQuery);
|
|
||||||
}
|
}
|
||||||
}
|
// empty query -> return all
|
||||||
|
const q = query.trim();
|
||||||
/**
|
if (!q) {
|
||||||
* Search by text across all searchable fields (fallback method)
|
// empty query: fetch all, apply opts
|
||||||
* @param searchText The text to search for in all searchable fields
|
return await (this as any).execQuery({}, opts);
|
||||||
* @returns Array of matching documents
|
}
|
||||||
*/
|
// simple exact field:value (no spaces, no wildcards, no quotes)
|
||||||
private static async searchByTextAcrossFields<T>(
|
// simple exact field:value (no spaces, wildcards, quotes)
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
|
||||||
searchText: string,
|
if (simpleExact) {
|
||||||
): Promise<T[]> {
|
const field = simpleExact[1];
|
||||||
try {
|
const value = simpleExact[2];
|
||||||
const className = (this as any).className || this.name;
|
if (!searchableFields.includes(field)) {
|
||||||
const searchableFields = getSearchableFields(className);
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
|
|
||||||
// Fallback to direct filter if we have searchable fields
|
|
||||||
if (searchableFields.length > 0) {
|
|
||||||
// Create a simple $or query with regex for each field
|
|
||||||
const orConditions = searchableFields.map((field) => ({
|
|
||||||
[field]: { $regex: searchText, $options: 'i' },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const filter = { $or: orConditions };
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try with MongoDB filter first
|
|
||||||
return await (this as any).getInstances(filter);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('MongoDB filter failed, falling back to in-memory search');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// simple field:value search
|
||||||
// Last resort: get all and filter in memory
|
return await (this as any).execQuery({ [field]: value }, opts);
|
||||||
const allDocs = await (this as any).getInstances({});
|
}
|
||||||
const lowerSearchText = searchText.toLowerCase();
|
// quoted phrase across all searchable fields: exact match of phrase
|
||||||
|
const quoted = q.match(/^"(.+)"$|^'(.+)'$/);
|
||||||
return allDocs.filter((doc: any) => {
|
if (quoted) {
|
||||||
for (const field of searchableFields) {
|
const phrase = quoted[1] || quoted[2] || '';
|
||||||
const value = doc[field];
|
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
|
||||||
if (value && typeof value === 'string' && value.toLowerCase().includes(lowerSearchText)) {
|
const pattern = parts.join('\\s+');
|
||||||
return true;
|
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
|
||||||
|
return await (this as any).execQuery({ $or: orConds }, opts);
|
||||||
|
}
|
||||||
|
// wildcard field:value (supports * and ?) -> direct regex on that field
|
||||||
|
const wildcardField = q.match(/^(\w+):(.+[*?].*)$/);
|
||||||
|
if (wildcardField) {
|
||||||
|
const field = wildcardField[1];
|
||||||
|
// Support quoted wildcard patterns: strip surrounding quotes
|
||||||
|
let pattern = wildcardField[2];
|
||||||
|
if ((pattern.startsWith('"') && pattern.endsWith('"')) ||
|
||||||
|
(pattern.startsWith("'") && pattern.endsWith("'"))) {
|
||||||
|
pattern = pattern.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (!searchableFields.includes(field)) {
|
||||||
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
|
}
|
||||||
|
// escape regex special chars except * and ?, then convert wildcards
|
||||||
|
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
|
const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
return await (this as any).execQuery({ [field]: { $regex: regexPattern, $options: 'i' } }, opts);
|
||||||
|
}
|
||||||
|
// wildcard plain term across all fields (supports * and ?)
|
||||||
|
if (!q.includes(':') && (q.includes('*') || q.includes('?'))) {
|
||||||
|
// build wildcard regex pattern: escape all except * and ? then convert
|
||||||
|
const escaped = q.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
|
||||||
|
return await (this as any).execQuery({ $or: orConds }, opts);
|
||||||
|
}
|
||||||
|
// implicit AND for multiple tokens: free terms, quoted phrases, and field:values
|
||||||
|
{
|
||||||
|
// Split query into tokens, preserving quoted substrings
|
||||||
|
const rawTokens = q.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
||||||
|
// Only apply when more than one token and no boolean operators or grouping
|
||||||
|
if (
|
||||||
|
rawTokens.length > 1 &&
|
||||||
|
!/(\bAND\b|\bOR\b|\bNOT\b|\(|\))/i.test(q) &&
|
||||||
|
!/\[|\]/.test(q)
|
||||||
|
) {
|
||||||
|
const andConds: any[] = [];
|
||||||
|
for (let token of rawTokens) {
|
||||||
|
// field:value token
|
||||||
|
const fv = token.match(/^(\w+):(.+)$/);
|
||||||
|
if (fv) {
|
||||||
|
const field = fv[1];
|
||||||
|
let value = fv[2];
|
||||||
|
if (!searchableFields.includes(field)) {
|
||||||
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
|
}
|
||||||
|
// Strip surrounding quotes if present
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
// Wildcard search?
|
||||||
|
if (value.includes('*') || value.includes('?')) {
|
||||||
|
const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
andConds.push({ [field]: { $regex: pattern, $options: 'i' } });
|
||||||
|
} else {
|
||||||
|
andConds.push({ [field]: value });
|
||||||
|
}
|
||||||
|
} else if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
||||||
|
// Quoted free phrase across all fields
|
||||||
|
const phrase = token.slice(1, -1);
|
||||||
|
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
|
||||||
|
const pattern = parts.join('\\s+');
|
||||||
|
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })) });
|
||||||
|
} else {
|
||||||
|
// Free term across all fields
|
||||||
|
const esc = escapeForRegex(token);
|
||||||
|
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return await (this as any).execQuery({ $and: andConds }, opts);
|
||||||
});
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error in searchByTextAcrossFields: ${error.message}`);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
// detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
|
||||||
|
const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
|
||||||
|
if (luceneSyntax.test(q)) {
|
||||||
|
const filter = (this as any).createSearchFilter(q);
|
||||||
|
return await (this as any).execQuery(filter, opts);
|
||||||
|
}
|
||||||
|
// multi-term unquoted -> AND of regex across fields for each term
|
||||||
|
const terms = q.split(/\s+/);
|
||||||
|
if (terms.length > 1) {
|
||||||
|
const andConds = terms.map((term) => {
|
||||||
|
const esc = escapeForRegex(term);
|
||||||
|
const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
|
||||||
|
return { $or: ors };
|
||||||
|
});
|
||||||
|
return await (this as any).execQuery({ $and: andConds }, opts);
|
||||||
|
}
|
||||||
|
// single term -> regex across all searchable fields
|
||||||
|
const esc = escapeForRegex(q);
|
||||||
|
const orConds = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
|
||||||
|
return await (this as any).execQuery({ $or: orConds }, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// INSTANCE
|
||||||
|
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -480,35 +599,52 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* saves this instance but not any connected items
|
* saves this instance (optionally within a transaction)
|
||||||
* may lead to data inconsistencies, but is faster
|
|
||||||
*/
|
*/
|
||||||
public async save() {
|
public async save(opts?: { session?: plugins.mongodb.ClientSession }) {
|
||||||
|
// allow hook before saving
|
||||||
|
if (typeof (this as any).beforeSave === 'function') {
|
||||||
|
await (this as any).beforeSave();
|
||||||
|
}
|
||||||
// tslint:disable-next-line: no-this-assignment
|
// tslint:disable-next-line: no-this-assignment
|
||||||
const self: any = this;
|
const self: any = this;
|
||||||
let dbResult: any;
|
let dbResult: any;
|
||||||
|
// update timestamp
|
||||||
this._updatedAt = new Date().toISOString();
|
this._updatedAt = new Date().toISOString();
|
||||||
|
// perform insert or update
|
||||||
switch (this.creationStatus) {
|
switch (this.creationStatus) {
|
||||||
case 'db':
|
case 'db':
|
||||||
dbResult = await this.collection.update(self);
|
dbResult = await this.collection.update(self, { session: opts?.session });
|
||||||
break;
|
break;
|
||||||
case 'new':
|
case 'new':
|
||||||
dbResult = await this.collection.insert(self);
|
dbResult = await this.collection.insert(self, { session: opts?.session });
|
||||||
this.creationStatus = 'db';
|
this.creationStatus = 'db';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.error('neither new nor in db?');
|
logger.log('error', 'neither new nor in db?');
|
||||||
|
}
|
||||||
|
// allow hook after saving
|
||||||
|
if (typeof (this as any).afterSave === 'function') {
|
||||||
|
await (this as any).afterSave();
|
||||||
}
|
}
|
||||||
return dbResult;
|
return dbResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* deletes a document from the database
|
* deletes a document from the database (optionally within a transaction)
|
||||||
*/
|
*/
|
||||||
public async delete() {
|
public async delete(opts?: { session?: plugins.mongodb.ClientSession }) {
|
||||||
await this.collection.delete(this);
|
// allow hook before deleting
|
||||||
|
if (typeof (this as any).beforeDelete === 'function') {
|
||||||
|
await (this as any).beforeDelete();
|
||||||
|
}
|
||||||
|
// perform deletion
|
||||||
|
const result = await this.collection.delete(this, { session: opts?.session });
|
||||||
|
// allow hook after delete
|
||||||
|
if (typeof (this as any).afterDelete === 'function') {
|
||||||
|
await (this as any).afterDelete();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -532,11 +668,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
/**
|
/**
|
||||||
* updates an object from db
|
* updates an object from db
|
||||||
*/
|
*/
|
||||||
public async updateFromDb() {
|
public async updateFromDb(): Promise<boolean> {
|
||||||
const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject());
|
const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject());
|
||||||
for (const key of Object.keys(mongoDbNativeDoc)) {
|
if (!mongoDbNativeDoc) {
|
||||||
this[key] = mongoDbNativeDoc[key];
|
return false; // Document not found in database
|
||||||
}
|
}
|
||||||
|
for (const key of Object.keys(mongoDbNativeDoc)) {
|
||||||
|
const rawValue = mongoDbNativeDoc[key];
|
||||||
|
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
||||||
|
const opts = optionsMap[key];
|
||||||
|
this[key] = opts && typeof opts.deserialize === 'function'
|
||||||
|
? opts.deserialize(rawValue)
|
||||||
|
: rawValue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -544,9 +689,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
*/
|
*/
|
||||||
public async createSavableObject(): Promise<TImplements> {
|
public async createSavableObject(): Promise<TImplements> {
|
||||||
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
|
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
|
||||||
const saveableProperties = [...this.globalSaveableProperties, ...this.saveableProperties];
|
const globalProps = this.globalSaveableProperties || [];
|
||||||
|
const specificProps = this.saveableProperties || [];
|
||||||
|
const saveableProperties = [...globalProps, ...specificProps];
|
||||||
|
// apply custom serialization if configured
|
||||||
|
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
||||||
for (const propertyNameString of saveableProperties) {
|
for (const propertyNameString of saveableProperties) {
|
||||||
saveableObject[propertyNameString] = this[propertyNameString];
|
const rawValue = (this as any)[propertyNameString];
|
||||||
|
const opts = optionsMap[propertyNameString];
|
||||||
|
(saveableObject as any)[propertyNameString] = opts && typeof opts.serialize === 'function'
|
||||||
|
? opts.serialize(rawValue)
|
||||||
|
: rawValue;
|
||||||
}
|
}
|
||||||
return saveableObject as TImplements;
|
return saveableObject as TImplements;
|
||||||
}
|
}
|
||||||
|
@@ -18,7 +18,7 @@ export class EasyStore<T> {
|
|||||||
public nameId: string;
|
public nameId: string;
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
public ephermal: {
|
public ephemeral: {
|
||||||
activated: boolean;
|
activated: boolean;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
};
|
};
|
||||||
@@ -32,8 +32,8 @@ export class EasyStore<T> {
|
|||||||
return SmartdataEasyStore;
|
return SmartdataEasyStore;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
constructor(nameIdArg: string, smnartdataDbRefArg: SmartdataDb) {
|
constructor(nameIdArg: string, smartdataDbRefArg: SmartdataDb) {
|
||||||
this.smartdataDbRef = smnartdataDbRefArg;
|
this.smartdataDbRef = smartdataDbRefArg;
|
||||||
this.nameId = nameIdArg;
|
this.nameId = nameIdArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,10 +110,12 @@ export class EasyStore<T> {
|
|||||||
await easyStore.save();
|
await easyStore.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async cleanUpEphermal() {
|
public async cleanUpEphemeral() {
|
||||||
while (
|
// Clean up ephemeral data periodically while connected
|
||||||
(await this.smartdataDbRef.statusConnectedDeferred.promise) &&
|
while (this.smartdataDbRef.status === 'connected') {
|
||||||
this.smartdataDbRef.status === 'connected'
|
await plugins.smartdelay.delayFor(60000); // Check every minute
|
||||||
) {}
|
// TODO: Implement actual cleanup logic for ephemeral data
|
||||||
|
// For now, this prevents the infinite CPU loop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
* Lucene to MongoDB query adapter for SmartData
|
* Lucene to MongoDB query adapter for SmartData
|
||||||
*/
|
*/
|
||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
import { logger } from './logging.js';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
type NodeType =
|
type NodeType =
|
||||||
@@ -290,11 +291,11 @@ export class LuceneParser {
|
|||||||
const includeLower = this.tokens[this.pos] === '[';
|
const includeLower = this.tokens[this.pos] === '[';
|
||||||
const includeUpper = this.tokens[this.pos + 4] === ']';
|
const includeUpper = this.tokens[this.pos + 4] === ']';
|
||||||
|
|
||||||
this.pos++; // Skip open bracket
|
// Ensure tokens for lower, TO, upper, and closing bracket exist
|
||||||
|
|
||||||
if (this.pos + 4 >= this.tokens.length) {
|
if (this.pos + 4 >= this.tokens.length) {
|
||||||
throw new Error('Invalid range query syntax');
|
throw new Error('Invalid range query syntax');
|
||||||
}
|
}
|
||||||
|
this.pos++; // Skip open bracket
|
||||||
|
|
||||||
const lower = this.tokens[this.pos];
|
const lower = this.tokens[this.pos];
|
||||||
this.pos++;
|
this.pos++;
|
||||||
@@ -329,7 +330,16 @@ export class LuceneParser {
|
|||||||
* FIXED VERSION - proper MongoDB query structure
|
* FIXED VERSION - proper MongoDB query structure
|
||||||
*/
|
*/
|
||||||
export class LuceneToMongoTransformer {
|
export class LuceneToMongoTransformer {
|
||||||
constructor() {}
|
private defaultFields: string[];
|
||||||
|
constructor(defaultFields: string[] = []) {
|
||||||
|
this.defaultFields = defaultFields;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Escape special characters for use in RegExp patterns
|
||||||
|
*/
|
||||||
|
private escapeRegex(input: string): string {
|
||||||
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform a Lucene AST node to a MongoDB query
|
* Transform a Lucene AST node to a MongoDB query
|
||||||
@@ -366,18 +376,21 @@ export class LuceneToMongoTransformer {
|
|||||||
* FIXED: properly structured $or query for multiple fields
|
* FIXED: properly structured $or query for multiple fields
|
||||||
*/
|
*/
|
||||||
private transformTerm(node: TermNode, searchFields?: string[]): any {
|
private transformTerm(node: TermNode, searchFields?: string[]): any {
|
||||||
// If specific fields are provided, search across those fields
|
// Build regex pattern, support wildcard (*) and fuzzy (?) if present
|
||||||
if (searchFields && searchFields.length > 0) {
|
const term = node.value;
|
||||||
// Create an $or query to search across multiple fields
|
// Determine regex pattern: wildcard conversion or exact escape
|
||||||
const orConditions = searchFields.map((field) => ({
|
let pattern: string;
|
||||||
[field]: { $regex: node.value, $options: 'i' },
|
if (term.includes('*') || term.includes('?')) {
|
||||||
}));
|
pattern = this.luceneWildcardToRegex(term);
|
||||||
|
} else {
|
||||||
return { $or: orConditions };
|
pattern = this.escapeRegex(term);
|
||||||
}
|
}
|
||||||
|
// Search across provided fields or default fields
|
||||||
// Otherwise, use text search (requires a text index on desired fields)
|
const fields = searchFields && searchFields.length > 0 ? searchFields : this.defaultFields;
|
||||||
return { $text: { $search: node.value } };
|
const orConditions = fields.map((field) => ({
|
||||||
|
[field]: { $regex: pattern, $options: 'i' },
|
||||||
|
}));
|
||||||
|
return { $or: orConditions };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -385,17 +398,14 @@ export class LuceneToMongoTransformer {
|
|||||||
* FIXED: properly structured $or query for multiple fields
|
* FIXED: properly structured $or query for multiple fields
|
||||||
*/
|
*/
|
||||||
private transformPhrase(node: PhraseNode, searchFields?: string[]): any {
|
private transformPhrase(node: PhraseNode, searchFields?: string[]): any {
|
||||||
// If specific fields are provided, search phrase across those fields
|
// Use regex across provided fields or default fields, respecting word boundaries
|
||||||
if (searchFields && searchFields.length > 0) {
|
const parts = node.value.split(/\s+/).map((t) => this.escapeRegex(t));
|
||||||
const orConditions = searchFields.map((field) => ({
|
const pattern = parts.join('\\s+');
|
||||||
[field]: { $regex: `${node.value.replace(/\s+/g, '\\s+')}`, $options: 'i' },
|
const fields = searchFields && searchFields.length > 0 ? searchFields : this.defaultFields;
|
||||||
}));
|
const orConditions = fields.map((field) => ({
|
||||||
|
[field]: { $regex: pattern, $options: 'i' },
|
||||||
return { $or: orConditions };
|
}));
|
||||||
}
|
return { $or: orConditions };
|
||||||
|
|
||||||
// For phrases, we use a regex to ensure exact matches
|
|
||||||
return { $text: { $search: `"${node.value}"` } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -429,9 +439,14 @@ export class LuceneToMongoTransformer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case for exact term matches on fields
|
// Special case for exact term matches on fields (supporting wildcard characters)
|
||||||
if (node.value.type === 'TERM') {
|
if (node.value.type === 'TERM') {
|
||||||
return { [node.field]: { $regex: (node.value as TermNode).value, $options: 'i' } };
|
const val = (node.value as TermNode).value;
|
||||||
|
if (val.includes('*') || val.includes('?')) {
|
||||||
|
const regex = this.luceneWildcardToRegex(val);
|
||||||
|
return { [node.field]: { $regex: regex, $options: 'i' } };
|
||||||
|
}
|
||||||
|
return { [node.field]: { $regex: val, $options: 'i' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case for phrase matches on fields
|
// Special case for phrase matches on fields
|
||||||
@@ -626,7 +641,7 @@ export class LuceneToMongoTransformer {
|
|||||||
/**
|
/**
|
||||||
* Convert Lucene wildcards to MongoDB regex patterns
|
* Convert Lucene wildcards to MongoDB regex patterns
|
||||||
*/
|
*/
|
||||||
private luceneWildcardToRegex(wildcardPattern: string): string {
|
public luceneWildcardToRegex(wildcardPattern: string): string {
|
||||||
// Replace Lucene wildcards with regex equivalents
|
// Replace Lucene wildcards with regex equivalents
|
||||||
// * => .*
|
// * => .*
|
||||||
// ? => .
|
// ? => .
|
||||||
@@ -691,7 +706,8 @@ export class SmartdataLuceneAdapter {
|
|||||||
*/
|
*/
|
||||||
constructor(defaultSearchFields?: string[]) {
|
constructor(defaultSearchFields?: string[]) {
|
||||||
this.parser = new LuceneParser();
|
this.parser = new LuceneParser();
|
||||||
this.transformer = new LuceneToMongoTransformer();
|
// Pass default searchable fields into transformer
|
||||||
|
this.transformer = new LuceneToMongoTransformer(defaultSearchFields || []);
|
||||||
if (defaultSearchFields) {
|
if (defaultSearchFields) {
|
||||||
this.defaultSearchFields = defaultSearchFields;
|
this.defaultSearchFields = defaultSearchFields;
|
||||||
}
|
}
|
||||||
@@ -704,7 +720,7 @@ export class SmartdataLuceneAdapter {
|
|||||||
*/
|
*/
|
||||||
convert(luceneQuery: string, searchFields?: string[]): any {
|
convert(luceneQuery: string, searchFields?: string[]): any {
|
||||||
try {
|
try {
|
||||||
// For simple single term queries, create a simpler query structure
|
// For simple single-term queries (no field:, boolean, grouping), use simpler regex
|
||||||
if (
|
if (
|
||||||
!luceneQuery.includes(':') &&
|
!luceneQuery.includes(':') &&
|
||||||
!luceneQuery.includes(' AND ') &&
|
!luceneQuery.includes(' AND ') &&
|
||||||
@@ -713,13 +729,17 @@ export class SmartdataLuceneAdapter {
|
|||||||
!luceneQuery.includes('(') &&
|
!luceneQuery.includes('(') &&
|
||||||
!luceneQuery.includes('[')
|
!luceneQuery.includes('[')
|
||||||
) {
|
) {
|
||||||
// This is a simple term, use a more direct approach
|
|
||||||
const fieldsToSearch = searchFields || this.defaultSearchFields;
|
const fieldsToSearch = searchFields || this.defaultSearchFields;
|
||||||
|
|
||||||
if (fieldsToSearch && fieldsToSearch.length > 0) {
|
if (fieldsToSearch && fieldsToSearch.length > 0) {
|
||||||
|
// Handle wildcard characters in query
|
||||||
|
let pattern = luceneQuery;
|
||||||
|
if (luceneQuery.includes('*') || luceneQuery.includes('?')) {
|
||||||
|
// Use transformer to convert wildcard pattern
|
||||||
|
pattern = this.transformer.luceneWildcardToRegex(luceneQuery);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
$or: fieldsToSearch.map((field) => ({
|
$or: fieldsToSearch.map((field) => ({
|
||||||
[field]: { $regex: luceneQuery, $options: 'i' },
|
[field]: { $regex: pattern, $options: 'i' },
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -735,7 +755,7 @@ export class SmartdataLuceneAdapter {
|
|||||||
// Transform the AST to a MongoDB query
|
// Transform the AST to a MongoDB query
|
||||||
return this.transformWithFields(ast, fieldsToSearch);
|
return this.transformWithFields(ast, fieldsToSearch);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to convert Lucene query "${luceneQuery}":`, error);
|
logger.log('error', `Failed to convert Lucene query "${luceneQuery}":`, error);
|
||||||
throw new Error(`Failed to convert Lucene query: ${error}`);
|
throw new Error(`Failed to convert Lucene query: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,37 +1,73 @@
|
|||||||
import { SmartDataDbDoc } from './classes.doc.js';
|
import { SmartDataDbDoc } from './classes.doc.js';
|
||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a wrapper for the native mongodb cursor. Exposes better
|
* a wrapper for the native mongodb cursor. Exposes better
|
||||||
*/
|
*/
|
||||||
export class SmartdataDbWatcher<T = any> {
|
/**
|
||||||
|
* Wraps a MongoDB ChangeStream with RxJS and EventEmitter support.
|
||||||
|
*/
|
||||||
|
export class SmartdataDbWatcher<T = any> extends EventEmitter {
|
||||||
// STATIC
|
// STATIC
|
||||||
public readyDeferred = plugins.smartpromise.defer();
|
public readyDeferred = plugins.smartpromise.defer();
|
||||||
|
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
private changeStream: plugins.mongodb.ChangeStream<T>;
|
private changeStream: plugins.mongodb.ChangeStream<T>;
|
||||||
|
private rawSubject: plugins.smartrx.rxjs.Subject<T>;
|
||||||
public changeSubject = new plugins.smartrx.rxjs.Subject<T>();
|
/** Emits change documents (or arrays of documents if buffered) */
|
||||||
|
public changeSubject: any;
|
||||||
|
/**
|
||||||
|
* @param changeStreamArg native MongoDB ChangeStream
|
||||||
|
* @param smartdataDbDocArg document class for instance creation
|
||||||
|
* @param opts.bufferTimeMs optional milliseconds to buffer events via RxJS
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
changeStreamArg: plugins.mongodb.ChangeStream<T>,
|
changeStreamArg: plugins.mongodb.ChangeStream<T>,
|
||||||
smartdataDbDocArg: typeof SmartDataDbDoc,
|
smartdataDbDocArg: typeof SmartDataDbDoc,
|
||||||
|
opts?: { bufferTimeMs?: number },
|
||||||
) {
|
) {
|
||||||
|
super();
|
||||||
|
this.rawSubject = new plugins.smartrx.rxjs.Subject<T>();
|
||||||
|
// Apply buffering if requested
|
||||||
|
if (opts && opts.bufferTimeMs) {
|
||||||
|
this.changeSubject = this.rawSubject.pipe(plugins.smartrx.rxjs.ops.bufferTime(opts.bufferTimeMs));
|
||||||
|
} else {
|
||||||
|
this.changeSubject = this.rawSubject;
|
||||||
|
}
|
||||||
this.changeStream = changeStreamArg;
|
this.changeStream = changeStreamArg;
|
||||||
this.changeStream.on('change', async (item: any) => {
|
this.changeStream.on('change', async (item: any) => {
|
||||||
if (!item.fullDocument) {
|
let docInstance: T = null;
|
||||||
this.changeSubject.next(null);
|
if (item.fullDocument) {
|
||||||
return;
|
docInstance = smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(
|
||||||
|
item.fullDocument
|
||||||
|
) as any as T;
|
||||||
}
|
}
|
||||||
this.changeSubject.next(
|
// Notify subscribers
|
||||||
smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(item.fullDocument) as any as T,
|
this.rawSubject.next(docInstance);
|
||||||
);
|
this.emit('change', docInstance);
|
||||||
});
|
});
|
||||||
|
// Signal readiness after one tick
|
||||||
plugins.smartdelay.delayFor(0).then(() => {
|
plugins.smartdelay.delayFor(0).then(() => {
|
||||||
this.readyDeferred.resolve();
|
this.readyDeferred.resolve();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async close() {
|
/**
|
||||||
|
* Close the change stream, complete the RxJS subject, and remove listeners.
|
||||||
|
*/
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
// Close MongoDB ChangeStream
|
||||||
await this.changeStream.close();
|
await this.changeStream.close();
|
||||||
|
// Complete the subject to teardown any buffering operators
|
||||||
|
this.rawSubject.complete();
|
||||||
|
// Remove all EventEmitter listeners
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Alias for close(), matching README usage
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
return this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user