Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4058e51dfb | |||
| d30ce5ccc7 | |||
| 97d9302e71 | |||
| fa60f625e9 | |||
| afd79cfabc | |||
| f3a4a3bbba | |||
| 78207ffad6 | |||
| abf84359b4 | |||
| 54fa433d1a | |||
| de23b44a23 | |||
| 1c4f50fbd6 | |||
| 3270aa2042 | |||
| b0dc5f8a60 | |||
| 03431535d7 | |||
| 27c1500db5 | |||
| 3bbb78add8 | |||
| 9d779329e1 | |||
| cdc6b029af | |||
| 39c0ba7bea | |||
| e4faca88ba | |||
| 40bc408d8f | |||
| 3c8308561e | |||
| 49b121aa5b | |||
| 514d3dbd29 | |||
| 2b7316dc46 | |||
| 11a1345891 | |||
| 2fe3a72eaf | |||
| fb7e82557b | |||
| 8a3425e554 | |||
| d2092cc5f3 | |||
| 1a621ca64e | |||
| f6cc07880a | |||
| bf4b11f1f5 | |||
| 181e9da151 | |||
| 3013edb2eb | |||
| 604e4ba265 | |||
| 477f446c34 | |||
| fbb8bb685c | |||
| 4cf62fd91c | |||
| 8ee45c5646 | |||
| 12f1630adf | |||
| 0a349180b2 | |||
| 23aa29a5b8 | |||
| 5bf2aae2b9 | |||
| 5cf9155205 | |||
| ef5491075f | |||
| 3f5101c061 | |||
| 4f1d359752 | |||
| aead721a58 | |||
| c3a8a15225 | |||
| 026f2acc89 | |||
| 1cd0f09598 | |||
| d254f58a05 | |||
| c5e7b6f982 | |||
| d30c9619c5 | |||
| 7344ae2db3 | |||
| 3b29a150a8 | |||
| 59186d84a9 | |||
| 7fab4e5dd0 | |||
| 0dbaa1bc5d | |||
| 8b37ebc8f9 | |||
| 5d757207c8 | |||
| c80df05fdf | |||
| 9be43a85ef | |||
| bf66209d3e | |||
| cdd1ae2c9b |
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@code.foss.global/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@code.foss.global/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -16,4 +16,12 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
dist_*/
|
dist_*/
|
||||||
|
|
||||||
#------# custom
|
# rust
|
||||||
|
rust/target/
|
||||||
|
dist_rust/
|
||||||
|
|
||||||
|
# AI
|
||||||
|
.claude/
|
||||||
|
.serena/
|
||||||
|
|
||||||
|
#------# custom
|
||||||
|
|||||||
Binary file not shown.
@@ -1,44 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -1,15 +1,5 @@
|
|||||||
{
|
{
|
||||||
"npmdocker": {
|
"@git.zone/cli": {
|
||||||
"baseImage": "hosttoday/ht-docker-node:mongo",
|
|
||||||
"command": "npmci test stable",
|
|
||||||
"dockerSock": false
|
|
||||||
},
|
|
||||||
"npmci": {
|
|
||||||
"npmGlobalTools": [],
|
|
||||||
"npmAccessLevel": "public",
|
|
||||||
"npmRegistryUrl": "registry.npmjs.org"
|
|
||||||
},
|
|
||||||
"gitzone": {
|
|
||||||
"projectType": "npm",
|
"projectType": "npm",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
@@ -28,9 +18,25 @@
|
|||||||
"custom data types",
|
"custom data types",
|
||||||
"ODM"
|
"ODM"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tsdoc": {
|
"@git.zone/tsdoc": {
|
||||||
"legal": "\n## License and Legal Information\n\nThis 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. \n\n**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.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy 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.\n"
|
"legal": "\n## License and Legal Information\n\nThis 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. \n\n**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.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy 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.\n"
|
||||||
|
},
|
||||||
|
"@git.zone/tsdocker": {
|
||||||
|
"baseImage": "hosttoday/ht-docker-node:mongo",
|
||||||
|
"command": "npmci test stable",
|
||||||
|
"dockerSock": false
|
||||||
|
},
|
||||||
|
"@ship.zone/szci": {
|
||||||
|
"npmGlobalTools": [],
|
||||||
|
"npmRegistryUrl": "registry.npmjs.org"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"json.schemas": [
|
"json.schemas": [
|
||||||
{
|
{
|
||||||
"fileMatch": ["/npmextra.json"],
|
"fileMatch": ["/.smartconfig.json"],
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
317
changelog.md
317
changelog.md
@@ -1,6 +1,255 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-07 - 7.1.7 - fix(collectionfactory)
|
||||||
|
isolate collection caching per database and add easy store replace semantics
|
||||||
|
|
||||||
|
- Change CollectionFactory to cache collections per SmartdataDb instance instead of by class name globally, preventing cross-database collection reuse.
|
||||||
|
- Add EasyStore.replace() for full object replacement while keeping writeAll() merge behavior for backward compatibility.
|
||||||
|
- Add regression tests covering multi-database collection isolation, replace() key removal, and writeAll() merge behavior.
|
||||||
|
|
||||||
|
## 2026-04-05 - 7.1.6 - fix(collection)
|
||||||
|
improve duplicate key error reporting on insert
|
||||||
|
|
||||||
|
- Wrap insertOne() in error handling to detect MongoDB duplicate key conflicts
|
||||||
|
- Log a clearer message with the collection name and identifiable object when unique indexes are involved
|
||||||
|
- Guide callers to use getInstance() or save() on a db-retrieved instance when a duplicate already exists
|
||||||
|
|
||||||
|
## 2026-04-05 - 7.1.5 - fix(collection)
|
||||||
|
ensure unique indexes are marked before upsert operations
|
||||||
|
|
||||||
|
- calls unique index marking during collection updates before executing upsert logic
|
||||||
|
- helps keep update behavior aligned with index handling already applied on inserts
|
||||||
|
|
||||||
|
## 2026-04-05 - 7.1.4 - fix(collection)
|
||||||
|
improve index creation resilience and add collection integrity checks
|
||||||
|
|
||||||
|
- Handle MongoDB index creation failures with structured logging instead of failing silently or racing on repeated attempts
|
||||||
|
- Log duplicate field values when unique index creation fails due to existing duplicate data
|
||||||
|
- Await unique and regular index creation during insert operations to ensure index setup completes predictably
|
||||||
|
- Add collection integrity checks for estimated vs actual document counts and duplicate values on tracked unique fields
|
||||||
|
- Expose collection integrity checks through the document class API
|
||||||
|
|
||||||
|
## 2026-03-26 - 7.1.3 - fix(deps)
|
||||||
|
bump development dependencies for tooling and Node types
|
||||||
|
|
||||||
|
- update @git.zone/tsrun from ^2.0.1 to ^2.0.2
|
||||||
|
- update @git.zone/tstest from ^3.5.1 to ^3.6.0
|
||||||
|
- update @types/node from ^22.15.2 to ^25.5.0
|
||||||
|
|
||||||
|
## 2026-03-24 - 7.1.2 - fix(docs)
|
||||||
|
refresh project guidance for TC39 decorators, build configuration, and dependency compatibility
|
||||||
|
|
||||||
|
- streamlines readme hints to focus on current decorator patterns and runtime support
|
||||||
|
- adds compatibility notes for the updated build toolchain and dependency APIs
|
||||||
|
- includes the project license file in the repository
|
||||||
|
|
||||||
|
## 2026-03-24 - 7.1.1 - fix(build)
|
||||||
|
update build and test tooling configuration, migrate project config to .smartconfig.json, and align TypeScript typings
|
||||||
|
|
||||||
|
- Switch the build script to tsbuild tsfolders and upgrade core build/test dependencies including @git.zone/tsbuild, @git.zone/tstest, and @git.zone/tsrun.
|
||||||
|
- Replace npmextra.json with .smartconfig.json and update package packaging to include the new config file.
|
||||||
|
- Update test files to import tapbundle from @git.zone/tstest/tapbundle and remove the standalone @push.rocks/tapbundle dependency.
|
||||||
|
- Adjust TypeScript configuration and source typings for stricter compatibility, including node types and definite assignment/nullability fixes.
|
||||||
|
- Fix Gitea workflow repository URLs for code.foss.global and expand .gitignore for generated Rust and local tooling directories.
|
||||||
|
|
||||||
|
## 2026-02-26 - 7.1.0 - feat(config)
|
||||||
|
|
||||||
|
normalize npmextra.json to namespaced keys and add CI/release configuration
|
||||||
|
|
||||||
|
- Replaced legacy keys (npmdocker, npmci, gitzone, tsdoc) with namespaced package keys (@git.zone/cli, @git.zone/tsdoc, @git.zone/tsdocker, @ship.zone/szci).
|
||||||
|
- Moved tsdoc legal text under @git.zone/tsdoc.
|
||||||
|
- Added release configuration with registries (https://verdaccio.lossless.digital and https://registry.npmjs.org) and accessLevel public under @git.zone/cli.
|
||||||
|
- Added @git.zone/tsdocker CI/docker settings and @ship.zone/szci npm registry/tooling settings.
|
||||||
|
- Removed old top-level entries to consolidate tooling configuration under scoped keys.
|
||||||
|
|
||||||
|
## 2026-02-26 - 7.0.16 - fix(mongodb)
|
||||||
|
|
||||||
|
set default socketTimeoutMS to 30000ms in MongoClient options to prevent hung operations from holding connections
|
||||||
|
|
||||||
|
- Adds socketTimeoutMS: 30000 to MongoClient clientOptions in ts/classes.db.ts
|
||||||
|
- Helps prevent hung operations from indefinitely holding connections by enforcing a 30s socket timeout
|
||||||
|
- Non-breaking change (defaults only)
|
||||||
|
|
||||||
|
## 2025-12-01 - 7.0.15 - fix(classes.doc)
|
||||||
|
|
||||||
|
Avoid emitting instance fields for collection and manager to preserve decorator-defined prototype getters
|
||||||
|
|
||||||
|
- ts/classes.doc.ts: changed instance properties `collection` and `manager` to `declare` so TypeScript does not emit them as own properties — prevents ES2022 class fields from shadowing prototype getters created by @Collection and @managed decorators.
|
||||||
|
- readme.hints.md: added documentation explaining the ES2022 class fields issue and recommending use of `declare` for type-only instance properties; marks the fix as v7.0.15.
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.14 - fix(classes.collection)
|
||||||
|
|
||||||
|
Centralize TC39 decorator metadata initialization and use context.metadata in class decorators
|
||||||
|
|
||||||
|
- Add initializeDecoratorMetadata helper to initialize prototype and constructor properties from TC39 decorator metadata
|
||||||
|
- Refactor Collection and managed decorators to call initializeDecoratorMetadata with context.metadata
|
||||||
|
- Remove direct reliance on constructor[Symbol.metadata] in class decorators to avoid read-only assignment issues
|
||||||
|
- Ensure consistent initialization of saveableProperties, globalSaveableProperties, uniqueIndexes, regularIndexes, searchableFields and \_svDbOptions
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.13 - fix(classes.doc)
|
||||||
|
|
||||||
|
Remove noisy debug logging from decorators and serialization logic
|
||||||
|
|
||||||
|
- Removed debug logger calls from globalSvDb decorator initialization
|
||||||
|
- Removed debug logger calls from svDb decorator initialization and svDb options handling
|
||||||
|
- Removed debug logger calls from unI and index decorator initializers
|
||||||
|
- Removed debug logging in createSavableObject to reduce console noise; no functional changes
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.12 - fix(collection)
|
||||||
|
|
||||||
|
Ensure TC39 decorator metadata is initialized on both original and decorated constructors/prototypes and add debug logging
|
||||||
|
|
||||||
|
- Initialize metadata-driven prototype properties (globalSaveableProperties, saveableProperties, uniqueIndexes, regularIndexes) on both the decorated class prototype and the original constructor prototype to avoid closure/compatibility issues
|
||||||
|
- Initialize searchableFields on both the decorated constructor and the original constructor so text-index creation and searches see the fields correctly
|
||||||
|
- Forward and initialize \_svDbOptions from decorator metadata onto the original constructor to preserve custom serialization options
|
||||||
|
- Add debug logging in the Collection decorator and in createSavableObject to surface metadata and saveable-property counts for easier troubleshooting
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.9 - fix(classes.collection)
|
||||||
|
|
||||||
|
Fix closure bug in Collection decorator by defining collection getter on original constructor and prototype
|
||||||
|
|
||||||
|
- Define the collection getter on the original constructor so class-level references (e.g. `User.collection`) resolve to the decorated collection instead of the original constructor's closure value.
|
||||||
|
- Also define the getter on the original constructor's prototype to ensure instance access works consistently across runtimes (Deno/Node).
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.8 - fix(classes.collection)
|
||||||
|
|
||||||
|
Fix closure issue in managed decorator so Class.collection/instance.collection resolve correctly
|
||||||
|
|
||||||
|
- Resolve closure bug in the managed() decorator where class methods referencing Class.collection (or instance.collection) could receive the original constructor's captured value and thus the wrong collection/manager.
|
||||||
|
- Define dynamic getters on the original constructor and its prototype that compute the collection from the proper manager/db at access time (supports direct manager objects, delayed manager factory functions, and fallback to defaultManager).
|
||||||
|
- Getters are defined as non-enumerable and configurable to preserve compatibility with existing consumers.
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.7 - fix(decorators)
|
||||||
|
|
||||||
|
Fix decorator metadata initialization and Lucene query transformation
|
||||||
|
|
||||||
|
- Ensure TC39 decorator metadata is used to initialize prototype properties so decorators work reliably across runtimes (context.metadata / Symbol.metadata shim imported early).
|
||||||
|
- Field and class decorators now populate and consume metadata for saveable properties, indexes and searchable fields so prototype initialization happens before instance creation.
|
||||||
|
- Fix Lucene -> MongoDB transformer to produce correct $or/$and/$not structures and improve wildcard/fuzzy/range handling for search queries.
|
||||||
|
- Improve collection initialization to auto-create compound text indexes from searchableFields and ensure index creation is idempotent.
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.6 - fix(classes.collection)
|
||||||
|
|
||||||
|
Guard against missing collection before attaching document constructor in Collection decorator
|
||||||
|
|
||||||
|
- Added a truthy check for `coll` before setting `(coll as any).docCtor` in the Collection decorator (ts/classes.collection.ts).
|
||||||
|
- Prevents a potential TypeError when `collectionFactory.getCollection` returns null/undefined during decorator initialization.
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.5 - fix(package)
|
||||||
|
|
||||||
|
Add package exports entry and remove legacy main/typings fields
|
||||||
|
|
||||||
|
- Added an "exports" entry in package.json mapping "." to ./dist_ts/index.js to declare the package's ESM entrypoint.
|
||||||
|
- Removed legacy "main" and "typings" fields from package.json.
|
||||||
|
- Improves Node/module resolution and modern bundler compatibility by using the package exports field.
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.4 - fix(decorators)
|
||||||
|
|
||||||
|
Add Symbol.metadata polyfill and import it at entry to ensure decorator metadata is available
|
||||||
|
|
||||||
|
- Add ts/shim.ts: defines Symbol.metadata when missing (polyfill for TC39 Stage 3 decorator metadata).
|
||||||
|
- Import './shim.js' at the very top of ts/index.ts so the polyfill runs before any decorator code or exports are evaluated.
|
||||||
|
- Prevents runtime errors when decorators rely on Symbol.metadata and improves compatibility across runtimes/environments.
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.3 - fix(build)
|
||||||
|
|
||||||
|
Bump devDependency @git.zone/tsbuild to ^3.1.2
|
||||||
|
|
||||||
|
- Updated @git.zone/tsbuild in devDependencies from ^3.1.1 to ^3.1.2
|
||||||
|
|
||||||
|
## 2025-11-28 - 7.0.2 - fix(collectionfactory)
|
||||||
|
|
||||||
|
Simplify CollectionFactory.getCollection: remove unnecessary IIFE and instantiate collection only when dbArg is SmartdataDb
|
||||||
|
|
||||||
|
- Remove redundant IIFE wrapper in getCollection for improved readability
|
||||||
|
- Only create and cache a SmartdataCollection when dbArg is an instance of SmartdataDb
|
||||||
|
- Avoid assigning undefined to the collections map by guarding instantiation and returning existing collection
|
||||||
|
|
||||||
|
## 2025-11-27 - 7.0.1 - fix(build)
|
||||||
|
|
||||||
|
Update build tooling and TypeScript compilation target
|
||||||
|
|
||||||
|
- Bump devDependency @git.zone/tsbuild from ^3.1.0 to ^3.1.1.
|
||||||
|
- Update tsconfig.json compiler target from ES2022 to ES2024 (affects emitted JS language level).
|
||||||
|
|
||||||
|
## 2025-11-27 - 7.0.0 - BREAKING CHANGE(mongodb)
|
||||||
|
|
||||||
|
Upgrade dependencies: bump mongodb to ^7.0.0 and @git.zone/tstest to ^3.1.3
|
||||||
|
|
||||||
|
- Bump 'mongodb' dependency from ^6.20.0 to ^7.0.0 — major version upgrade; may introduce breaking API changes and require code updates or verification against the new driver.
|
||||||
|
- Update devDependency '@git.zone/tstest' from ^2.8.1 to ^3.1.3 — test tooling updated.
|
||||||
|
|
||||||
|
## 2025-11-17 - 6.0.0 - BREAKING CHANGE(decorators)
|
||||||
|
|
||||||
|
Migrate to TC39 Stage 3 decorators and refactor decorator metadata handling; update class initialization, lucene adapter fixes and docs
|
||||||
|
|
||||||
|
- Switch all decorators to TC39 Stage 3 signatures and metadata usage (use context.metadata and context.addInitializer) — affects svDb, globalSvDb, searchable, unI, index, Collection and managed.
|
||||||
|
- Refactor Collection/managed decorators to read and initialize prototype/constructor properties from context.metadata to ensure prototype properties are available before instance creation (ts/classes.collection.ts).
|
||||||
|
- Improve search implementation: add a Lucene parser and transformer with safer MongoDB query generation, wildcard/fuzzy handling and properly structured boolean operators (ts/classes.lucene.adapter.ts).
|
||||||
|
- Search integration updated to use the new adapter and handle advanced Lucene syntax and edge cases more robustly.
|
||||||
|
- Bump dev tooling versions: @git.zone/tsbuild -> ^3.1.0 and @git.zone/tsrun -> ^2.0.0.
|
||||||
|
- Documentation: update README and add readme.hints.md describing the TC39 decorator migration, minimum TypeScript (>=5.2) and Deno notes; tests adjusted accordingly.
|
||||||
|
- Clean up project memory/config files related to the previous decorator approach and Deno configuration adjustments.
|
||||||
|
|
||||||
|
## 2025-11-17 - 5.16.7 - fix(classes.collection)
|
||||||
|
|
||||||
|
Improve Deno and TypeScript compatibility: Collection decorator \_svDbOptions forwarding and config cleanup
|
||||||
|
|
||||||
|
- Collection decorator: capture original constructor and forward \_svDbOptions to ensure property decorator options (serialize/deserialize) remain accessible in Deno environments.
|
||||||
|
- Collection decorator: keep instance getter defined on prototype for Deno compatibility (no behavior change, clarifies forwarding logic).
|
||||||
|
- Build/config: removed experimentalDecorators and useDefineForClassFields from deno.json and tsconfig.json to avoid Deno/TS build issues and rely on default compilation settings.
|
||||||
|
|
||||||
|
## 2025-11-17 - 5.16.6 - fix(classes)
|
||||||
|
|
||||||
|
Add Deno compatibility, prototype-safe decorators and safe collection accessor; bump a few deps
|
||||||
|
|
||||||
|
- Add deno.json to enable experimentalDecorators and target ES2022/DOM for Deno builds.
|
||||||
|
- Introduce getCollectionSafe() on SmartDataDbDoc and use it for save/update/delete/findOne to avoid runtime errors when instance 'collection' is not present.
|
||||||
|
- Change several instance properties (globalSaveableProperties, uniqueIndexes, regularIndexes, saveableProperties) to 'declare' so decorator-set prototype properties are not shadowed (Deno compatibility).
|
||||||
|
- Enhance @Collection decorator: capture original constructor/prototype for Deno, define prototype getter for collection on decorated class, attach docCtor for searchableFields, and forward \_svDbOptions to the original constructor to preserve serializer metadata.
|
||||||
|
- Improve text/search index handling by relying on docCtor.searchableFields and guarding text index creation.
|
||||||
|
- Bump dependencies/devDependencies: @push.rocks/smartmongo -> ^2.0.14, @git.zone/tsbuild -> ^2.7.1, @git.zone/tstest -> ^2.8.1.
|
||||||
|
- These are non-breaking runtime compatibility and developer-experience fixes; intended as a patch release.
|
||||||
|
|
||||||
|
## 2025-11-16 - 5.16.5 - fix(watcher)
|
||||||
|
|
||||||
|
Update dependencies, tooling and watcher import; add .serena cache ignore
|
||||||
|
|
||||||
|
- Bump runtime dependencies: @push.rocks/smartlog 3.1.8 → 3.1.10, @push.rocks/smartstring 4.0.15 → 4.1.0, @push.rocks/taskbuffer 3.1.7 → 3.4.0, @tsclass/tsclass 9.2.0 → 9.3.0, mongodb 6.18.0 → 6.20.0
|
||||||
|
- Bump devDependencies: @git.zone/tsbuild 2.6.7 → 2.6.8, @git.zone/tsrun 1.2.44 → 1.6.2, @git.zone/tstest 2.3.5 → 2.6.2
|
||||||
|
- Switch EventEmitter import to node:events in ts/classes.watcher.ts to use the namespaced Node import
|
||||||
|
- Add .serena/.gitignore to ignore /cache
|
||||||
|
|
||||||
|
## 2025-08-18 - 5.16.4 - fix(classes.doc (convertFilterForMongoDb))
|
||||||
|
|
||||||
|
Improve filter conversion: handle logical operators, merge operator objects, add nested filter tests and docs, and fix test script
|
||||||
|
|
||||||
|
- Fix package.json test script: remove stray dot in tstest --verbose argument to ensure tests run correctly
|
||||||
|
- Enhance convertFilterForMongoDb in ts/classes.doc.ts to properly handle logical operators ($and, $or, $nor, $not) and return them recursively
|
||||||
|
- Merge operator objects for the same field path (e.g. combining $gte and $lte) to avoid overwriting operator clauses when object and dot-notation are mixed
|
||||||
|
- Add validation/guards for operator argument types (e.g. $in, $nin, $all must be arrays; $size must be numeric) and preserve existing behavior blocking $where for security
|
||||||
|
- Add comprehensive nested filter tests in test/test.filters.ts to cover deep nested object queries, $elemMatch, array size, $all, $in on nested fields and more
|
||||||
|
- Expand README filtering section with detailed examples for basic filtering, deep nested filters, comparison operators, array operations, logical and element operators, and advanced patterns
|
||||||
|
|
||||||
|
## 2025-08-18 - 5.16.3 - fix(docs)
|
||||||
|
|
||||||
|
Add local Claude settings and remove outdated codex.md
|
||||||
|
|
||||||
|
- Added .claude/settings.local.json to store local Claude/assistant permissions and configuration.
|
||||||
|
- Removed codex.md (project overview) — documentation file deleted.
|
||||||
|
- No runtime/library code changes; documentation/configuration-only update, bump patch version.
|
||||||
|
|
||||||
|
## 2025-08-18 - 5.16.2 - fix(readme)
|
||||||
|
|
||||||
|
Update README: clarify examples, expand search/cursor/docs and add local Claude settings
|
||||||
|
|
||||||
|
- Refined README wording and structure: clearer Quick Start, improved examples and developer-focused phrasing
|
||||||
|
- Expanded documentation for search, cursors, change streams, distributed coordination, transactions and EasyStore with more concrete code examples
|
||||||
|
- Adjusted code examples to show safer defaults (ID generation, status/tags, connection pooling) and improved best-practices guidance
|
||||||
|
- Added .claude/settings.local.json to provide local assistant/CI permission configuration
|
||||||
|
|
||||||
## 2025-08-12 - 5.16.1 - fix(core)
|
## 2025-08-12 - 5.16.1 - fix(core)
|
||||||
|
|
||||||
Improve error handling and logging; enhance search query sanitization; update dependency versions and documentation
|
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
|
- Replaced console.log and console.warn with structured logger.log calls throughout the core modules
|
||||||
@@ -11,6 +260,7 @@ Improve error handling and logging; enhance search query sanitization; update de
|
|||||||
- Updated README with improved instructions, feature highlights, and quick start sections
|
- Updated README with improved instructions, feature highlights, and quick start sections
|
||||||
|
|
||||||
## 2025-04-25 - 5.16.0 - feat(watcher)
|
## 2025-04-25 - 5.16.0 - feat(watcher)
|
||||||
|
|
||||||
Enhance change stream watchers with buffering and EventEmitter support; update dependency versions
|
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
|
- Bumped smartmongo from ^2.0.11 to ^2.0.12 and smartrx from ^3.0.7 to ^3.0.10
|
||||||
@@ -19,6 +269,7 @@ Enhance change stream watchers with buffering and EventEmitter support; update d
|
|||||||
- Modified SmartdataDbWatcher to extend EventEmitter and support event notifications
|
- Modified SmartdataDbWatcher to extend EventEmitter and support event notifications
|
||||||
|
|
||||||
## 2025-04-24 - 5.15.1 - fix(cursor)
|
## 2025-04-24 - 5.15.1 - fix(cursor)
|
||||||
|
|
||||||
Improve cursor usage documentation and refactor getCursor API to support native cursor modifiers
|
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.
|
- Updated examples in readme.md to demonstrate manual iteration using cursor.next() and proper cursor closing.
|
||||||
@@ -26,6 +277,7 @@ Improve cursor usage documentation and refactor getCursor API to support native
|
|||||||
- Added new tests in test/test.cursor.ts to verify cursor operations, including limits, sorting, and skipping.
|
- 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)
|
## 2025-04-24 - 5.15.0 - feat(svDb)
|
||||||
|
|
||||||
Enhance svDb decorator to support custom serialization and deserialization options
|
Enhance svDb decorator to support custom serialization and deserialization options
|
||||||
|
|
||||||
- Added an optional options parameter to the svDb decorator to accept serialize/deserialize functions
|
- Added an optional options parameter to the svDb decorator to accept serialize/deserialize functions
|
||||||
@@ -33,6 +285,7 @@ Enhance svDb decorator to support custom serialization and deserialization optio
|
|||||||
- Updated createSavableObject to use custom serialization when available
|
- Updated createSavableObject to use custom serialization when available
|
||||||
|
|
||||||
## 2025-04-23 - 5.14.1 - fix(db operations)
|
## 2025-04-23 - 5.14.1 - fix(db operations)
|
||||||
|
|
||||||
Update transaction API to consistently pass optional session parameters across database 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
|
- Revised transaction support in readme to use startSession without await and showcased session usage in getInstance and save calls
|
||||||
@@ -41,14 +294,16 @@ Update transaction API to consistently pass optional session parameters across d
|
|||||||
- Improved overall consistency of transactional APIs across the library
|
- Improved overall consistency of transactional APIs across the library
|
||||||
|
|
||||||
## 2025-04-23 - 5.14.0 - feat(doc)
|
## 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.
|
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 beforeSave hook if defined before performing insert or update.
|
||||||
- Calls afterSave hook after a document is saved.
|
- Calls afterSave hook after a document is saved.
|
||||||
- Calls beforeDelete hook before deletion and afterDelete hook afterward.
|
- Calls beforeDelete hook before deletion and afterDelete hook afterward.
|
||||||
- Ensures _updatedAt timestamp is refreshed during save operations.
|
- Ensures \_updatedAt timestamp is refreshed during save operations.
|
||||||
|
|
||||||
## 2025-04-22 - 5.13.1 - fix(search)
|
## 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.
|
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
|
- Replace previous implicit AND logic with tokenization that preserves quoted substrings
|
||||||
@@ -56,6 +311,7 @@ Improve search query parsing for implicit AND queries by preserving quoted subst
|
|||||||
- Ensure errors are thrown for non-searchable fields in field-specific queries
|
- Ensure errors are thrown for non-searchable fields in field-specific queries
|
||||||
|
|
||||||
## 2025-04-22 - 5.13.0 - feat(search)
|
## 2025-04-22 - 5.13.0 - feat(search)
|
||||||
|
|
||||||
Improve search query handling and update documentation
|
Improve search query handling and update documentation
|
||||||
|
|
||||||
- Added 'codex.md' providing a high-level project overview and detailed search API documentation.
|
- Added 'codex.md' providing a high-level project overview and detailed search API documentation.
|
||||||
@@ -64,12 +320,14 @@ Improve search query handling and update documentation
|
|||||||
- Updated tests in test/test.search.ts to cover new combined query scenarios and ensure robust behavior.
|
- 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)
|
## 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
|
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
|
- 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
|
- 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)
|
## 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.
|
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.
|
- Updated regex for field:value parsing to capture full value with wildcards.
|
||||||
@@ -78,6 +336,7 @@ Improve implicit AND logic for mixed free term and field queries in search and e
|
|||||||
- Extended tests to cover combined free term and wildcard field searches, including error cases.
|
- Extended tests to cover combined free term and wildcard field searches, including error cases.
|
||||||
|
|
||||||
## 2025-04-22 - 5.12.0 - feat(doc/search)
|
## 2025-04-22 - 5.12.0 - feat(doc/search)
|
||||||
|
|
||||||
Enhance search functionality with filter and validate options for advanced query control
|
Enhance search functionality with filter and validate options for advanced query control
|
||||||
|
|
||||||
- Added 'filter' option to merge additional MongoDB query constraints in search
|
- Added 'filter' option to merge additional MongoDB query constraints in search
|
||||||
@@ -86,6 +345,7 @@ Enhance search functionality with filter and validate options for advanced query
|
|||||||
- Updated tests to cover new search scenarios and fallback mechanisms
|
- Updated tests to cover new search scenarios and fallback mechanisms
|
||||||
|
|
||||||
## 2025-04-22 - 5.11.4 - fix(search)
|
## 2025-04-22 - 5.11.4 - fix(search)
|
||||||
|
|
||||||
Implement implicit AND logic for mixed simple term and field:value queries in 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
|
- Added a new branch to detect and handle search queries that mix field:value pairs with plain terms without explicit operators
|
||||||
@@ -93,6 +353,7 @@ Implement implicit AND logic for mixed simple term and field:value queries in se
|
|||||||
- Ensures proper parsing and improved robustness of search filters
|
- Ensures proper parsing and improved robustness of search filters
|
||||||
|
|
||||||
## 2025-04-22 - 5.11.3 - fix(lucene adapter and search tests)
|
## 2025-04-22 - 5.11.3 - fix(lucene adapter and search tests)
|
||||||
|
|
||||||
Improve range query parsing in Lucene adapter and expand search test coverage
|
Improve range query parsing in Lucene adapter and expand search test coverage
|
||||||
|
|
||||||
- Added a new 'testSearch' script in package.json to run search tests.
|
- Added a new 'testSearch' script in package.json to run search tests.
|
||||||
@@ -101,12 +362,14 @@ Improve range query parsing in Lucene adapter and expand search test coverage
|
|||||||
- Fixed token validation in the parseRange method of the Lucene adapter to ensure proper error handling.
|
- Fixed token validation in the parseRange method of the Lucene adapter to ensure proper error handling.
|
||||||
|
|
||||||
## 2025-04-21 - 5.11.2 - fix(readme)
|
## 2025-04-21 - 5.11.2 - fix(readme)
|
||||||
|
|
||||||
Update readme to clarify usage of searchable fields retrieval
|
Update readme to clarify usage of searchable fields retrieval
|
||||||
|
|
||||||
- Replaced getSearchableFields('Product') with Product.getSearchableFields()
|
- Replaced getSearchableFields('Product') with Product.getSearchableFields()
|
||||||
- Updated documentation to reference the static method Class.getSearchableFields()
|
- Updated documentation to reference the static method Class.getSearchableFields()
|
||||||
|
|
||||||
## 2025-04-21 - 5.11.1 - fix(doc)
|
## 2025-04-21 - 5.11.1 - fix(doc)
|
||||||
|
|
||||||
Refactor searchable fields API and improve collection registration.
|
Refactor searchable fields API and improve collection registration.
|
||||||
|
|
||||||
- Removed the standalone getSearchableFields utility in favor of a static method on document classes.
|
- Removed the standalone getSearchableFields utility in favor of a static method on document classes.
|
||||||
@@ -115,11 +378,13 @@ Refactor searchable fields API and improve collection registration.
|
|||||||
- Added try/catch in test cleanup to gracefully handle dropDatabase errors.
|
- Added try/catch in test cleanup to gracefully handle dropDatabase errors.
|
||||||
|
|
||||||
## 2025-04-21 - 5.11.0 - feat(ts/classes.lucene.adapter)
|
## 2025-04-21 - 5.11.0 - feat(ts/classes.lucene.adapter)
|
||||||
|
|
||||||
Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities.
|
Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities.
|
||||||
|
|
||||||
- Changed luceneWildcardToRegex from private to public in ts/classes.lucene.adapter.ts.
|
- Changed luceneWildcardToRegex from private to public in ts/classes.lucene.adapter.ts.
|
||||||
|
|
||||||
## 2025-04-21 - 5.10.0 - feat(search)
|
## 2025-04-21 - 5.10.0 - feat(search)
|
||||||
|
|
||||||
Improve search functionality: update documentation, refine Lucene query transformation, and add advanced search tests
|
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
|
- Updated readme.md with detailed Lucene‑style search examples and use cases
|
||||||
@@ -128,6 +393,7 @@ Improve search functionality: update documentation, refine Lucene query transfor
|
|||||||
- Added new advanced search tests covering boolean operators, grouping, quoted phrases, and wildcard queries
|
- Added new advanced search tests covering boolean operators, grouping, quoted phrases, and wildcard queries
|
||||||
|
|
||||||
## 2025-04-18 - 5.9.2 - fix(documentation)
|
## 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.
|
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.
|
- Replaced 'searchWithLucene' examples with 'search(query)' in the README.
|
||||||
@@ -135,24 +401,28 @@ Update search API documentation to replace deprecated searchWithLucene examples
|
|||||||
- Clarified guidelines for creating MongoDB text indexes on searchable fields for optimized search performance.
|
- Clarified guidelines for creating MongoDB text indexes on searchable fields for optimized search performance.
|
||||||
|
|
||||||
## 2025-04-18 - 5.9.1 - fix(search)
|
## 2025-04-18 - 5.9.1 - fix(search)
|
||||||
|
|
||||||
Refactor search tests to use unified search API and update text index type casting
|
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
|
- Replaced all calls from searchWithLucene with search in test/search tests
|
||||||
- Updated text index specification in the collection class to use proper type casting
|
- Updated text index specification in the collection class to use proper type casting
|
||||||
|
|
||||||
## 2025-04-18 - 5.9.0 - feat(collections/search)
|
## 2025-04-18 - 5.9.0 - feat(collections/search)
|
||||||
|
|
||||||
Improve text index creation and search fallback mechanisms in collections and document search methods
|
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.
|
- 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.
|
- 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)
|
## 2025-04-17 - 5.8.4 - fix(core)
|
||||||
|
|
||||||
Update commit metadata with no functional code changes
|
Update commit metadata with no functional code changes
|
||||||
|
|
||||||
- Commit info and documentation refreshed
|
- Commit info and documentation refreshed
|
||||||
- No code or test changes detected in the diff
|
- No code or test changes detected in the diff
|
||||||
|
|
||||||
## 2025-04-17 - 5.8.3 - fix(readme)
|
## 2025-04-17 - 5.8.3 - fix(readme)
|
||||||
|
|
||||||
Improve readme documentation on data models and connection management
|
Improve readme documentation on data models and connection management
|
||||||
|
|
||||||
- Clarify that data models use @Collection, @unI, @svDb, @index, and @searchable decorators
|
- Clarify that data models use @Collection, @unI, @svDb, @index, and @searchable decorators
|
||||||
@@ -161,12 +431,14 @@ Improve readme documentation on data models and connection management
|
|||||||
- Revise license section to reference the MIT License without including additional legal details
|
- Revise license section to reference the MIT License without including additional legal details
|
||||||
|
|
||||||
## 2025-04-14 - 5.8.2 - fix(classes.doc.ts)
|
## 2025-04-14 - 5.8.2 - fix(classes.doc.ts)
|
||||||
|
|
||||||
Ensure collection initialization before creating a cursor in getCursorExtended
|
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
|
- Added 'await collection.init()' to guarantee that the MongoDB collection is initialized before using the cursor
|
||||||
- Prevents potential runtime errors when accessing collection.mongoDbCollection
|
- Prevents potential runtime errors when accessing collection.mongoDbCollection
|
||||||
|
|
||||||
## 2025-04-14 - 5.8.1 - fix(cursor, doc)
|
## 2025-04-14 - 5.8.1 - fix(cursor, doc)
|
||||||
|
|
||||||
Add explicit return types and casts to SmartdataDbCursor methods and update getCursorExtended signature in SmartDataDbDoc.
|
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 next() in SmartdataDbCursor and cast return value to T.
|
||||||
@@ -174,12 +446,14 @@ Add explicit return types and casts to SmartdataDbCursor methods and update getC
|
|||||||
- Update getCursorExtended to return Promise<SmartdataDbCursor<T>> for clearer type safety.
|
- 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
|
||||||
|
|
||||||
- Introduced asynchronous toArray method in SmartdataDbCursor which retrieves all documents from the MongoDB cursor
|
- Introduced asynchronous toArray method in SmartdataDbCursor which retrieves all documents from the MongoDB cursor
|
||||||
- Maps each native document to a SmartDataDbDoc instance using createInstanceFromMongoDbNativeDoc for consistent API usage
|
- Maps each native document to a SmartDataDbDoc instance using createInstanceFromMongoDbNativeDoc for consistent API usage
|
||||||
|
|
||||||
## 2025-04-14 - 5.7.0 - feat(SmartDataDbDoc)
|
## 2025-04-14 - 5.7.0 - feat(SmartDataDbDoc)
|
||||||
|
|
||||||
Add extended cursor method getCursorExtended for flexible cursor modifications
|
Add extended cursor method getCursorExtended for flexible cursor modifications
|
||||||
|
|
||||||
- Introduces getCursorExtended in classes.doc.ts to allow modifier functions for MongoDB cursors
|
- Introduces getCursorExtended in classes.doc.ts to allow modifier functions for MongoDB cursors
|
||||||
@@ -187,6 +461,7 @@ Add extended cursor method getCursorExtended for flexible cursor modifications
|
|||||||
- Enhances querying capabilities by enabling customized cursor transformations
|
- Enhances querying capabilities by enabling customized cursor transformations
|
||||||
|
|
||||||
## 2025-04-07 - 5.6.0 - feat(indexing)
|
## 2025-04-07 - 5.6.0 - feat(indexing)
|
||||||
|
|
||||||
Add support for regular index creation in documents and collections
|
Add support for regular index creation in documents and collections
|
||||||
|
|
||||||
- Implement new index decorator in classes.doc.ts to mark properties with regular indexing options
|
- Implement new index decorator in classes.doc.ts to mark properties with regular indexing options
|
||||||
@@ -194,6 +469,7 @@ Add support for regular index creation in documents and collections
|
|||||||
- Enhance document structure to store and utilize regular index configurations
|
- Enhance document structure to store and utilize regular index configurations
|
||||||
|
|
||||||
## 2025-04-06 - 5.5.1 - fix(ci & formatting)
|
## 2025-04-06 - 5.5.1 - fix(ci & formatting)
|
||||||
|
|
||||||
Minor fixes: update CI workflow image and npmci package references, adjust package.json and readme URLs, and apply consistent code formatting.
|
Minor fixes: update CI workflow image and npmci package references, adjust package.json and readme URLs, and apply consistent code formatting.
|
||||||
|
|
||||||
- Update image and repo URL in Gitea workflows from GitLab to code.foss.global
|
- Update image and repo URL in Gitea workflows from GitLab to code.foss.global
|
||||||
@@ -203,6 +479,7 @@ Minor fixes: update CI workflow image and npmci package references, adjust packa
|
|||||||
- Minor update to .gitignore custom section label
|
- Minor update to .gitignore custom section label
|
||||||
|
|
||||||
## 2025-04-06 - 5.5.0 - feat(search)
|
## 2025-04-06 - 5.5.0 - feat(search)
|
||||||
|
|
||||||
Enhance search functionality with robust Lucene query transformation and reliable fallback mechanisms
|
Enhance search functionality with robust Lucene query transformation and reliable fallback mechanisms
|
||||||
|
|
||||||
- Improve Lucene adapter to properly structure $or queries for term, phrase, wildcard, and fuzzy search
|
- Improve Lucene adapter to properly structure $or queries for term, phrase, wildcard, and fuzzy search
|
||||||
@@ -210,15 +487,17 @@ Enhance search functionality with robust Lucene query transformation and reliabl
|
|||||||
- Update readme and tests with extensive examples for @searchable fields and Lucene-based queries
|
- Update readme and tests with extensive examples for @searchable fields and Lucene-based queries
|
||||||
|
|
||||||
## 2025-04-06 - 5.4.0 - feat(core)
|
## 2025-04-06 - 5.4.0 - feat(core)
|
||||||
|
|
||||||
Refactor file structure and update dependency versions
|
Refactor file structure and update dependency versions
|
||||||
|
|
||||||
- Renamed files and modules from 'smartdata.classes.*' to 'classes.*' and adjusted corresponding import paths.
|
- Renamed files and modules from 'smartdata.classes._' to 'classes._' and adjusted corresponding import paths.
|
||||||
- Updated dependency versions: '@push.rocks/smartmongo' to ^2.0.11, '@tsclass/tsclass' to ^8.2.0, and 'mongodb' to ^6.15.0.
|
- Updated dependency versions: '@push.rocks/smartmongo' to ^2.0.11, '@tsclass/tsclass' to ^8.2.0, and 'mongodb' to ^6.15.0.
|
||||||
- Renamed dev dependency packages from '@gitzone/...' to '@git.zone/...' and updated '@push.rocks/tapbundle' and '@types/node'.
|
- Renamed dev dependency packages from '@gitzone/...' to '@git.zone/...' and updated '@push.rocks/tapbundle' and '@types/node'.
|
||||||
- Fixed YAML workflow command: replaced 'pnpm install -g @gitzone/tsdoc' with 'pnpm install -g @git.zone/tsdoc'.
|
- Fixed YAML workflow command: replaced 'pnpm install -g @gitzone/tsdoc' with 'pnpm install -g @git.zone/tsdoc'.
|
||||||
- Added package manager configuration and pnpm-workspace.yaml for built dependencies.
|
- Added package manager configuration and pnpm-workspace.yaml for built dependencies.
|
||||||
|
|
||||||
## 2025-03-10 - 5.3.0 - feat(docs)
|
## 2025-03-10 - 5.3.0 - feat(docs)
|
||||||
|
|
||||||
Enhance documentation with updated installation instructions and comprehensive usage examples covering advanced features such as deep queries, automatic indexing, and distributed coordination.
|
Enhance documentation with updated installation instructions and comprehensive usage examples covering advanced features such as deep queries, automatic indexing, and distributed coordination.
|
||||||
|
|
||||||
- Added pnpm installation command
|
- Added pnpm installation command
|
||||||
@@ -228,11 +507,13 @@ Enhance documentation with updated installation instructions and comprehensive u
|
|||||||
- Included detailed examples for transactions, deep object queries, and document lifecycle hooks
|
- Included detailed examples for transactions, deep object queries, and document lifecycle hooks
|
||||||
|
|
||||||
## 2025-02-03 - 5.2.12 - fix(documentation)
|
## 2025-02-03 - 5.2.12 - fix(documentation)
|
||||||
|
|
||||||
Remove license badge from README
|
Remove license badge from README
|
||||||
|
|
||||||
- Removed the license badge from the README file, ensuring compliance with branding guidelines.
|
- Removed the license badge from the README file, ensuring compliance with branding guidelines.
|
||||||
|
|
||||||
## 2025-02-03 - 5.2.11 - fix(documentation)
|
## 2025-02-03 - 5.2.11 - fix(documentation)
|
||||||
|
|
||||||
Updated project documentation for accuracy and added advanced feature details
|
Updated project documentation for accuracy and added advanced feature details
|
||||||
|
|
||||||
- Added details for EasyStore, Distributed Coordination, and Real-time Data Watching features.
|
- Added details for EasyStore, Distributed Coordination, and Real-time Data Watching features.
|
||||||
@@ -240,158 +521,188 @@ Updated project documentation for accuracy and added advanced feature details
|
|||||||
- Re-organized advanced usage section to showcase additional features separately.
|
- Re-organized advanced usage section to showcase additional features separately.
|
||||||
|
|
||||||
## 2024-09-05 - 5.2.10 - fix(smartdata.classes.doc)
|
## 2024-09-05 - 5.2.10 - fix(smartdata.classes.doc)
|
||||||
|
|
||||||
Fix issue with array handling in convertFilterForMongoDb function
|
Fix issue with array handling in convertFilterForMongoDb function
|
||||||
|
|
||||||
- Corrected the logic to properly handle array filters in the convertFilterForMongoDb function to avoid incorrect assignments.
|
- Corrected the logic to properly handle array filters in the convertFilterForMongoDb function to avoid incorrect assignments.
|
||||||
|
|
||||||
## 2024-09-05 - 5.2.9 - fix(smartdata.classes.doc)
|
## 2024-09-05 - 5.2.9 - fix(smartdata.classes.doc)
|
||||||
|
|
||||||
Fixed issue with convertFilterForMongoDb to handle array operators.
|
Fixed issue with convertFilterForMongoDb to handle array operators.
|
||||||
|
|
||||||
- Updated the convertFilterForMongoDb function in smartdata.classes.doc.ts to properly handle array operators like $in and $all.
|
- Updated the convertFilterForMongoDb function in smartdata.classes.doc.ts to properly handle array operators like $in and $all.
|
||||||
|
|
||||||
## 2024-09-05 - 5.2.8 - fix(smartdata.classes.doc)
|
## 2024-09-05 - 5.2.8 - fix(smartdata.classes.doc)
|
||||||
|
|
||||||
Fix key handling in convertFilterForMongoDb function
|
Fix key handling in convertFilterForMongoDb function
|
||||||
|
|
||||||
- Fixed an issue in convertFilterForMongoDb that allowed keys with dots which could cause errors.
|
- Fixed an issue in convertFilterForMongoDb that allowed keys with dots which could cause errors.
|
||||||
|
|
||||||
## 2024-09-05 - 5.2.7 - fix(core)
|
## 2024-09-05 - 5.2.7 - fix(core)
|
||||||
|
|
||||||
Fixed issue with handling filter keys containing dots in smartdata.classes.doc.ts
|
Fixed issue with handling filter keys containing dots in smartdata.classes.doc.ts
|
||||||
|
|
||||||
- Fixed an error in the convertFilterForMongoDb function which previously threw an error when keys contained dots.
|
- Fixed an error in the convertFilterForMongoDb function which previously threw an error when keys contained dots.
|
||||||
|
|
||||||
## 2024-06-18 - 5.2.6 - Chore
|
## 2024-06-18 - 5.2.6 - Chore
|
||||||
|
|
||||||
Maintenance Release
|
Maintenance Release
|
||||||
|
|
||||||
- Release version 5.2.6
|
- Release version 5.2.6
|
||||||
|
|
||||||
## 2024-05-31 - 5.2.2 - Bug Fixes
|
## 2024-05-31 - 5.2.2 - Bug Fixes
|
||||||
|
|
||||||
Fixes and Maintenance
|
Fixes and Maintenance
|
||||||
|
|
||||||
- Fixed issue where `_createdAt` and `_updatedAt` registered saveableProperties for all document types
|
- Fixed issue where `_createdAt` and `_updatedAt` registered saveableProperties for all document types
|
||||||
|
|
||||||
## 2024-04-15 - 5.1.2 - New Feature
|
## 2024-04-15 - 5.1.2 - New Feature
|
||||||
|
|
||||||
Enhancements and Bug Fixes
|
Enhancements and Bug Fixes
|
||||||
|
|
||||||
- Added static `.getCount({})` method to `SmartDataDbDoc`
|
- Added static `.getCount({})` method to `SmartDataDbDoc`
|
||||||
- Changed fields `_createdAt` and `_updatedAt` to ISO format
|
- Changed fields `_createdAt` and `_updatedAt` to ISO format
|
||||||
|
|
||||||
## 2024-04-14 - 5.0.43 - New Feature
|
## 2024-04-14 - 5.0.43 - New Feature
|
||||||
|
|
||||||
New Feature Addition
|
New Feature Addition
|
||||||
|
|
||||||
- Added default `_createdAt` and `_updatedAt` fields, fixes #1
|
- Added default `_createdAt` and `_updatedAt` fields, fixes #1
|
||||||
|
|
||||||
## 2024-03-30 - 5.0.41 - Bug Fixes
|
## 2024-03-30 - 5.0.41 - Bug Fixes
|
||||||
|
|
||||||
Improvements and Fixes
|
Improvements and Fixes
|
||||||
|
|
||||||
- Improved `tsconfig.json` for ES Module use
|
- Improved `tsconfig.json` for ES Module use
|
||||||
|
|
||||||
## 2023-07-10 - 5.0.20 - Chore
|
## 2023-07-10 - 5.0.20 - Chore
|
||||||
|
|
||||||
Organizational Changes
|
Organizational Changes
|
||||||
|
|
||||||
- Switched to new org scheme
|
- Switched to new org scheme
|
||||||
|
|
||||||
## 2023-07-21 - 5.0.21 to 5.0.26 - Fixes
|
## 2023-07-21 - 5.0.21 to 5.0.26 - Fixes
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2023-07-21 - 5.0.20 - Chore
|
## 2023-07-21 - 5.0.20 - Chore
|
||||||
|
|
||||||
Organizational Changes
|
Organizational Changes
|
||||||
|
|
||||||
- Switch to the new org scheme
|
- Switch to the new org scheme
|
||||||
|
|
||||||
## 2023-06-25 - 5.0.14 to 5.0.19 - Fixes
|
## 2023-06-25 - 5.0.14 to 5.0.19 - Fixes
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2022-05-17 - 5.0.0 - Major Update
|
## 2022-05-17 - 5.0.0 - Major Update
|
||||||
|
|
||||||
Breaking Changes
|
Breaking Changes
|
||||||
|
|
||||||
- Switched to ESM
|
- Switched to ESM
|
||||||
|
|
||||||
## 2022-05-18 - 5.0.2 - Bug Fixes
|
## 2022-05-18 - 5.0.2 - Bug Fixes
|
||||||
|
|
||||||
Bug Fixes
|
Bug Fixes
|
||||||
|
|
||||||
- The `watcher.changeSubject` now emits the correct type into observer functions
|
- The `watcher.changeSubject` now emits the correct type into observer functions
|
||||||
|
|
||||||
## 2022-05-17 - 5.0.1 - Chore
|
## 2022-05-17 - 5.0.1 - Chore
|
||||||
|
|
||||||
Testing Improvements
|
Testing Improvements
|
||||||
|
|
||||||
- Tests now use `@pushrocks/smartmongo` backed by `wiredTiger`
|
- Tests now use `@pushrocks/smartmongo` backed by `wiredTiger`
|
||||||
|
|
||||||
## 2022-05-17 to 2022-11-08 - 5.0.8 to 5.0.10
|
## 2022-05-17 to 2022-11-08 - 5.0.8 to 5.0.10
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2021-11-12 - 4.0.17 to 4.0.20
|
## 2021-11-12 - 4.0.17 to 4.0.20
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2021-09-17 - 4.0.10 to 4.0.16
|
## 2021-09-17 - 4.0.10 to 4.0.16
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2021-06-09 - 4.0.1 to 4.0.9
|
## 2021-06-09 - 4.0.1 to 4.0.9
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2021-06-06 - 4.0.0 - Major Update
|
## 2021-06-06 - 4.0.0 - Major Update
|
||||||
|
|
||||||
Major Release
|
Major Release
|
||||||
|
|
||||||
- Maintenance and core updates
|
- Maintenance and core updates
|
||||||
|
|
||||||
## 2021-05-17 - 3.1.56 - Chore
|
## 2021-05-17 - 3.1.56 - Chore
|
||||||
|
|
||||||
Maintenance Release
|
Maintenance Release
|
||||||
|
|
||||||
- Release version 3.1.56
|
- Release version 3.1.56
|
||||||
|
|
||||||
## 2020-09-09 - 3.1.44 to 3.1.52
|
## 2020-09-09 - 3.1.44 to 3.1.52
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2020-06-12 - 3.1.26 to 3.1.28
|
## 2020-06-12 - 3.1.26 to 3.1.28
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2020-02-18 - 3.1.23 to 3.1.25
|
## 2020-02-18 - 3.1.23 to 3.1.25
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2019-09-11 - 3.1.20 to 3.1.22
|
## 2019-09-11 - 3.1.20 to 3.1.22
|
||||||
|
|
||||||
Multiple Fix Releases
|
Multiple Fix Releases
|
||||||
|
|
||||||
- Various core updates and bug fixes
|
- Various core updates and bug fixes
|
||||||
|
|
||||||
## 2018-07-10 - 3.0.5 - New Feature
|
## 2018-07-10 - 3.0.5 - New Feature
|
||||||
|
|
||||||
Added Feature
|
Added Feature
|
||||||
|
|
||||||
- Added custom unique indexes to `SmartdataDoc`
|
- Added custom unique indexes to `SmartdataDoc`
|
||||||
|
|
||||||
## 2018-07-08 - 3.0.1 - Chore
|
## 2018-07-08 - 3.0.1 - Chore
|
||||||
|
|
||||||
Dependencies Update
|
Dependencies Update
|
||||||
|
|
||||||
- Updated mongodb dependencies
|
- Updated mongodb dependencies
|
||||||
|
|
||||||
## 2018-07-08 - 3.0.0 - Major Update
|
## 2018-07-08 - 3.0.0 - Major Update
|
||||||
|
|
||||||
Refactor and Cleanup
|
Refactor and Cleanup
|
||||||
|
|
||||||
- Cleaned project structure
|
- Cleaned project structure
|
||||||
|
|
||||||
## 2018-01-16 - 2.0.7 - Breaking Change
|
## 2018-01-16 - 2.0.7 - Breaking Change
|
||||||
|
|
||||||
Big Changes
|
Big Changes
|
||||||
|
|
||||||
- Switched to `@pushrocks` scope and moved from `rethinkdb` to `mongodb`
|
- Switched to `@pushrocks` scope and moved from `rethinkdb` to `mongodb`
|
||||||
|
|
||||||
## 2018-01-12 - 2.0.0 - Major Release
|
## 2018-01-12 - 2.0.0 - Major Release
|
||||||
|
|
||||||
Core Updates
|
Core Updates
|
||||||
|
|
||||||
- Updated CI configurations
|
- Updated CI configurations
|
||||||
|
|
||||||
|
|||||||
77
codex.md
77
codex.md
@@ -1,77 +0,0 @@
|
|||||||
# 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
|
|
||||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
46
package.json
46
package.json
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartdata",
|
"name": "@push.rocks/smartdata",
|
||||||
"version": "5.16.1",
|
"version": "7.1.7",
|
||||||
"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",
|
"exports": {
|
||||||
"typings": "dist_ts/index.d.ts",
|
".": "./dist_ts/index.js"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "tstest test/ --verbose",
|
"test": "tstest test/ --verbose --logfile --timeout 120",
|
||||||
"testSearch": "tsx test/test.search.ts",
|
"testSearch": "tsx test/test.search.ts",
|
||||||
"build": "tsbuild --web --allowimplicitany",
|
"build": "tsbuild tsfolders",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -23,26 +24,25 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://code.foss.global/push.rocks/smartdata#readme",
|
"homepage": "https://code.foss.global/push.rocks/smartdata#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
"@push.rocks/smartdelay": "^3.0.1",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smartlog": "^3.1.8",
|
"@push.rocks/smartlog": "^3.2.1",
|
||||||
"@push.rocks/smartmongo": "^2.0.12",
|
"@push.rocks/smartmongo": "^5.1.0",
|
||||||
"@push.rocks/smartpromise": "^4.0.2",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstring": "^4.0.15",
|
"@push.rocks/smartstring": "^4.1.0",
|
||||||
"@push.rocks/smarttime": "^4.0.6",
|
"@push.rocks/smarttime": "^4.2.3",
|
||||||
"@push.rocks/smartunique": "^3.0.8",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/taskbuffer": "^3.1.7",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@tsclass/tsclass": "^9.2.0",
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
"mongodb": "^6.18.0"
|
"mongodb": "^7.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.6.4",
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
"@git.zone/tsrun": "^1.2.44",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tstest": "^2.3.2",
|
"@git.zone/tstest": "^3.6.0",
|
||||||
"@push.rocks/qenv": "^6.0.5",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/tapbundle": "^6.0.3",
|
"@types/node": "^25.5.0"
|
||||||
"@types/node": "^22.15.2"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"dist_ts_web/**/*",
|
"dist_ts_web/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
".smartconfig.json",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
],
|
],
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
|||||||
9331
pnpm-lock.yaml
generated
9331
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
onlyBuiltDependencies:
|
|
||||||
- esbuild
|
|
||||||
- mongodb-memory-server
|
|
||||||
- puppeteer
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Project Memory - Smartdata
|
||||||
|
|
||||||
|
## TC39 Decorator Pattern
|
||||||
|
|
||||||
|
- **Field decorators**: Write to `context.metadata`
|
||||||
|
- **Class decorators**: Read from `context.metadata` (same shared object)
|
||||||
|
- `Symbol.metadata` on constructors is read-only (managed by runtime)
|
||||||
|
- Field decorators run before class decorators (guaranteed order)
|
||||||
|
- `declare` keyword for instance properties accessed via prototype getters (avoids ES2022 shadowing)
|
||||||
|
|
||||||
|
### Runtime Compatibility
|
||||||
|
|
||||||
|
- ✅ Node.js v20+ / v25+: Full TC39 support
|
||||||
|
- ✅ Deno v2.x: Full TC39 support
|
||||||
|
- ❌ Bun: No TC39 support (uses legacy decorators only)
|
||||||
|
|
||||||
|
## Build Configuration (v7.1.0+)
|
||||||
|
|
||||||
|
- **Build tool**: `@git.zone/tsbuild` v4 with `tsbuild tsfolders`
|
||||||
|
- **tsconfig.json**: Includes `"types": ["node"]` since tsbuild v4 defaults to DOM+ESNext lib only
|
||||||
|
- **Strict mode**: tsbuild v4 enables strict checks; properties use `!` definite assignment or `declare`
|
||||||
|
- **Test imports**: Use `@git.zone/tstest/tapbundle` (NOT `@push.rocks/tapbundle`)
|
||||||
|
- **Config file**: `.smartconfig.json` (renamed from `npmextra.json`)
|
||||||
|
|
||||||
|
## Dependencies (v7.1.0+)
|
||||||
|
|
||||||
|
- `@push.rocks/taskbuffer` v8: distributedCoordination API at `taskbuffer.distributedCoordination.*`
|
||||||
|
- `@push.rocks/smartmongo` v5: API compatible (`createAndStart`, `getMongoDescriptor`, `stop`, `stopAndDumpToDir`)
|
||||||
|
- `mongodb` v7.1: ChangeStream requires `Document` constraint, use `any` for generic watcher
|
||||||
|
|||||||
683
readme.md
683
readme.md
@@ -2,37 +2,36 @@
|
|||||||
|
|
||||||
[](https://www.npmjs.com/package/@push.rocks/smartdata)
|
[](https://www.npmjs.com/package/@push.rocks/smartdata)
|
||||||
|
|
||||||
**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.
|
**The ultimate TypeScript-first MongoDB ODM** — type-safe decorators, real-time change streams, Lucene-powered search, distributed leader election, and cursor streaming. Built for modern applications that demand performance, correctness, and developer experience.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
## 🌟 Why SmartData?
|
## 🌟 Why SmartData?
|
||||||
|
|
||||||
SmartData isn't just another MongoDB wrapper - it's a complete data management powerhouse that transforms how you work with databases:
|
- 🔒 **100% Type-Safe** — TC39 Stage 3 decorators, generic filters, and compile-time query validation
|
||||||
|
- ⚡ **High Performance** — Connection pooling, cursor streaming, and automatic indexing
|
||||||
- 🔒 **100% Type-Safe**: Full TypeScript with decorators, generics, and deep query typing
|
- 🔄 **Real-time Ready** — MongoDB Change Streams with RxJS for reactive applications
|
||||||
- ⚡ **Lightning Fast**: Connection pooling, cursor streaming, and optimized indexing
|
- 🌍 **Distributed Systems** — Built-in leader election and task coordination via `@push.rocks/taskbuffer`
|
||||||
- 🔄 **Real-time Sync**: MongoDB Change Streams with RxJS for reactive applications
|
- 🛡️ **Security First** — `$where` injection prevention, operator allow-listing, and input sanitization
|
||||||
- 🌍 **Distributed Ready**: Built-in leader election and task coordination
|
- 🔎 **Lucene Search** — Full-text, wildcard, boolean, and range queries out of the box
|
||||||
- 🛡️ **Security First**: NoSQL injection prevention, credential encoding, and secure defaults
|
- 🎯 **Great DX** — Intuitive API, IntelliSense that just works, and lifecycle hooks
|
||||||
- 🎯 **Developer Friendly**: Intuitive API, powerful search, and amazing DX
|
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using npm
|
|
||||||
npm install @push.rocks/smartdata --save
|
|
||||||
|
|
||||||
# Using pnpm (recommended)
|
|
||||||
pnpm add @push.rocks/smartdata
|
pnpm add @push.rocks/smartdata
|
||||||
|
|
||||||
# Using yarn
|
|
||||||
yarn add @push.rocks/smartdata
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚦 Requirements
|
## 🚦 Requirements
|
||||||
|
|
||||||
- **Node.js** >= 16.x
|
- **Node.js** >= 20.x
|
||||||
- **MongoDB** >= 4.4
|
- **Deno** >= 2.0 (for Deno projects)
|
||||||
- **TypeScript** >= 4.x (for development)
|
- **MongoDB** >= 5.0
|
||||||
|
- **TypeScript** >= 5.2 (for TC39 decorator support)
|
||||||
|
|
||||||
|
> **Note**: SmartData uses TC39 Stage 3 decorators (the standard). Make sure `experimentalDecorators` is **not** set in your tsconfig.json. Bun is not currently supported as it doesn't implement TC39 decorators yet.
|
||||||
|
|
||||||
## 🎯 Quick Start
|
## 🎯 Quick Start
|
||||||
|
|
||||||
@@ -41,21 +40,15 @@ yarn add @push.rocks/smartdata
|
|||||||
```typescript
|
```typescript
|
||||||
import { SmartdataDb } from '@push.rocks/smartdata';
|
import { SmartdataDb } from '@push.rocks/smartdata';
|
||||||
|
|
||||||
// Create a database instance with smart defaults
|
|
||||||
const db = new SmartdataDb({
|
const db = new SmartdataDb({
|
||||||
mongoDbUrl: 'mongodb://localhost:27017/myapp',
|
mongoDbUrl: 'mongodb://localhost:27017/myapp',
|
||||||
mongoDbName: 'myapp',
|
mongoDbName: 'myapp',
|
||||||
mongoDbUser: 'username',
|
mongoDbUser: 'username',
|
||||||
mongoDbPass: '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 with automatic retry and connection pooling
|
|
||||||
await db.init();
|
await db.init();
|
||||||
|
console.log(db.status); // 'connected'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2️⃣ Define Your Data Models
|
### 2️⃣ Define Your Data Models
|
||||||
@@ -69,38 +62,30 @@ import {
|
|||||||
index,
|
index,
|
||||||
searchable,
|
searchable,
|
||||||
} from '@push.rocks/smartdata';
|
} from '@push.rocks/smartdata';
|
||||||
import { ObjectId } from 'mongodb';
|
|
||||||
|
|
||||||
@Collection(() => db)
|
@Collection(() => db)
|
||||||
class User extends SmartDataDbDoc<User, User> {
|
class User extends SmartDataDbDoc<User, User> {
|
||||||
@unI()
|
@unI()
|
||||||
public id: string = 'unique-user-id'; // Unique index
|
public id!: string;
|
||||||
|
|
||||||
@svDb()
|
|
||||||
@searchable() // Enable full-text search
|
|
||||||
public username: string;
|
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
@searchable()
|
@searchable()
|
||||||
@index({ unique: false }) // Regular index for performance
|
public username!: string;
|
||||||
public email: string;
|
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
public organizationId: ObjectId; // Automatically handled as BSON ObjectId
|
@searchable()
|
||||||
|
@index({ unique: false })
|
||||||
|
public email!: string;
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
public profilePicture: Buffer; // Automatically handled as BSON Binary
|
public status!: 'active' | 'inactive' | 'pending';
|
||||||
|
|
||||||
@svDb({
|
@svDb()
|
||||||
// Custom serialization for complex objects
|
public tags!: string[];
|
||||||
serialize: (data) => JSON.stringify(data),
|
|
||||||
deserialize: (data) => JSON.parse(data),
|
|
||||||
})
|
|
||||||
public preferences: Record<string, any>;
|
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
public createdAt: Date = new Date();
|
public createdAt: Date = new Date();
|
||||||
|
|
||||||
constructor(username: string, email: string) {
|
constructor(username: string, email: string) {
|
||||||
super();
|
super();
|
||||||
this.username = username;
|
this.username = username;
|
||||||
@@ -109,477 +94,403 @@ class User extends SmartDataDbDoc<User, User> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3️⃣ Perform CRUD Operations
|
### 3️⃣ CRUD Operations
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ✨ Create
|
// ✨ Create
|
||||||
const user = new User('johndoe', 'john@example.com');
|
const user = new User('johndoe', 'john@example.com');
|
||||||
|
user.status = 'active';
|
||||||
|
user.tags = ['developer', 'typescript'];
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
||||||
// 🔍 Read
|
// 🔍 Read — fully type-safe filters
|
||||||
const foundUser = await User.getInstance({ username: 'johndoe' });
|
const foundUser = await User.getInstance({ username: 'johndoe' });
|
||||||
const allUsers = await User.getInstances({ email: 'john@example.com' });
|
const activeUsers = await User.getInstances({ status: 'active' });
|
||||||
|
|
||||||
// ✏️ Update
|
// ✏️ Update
|
||||||
foundUser.email = 'newemail@example.com';
|
foundUser.email = 'newemail@example.com';
|
||||||
await foundUser.save();
|
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
|
// 🗑️ Delete
|
||||||
await foundUser.delete();
|
await foundUser.delete();
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔥 Advanced Features
|
## 🔥 Features
|
||||||
|
|
||||||
### 🔎 Powerful Search Engine
|
### 🎯 Type-Safe Query Filters
|
||||||
|
|
||||||
SmartData includes a Lucene-style search engine with automatic field indexing:
|
SmartData provides a rich, type-safe filtering system supporting all MongoDB operators with full IntelliSense:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Comparison operators
|
||||||
|
const adults = await User.getInstances({
|
||||||
|
age: { $gte: 18, $lt: 65 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Array operators
|
||||||
|
const experts = await User.getInstances({
|
||||||
|
tags: { $all: ['typescript', 'mongodb'] },
|
||||||
|
skills: { $size: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logical operators
|
||||||
|
const complex = await Order.getInstances({
|
||||||
|
$and: [
|
||||||
|
{ status: 'active' },
|
||||||
|
{ $or: [{ priority: 'high' }, { value: { $gte: 1000 } }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deep nested object queries
|
||||||
|
const users = await User.getInstances({
|
||||||
|
profile: {
|
||||||
|
settings: {
|
||||||
|
notifications: { email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dot notation
|
||||||
|
const sameUsers = await User.getInstances({
|
||||||
|
'profile.settings.notifications.email': true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regex patterns
|
||||||
|
const gmailUsers = await User.getInstances({
|
||||||
|
email: { $regex: '@gmail\\.com$', $options: 'i' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security**: The `$where` operator is automatically blocked to prevent NoSQL injection. Unknown operators trigger warnings.
|
||||||
|
|
||||||
|
### 🔎 Lucene-Powered Search
|
||||||
|
|
||||||
|
Mark fields with `@searchable()` to enable a built-in search engine with automatic compound text indexing:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Collection(() => db)
|
@Collection(() => db)
|
||||||
class Product extends SmartDataDbDoc<Product, Product> {
|
class Product extends SmartDataDbDoc<Product, Product> {
|
||||||
@unI() public id: string;
|
@unI() public id!: string;
|
||||||
@svDb() @searchable() public name: string;
|
@svDb() @searchable() public name!: string;
|
||||||
@svDb() @searchable() public description: string;
|
@svDb() @searchable() public description!: string;
|
||||||
@svDb() @searchable() public category: string;
|
@svDb() @searchable() public category!: string;
|
||||||
@svDb() public price: number;
|
@svDb() public price!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 Exact phrase search
|
// Simple text search across all @searchable fields
|
||||||
await Product.search('"MacBook Pro 16"');
|
const results = await Product.search('laptop');
|
||||||
|
|
||||||
// 🔤 Wildcard search
|
// Field-specific search
|
||||||
await Product.search('Mac*');
|
const electronics = await Product.search('category:Electronics');
|
||||||
|
|
||||||
// 📁 Field-specific search
|
// Wildcard
|
||||||
await Product.search('category:Electronics');
|
const matches = await Product.search('Mac*');
|
||||||
|
|
||||||
// 🧮 Boolean operators
|
// Boolean operators (AND, OR, NOT)
|
||||||
await Product.search('(laptop OR desktop) AND NOT gaming');
|
const query = await Product.search('laptop AND NOT gaming');
|
||||||
|
|
||||||
// 🔐 Secure multi-field search
|
// Phrase search
|
||||||
await Product.search('TypeScript MongoDB'); // Automatically escaped
|
const exact = await Product.search('"MacBook Pro"');
|
||||||
|
|
||||||
// 🏷️ Scoped search with filters
|
// Range queries
|
||||||
await Product.search('laptop', {
|
const midRange = await Product.search('price:[100 TO 500]');
|
||||||
filter: { price: { $lt: 2000 } },
|
|
||||||
validate: (p) => p.inStock === true
|
// Combined with MongoDB filters and post-fetch validation
|
||||||
|
const affordable = await Product.search('laptop', {
|
||||||
|
filter: { price: { $lte: 1500 } },
|
||||||
|
validate: async (p) => p.price > 0,
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 💾 EasyStore - Type-Safe Key-Value Storage
|
|
||||||
|
|
||||||
Perfect for configuration, caching, and shared state:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface AppConfig {
|
|
||||||
apiKey: string;
|
|
||||||
features: {
|
|
||||||
darkMode: boolean;
|
|
||||||
notifications: boolean;
|
|
||||||
};
|
|
||||||
limits: {
|
|
||||||
maxUsers: number;
|
|
||||||
maxStorage: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a type-safe store
|
|
||||||
const config = await db.createEasyStore<AppConfig>('app-config');
|
|
||||||
|
|
||||||
// Write with full IntelliSense
|
|
||||||
await config.writeKey('features', {
|
|
||||||
darkMode: true,
|
|
||||||
notifications: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read with guaranteed types
|
|
||||||
const features = await config.readKey('features');
|
|
||||||
// TypeScript knows: features.darkMode is boolean
|
|
||||||
|
|
||||||
// Atomic operations
|
|
||||||
await config.writeAll({
|
|
||||||
apiKey: 'new-key',
|
|
||||||
limits: { maxUsers: 1000, maxStorage: 5000 }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete a key
|
|
||||||
await config.deleteKey('features');
|
|
||||||
|
|
||||||
// Wipe entire store
|
|
||||||
await config.wipe();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🌐 Distributed Coordination
|
|
||||||
|
|
||||||
Build resilient distributed systems with automatic leader election:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const coordinator = new SmartdataDistributedCoordinator(db);
|
|
||||||
|
|
||||||
// Start coordination with automatic heartbeat
|
|
||||||
await coordinator.start();
|
|
||||||
|
|
||||||
// Check if this instance is the leader
|
|
||||||
const eligibleLeader = await coordinator.getEligibleLeader();
|
|
||||||
const isLeader = eligibleLeader?.id === coordinator.id;
|
|
||||||
|
|
||||||
if (isLeader) {
|
|
||||||
console.log('🎖️ This instance is now the leader!');
|
|
||||||
// Leader-specific tasks are handled internally by leadFunction()
|
|
||||||
// The coordinator automatically manages leader election and failover
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire distributed task requests (for taskbuffer integration)
|
|
||||||
const result = await coordinator.fireDistributedTaskRequest({
|
|
||||||
taskName: 'maintenance',
|
|
||||||
taskExecutionTime: Date.now(),
|
|
||||||
requestResponseId: 'unique-id'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
await coordinator.stop();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 📡 Real-Time Change Streams
|
### 📡 Real-Time Change Streams
|
||||||
|
|
||||||
React to database changes instantly with RxJS integration:
|
Watch for database changes with RxJS subjects and EventEmitter support:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Watch for specific changes
|
|
||||||
const watcher = await User.watch(
|
const watcher = await User.watch(
|
||||||
{ active: true }, // Only watch active users
|
{ status: 'active' },
|
||||||
{
|
{
|
||||||
fullDocument: 'updateLookup', // Include full document
|
fullDocument: 'updateLookup',
|
||||||
bufferTimeMs: 100, // Buffer for performance
|
bufferTimeMs: 100, // optional: buffer changes via RxJS
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe with RxJS (emits documents or arrays if buffered)
|
// RxJS subscription
|
||||||
watcher.changeSubject
|
watcher.changeSubject.subscribe((user) => {
|
||||||
.pipe(
|
console.log('User changed:', user);
|
||||||
filter(user => user !== null), // Filter out deletions
|
|
||||||
)
|
|
||||||
.subscribe(user => {
|
|
||||||
console.log(`📢 User change detected: ${user.username}`);
|
|
||||||
sendNotification(user.email);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Or use EventEmitter pattern
|
|
||||||
watcher.on('change', (user) => {
|
|
||||||
if (user) {
|
|
||||||
console.log(`✏️ User changed: ${user.username}`);
|
|
||||||
} else {
|
|
||||||
console.log(`👋 User deleted`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up when done
|
// Or EventEmitter style
|
||||||
await watcher.stop();
|
watcher.on('change', (user) => {
|
||||||
|
console.log('User changed:', user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await watcher.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🎯 Cursor Operations for Large Datasets
|
### 🔄 Cursor Streaming
|
||||||
|
|
||||||
Handle millions of documents efficiently:
|
Process large datasets without memory pressure:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Create a cursor with modifiers
|
|
||||||
const cursor = await User.getCursor(
|
const cursor = await User.getCursor(
|
||||||
{ active: true },
|
{ status: 'active' },
|
||||||
{
|
{
|
||||||
modifier: (cursor) => cursor
|
modifier: (c) => c.sort({ createdAt: -1 }).limit(10000),
|
||||||
.sort({ createdAt: -1 })
|
},
|
||||||
.skip(100)
|
|
||||||
.limit(50)
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stream processing - memory efficient
|
// Iterate one-by-one
|
||||||
await cursor.forEach(async (user) => {
|
await cursor.forEach(async (user) => {
|
||||||
await processUser(user);
|
await processUser(user);
|
||||||
// Processes one at a time, minimal memory usage
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manual iteration
|
// Or collect into an array
|
||||||
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();
|
const users = await cursor.toArray();
|
||||||
|
|
||||||
// Always clean up
|
// Always close when done
|
||||||
await cursor.close();
|
await cursor.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔐 Transaction Support
|
### 🔐 Transactions
|
||||||
|
|
||||||
Ensure data consistency with MongoDB transactions:
|
Ensure atomic consistency across multiple operations:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const session = db.startSession();
|
const session = db.startSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await session.withTransaction(async () => {
|
await session.withTransaction(async () => {
|
||||||
// All operations in this block are atomic
|
const sender = await User.getInstance({ id: 'user-1' }, { session });
|
||||||
const sender = await User.getInstance(
|
|
||||||
{ id: 'user-1' },
|
|
||||||
session // Pass session to all operations
|
|
||||||
);
|
|
||||||
sender.balance -= 100;
|
sender.balance -= 100;
|
||||||
await sender.save({ session });
|
await sender.save({ session });
|
||||||
|
|
||||||
const receiver = await User.getInstance(
|
const receiver = await User.getInstance({ id: 'user-2' }, { session });
|
||||||
{ id: 'user-2' },
|
|
||||||
session
|
|
||||||
);
|
|
||||||
receiver.balance += 100;
|
receiver.balance += 100;
|
||||||
await receiver.save({ session });
|
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();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🎨 Custom Serialization
|
### 💾 EasyStore — Type-Safe Key-Value Storage
|
||||||
|
|
||||||
Handle complex data types with custom serializers:
|
Built on top of SmartData collections, EasyStore provides simple key-value persistence:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class Document extends SmartDataDbDoc<Document, Document> {
|
interface AppConfig {
|
||||||
|
apiKey: string;
|
||||||
|
features: { darkMode: boolean; notifications: boolean };
|
||||||
|
limits: { maxUsers: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await db.createEasyStore<AppConfig>('app-config');
|
||||||
|
|
||||||
|
// Write
|
||||||
|
await config.writeKey('features', { darkMode: true, notifications: false });
|
||||||
|
|
||||||
|
// Read
|
||||||
|
const features = await config.readKey('features');
|
||||||
|
// TypeScript knows: features.darkMode is boolean ✅
|
||||||
|
|
||||||
|
// Read all
|
||||||
|
const all = await config.readAll();
|
||||||
|
|
||||||
|
// Write multiple keys
|
||||||
|
await config.writeAll({ apiKey: 'new-key', limits: { maxUsers: 500 } });
|
||||||
|
|
||||||
|
// Delete a key
|
||||||
|
await config.deleteKey('features');
|
||||||
|
|
||||||
|
// Wipe the store
|
||||||
|
await config.wipe();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🌐 Distributed Coordination
|
||||||
|
|
||||||
|
Built-in leader election using MongoDB for coordination, integrating with `@push.rocks/taskbuffer`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SmartdataDistributedCoordinator } from '@push.rocks/smartdata';
|
||||||
|
|
||||||
|
const coordinator = new SmartdataDistributedCoordinator(db);
|
||||||
|
|
||||||
|
// Start coordination — automatic heartbeat and leader election
|
||||||
|
await coordinator.start();
|
||||||
|
|
||||||
|
// Fire distributed task requests
|
||||||
|
const result = await coordinator.fireDistributedTaskRequest({
|
||||||
|
submitterId: 'instance-1',
|
||||||
|
requestResponseId: 'unique-id',
|
||||||
|
taskName: 'process-payments',
|
||||||
|
taskVersion: '1.0.0',
|
||||||
|
taskExecutionTime: Date.now(),
|
||||||
|
taskExecutionTimeout: 30000,
|
||||||
|
taskExecutionParallel: 1,
|
||||||
|
status: 'requesting',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown with leadership handoff
|
||||||
|
await coordinator.stop();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🎨 Custom Serialization
|
||||||
|
|
||||||
|
Transform data on its way in and out of MongoDB:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Collection(() => db)
|
||||||
|
class Doc extends SmartDataDbDoc<Doc, Doc> {
|
||||||
@svDb({
|
@svDb({
|
||||||
// Encrypt sensitive data before storing
|
|
||||||
serialize: async (value) => {
|
|
||||||
return await encrypt(value);
|
|
||||||
},
|
|
||||||
// Decrypt when reading
|
|
||||||
deserialize: async (value) => {
|
|
||||||
return await decrypt(value);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
public sensitiveData: 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),
|
serialize: (set) => Array.from(set),
|
||||||
deserialize: (arr) => new Set(arr)
|
deserialize: (arr) => new Set(arr),
|
||||||
})
|
})
|
||||||
public tags: Set<string>;
|
public tags!: Set<string>;
|
||||||
|
|
||||||
|
@svDb({
|
||||||
|
serialize: (date) => date?.toISOString(),
|
||||||
|
deserialize: (str) => (str ? new Date(str) : null),
|
||||||
|
})
|
||||||
|
public scheduledAt!: Date | null;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🎣 Lifecycle Hooks
|
### 🎣 Lifecycle Hooks
|
||||||
|
|
||||||
Add custom logic at any point in the document lifecycle:
|
Add custom logic before and after save/delete:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Collection(() => db)
|
@Collection(() => db)
|
||||||
class Order extends SmartDataDbDoc<Order, Order> {
|
class Order extends SmartDataDbDoc<Order, Order> {
|
||||||
@unI() public id: string;
|
@unI() public id!: string;
|
||||||
@svDb() public items: OrderItem[];
|
@svDb() public items!: Array<{ product: string; quantity: number; price: number }>;
|
||||||
@svDb() public total: number;
|
@svDb() public totalAmount!: number;
|
||||||
@svDb() public status: 'pending' | 'paid' | 'shipped';
|
|
||||||
|
|
||||||
// Validate and calculate before saving
|
|
||||||
async beforeSave() {
|
async beforeSave() {
|
||||||
this.total = this.items.reduce((sum, item) =>
|
this.totalAmount = this.items.reduce((s, i) => s + i.price * i.quantity, 0);
|
||||||
sum + (item.price * item.quantity), 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.items.length === 0) {
|
|
||||||
throw new Error('Order must have items!');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notifications after saving
|
|
||||||
async afterSave() {
|
async afterSave() {
|
||||||
if (this.status === 'paid') {
|
await notificationService.orderUpdated(this.id);
|
||||||
await sendOrderConfirmation(this);
|
|
||||||
await notifyWarehouse(this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent deletion of shipped orders
|
|
||||||
async beforeDelete() {
|
async beforeDelete() {
|
||||||
if (this.status === 'shipped') {
|
if (this.totalAmount > 0) throw new Error('Cannot delete non-zero orders');
|
||||||
throw new Error('Cannot delete shipped orders!');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit logging
|
|
||||||
async afterDelete() {
|
async afterDelete() {
|
||||||
await auditLog.record({
|
await cache.delete(`order:${this.id}`);
|
||||||
action: 'order_deleted',
|
|
||||||
orderId: this.id,
|
|
||||||
timestamp: new Date()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔍 Deep Query Type Safety
|
### 🏗️ Indexing
|
||||||
|
|
||||||
TypeScript knows your nested object structure:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface UserProfile {
|
|
||||||
personal: {
|
|
||||||
name: {
|
|
||||||
first: string;
|
|
||||||
last: string;
|
|
||||||
};
|
|
||||||
age: number;
|
|
||||||
};
|
|
||||||
address: {
|
|
||||||
street: string;
|
|
||||||
city: string;
|
|
||||||
country: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Collection(() => db)
|
@Collection(() => db)
|
||||||
class Profile extends SmartDataDbDoc<Profile, Profile> {
|
class HighPerformanceDoc extends SmartDataDbDoc<HighPerformanceDoc, HighPerformanceDoc> {
|
||||||
@unI() public id: string;
|
@unI()
|
||||||
@svDb() public data: UserProfile;
|
public id!: string; // Unique index
|
||||||
}
|
|
||||||
|
|
||||||
// TypeScript enforces correct paths and types!
|
@index()
|
||||||
const profiles = await Profile.getInstances({
|
public userId!: string; // Regular index
|
||||||
'data.personal.name.first': 'John', // ✅ Type-checked
|
|
||||||
'data.address.country': 'USA', // ✅ Type-checked
|
@index({ sparse: true })
|
||||||
'data.personal.age': { $gte: 18 }, // ✅ Type-checked
|
public deletedAt?: Date; // Sparse index — only indexes docs where field exists
|
||||||
// 'data.invalid.path': 'value' // ❌ TypeScript error!
|
|
||||||
});
|
@index({ expireAfterSeconds: 86400 })
|
||||||
|
public sessionToken!: string; // TTL index — auto-expires after 24h
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛡️ Security Features
|
### 🔧 Connection Options
|
||||||
|
|
||||||
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
|
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ DO: Use connection pooling options
|
|
||||||
const db = new SmartdataDb({
|
const db = new SmartdataDb({
|
||||||
mongoDbUrl: 'mongodb://localhost:27017/myapp',
|
mongoDbUrl: 'mongodb://localhost:27017',
|
||||||
maxPoolSize: 50, // Adjust based on your load
|
mongoDbName: 'myapp',
|
||||||
maxIdleTimeMS: 300000 // 5 minutes
|
mongoDbUser: 'admin',
|
||||||
});
|
mongoDbPass: 's3cret',
|
||||||
|
|
||||||
// ✅ DO: Always close connections on shutdown
|
// Connection pool tuning (all optional)
|
||||||
process.on('SIGTERM', async () => {
|
maxPoolSize: 100, // Max connections (default: 100)
|
||||||
await db.close();
|
maxIdleTimeMS: 300000, // Close idle connections after 5min (default)
|
||||||
process.exit(0);
|
serverSelectionTimeoutMS: 30000, // Timeout for server selection
|
||||||
|
socketTimeoutMS: 30000, // Socket timeout to prevent hung operations
|
||||||
});
|
});
|
||||||
|
|
||||||
// ❌ DON'T: Create multiple DB instances for the same database
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Performance Optimization
|
## 📚 Decorators Reference
|
||||||
```typescript
|
|
||||||
// ✅ DO: Use cursors for large datasets
|
|
||||||
const cursor = await LargeCollection.getCursor({});
|
|
||||||
await cursor.forEach(async (doc) => {
|
|
||||||
await processDocument(doc);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ❌ DON'T: Load everything into memory
|
| Decorator | Target | Description |
|
||||||
const allDocs = await LargeCollection.getInstances({}); // Could OOM!
|
|-----------|--------|-------------|
|
||||||
|
| `@Collection(dbGetter)` | Class | Binds a document class to a MongoDB collection |
|
||||||
|
| `@managed(managerGetter?)` | Class | Like `@Collection` but controlled by a manager instance |
|
||||||
|
| `@unI()` | Field | Marks as unique index + saveable |
|
||||||
|
| `@svDb(options?)` | Field | Marks field as saveable, with optional `serialize`/`deserialize` |
|
||||||
|
| `@index(options?)` | Field | Creates a regular MongoDB index |
|
||||||
|
| `@searchable()` | Field | Enables Lucene-style text search on this field |
|
||||||
|
| `@globalSvDb()` | Field | Marks field as globally saveable across all doc types |
|
||||||
|
|
||||||
// ✅ DO: Create indexes for frequent queries
|
## 📚 API Reference
|
||||||
@index() public frequentlyQueried: string;
|
|
||||||
|
|
||||||
// ✅ DO: Use projections when you don't need all fields
|
### Core Classes
|
||||||
const cursor = await User.getCursor(
|
|
||||||
{ active: true },
|
|
||||||
{ projection: { username: 1, email: 1 } }
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Safety
|
| Class | Description |
|
||||||
```typescript
|
|-------|-------------|
|
||||||
// ✅ DO: Leverage TypeScript's type system
|
| `SmartdataDb` | Database connection, session management, EasyStore factory |
|
||||||
interface StrictUserData {
|
| `SmartDataDbDoc<T, TImpl>` | Base class for all document models |
|
||||||
verified: boolean;
|
| `SmartdataCollection<T>` | Underlying collection manager (usually accessed indirectly) |
|
||||||
roles: ('admin' | 'user' | 'guest')[];
|
| `SmartdataDbCursor<T>` | Cursor for streaming large result sets |
|
||||||
}
|
| `SmartdataDbWatcher<T>` | Change stream watcher with RxJS + EventEmitter |
|
||||||
|
| `SmartdataDistributedCoordinator` | Leader election and distributed task coordination |
|
||||||
|
| `EasyStore<T>` | Type-safe key-value store backed by a collection |
|
||||||
|
|
||||||
@Collection(() => db)
|
### Key Static Methods on `SmartDataDbDoc`
|
||||||
class StrictUser extends SmartDataDbDoc<StrictUser, StrictUser> {
|
|
||||||
@svDb() public data: StrictUserData; // Fully typed!
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ DO: Use DeepQuery for nested queries
|
| Method | Description |
|
||||||
import { DeepQuery } from '@push.rocks/smartdata';
|
|--------|-------------|
|
||||||
|
| `getInstances(filter, opts?)` | Find multiple documents |
|
||||||
|
| `getInstance(filter, opts?)` | Find a single document (or `null`) |
|
||||||
|
| `getCursor(filter, opts?)` | Get a streaming cursor |
|
||||||
|
| `getCount(filter?)` | Count matching documents |
|
||||||
|
| `watch(filter, opts?)` | Watch for real-time changes |
|
||||||
|
| `search(query, opts?)` | Lucene-style full-text search |
|
||||||
|
| `forEach(filter, fn)` | Iterate all matches with a callback |
|
||||||
|
| `getNewId(length?)` | Generate a class-prefixed unique ID |
|
||||||
|
| `createSearchFilter(luceneQuery)` | Convert Lucene query to MongoDB filter |
|
||||||
|
| `getSearchableFields()` | List all `@searchable()` fields |
|
||||||
|
|
||||||
const query: DeepQuery<StrictUser> = {
|
### Key Instance Methods on `SmartDataDbDoc`
|
||||||
'data.verified': true,
|
|
||||||
'data.roles': { $in: ['admin'] }
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Performance Benchmarks
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
SmartData has been battle-tested in production environments:
|
| `save(opts?)` | Insert or update the document |
|
||||||
|
| `delete(opts?)` | Delete the document |
|
||||||
- **🚀 Connection Pooling**: 100+ concurrent connections with <10ms latency
|
| `updateFromDb()` | Refresh fields from the database |
|
||||||
- **⚡ Query Performance**: Indexed searches return in <5ms for millions of documents
|
| `saveDeep(savedMap?)` | Recursively save referenced documents |
|
||||||
- **📦 Memory Efficient**: Stream processing keeps memory under 100MB for any dataset size
|
| `createSavableObject()` | Serialize to a plain object for persistence |
|
||||||
- **🔄 Real-time Updates**: Change streams deliver updates in <50ms
|
| `createIdentifiableObject()` | Extract unique index fields for filtering |
|
||||||
|
|
||||||
## 🤝 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 licensed under the MIT License. A copy of the license can be found in the [LICENSE](./license) 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.
|
**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.
|
||||||
|
|
||||||
### Trademarks
|
### Trademarks
|
||||||
|
|
||||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District court Bremen HRB 35230 HB, Germany
|
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 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.
|
||||||
|
|||||||
70
test/test.collectionfactory.ts
Normal file
70
test/test.collectionfactory.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
|
import * as smartdata from '../ts/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for:
|
||||||
|
*
|
||||||
|
* 1. CollectionFactory per-db cache (previously keyed by class name
|
||||||
|
* alone, which made two SmartdataDb instances share a single
|
||||||
|
* collection bound to whichever db happened to request it first).
|
||||||
|
* 2. EasyStore.replace() — new atomic-replace method that clears keys
|
||||||
|
* not present in the new object. EasyStore.writeAll() still merges
|
||||||
|
* for backward compatibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let smartmongoInstance: smartmongo.SmartMongo;
|
||||||
|
let dbA: smartdata.SmartdataDb;
|
||||||
|
let dbB: smartdata.SmartdataDb;
|
||||||
|
|
||||||
|
tap.test('setup: two dbs against the same replica set', async () => {
|
||||||
|
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
|
||||||
|
const desc = await smartmongoInstance.getMongoDescriptor();
|
||||||
|
dbA = new smartdata.SmartdataDb({ ...desc, mongoDbName: 'cf_test_a' });
|
||||||
|
dbB = new smartdata.SmartdataDb({ ...desc, mongoDbName: 'cf_test_b' });
|
||||||
|
await dbA.init();
|
||||||
|
await dbB.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('CollectionFactory: same class name in two dbs yields two collections', async () => {
|
||||||
|
const easyA = await dbA.createEasyStore<{ marker: string }>('shared');
|
||||||
|
const easyB = await dbB.createEasyStore<{ marker: string }>('shared');
|
||||||
|
await easyA.writeKey('marker', 'A');
|
||||||
|
await easyB.writeKey('marker', 'B');
|
||||||
|
const fromA = await easyA.readKey('marker');
|
||||||
|
const fromB = await easyB.readKey('marker');
|
||||||
|
expect(fromA).toEqual('A');
|
||||||
|
// Under the old singleton bug, this would be 'A' because the second
|
||||||
|
// createEasyStore() call would receive the collection bound to dbA.
|
||||||
|
expect(fromB).toEqual('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EasyStore.replace: drops keys not present in the new object', async () => {
|
||||||
|
const store = await dbA.createEasyStore<{ a?: string; b?: string }>('replace_test');
|
||||||
|
await store.writeKey('a', '1');
|
||||||
|
await store.writeKey('b', '2');
|
||||||
|
expect(await store.readKey('a')).toEqual('1');
|
||||||
|
expect(await store.readKey('b')).toEqual('2');
|
||||||
|
await store.replace({ a: 'only-a' });
|
||||||
|
expect(await store.readKey('a')).toEqual('only-a');
|
||||||
|
// `b` must be gone — this is the whole point of replace() over writeAll().
|
||||||
|
expect(await store.readKey('b')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EasyStore.writeAll: still merges (back-compat)', async () => {
|
||||||
|
const store = await dbA.createEasyStore<{ a?: string; b?: string }>('merge_test');
|
||||||
|
await store.writeAll({ a: '1' });
|
||||||
|
await store.writeAll({ b: '2' });
|
||||||
|
expect(await store.readKey('a')).toEqual('1');
|
||||||
|
expect(await store.readKey('b')).toEqual('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('teardown', async () => {
|
||||||
|
await dbA.mongoDb.dropDatabase();
|
||||||
|
await dbB.mongoDb.dropDatabase();
|
||||||
|
await dbA.close();
|
||||||
|
await dbB.close();
|
||||||
|
await smartmongoInstance.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import { smartunique } from '../ts/plugins.js';
|
import { smartunique } from '../ts/plugins.js';
|
||||||
import * as smartdata from '../ts/index.js';
|
import * as smartdata from '../ts/index.js';
|
||||||
|
|||||||
255
test/test.deno.ts
Normal file
255
test/test.deno.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// TODO: Decorator support during testing for bun and deno in @git.zone/tstest
|
||||||
|
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
|
import { smartunique } from '../ts/plugins.js';
|
||||||
|
|
||||||
|
import * as mongodb from 'mongodb';
|
||||||
|
|
||||||
|
const testQenv = new Qenv(process.cwd(), process.cwd() + '/.nogit/');
|
||||||
|
|
||||||
|
console.log(process.memoryUsage());
|
||||||
|
|
||||||
|
// the tested module
|
||||||
|
import * as smartdata from '../ts/index.js';
|
||||||
|
|
||||||
|
// =======================================
|
||||||
|
// Connecting to the database server
|
||||||
|
// =======================================
|
||||||
|
|
||||||
|
let smartmongoInstance: smartmongo.SmartMongo;
|
||||||
|
let testDb: smartdata.SmartdataDb;
|
||||||
|
|
||||||
|
const totalCars = 2000;
|
||||||
|
|
||||||
|
tap.test('should create a testinstance as database', async () => {
|
||||||
|
const databaseName = `test-smartdata-deno-${smartunique.shortId()}`;
|
||||||
|
testDb = new smartdata.SmartdataDb({
|
||||||
|
mongoDbUrl: await testQenv.getEnvVarOnDemand('MONGODB_URL'),
|
||||||
|
mongoDbName: databaseName,
|
||||||
|
});
|
||||||
|
await testDb.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =======================================
|
||||||
|
// The actual tests
|
||||||
|
// =======================================
|
||||||
|
|
||||||
|
// ------
|
||||||
|
// Collections
|
||||||
|
// ------
|
||||||
|
|
||||||
|
@smartdata.Collection(() => {
|
||||||
|
return testDb;
|
||||||
|
})
|
||||||
|
class Car extends smartdata.SmartDataDbDoc<Car, Car> {
|
||||||
|
@smartdata.unI()
|
||||||
|
public index: string = smartunique.shortId();
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
public color: string;
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
public brand: string;
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
public testBuffer = Buffer.from('hello');
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
deepData = {
|
||||||
|
sodeep: 'yes',
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(colorArg: string, brandArg: string) {
|
||||||
|
super();
|
||||||
|
this.color = colorArg;
|
||||||
|
this.brand = brandArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('should create a new id', async () => {
|
||||||
|
const newid = await Car.getNewId();
|
||||||
|
console.log(newid);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should save the car to the db', async (toolsArg) => {
|
||||||
|
const myCar = new Car('red', 'Volvo');
|
||||||
|
console.log('Car.collection.smartdataDb:', (Car.collection as any).smartdataDb?.mongoDb?.databaseName);
|
||||||
|
console.log('Car.collection.collectionName:', (Car.collection as any).collectionName);
|
||||||
|
console.log('testDb.mongoDb.databaseName:', testDb.mongoDb.databaseName);
|
||||||
|
await myCar.save();
|
||||||
|
|
||||||
|
const myCar2 = new Car('red', 'Volvo');
|
||||||
|
await myCar2.save();
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
const gottenCarInstance = await Car.getInstance({});
|
||||||
|
console.log(gottenCarInstance.testBuffer instanceof mongodb.Binary);
|
||||||
|
process.memoryUsage();
|
||||||
|
do {
|
||||||
|
const myCar3 = new Car('red', 'Renault');
|
||||||
|
await myCar3.save();
|
||||||
|
counter++;
|
||||||
|
if (counter % 100 === 0) {
|
||||||
|
console.log(
|
||||||
|
`Filled database with ${counter} of ${totalCars} Cars and memory usage ${
|
||||||
|
process.memoryUsage().rss / 1e6
|
||||||
|
} MB`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} while (counter < totalCars);
|
||||||
|
console.log(process.memoryUsage());
|
||||||
|
|
||||||
|
// DEBUG: Check what's actually in the database
|
||||||
|
const savedCount = await Car.getCount({});
|
||||||
|
console.log('Total cars saved in DB:', savedCount);
|
||||||
|
const renaultCount = await Car.getCount({ brand: 'Renault' });
|
||||||
|
console.log('Renault cars in DB:', renaultCount);
|
||||||
|
|
||||||
|
// Check what's actually in the first saved car
|
||||||
|
const firstCar = await Car.getInstance({});
|
||||||
|
console.log('First car data:', JSON.stringify({
|
||||||
|
color: firstCar?.color,
|
||||||
|
brand: firstCar?.brand,
|
||||||
|
index: firstCar?.index
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('expect to get instance of Car with shallow match', async () => {
|
||||||
|
console.log('Before query - testDb.mongoDb.databaseName:', testDb.mongoDb.databaseName);
|
||||||
|
console.log('Before query - Car.collection.smartdataDb:', (Car.collection as any).smartdataDb?.mongoDb?.databaseName);
|
||||||
|
console.log('Before query - Car.collection.collectionName:', (Car.collection as any).collectionName);
|
||||||
|
|
||||||
|
const totalQueryCycles = totalCars / 2;
|
||||||
|
let counter = 0;
|
||||||
|
do {
|
||||||
|
const timeStart = Date.now();
|
||||||
|
const myCars = await Car.getInstances({
|
||||||
|
brand: 'Renault',
|
||||||
|
});
|
||||||
|
if (counter % 10 === 0) {
|
||||||
|
console.log(
|
||||||
|
`performed ${counter} of ${totalQueryCycles} total query cycles: took ${
|
||||||
|
Date.now() - timeStart
|
||||||
|
}ms to query a set of 2000 with memory footprint ${process.memoryUsage().rss / 1e6} MB`,
|
||||||
|
);
|
||||||
|
console.log('myCars.length:', myCars.length);
|
||||||
|
console.log('myCars[0]:', myCars[0]);
|
||||||
|
}
|
||||||
|
expect(myCars[0].deepData.sodeep).toEqual('yes');
|
||||||
|
expect(myCars[0].brand).toEqual('Renault');
|
||||||
|
counter++;
|
||||||
|
} while (counter < totalQueryCycles);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('expect to get instance of Car with deep match', async () => {
|
||||||
|
const totalQueryCycles = totalCars / 6;
|
||||||
|
let counter = 0;
|
||||||
|
do {
|
||||||
|
const timeStart = Date.now();
|
||||||
|
const myCars2 = await Car.getInstances({
|
||||||
|
deepData: {
|
||||||
|
sodeep: 'yes',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (counter % 10 === 0) {
|
||||||
|
console.log(
|
||||||
|
`performed ${counter} of ${totalQueryCycles} total query cycles: took ${
|
||||||
|
Date.now() - timeStart
|
||||||
|
}ms to deep query a set of 2000 with memory footprint ${process.memoryUsage().rss / 1e6} MB`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(myCars2[0].deepData.sodeep).toEqual('yes');
|
||||||
|
expect(myCars2[0].brand).toEqual('Volvo');
|
||||||
|
counter++;
|
||||||
|
} while (counter < totalQueryCycles);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('expect to get instance of Car and update it', async () => {
|
||||||
|
const myCar = await Car.getInstance<Car>({
|
||||||
|
brand: 'Volvo',
|
||||||
|
});
|
||||||
|
expect(myCar.color).toEqual('red');
|
||||||
|
myCar.color = 'blue';
|
||||||
|
await myCar.save();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should be able to delete an instance of car', async () => {
|
||||||
|
const myCars = await Car.getInstances({
|
||||||
|
brand: 'Volvo',
|
||||||
|
color: 'blue',
|
||||||
|
});
|
||||||
|
console.log(myCars);
|
||||||
|
expect(myCars[0].color).toEqual('blue');
|
||||||
|
for (const myCar of myCars) {
|
||||||
|
await myCar.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const myCar2 = await Car.getInstance<Car>({
|
||||||
|
brand: 'Volvo',
|
||||||
|
});
|
||||||
|
expect(myCar2.color).toEqual('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
// tslint:disable-next-line: max-classes-per-file
|
||||||
|
@smartdata.Collection(() => {
|
||||||
|
return testDb;
|
||||||
|
})
|
||||||
|
class Truck extends smartdata.SmartDataDbDoc<Car, Car> {
|
||||||
|
@smartdata.unI()
|
||||||
|
public id: string = smartunique.shortId();
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
public color: string;
|
||||||
|
|
||||||
|
@smartdata.svDb()
|
||||||
|
public brand: string;
|
||||||
|
|
||||||
|
constructor(colorArg: string, brandArg: string) {
|
||||||
|
super();
|
||||||
|
this.color = colorArg;
|
||||||
|
this.brand = brandArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('should store a new Truck', async () => {
|
||||||
|
const truck = new Truck('blue', 'MAN');
|
||||||
|
await truck.save();
|
||||||
|
const myTruck2 = await Truck.getInstance({ color: 'blue' });
|
||||||
|
expect(myTruck2.color).toEqual('blue');
|
||||||
|
myTruck2.color = 'red';
|
||||||
|
await myTruck2.save();
|
||||||
|
const myTruck3 = await Truck.getInstance({ color: 'blue' });
|
||||||
|
expect(myTruck3).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return a count', async () => {
|
||||||
|
const truckCount = await Truck.getCount();
|
||||||
|
expect(truckCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should use a cursor', async () => {
|
||||||
|
const cursor = await Car.getCursor({});
|
||||||
|
let counter = 0;
|
||||||
|
await cursor.forEach(async (carArg) => {
|
||||||
|
counter++;
|
||||||
|
counter % 50 === 0 ? console.log(`50 more of ${carArg.color}`) : null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =======================================
|
||||||
|
// close the database connection
|
||||||
|
// =======================================
|
||||||
|
tap.test('close', async () => {
|
||||||
|
if (smartmongoInstance) {
|
||||||
|
await smartmongoInstance.stopAndDumpToDir('./.nogit/dbdump/test.ts');
|
||||||
|
} else {
|
||||||
|
await testDb.mongoDb.dropDatabase();
|
||||||
|
await testDb.close();
|
||||||
|
}
|
||||||
|
setTimeout(() => process.exit(), 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start({ throwOnError: true });
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import type * as taskbuffer from '@push.rocks/taskbuffer';
|
import type * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import { smartunique } from '../ts/plugins.js';
|
import { smartunique } from '../ts/plugins.js';
|
||||||
|
|||||||
819
test/test.filters.ts
Normal file
819
test/test.filters.ts
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
|
import * as smartdata from '../ts/index.js';
|
||||||
|
|
||||||
|
const { SmartdataDb, Collection, svDb, unI, index } = smartdata;
|
||||||
|
|
||||||
|
let smartmongoInstance: smartmongo.SmartMongo;
|
||||||
|
let testDb: smartdata.SmartdataDb;
|
||||||
|
|
||||||
|
// Define test document classes
|
||||||
|
@Collection(() => testDb)
|
||||||
|
class TestUser extends smartdata.SmartDataDbDoc<TestUser, TestUser> {
|
||||||
|
@unI()
|
||||||
|
public id: string = smartunique.shortId();
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public age: number;
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public email: string;
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public roles: string[];
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public tags: string[];
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public status: 'active' | 'inactive' | 'pending';
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public metadata: {
|
||||||
|
lastLogin?: Date;
|
||||||
|
loginCount?: number;
|
||||||
|
preferences?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public scores: number[];
|
||||||
|
|
||||||
|
constructor(data: Partial<TestUser> = {}) {
|
||||||
|
super();
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Collection(() => testDb)
|
||||||
|
class TestOrder extends smartdata.SmartDataDbDoc<TestOrder, TestOrder> {
|
||||||
|
@unI()
|
||||||
|
public id: string = smartunique.shortId();
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public userId: string;
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public items: Array<{
|
||||||
|
product: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public totalAmount: number;
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public status: string;
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public tags: string[];
|
||||||
|
|
||||||
|
constructor(data: Partial<TestOrder> = {}) {
|
||||||
|
super();
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup and teardown
|
||||||
|
tap.test('should create a test database instance', async () => {
|
||||||
|
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
|
||||||
|
testDb = new smartdata.SmartdataDb(await smartmongoInstance.getMongoDescriptor());
|
||||||
|
await testDb.init();
|
||||||
|
expect(testDb).toBeInstanceOf(SmartdataDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create test data', async () => {
|
||||||
|
// Create test users
|
||||||
|
const users = [
|
||||||
|
new TestUser({
|
||||||
|
name: 'John Doe',
|
||||||
|
age: 30,
|
||||||
|
email: 'john@example.com',
|
||||||
|
roles: ['admin', 'user'],
|
||||||
|
tags: ['javascript', 'nodejs', 'mongodb'],
|
||||||
|
status: 'active',
|
||||||
|
metadata: { loginCount: 5, lastLogin: new Date() },
|
||||||
|
scores: [85, 90, 78]
|
||||||
|
}),
|
||||||
|
new TestUser({
|
||||||
|
name: 'Jane Smith',
|
||||||
|
age: 25,
|
||||||
|
email: 'jane@example.com',
|
||||||
|
roles: ['user'],
|
||||||
|
tags: ['python', 'mongodb'],
|
||||||
|
status: 'active',
|
||||||
|
metadata: { loginCount: 3 },
|
||||||
|
scores: [92, 88, 95]
|
||||||
|
}),
|
||||||
|
new TestUser({
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
age: 35,
|
||||||
|
email: 'bob@example.com',
|
||||||
|
roles: ['moderator', 'user'],
|
||||||
|
tags: ['javascript', 'react', 'nodejs'],
|
||||||
|
status: 'inactive',
|
||||||
|
metadata: { loginCount: 0 },
|
||||||
|
scores: [70, 75, 80]
|
||||||
|
}),
|
||||||
|
new TestUser({
|
||||||
|
name: 'Alice Brown',
|
||||||
|
age: 28,
|
||||||
|
email: 'alice@example.com',
|
||||||
|
roles: ['admin'],
|
||||||
|
tags: ['typescript', 'angular', 'mongodb'],
|
||||||
|
status: 'active',
|
||||||
|
metadata: { loginCount: 10 },
|
||||||
|
scores: [95, 98, 100]
|
||||||
|
}),
|
||||||
|
new TestUser({
|
||||||
|
name: 'Charlie Wilson',
|
||||||
|
age: 22,
|
||||||
|
email: 'charlie@example.com',
|
||||||
|
roles: ['user'],
|
||||||
|
tags: ['golang', 'kubernetes'],
|
||||||
|
status: 'pending',
|
||||||
|
metadata: { loginCount: 1 },
|
||||||
|
scores: [60, 65]
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test orders
|
||||||
|
const orders = [
|
||||||
|
new TestOrder({
|
||||||
|
userId: users[0].id,
|
||||||
|
items: [
|
||||||
|
{ product: 'laptop', quantity: 1, price: 1200 },
|
||||||
|
{ product: 'mouse', quantity: 2, price: 25 }
|
||||||
|
],
|
||||||
|
totalAmount: 1250,
|
||||||
|
status: 'completed',
|
||||||
|
tags: ['electronics', 'priority']
|
||||||
|
}),
|
||||||
|
new TestOrder({
|
||||||
|
userId: users[1].id,
|
||||||
|
items: [
|
||||||
|
{ product: 'book', quantity: 3, price: 15 },
|
||||||
|
{ product: 'pen', quantity: 5, price: 2 }
|
||||||
|
],
|
||||||
|
totalAmount: 55,
|
||||||
|
status: 'pending',
|
||||||
|
tags: ['stationery']
|
||||||
|
}),
|
||||||
|
new TestOrder({
|
||||||
|
userId: users[0].id,
|
||||||
|
items: [
|
||||||
|
{ product: 'laptop', quantity: 2, price: 1200 },
|
||||||
|
{ product: 'keyboard', quantity: 2, price: 80 }
|
||||||
|
],
|
||||||
|
totalAmount: 2560,
|
||||||
|
status: 'processing',
|
||||||
|
tags: ['electronics', 'bulk']
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
await order.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedUsers = await TestUser.getInstances({});
|
||||||
|
const savedOrders = await TestOrder.getInstances({});
|
||||||
|
expect(savedUsers.length).toEqual(5);
|
||||||
|
expect(savedOrders.length).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= BASIC FILTER TESTS =============
|
||||||
|
tap.test('should filter by simple equality', async () => {
|
||||||
|
const users = await TestUser.getInstances({ name: 'John Doe' });
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('John Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter by multiple fields (implicit AND)', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
status: 'active',
|
||||||
|
age: 30
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('John Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter by nested object fields', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
'metadata.loginCount': 5
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('John Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= COMPREHENSIVE NESTED FILTER TESTS =============
|
||||||
|
tap.test('should filter by nested object with direct object syntax', async () => {
|
||||||
|
// Direct nested object matching (exact match)
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
metadata: {
|
||||||
|
loginCount: 5,
|
||||||
|
lastLogin: (await TestUser.getInstances({}))[0].metadata.lastLogin // Get the exact date
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('John Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter by partial nested object match', async () => {
|
||||||
|
// When using object syntax, only specified fields must match
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
metadata: { loginCount: 5 } // Only checks loginCount, ignores other fields
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('John Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should combine nested object and dot notation', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
metadata: { loginCount: { $gte: 3 } }, // Object syntax with operator
|
||||||
|
'metadata.loginCount': { $lte: 10 } // Dot notation with operator
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(3); // Jane (3), John (5), and Alice (10) have loginCount between 3-10
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter nested fields with operators using dot notation', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
'metadata.loginCount': { $gte: 5 }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2); // John (5) and Alice (10)
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Alice Brown', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter nested fields with multiple operators', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
'metadata.loginCount': { $gte: 3, $lt: 10 }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2); // Jane (3) and John (5)
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Jane Smith', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle deeply nested object structures', async () => {
|
||||||
|
// First, create a user with deep nesting in preferences
|
||||||
|
const deepUser = new TestUser({
|
||||||
|
name: 'Deep Nester',
|
||||||
|
age: 40,
|
||||||
|
email: 'deep@example.com',
|
||||||
|
roles: ['admin'],
|
||||||
|
tags: [],
|
||||||
|
status: 'active',
|
||||||
|
metadata: {
|
||||||
|
loginCount: 1,
|
||||||
|
preferences: {
|
||||||
|
theme: {
|
||||||
|
colors: {
|
||||||
|
primary: '#000000',
|
||||||
|
secondary: '#ffffff'
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
heading: 'Arial',
|
||||||
|
body: 'Helvetica'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
email: true,
|
||||||
|
push: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scores: []
|
||||||
|
});
|
||||||
|
await deepUser.save();
|
||||||
|
|
||||||
|
// Test deep nesting with dot notation
|
||||||
|
const deepResults = await TestUser.getInstances({
|
||||||
|
'metadata.preferences.theme.colors.primary': '#000000'
|
||||||
|
});
|
||||||
|
expect(deepResults.length).toEqual(1);
|
||||||
|
expect(deepResults[0].name).toEqual('Deep Nester');
|
||||||
|
|
||||||
|
// Test deep nesting with operators
|
||||||
|
const boolResults = await TestUser.getInstances({
|
||||||
|
'metadata.preferences.notifications.email': { $eq: true }
|
||||||
|
});
|
||||||
|
expect(boolResults.length).toEqual(1);
|
||||||
|
expect(boolResults[0].name).toEqual('Deep Nester');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await deepUser.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter arrays of nested objects using $elemMatch', async () => {
|
||||||
|
const orders = await TestOrder.getInstances({
|
||||||
|
items: {
|
||||||
|
$elemMatch: {
|
||||||
|
product: 'laptop',
|
||||||
|
price: { $gte: 1000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(orders.length).toEqual(2); // Both laptop orders have price >= 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter nested arrays with dot notation', async () => {
|
||||||
|
// Query for any order that has an item with specific product
|
||||||
|
const orders = await TestOrder.getInstances({
|
||||||
|
'items.product': 'laptop'
|
||||||
|
});
|
||||||
|
expect(orders.length).toEqual(2); // Two orders contain laptops
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should combine nested object filters with logical operators', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
$or: [
|
||||||
|
{ 'metadata.loginCount': { $gte: 10 } }, // Alice has 10
|
||||||
|
{
|
||||||
|
$and: [
|
||||||
|
{ 'metadata.loginCount': { $lt: 5 } }, // Jane has 3, Bob has 0, Charlie has 1
|
||||||
|
{ status: 'active' } // Jane is active, Bob is inactive, Charlie is pending
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2); // Alice (loginCount >= 10), Jane (loginCount < 5 AND active)
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Alice Brown', 'Jane Smith']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle null and undefined in nested fields', async () => {
|
||||||
|
// Users without lastLogin
|
||||||
|
const noLastLogin = await TestUser.getInstances({
|
||||||
|
'metadata.lastLogin': { $exists: false }
|
||||||
|
});
|
||||||
|
expect(noLastLogin.length).toEqual(4); // Everyone except John
|
||||||
|
|
||||||
|
// Users with preferences (none have it set)
|
||||||
|
const withPreferences = await TestUser.getInstances({
|
||||||
|
'metadata.preferences': { $exists: true }
|
||||||
|
});
|
||||||
|
expect(withPreferences.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter nested arrays by size', async () => {
|
||||||
|
// Create an order with specific number of items
|
||||||
|
const multiItemOrder = new TestOrder({
|
||||||
|
userId: 'test-user',
|
||||||
|
items: [
|
||||||
|
{ product: 'item1', quantity: 1, price: 10 },
|
||||||
|
{ product: 'item2', quantity: 2, price: 20 },
|
||||||
|
{ product: 'item3', quantity: 3, price: 30 },
|
||||||
|
{ product: 'item4', quantity: 4, price: 40 }
|
||||||
|
],
|
||||||
|
totalAmount: 100,
|
||||||
|
status: 'pending',
|
||||||
|
tags: ['test']
|
||||||
|
});
|
||||||
|
await multiItemOrder.save();
|
||||||
|
|
||||||
|
const fourItemOrders = await TestOrder.getInstances({
|
||||||
|
items: { $size: 4 }
|
||||||
|
});
|
||||||
|
expect(fourItemOrders.length).toEqual(1);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await multiItemOrder.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle nested field comparison between documents', async () => {
|
||||||
|
// Find users where loginCount equals their age divided by 6 (John: 30/6=5)
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
$and: [
|
||||||
|
{ 'metadata.loginCount': 5 },
|
||||||
|
{ age: 30 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('John Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $in on nested fields', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
'metadata.loginCount': { $in: [0, 1, 5] }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(3); // Bob (0), Charlie (1), John (5)
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Bob Johnson', 'Charlie Wilson', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter nested arrays with $all', async () => {
|
||||||
|
// Create an order with multiple tags
|
||||||
|
const taggedOrder = new TestOrder({
|
||||||
|
userId: 'test-user',
|
||||||
|
items: [{ product: 'test', quantity: 1, price: 10 }],
|
||||||
|
totalAmount: 10,
|
||||||
|
status: 'completed',
|
||||||
|
tags: ['urgent', 'priority', 'electronics']
|
||||||
|
});
|
||||||
|
await taggedOrder.save();
|
||||||
|
|
||||||
|
const priorityElectronics = await TestOrder.getInstances({
|
||||||
|
tags: { $all: ['priority', 'electronics'] }
|
||||||
|
});
|
||||||
|
expect(priorityElectronics.length).toEqual(2); // Original order and new one
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await taggedOrder.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= COMPARISON OPERATOR TESTS =============
|
||||||
|
tap.test('should filter using $gt operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
age: { $gt: 30 }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('Bob Johnson');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $gte operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
age: { $gte: 30 }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Bob Johnson', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $lt operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
age: { $lt: 25 }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('Charlie Wilson');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $lte operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
age: { $lte: 25 }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Charlie Wilson', 'Jane Smith']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $ne operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
status: { $ne: 'active' }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const statuses = users.map(u => u.status).sort();
|
||||||
|
expect(statuses).toEqual(['inactive', 'pending']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using multiple comparison operators', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
age: { $gte: 25, $lt: 30 }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Alice Brown', 'Jane Smith']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= ARRAY OPERATOR TESTS =============
|
||||||
|
tap.test('should filter using $in operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
status: { $in: ['active', 'pending'] }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(4);
|
||||||
|
expect(users.every(u => ['active', 'pending'].includes(u.status))).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter arrays using $in operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
roles: { $in: ['admin'] }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Alice Brown', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $nin operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
status: { $nin: ['inactive', 'pending'] }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(3);
|
||||||
|
expect(users.every(u => u.status === 'active')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter arrays using $all operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
tags: { $all: ['javascript', 'nodejs'] }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Bob Johnson', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter arrays using $size operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
scores: { $size: 2 }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('Charlie Wilson');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter arrays using $elemMatch operator', async () => {
|
||||||
|
const orders = await TestOrder.getInstances({
|
||||||
|
items: {
|
||||||
|
$elemMatch: {
|
||||||
|
product: 'laptop',
|
||||||
|
quantity: { $gte: 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(orders.length).toEqual(1);
|
||||||
|
expect(orders[0].totalAmount).toEqual(2560);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $elemMatch with single condition', async () => {
|
||||||
|
const orders = await TestOrder.getInstances({
|
||||||
|
items: {
|
||||||
|
$elemMatch: {
|
||||||
|
price: { $gt: 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(orders.length).toEqual(2);
|
||||||
|
expect(orders.every(o => o.items.some(i => i.price > 100))).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= LOGICAL OPERATOR TESTS =============
|
||||||
|
tap.test('should filter using $or operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
$or: [
|
||||||
|
{ age: { $lt: 25 } },
|
||||||
|
{ status: 'inactive' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Bob Johnson', 'Charlie Wilson']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $and operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
$and: [
|
||||||
|
{ status: 'active' },
|
||||||
|
{ age: { $gte: 28 } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Alice Brown', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $nor operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
$nor: [
|
||||||
|
{ status: 'inactive' },
|
||||||
|
{ age: { $lt: 25 } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(3);
|
||||||
|
expect(users.every(u => u.status !== 'inactive' && u.age >= 25)).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using nested logical operators', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
$or: [
|
||||||
|
{
|
||||||
|
$and: [
|
||||||
|
{ status: 'active' },
|
||||||
|
{ roles: { $in: ['admin'] } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ age: { $lt: 23 } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(3);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Alice Brown', 'Charlie Wilson', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= ELEMENT OPERATOR TESTS =============
|
||||||
|
tap.test('should filter using $exists operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
'metadata.lastLogin': { $exists: true }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('John Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter using $exists false', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
'metadata.preferences': { $exists: false }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= COMPLEX FILTER TESTS =============
|
||||||
|
tap.test('should handle complex nested filters', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
$and: [
|
||||||
|
{ status: 'active' },
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ age: { $gte: 30 } },
|
||||||
|
{ roles: { $all: ['admin'] } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ tags: { $in: ['mongodb'] } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Alice Brown', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should combine multiple operator types', async () => {
|
||||||
|
const orders = await TestOrder.getInstances({
|
||||||
|
$and: [
|
||||||
|
{ totalAmount: { $gte: 100 } },
|
||||||
|
{ status: { $in: ['completed', 'processing'] } },
|
||||||
|
{ tags: { $in: ['electronics'] } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(orders.length).toEqual(2);
|
||||||
|
expect(orders.every(o => o.totalAmount >= 100)).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= ERROR HANDLING TESTS =============
|
||||||
|
tap.test('should throw error for $where operator', async () => {
|
||||||
|
let error: Error | null = null;
|
||||||
|
try {
|
||||||
|
await TestUser.getInstances({
|
||||||
|
$where: 'this.age > 25'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error?.message).toMatch(/\$where.*not allowed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should throw error for invalid $in value', async () => {
|
||||||
|
let error: Error | null = null;
|
||||||
|
try {
|
||||||
|
await TestUser.getInstances({
|
||||||
|
status: { $in: 'active' as any } // Should be an array
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error?.message).toMatch(/\$in.*requires.*array/);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should throw error for invalid $size value', async () => {
|
||||||
|
let error: Error | null = null;
|
||||||
|
try {
|
||||||
|
await TestUser.getInstances({
|
||||||
|
scores: { $size: '3' as any } // Should be a number
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error?.message).toMatch(/\$size.*requires.*numeric/);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should throw error for dots in field names', async () => {
|
||||||
|
let error: Error | null = null;
|
||||||
|
try {
|
||||||
|
await TestUser.getInstances({
|
||||||
|
'some.nested.field': { 'invalid.key': 'value' }
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error?.message).toMatch(/keys cannot contain dots/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= EDGE CASE TESTS =============
|
||||||
|
tap.test('should handle empty filter (return all)', async () => {
|
||||||
|
const users = await TestUser.getInstances({});
|
||||||
|
expect(users.length).toEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle null values in filter', async () => {
|
||||||
|
// First, create a user with null email
|
||||||
|
const nullUser = new TestUser({
|
||||||
|
name: 'Null User',
|
||||||
|
age: 40,
|
||||||
|
email: null as any,
|
||||||
|
roles: ['user'],
|
||||||
|
tags: [],
|
||||||
|
status: 'active',
|
||||||
|
metadata: {},
|
||||||
|
scores: []
|
||||||
|
});
|
||||||
|
await nullUser.save();
|
||||||
|
|
||||||
|
const users = await TestUser.getInstances({ email: null });
|
||||||
|
expect(users.length).toEqual(1);
|
||||||
|
expect(users[0].name).toEqual('Null User');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await nullUser.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle arrays as direct equality match', async () => {
|
||||||
|
// This tests that arrays without operators are treated as equality matches
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
roles: ['user'] // Exact match for array
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2); // Both Jane and Charlie have exactly ['user']
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Charlie Wilson', 'Jane Smith']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle regex operator', async () => {
|
||||||
|
const users = await TestUser.getInstances({
|
||||||
|
name: { $regex: '^J', $options: 'i' }
|
||||||
|
});
|
||||||
|
expect(users.length).toEqual(2);
|
||||||
|
const names = users.map(u => u.name).sort();
|
||||||
|
expect(names).toEqual(['Jane Smith', 'John Doe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle unknown operators by letting MongoDB reject them', async () => {
|
||||||
|
// Unknown operators should be passed through to MongoDB, which will reject them
|
||||||
|
let error: Error | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TestUser.getInstances({
|
||||||
|
age: { $unknownOp: 30 } as any
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error?.message).toMatch(/unknown operator.*\$unknownOp/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= PERFORMANCE TESTS =============
|
||||||
|
tap.test('should efficiently filter large result sets', async () => {
|
||||||
|
// Create many test documents
|
||||||
|
const manyUsers = [];
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
manyUsers.push(new TestUser({
|
||||||
|
name: `User ${i}`,
|
||||||
|
age: 20 + (i % 40),
|
||||||
|
email: `user${i}@example.com`,
|
||||||
|
roles: i % 3 === 0 ? ['admin'] : ['user'],
|
||||||
|
tags: i % 2 === 0 ? ['even', 'test'] : ['odd', 'test'],
|
||||||
|
status: i % 4 === 0 ? 'inactive' : 'active',
|
||||||
|
metadata: { loginCount: i },
|
||||||
|
scores: [i, i + 10, i + 20]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save in batches for efficiency
|
||||||
|
for (const user of manyUsers) {
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex filter that should still be fast
|
||||||
|
const startTime = Date.now();
|
||||||
|
const filtered = await TestUser.getInstances({
|
||||||
|
$and: [
|
||||||
|
{ age: { $gte: 30, $lt: 40 } },
|
||||||
|
{ status: 'active' },
|
||||||
|
{ tags: { $in: ['even'] } },
|
||||||
|
{ 'metadata.loginCount': { $gte: 20 } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
console.log(`Complex filter on 100+ documents took ${duration}ms`);
|
||||||
|
expect(duration).toBeLessThan(1000); // Should complete in under 1 second
|
||||||
|
expect(filtered.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
for (const user of manyUsers) {
|
||||||
|
await user.delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= CLEANUP =============
|
||||||
|
tap.test('should clean up test database', async () => {
|
||||||
|
await testDb.mongoDb.dropDatabase();
|
||||||
|
await testDb.close();
|
||||||
|
await smartmongoInstance.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
// TODO: Decorator support during testing for bun and deno in @git.zone/tstest
|
||||||
|
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import { smartunique } from '../ts/plugins.js';
|
import { smartunique } from '../ts/plugins.js';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import * as smartdata from '../ts/index.js';
|
import * as smartdata from '../ts/index.js';
|
||||||
import { searchable } from '../ts/classes.doc.js';
|
import { searchable } from '../ts/classes.doc.js';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import { smartunique } from '../ts/plugins.js';
|
import { smartunique } from '../ts/plugins.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import { smartunique } from '../ts/plugins.js';
|
import { smartunique } from '../ts/plugins.js';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import { smartunique } from '../ts/plugins.js';
|
import { smartunique } from '../ts/plugins.js';
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartdata',
|
name: '@push.rocks/smartdata',
|
||||||
version: '5.16.1',
|
version: '7.1.7',
|
||||||
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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,37 +21,85 @@ export type TDelayed<TDelayedArg> = () => TDelayedArg;
|
|||||||
|
|
||||||
const collectionFactory = new CollectionFactory();
|
const collectionFactory = new CollectionFactory();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize prototype and constructor properties from TC39 decorator metadata.
|
||||||
|
* Shared by both Collection and managed decorators.
|
||||||
|
*/
|
||||||
|
function initializeDecoratorMetadata(
|
||||||
|
constructor: { new (...args: any[]): any; prototype: any },
|
||||||
|
metadata: any
|
||||||
|
): void {
|
||||||
|
if (!metadata) return;
|
||||||
|
|
||||||
|
const proto = constructor.prototype;
|
||||||
|
const ctor = constructor as any;
|
||||||
|
|
||||||
|
// Prototype properties (instance-level)
|
||||||
|
if (metadata.globalSaveableProperties && !proto.globalSaveableProperties) {
|
||||||
|
proto.globalSaveableProperties = [...metadata.globalSaveableProperties];
|
||||||
|
}
|
||||||
|
if (metadata.saveableProperties && !proto.saveableProperties) {
|
||||||
|
proto.saveableProperties = [...metadata.saveableProperties];
|
||||||
|
}
|
||||||
|
if (metadata.uniqueIndexes && !proto.uniqueIndexes) {
|
||||||
|
proto.uniqueIndexes = [...metadata.uniqueIndexes];
|
||||||
|
}
|
||||||
|
if (metadata.regularIndexes && !proto.regularIndexes) {
|
||||||
|
proto.regularIndexes = [...metadata.regularIndexes];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructor properties (static-level)
|
||||||
|
if (metadata.searchableFields && !Array.isArray(ctor.searchableFields)) {
|
||||||
|
ctor.searchableFields = [...metadata.searchableFields];
|
||||||
|
}
|
||||||
|
if (metadata._svDbOptions && !ctor._svDbOptions) {
|
||||||
|
ctor._svDbOptions = { ...metadata._svDbOptions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is a decorator that will tell the decorated class what dbTable to use
|
* This is a decorator that will tell the decorated class what dbTable to use
|
||||||
* @param dbArg
|
* @param dbArg
|
||||||
*/
|
*/
|
||||||
export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) {
|
export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) {
|
||||||
return function classDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
|
return function classDecorator(value: Function, context: ClassDecoratorContext) {
|
||||||
const decoratedClass = class extends constructor {
|
if (context.kind !== 'class') {
|
||||||
public static className = constructor.name;
|
throw new Error('Collection can only decorate classes');
|
||||||
public static get collection() {
|
}
|
||||||
if (!(dbArg instanceof SmartdataDb)) {
|
|
||||||
dbArg = dbArg();
|
const constructor = value as { new (...args: any[]): any } & { className?: string };
|
||||||
}
|
|
||||||
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
const getCollection = () => {
|
||||||
// Attach document constructor for searchableFields lookup
|
if (!(dbArg instanceof SmartdataDb)) {
|
||||||
if (!(coll as any).docCtor) {
|
dbArg = dbArg();
|
||||||
(coll as any).docCtor = decoratedClass;
|
|
||||||
}
|
|
||||||
return coll;
|
|
||||||
}
|
}
|
||||||
public get collection() {
|
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||||
if (!(dbArg instanceof SmartdataDb)) {
|
// Attach document constructor for searchableFields lookup
|
||||||
dbArg = dbArg();
|
if (coll && !(coll as any).docCtor) {
|
||||||
}
|
(coll as any).docCtor = constructor;
|
||||||
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
|
||||||
if (!(coll as any).docCtor) {
|
|
||||||
(coll as any).docCtor = decoratedClass;
|
|
||||||
}
|
|
||||||
return coll;
|
|
||||||
}
|
}
|
||||||
|
return coll;
|
||||||
};
|
};
|
||||||
return decoratedClass;
|
|
||||||
|
// Add static className property directly on the constructor
|
||||||
|
(constructor as any).className = constructor.name;
|
||||||
|
|
||||||
|
// Define collection getter on constructor (static access)
|
||||||
|
Object.defineProperty(constructor, 'collection', {
|
||||||
|
get: getCollection,
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define collection getter on prototype (instance access)
|
||||||
|
Object.defineProperty(constructor.prototype, 'collection', {
|
||||||
|
get: getCollection,
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
initializeDecoratorMetadata(constructor, context.metadata);
|
||||||
|
return constructor as any;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,57 +117,51 @@ export const setDefaultManagerForDoc = <T,>(managerArg: IManager, dbDocArg: T):
|
|||||||
* @param dbArg
|
* @param dbArg
|
||||||
*/
|
*/
|
||||||
export function managed<TManager extends IManager>(managerArg?: TManager | TDelayed<TManager>) {
|
export function managed<TManager extends IManager>(managerArg?: TManager | TDelayed<TManager>) {
|
||||||
return function classDecorator<T extends { new (...args: any[]): any }>(constructor: T) {
|
return function classDecorator(value: Function, context: ClassDecoratorContext) {
|
||||||
const decoratedClass = class extends constructor {
|
if (context.kind !== 'class') {
|
||||||
public static className = constructor.name;
|
throw new Error('managed can only decorate classes');
|
||||||
public static get collection() {
|
}
|
||||||
let dbArg: SmartdataDb;
|
|
||||||
if (!managerArg) {
|
const constructor = value as { new (...args: any[]): any } & { className?: string };
|
||||||
dbArg = this.prototype.defaultManager.db;
|
(constructor as any).className = constructor.name;
|
||||||
} else if (managerArg['db']) {
|
|
||||||
dbArg = (managerArg as TManager).db;
|
// Resolution helpers (capture managerArg via closure)
|
||||||
} else {
|
const getManager = (defaultManagerFn: () => TManager): TManager => {
|
||||||
dbArg = (managerArg as TDelayed<TManager>)().db;
|
if (!managerArg) return defaultManagerFn();
|
||||||
}
|
if (managerArg['db']) return managerArg as TManager;
|
||||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
return (managerArg as TDelayed<TManager>)();
|
||||||
}
|
|
||||||
public get collection() {
|
|
||||||
let dbArg: SmartdataDb;
|
|
||||||
if (!managerArg) {
|
|
||||||
//console.log(this.defaultManager.db);
|
|
||||||
//process.exit(0)
|
|
||||||
dbArg = this.defaultManager.db;
|
|
||||||
} else if (managerArg['db']) {
|
|
||||||
dbArg = (managerArg as TManager).db;
|
|
||||||
} else {
|
|
||||||
dbArg = (managerArg as TDelayed<TManager>)().db;
|
|
||||||
}
|
|
||||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
|
||||||
}
|
|
||||||
public static get manager() {
|
|
||||||
let manager: TManager;
|
|
||||||
if (!managerArg) {
|
|
||||||
manager = this.prototype.defaultManager;
|
|
||||||
} else if (managerArg['db']) {
|
|
||||||
manager = managerArg as TManager;
|
|
||||||
} else {
|
|
||||||
manager = (managerArg as TDelayed<TManager>)();
|
|
||||||
}
|
|
||||||
return manager;
|
|
||||||
}
|
|
||||||
public get manager() {
|
|
||||||
let manager: TManager;
|
|
||||||
if (!managerArg) {
|
|
||||||
manager = this.defaultManager;
|
|
||||||
} else if (managerArg['db']) {
|
|
||||||
manager = managerArg as TManager;
|
|
||||||
} else {
|
|
||||||
manager = (managerArg as TDelayed<TManager>)();
|
|
||||||
}
|
|
||||||
return manager;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return decoratedClass;
|
|
||||||
|
const getDb = (defaultManagerFn: () => TManager): SmartdataDb => {
|
||||||
|
return getManager(defaultManagerFn).db;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static getters
|
||||||
|
Object.defineProperty(constructor, 'collection', {
|
||||||
|
get(this: any) { return collectionFactory.getCollection(constructor.name, getDb(() => this.prototype.defaultManager)); },
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(constructor, 'manager', {
|
||||||
|
get(this: any) { return getManager(() => this.prototype.defaultManager); },
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance getters
|
||||||
|
Object.defineProperty(constructor.prototype, 'collection', {
|
||||||
|
get(this: any) { return collectionFactory.getCollection(constructor.name, getDb(() => this.defaultManager)); },
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(constructor.prototype, 'manager', {
|
||||||
|
get(this: any) { return getManager(() => this.defaultManager); },
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
initializeDecoratorMetadata(constructor, context.metadata);
|
||||||
|
return constructor as any;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,8 +174,8 @@ export class SmartdataCollection<T> {
|
|||||||
/**
|
/**
|
||||||
* the collection that is used
|
* the collection that is used
|
||||||
*/
|
*/
|
||||||
public mongoDbCollection: plugins.mongodb.Collection;
|
public mongoDbCollection!: plugins.mongodb.Collection;
|
||||||
public objectValidation: IDocValidationFunc<T> = null;
|
public objectValidation: IDocValidationFunc<T> | null = null;
|
||||||
public collectionName: string;
|
public collectionName: string;
|
||||||
public smartdataDb: SmartdataDb;
|
public smartdataDb: SmartdataDb;
|
||||||
public uniqueIndexes: string[] = [];
|
public uniqueIndexes: string[] = [];
|
||||||
@@ -174,8 +216,15 @@ export class SmartdataCollection<T> {
|
|||||||
const indexSpec: Record<string, 'text'> = {};
|
const indexSpec: Record<string, 'text'> = {};
|
||||||
searchableFields.forEach(f => { indexSpec[f] = 'text'; });
|
searchableFields.forEach(f => { indexSpec[f] = 'text'; });
|
||||||
// Cast to any to satisfy TypeScript IndexSpecification typing
|
// Cast to any to satisfy TypeScript IndexSpecification typing
|
||||||
await this.mongoDbCollection.createIndex(indexSpec as any, { name: 'smartdata_text_index' });
|
try {
|
||||||
this.textIndexCreated = true;
|
await this.mongoDbCollection.createIndex(indexSpec as any, { name: 'smartdata_text_index' });
|
||||||
|
this.textIndexCreated = true;
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Failed to create text index on fields [${searchableFields.join(', ')}] in collection "${this.collectionName}": ${err?.message || String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,11 +235,25 @@ export class SmartdataCollection<T> {
|
|||||||
public async 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)) {
|
||||||
await this.mongoDbCollection.createIndex({ [key]: 1 }, {
|
// Claim the slot immediately to prevent concurrent inserts from retrying
|
||||||
unique: true,
|
|
||||||
});
|
|
||||||
// make sure we only call this once and not for every doc we create
|
|
||||||
this.uniqueIndexes.push(key);
|
this.uniqueIndexes.push(key);
|
||||||
|
try {
|
||||||
|
await this.mongoDbCollection.createIndex({ [key]: 1 }, {
|
||||||
|
unique: true,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorCode = err?.code || err?.codeName || 'unknown';
|
||||||
|
const errorMessage = err?.message || String(err);
|
||||||
|
logger.log(
|
||||||
|
'error',
|
||||||
|
`Failed to create unique index on field "${key}" in collection "${this.collectionName}". ` +
|
||||||
|
`MongoDB error [${errorCode}]: ${errorMessage}. ` +
|
||||||
|
`Uniqueness constraint on "${key}" is NOT enforced.`
|
||||||
|
);
|
||||||
|
if (errorCode === 11000 || errorCode === 'DuplicateKey' || String(errorMessage).includes('E11000')) {
|
||||||
|
await this.logDuplicatesForField(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,16 +266,66 @@ export class SmartdataCollection<T> {
|
|||||||
// 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)) {
|
||||||
await this.mongoDbCollection.createIndex(
|
// Claim the slot immediately to prevent concurrent retries
|
||||||
{ [indexDef.field]: 1 }, // Simple single-field index
|
|
||||||
indexDef.options
|
|
||||||
);
|
|
||||||
// Track that we've created this index
|
|
||||||
this.regularIndexes.push(indexDef);
|
this.regularIndexes.push(indexDef);
|
||||||
|
try {
|
||||||
|
await this.mongoDbCollection.createIndex(
|
||||||
|
{ [indexDef.field]: 1 }, // Simple single-field index
|
||||||
|
indexDef.options
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorCode = err?.code || err?.codeName || 'unknown';
|
||||||
|
const errorMessage = err?.message || String(err);
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Failed to create index on field "${indexKey}" in collection "${this.collectionName}". ` +
|
||||||
|
`MongoDB error [${errorCode}]: ${errorMessage}.`
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
indexDef.options?.unique &&
|
||||||
|
(errorCode === 11000 || errorCode === 'DuplicateKey' || String(errorMessage).includes('E11000'))
|
||||||
|
) {
|
||||||
|
await this.logDuplicatesForField(indexKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs duplicate values for a field to help diagnose unique index creation failures.
|
||||||
|
*/
|
||||||
|
private async logDuplicatesForField(field: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const pipeline = [
|
||||||
|
{ $group: { _id: `$${field}`, count: { $sum: 1 }, ids: { $push: '$_id' } } },
|
||||||
|
{ $match: { count: { $gt: 1 } } },
|
||||||
|
{ $limit: 5 },
|
||||||
|
];
|
||||||
|
const duplicates = await this.mongoDbCollection.aggregate(pipeline).toArray();
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
for (const dup of duplicates) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Duplicate values for "${field}" in "${this.collectionName}": ` +
|
||||||
|
`value=${JSON.stringify(dup._id)} appears ${dup.count} times ` +
|
||||||
|
`(document _ids: ${JSON.stringify(dup.ids.slice(0, 5))})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Unique index on "${field}" in "${this.collectionName}" was NOT created. ` +
|
||||||
|
`Resolve duplicates and restart to enforce uniqueness.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (aggErr: any) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Could not identify duplicate documents for field "${field}" in "${this.collectionName}": ${aggErr?.message || String(aggErr)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* adds a validation function that all newly inserted and updated objects have to pass
|
* adds a validation function that all newly inserted and updated objects have to pass
|
||||||
*/
|
*/
|
||||||
@@ -253,6 +366,28 @@ export class SmartdataCollection<T> {
|
|||||||
const cursor = this.mongoDbCollection.find(filterObject, { session: opts?.session });
|
const cursor = this.mongoDbCollection.find(filterObject, { session: opts?.session });
|
||||||
const result = await cursor.toArray();
|
const result = await cursor.toArray();
|
||||||
cursor.close();
|
cursor.close();
|
||||||
|
|
||||||
|
// In-memory check for duplicate _id values (should never happen)
|
||||||
|
if (result.length > 0) {
|
||||||
|
const idSet = new Set<string>();
|
||||||
|
const duplicateIds: string[] = [];
|
||||||
|
for (const doc of result) {
|
||||||
|
const idStr = String(doc._id);
|
||||||
|
if (idSet.has(idStr)) {
|
||||||
|
duplicateIds.push(idStr);
|
||||||
|
} else {
|
||||||
|
idSet.add(idStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (duplicateIds.length > 0) {
|
||||||
|
logger.log(
|
||||||
|
'error',
|
||||||
|
`Integrity issue in "${this.collectionName}": found ${duplicateIds.length} duplicate _id values ` +
|
||||||
|
`in findAll results: [${duplicateIds.slice(0, 5).join(', ')}]. This should never happen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +423,7 @@ export class SmartdataCollection<T> {
|
|||||||
);
|
);
|
||||||
const smartdataWatcher = new SmartdataDbWatcher(
|
const smartdataWatcher = new SmartdataDbWatcher(
|
||||||
changeStream,
|
changeStream,
|
||||||
smartdataDbDocArg,
|
smartdataDbDocArg!,
|
||||||
{ bufferTimeMs },
|
{ bufferTimeMs },
|
||||||
);
|
);
|
||||||
await smartdataWatcher.readyDeferred.promise;
|
await smartdataWatcher.readyDeferred.promise;
|
||||||
@@ -304,16 +439,30 @@ export class SmartdataCollection<T> {
|
|||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
await this.init();
|
await this.init();
|
||||||
await this.checkDoc(dbDocArg);
|
await this.checkDoc(dbDocArg);
|
||||||
this.markUniqueIndexes(dbDocArg.uniqueIndexes);
|
await this.markUniqueIndexes(dbDocArg.uniqueIndexes);
|
||||||
|
|
||||||
// Create regular indexes if available
|
// Create regular indexes if available
|
||||||
if (dbDocArg.regularIndexes && dbDocArg.regularIndexes.length > 0) {
|
if (dbDocArg.regularIndexes && dbDocArg.regularIndexes.length > 0) {
|
||||||
this.createRegularIndexes(dbDocArg.regularIndexes);
|
await this.createRegularIndexes(dbDocArg.regularIndexes);
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveableObject = await dbDocArg.createSavableObject();
|
const saveableObject = await dbDocArg.createSavableObject() as any;
|
||||||
const result = await this.mongoDbCollection.insertOne(saveableObject, { session: opts?.session });
|
try {
|
||||||
return result;
|
const result = await this.mongoDbCollection.insertOne(saveableObject, { session: opts?.session });
|
||||||
|
return result;
|
||||||
|
} catch (err: any) {
|
||||||
|
const isDuplicateKey = err?.code === 11000 || err?.codeName === 'DuplicateKey';
|
||||||
|
if (isDuplicateKey && dbDocArg.uniqueIndexes && dbDocArg.uniqueIndexes.length > 0) {
|
||||||
|
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
||||||
|
logger.log(
|
||||||
|
'error',
|
||||||
|
`Duplicate key conflict in "${this.collectionName}" on insert. ` +
|
||||||
|
`A document with ${JSON.stringify(identifiableObject)} already exists. ` +
|
||||||
|
`Use getInstance() to retrieve the existing document, or update it via save() on a db-retrieved instance.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -325,8 +474,9 @@ export class SmartdataCollection<T> {
|
|||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
await this.init();
|
await this.init();
|
||||||
await this.checkDoc(dbDocArg);
|
await this.checkDoc(dbDocArg);
|
||||||
|
await this.markUniqueIndexes(dbDocArg.uniqueIndexes);
|
||||||
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
||||||
const saveableObject = await dbDocArg.createSavableObject();
|
const saveableObject = await dbDocArg.createSavableObject() as any;
|
||||||
const updateableObject: any = {};
|
const updateableObject: any = {};
|
||||||
for (const key of Object.keys(saveableObject)) {
|
for (const key of Object.keys(saveableObject)) {
|
||||||
if (identifiableObject[key]) {
|
if (identifiableObject[key]) {
|
||||||
@@ -360,6 +510,74 @@ export class SmartdataCollection<T> {
|
|||||||
return this.mongoDbCollection.countDocuments(filterObject, { session: opts?.session });
|
return this.mongoDbCollection.countDocuments(filterObject, { session: opts?.session });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an integrity check on the collection.
|
||||||
|
* Compares estimated vs actual document count and checks for duplicates on unique index fields.
|
||||||
|
*/
|
||||||
|
public async checkCollectionIntegrity(): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
estimatedCount: number;
|
||||||
|
actualCount: number;
|
||||||
|
duplicateFields: Array<{ field: string; duplicateValues: number }>;
|
||||||
|
}> {
|
||||||
|
await this.init();
|
||||||
|
const result = {
|
||||||
|
ok: true,
|
||||||
|
estimatedCount: 0,
|
||||||
|
actualCount: 0,
|
||||||
|
duplicateFields: [] as Array<{ field: string; duplicateValues: number }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
result.estimatedCount = await this.mongoDbCollection.estimatedDocumentCount();
|
||||||
|
result.actualCount = await this.mongoDbCollection.countDocuments({});
|
||||||
|
|
||||||
|
if (result.estimatedCount !== result.actualCount) {
|
||||||
|
result.ok = false;
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Integrity check on "${this.collectionName}": estimatedDocumentCount=${result.estimatedCount} ` +
|
||||||
|
`but countDocuments=${result.actualCount}. Possible data inconsistency.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicates on each tracked unique index field
|
||||||
|
for (const field of this.uniqueIndexes) {
|
||||||
|
try {
|
||||||
|
const pipeline = [
|
||||||
|
{ $group: { _id: `$${field}`, count: { $sum: 1 } } },
|
||||||
|
{ $match: { count: { $gt: 1 } } },
|
||||||
|
{ $count: 'total' },
|
||||||
|
];
|
||||||
|
const countResult = await this.mongoDbCollection.aggregate(pipeline).toArray();
|
||||||
|
const dupCount = countResult[0]?.total || 0;
|
||||||
|
if (dupCount > 0) {
|
||||||
|
result.ok = false;
|
||||||
|
result.duplicateFields.push({ field, duplicateValues: dupCount });
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Integrity check on "${this.collectionName}": field "${field}" has ${dupCount} values with duplicates ` +
|
||||||
|
`despite being marked as unique.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (fieldErr: any) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Integrity check: could not verify uniqueness of "${field}" in "${this.collectionName}": ${fieldErr?.message || String(fieldErr)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
result.ok = false;
|
||||||
|
logger.log(
|
||||||
|
'error',
|
||||||
|
`Integrity check failed for "${this.collectionName}": ${err?.message || String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* checks a Doc for constraints
|
* checks a Doc for constraints
|
||||||
* if this.objectValidation is not set it passes.
|
* if this.objectValidation is not set it passes.
|
||||||
|
|||||||
@@ -2,18 +2,51 @@ import * as plugins from './plugins.js';
|
|||||||
import { SmartdataCollection } from './classes.collection.js';
|
import { SmartdataCollection } from './classes.collection.js';
|
||||||
import { SmartdataDb } from './classes.db.js';
|
import { SmartdataDb } from './classes.db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-SmartdataDb collection cache.
|
||||||
|
*
|
||||||
|
* Historically this class keyed its cache by class name alone, which meant
|
||||||
|
* the first SmartdataDb to request a collection of a given class name
|
||||||
|
* "won" — every subsequent call from a different SmartdataDb instance
|
||||||
|
* received the cached collection bound to the first db. That silently
|
||||||
|
* broke multi-tenant SaaS apps (one db per tenant), tests instantiating
|
||||||
|
* multiple SmartdataDbs in sequence, and any in-process db cluster switch.
|
||||||
|
*
|
||||||
|
* The cache is now keyed by `(SmartdataDb instance, className)` using a
|
||||||
|
* WeakMap of db → Map<className, SmartdataCollection>. Entries are GC'd
|
||||||
|
* with their parent db automatically.
|
||||||
|
*/
|
||||||
export class CollectionFactory {
|
export class CollectionFactory {
|
||||||
public collections: { [key: string]: SmartdataCollection<any> } = {};
|
private perDbCollections: WeakMap<SmartdataDb, Map<string, SmartdataCollection<any>>> = new WeakMap();
|
||||||
|
|
||||||
public getCollection = (nameArg: string, dbArg: SmartdataDb): SmartdataCollection<any> => {
|
public getCollection = (nameArg: string, dbArg: SmartdataDb): SmartdataCollection<any> => {
|
||||||
if (!this.collections[nameArg]) {
|
if (!(dbArg instanceof SmartdataDb)) {
|
||||||
this.collections[nameArg] = (() => {
|
// Preserve the historical behavior of returning undefined-ish for
|
||||||
if (dbArg instanceof SmartdataDb) {
|
// non-db args. All in-repo callers already guard on instanceof
|
||||||
// tslint:disable-next-line: no-string-literal
|
// before using the result (see classes.collection.ts).
|
||||||
return new SmartdataCollection(nameArg, dbArg);
|
return undefined as unknown as SmartdataCollection<any>;
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
return this.collections[nameArg];
|
let dbMap = this.perDbCollections.get(dbArg);
|
||||||
|
if (!dbMap) {
|
||||||
|
dbMap = new Map();
|
||||||
|
this.perDbCollections.set(dbArg, dbMap);
|
||||||
|
}
|
||||||
|
let coll = dbMap.get(nameArg);
|
||||||
|
if (!coll) {
|
||||||
|
coll = new SmartdataCollection(nameArg, dbArg);
|
||||||
|
dbMap.set(nameArg, coll);
|
||||||
|
}
|
||||||
|
return coll;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Internal back-compat shim. The previous field was a public
|
||||||
|
* Record<className, SmartdataCollection> but was not part of the
|
||||||
|
* documented public API. WeakMap is not iterable, so this getter returns
|
||||||
|
* an empty object — anyone actually relying on the old shape would get
|
||||||
|
* clean nothing rather than wrong-db data. Will be removed in 8.0.
|
||||||
|
*/
|
||||||
|
public get collections(): { [key: string]: SmartdataCollection<any> } {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export type TConnectionStatus = 'initial' | 'disconnected' | 'connected' | 'fail
|
|||||||
|
|
||||||
export class SmartdataDb {
|
export class SmartdataDb {
|
||||||
smartdataOptions: plugins.tsclass.database.IMongoDescriptor;
|
smartdataOptions: plugins.tsclass.database.IMongoDescriptor;
|
||||||
mongoDbClient: plugins.mongodb.MongoClient;
|
mongoDbClient!: plugins.mongodb.MongoClient;
|
||||||
mongoDb: plugins.mongodb.Db;
|
mongoDb!: plugins.mongodb.Db;
|
||||||
status: TConnectionStatus;
|
status: TConnectionStatus;
|
||||||
statusConnectedDeferred = plugins.smartpromise.defer();
|
statusConnectedDeferred = plugins.smartpromise.defer();
|
||||||
smartdataCollectionMap = new plugins.lik.ObjectMap<SmartdataCollection<any>>();
|
smartdataCollectionMap = new plugins.lik.ObjectMap<SmartdataCollection<any>>();
|
||||||
@@ -51,13 +51,14 @@ export class SmartdataDb {
|
|||||||
.replace('<user>', encodedUser)
|
.replace('<user>', encodedUser)
|
||||||
.replace('<PASSWORD>', encodedPass)
|
.replace('<PASSWORD>', encodedPass)
|
||||||
.replace('<password>', encodedPass)
|
.replace('<password>', encodedPass)
|
||||||
.replace('<DBNAME>', this.smartdataOptions.mongoDbName)
|
.replace('<DBNAME>', this.smartdataOptions.mongoDbName || '')
|
||||||
.replace('<dbname>', this.smartdataOptions.mongoDbName);
|
.replace('<dbname>', this.smartdataOptions.mongoDbName || '');
|
||||||
|
|
||||||
const clientOptions: plugins.mongodb.MongoClientOptions = {
|
const clientOptions: plugins.mongodb.MongoClientOptions = {
|
||||||
maxPoolSize: (this.smartdataOptions as any).maxPoolSize ?? 100,
|
maxPoolSize: (this.smartdataOptions as any).maxPoolSize ?? 100,
|
||||||
maxIdleTimeMS: (this.smartdataOptions as any).maxIdleTimeMS ?? 300000, // 5 minutes default
|
maxIdleTimeMS: (this.smartdataOptions as any).maxIdleTimeMS ?? 300000, // 5 minutes default
|
||||||
serverSelectionTimeoutMS: (this.smartdataOptions as any).serverSelectionTimeoutMS ?? 30000,
|
serverSelectionTimeoutMS: (this.smartdataOptions as any).serverSelectionTimeoutMS ?? 30000,
|
||||||
|
socketTimeoutMS: (this.smartdataOptions as any).socketTimeoutMS ?? 30000, // 30 seconds default — prevents hung operations from holding connections
|
||||||
retryWrites: true,
|
retryWrites: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ export class SmartdataDb {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.status = 'disconnected';
|
this.status = 'disconnected';
|
||||||
this.statusConnectedDeferred.reject(error);
|
this.statusConnectedDeferred.reject(error);
|
||||||
logger.log('error', `Failed to connect to database ${this.smartdataOptions.mongoDbName}: ${error.message}`);
|
logger.log('error', `Failed to connect to database ${this.smartdataOptions.mongoDbName}: ${(error as Error).message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import { logger } from './logging.js';
|
|||||||
export class DistributedClass extends SmartDataDbDoc<DistributedClass, DistributedClass> {
|
export class DistributedClass extends SmartDataDbDoc<DistributedClass, DistributedClass> {
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
@unI()
|
@unI()
|
||||||
public id: string;
|
public id!: string;
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
public data: {
|
public data!: {
|
||||||
status: 'initializing' | 'bidding' | 'settled' | 'stopped';
|
status: 'initializing' | 'bidding' | 'settled' | 'stopped';
|
||||||
biddingShortcode?: string;
|
biddingShortcode?: string;
|
||||||
biddingStartTime?: number;
|
biddingStartTime?: number;
|
||||||
@@ -40,8 +40,8 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
public readyPromise: Promise<any>;
|
public readyPromise: Promise<any>;
|
||||||
public db: SmartdataDb;
|
public db: SmartdataDb;
|
||||||
private asyncExecutionStack = new plugins.lik.AsyncExecutionStack();
|
private asyncExecutionStack = new plugins.lik.AsyncExecutionStack();
|
||||||
public ownInstance: DistributedClass;
|
public ownInstance!: DistributedClass;
|
||||||
public distributedWatcher: SmartdataDbWatcher<DistributedClass>;
|
public distributedWatcher!: SmartdataDbWatcher<DistributedClass>;
|
||||||
|
|
||||||
constructor(dbArg: SmartdataDb) {
|
constructor(dbArg: SmartdataDb) {
|
||||||
super();
|
super();
|
||||||
@@ -163,8 +163,8 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
} else if (
|
} else if (
|
||||||
(await DistributedClass.getInstances({})).find((instanceArg) => {
|
(await DistributedClass.getInstances({})).find((instanceArg) => {
|
||||||
return 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;
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
logger.log('info', 'too late to the bidding party... waiting for next round.');
|
logger.log('info', 'too late to the bidding party... waiting for next round.');
|
||||||
@@ -191,7 +191,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
logger.log('info', `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!) {
|
||||||
this.ownInstance.data.elected = false;
|
this.ownInstance.data.elected = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,7 +270,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
|||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
logger.log('warn', 'no result found for task request...');
|
logger.log('warn', 'no result found for task request...');
|
||||||
return null;
|
return null as any;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,15 +28,39 @@ export interface SearchOptions<T> {
|
|||||||
|
|
||||||
export type TDocCreation = 'db' | 'new' | 'mixed';
|
export type TDocCreation = 'db' | 'new' | 'mixed';
|
||||||
|
|
||||||
|
// Type for decorator metadata - extends TypeScript's built-in DecoratorMetadataObject
|
||||||
|
interface ISmartdataDecoratorMetadata extends DecoratorMetadataObject {
|
||||||
|
globalSaveableProperties?: string[];
|
||||||
|
saveableProperties?: string[];
|
||||||
|
uniqueIndexes?: string[];
|
||||||
|
regularIndexes?: Array<{field: string, options: IIndexOptions}>;
|
||||||
|
searchableFields?: string[];
|
||||||
|
_svDbOptions?: Record<string, SvDbOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
export function globalSvDb() {
|
export function globalSvDb() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
logger.log('debug', `called svDb() on >${target.constructor.name}.${key}<`);
|
if (context.kind !== 'field') {
|
||||||
if (!target.globalSaveableProperties) {
|
throw new Error('globalSvDb can only decorate fields');
|
||||||
target.globalSaveableProperties = [];
|
|
||||||
}
|
}
|
||||||
target.globalSaveableProperties.push(key);
|
|
||||||
|
// Store metadata at class level using Symbol.metadata
|
||||||
|
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||||
|
if (!metadata.globalSaveableProperties) {
|
||||||
|
metadata.globalSaveableProperties = [];
|
||||||
|
}
|
||||||
|
metadata.globalSaveableProperties.push(String(context.name));
|
||||||
|
|
||||||
|
// Use addInitializer to ensure prototype arrays are set up once
|
||||||
|
context.addInitializer(function(this: any) {
|
||||||
|
const proto = this.constructor.prototype;
|
||||||
|
const metadata = this.constructor[Symbol.metadata];
|
||||||
|
|
||||||
|
if (metadata && metadata.globalSaveableProperties && !proto.globalSaveableProperties) {
|
||||||
|
// Initialize prototype array from metadata (runs once per class)
|
||||||
|
proto.globalSaveableProperties = [...metadata.globalSaveableProperties];
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,20 +78,44 @@ export interface SvDbOptions {
|
|||||||
* saveable - saveable decorator to be used on class properties
|
* saveable - saveable decorator to be used on class properties
|
||||||
*/
|
*/
|
||||||
export function svDb(options?: SvDbOptions) {
|
export function svDb(options?: SvDbOptions) {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
logger.log('debug', `called svDb() on >${target.constructor.name}.${key}<`);
|
if (context.kind !== 'field') {
|
||||||
if (!target.saveableProperties) {
|
throw new Error('svDb can only decorate fields');
|
||||||
target.saveableProperties = [];
|
|
||||||
}
|
}
|
||||||
target.saveableProperties.push(key);
|
|
||||||
// attach custom serializer/deserializer options to the class constructor
|
const propName = String(context.name);
|
||||||
const ctor = target.constructor as any;
|
|
||||||
if (!ctor._svDbOptions) {
|
// Store metadata at class level using Symbol.metadata
|
||||||
ctor._svDbOptions = {};
|
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||||
|
if (!metadata.saveableProperties) {
|
||||||
|
metadata.saveableProperties = [];
|
||||||
}
|
}
|
||||||
|
metadata.saveableProperties.push(propName);
|
||||||
|
|
||||||
|
// Store options in metadata
|
||||||
if (options) {
|
if (options) {
|
||||||
ctor._svDbOptions[key] = options;
|
if (!metadata._svDbOptions) {
|
||||||
|
metadata._svDbOptions = {};
|
||||||
|
}
|
||||||
|
metadata._svDbOptions[propName] = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use addInitializer to ensure prototype arrays are set up once
|
||||||
|
context.addInitializer(function(this: any) {
|
||||||
|
const proto = this.constructor.prototype;
|
||||||
|
const ctor = this.constructor;
|
||||||
|
const metadata = ctor[Symbol.metadata];
|
||||||
|
|
||||||
|
if (metadata && metadata.saveableProperties && !proto.saveableProperties) {
|
||||||
|
// Initialize prototype array from metadata (runs once per class)
|
||||||
|
proto.saveableProperties = [...metadata.saveableProperties];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize svDbOptions from metadata
|
||||||
|
if (metadata && metadata._svDbOptions && !ctor._svDbOptions) {
|
||||||
|
ctor._svDbOptions = { ...metadata._svDbOptions };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,13 +123,30 @@ export function svDb(options?: SvDbOptions) {
|
|||||||
* searchable - marks a property as searchable with Lucene query syntax
|
* searchable - marks a property as searchable with Lucene query syntax
|
||||||
*/
|
*/
|
||||||
export function searchable() {
|
export function searchable() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
// Attach to class constructor for direct access
|
if (context.kind !== 'field') {
|
||||||
const ctor = target.constructor as any;
|
throw new Error('searchable can only decorate fields');
|
||||||
if (!Array.isArray(ctor.searchableFields)) {
|
|
||||||
ctor.searchableFields = [];
|
|
||||||
}
|
}
|
||||||
ctor.searchableFields.push(key);
|
|
||||||
|
const propName = String(context.name);
|
||||||
|
|
||||||
|
// Store metadata at class level
|
||||||
|
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||||
|
if (!metadata.searchableFields) {
|
||||||
|
metadata.searchableFields = [];
|
||||||
|
}
|
||||||
|
metadata.searchableFields.push(propName);
|
||||||
|
|
||||||
|
// Use addInitializer to set up constructor property once
|
||||||
|
context.addInitializer(function(this: any) {
|
||||||
|
const ctor = this.constructor as any;
|
||||||
|
const metadata = ctor[Symbol.metadata];
|
||||||
|
|
||||||
|
if (metadata && metadata.searchableFields && !Array.isArray(ctor.searchableFields)) {
|
||||||
|
// Initialize from metadata (runs once per class)
|
||||||
|
ctor.searchableFields = [...metadata.searchableFields];
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,20 +159,41 @@ function escapeForRegex(input: string): string {
|
|||||||
* unique index - decorator to mark a unique index
|
* unique index - decorator to mark a unique index
|
||||||
*/
|
*/
|
||||||
export function unI() {
|
export function unI() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
logger.log('debug', `called unI on >>${target.constructor.name}.${key}<<`);
|
if (context.kind !== 'field') {
|
||||||
|
throw new Error('unI can only decorate fields');
|
||||||
// mark the index as unique
|
|
||||||
if (!target.uniqueIndexes) {
|
|
||||||
target.uniqueIndexes = [];
|
|
||||||
}
|
}
|
||||||
target.uniqueIndexes.push(key);
|
|
||||||
|
|
||||||
// and also save it
|
const propName = String(context.name);
|
||||||
if (!target.saveableProperties) {
|
|
||||||
target.saveableProperties = [];
|
// Store metadata at class level
|
||||||
|
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||||
|
if (!metadata.uniqueIndexes) {
|
||||||
|
metadata.uniqueIndexes = [];
|
||||||
}
|
}
|
||||||
target.saveableProperties.push(key);
|
metadata.uniqueIndexes.push(propName);
|
||||||
|
|
||||||
|
// Also mark as saveable
|
||||||
|
if (!metadata.saveableProperties) {
|
||||||
|
metadata.saveableProperties = [];
|
||||||
|
}
|
||||||
|
if (!metadata.saveableProperties.includes(propName)) {
|
||||||
|
metadata.saveableProperties.push(propName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use addInitializer to ensure prototype arrays are set up once
|
||||||
|
context.addInitializer(function(this: any) {
|
||||||
|
const proto = this.constructor.prototype;
|
||||||
|
const metadata = this.constructor[Symbol.metadata];
|
||||||
|
|
||||||
|
if (metadata && metadata.uniqueIndexes && !proto.uniqueIndexes) {
|
||||||
|
proto.uniqueIndexes = [...metadata.uniqueIndexes];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata && metadata.saveableProperties && !proto.saveableProperties) {
|
||||||
|
proto.saveableProperties = [...metadata.saveableProperties];
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,73 +212,222 @@ export interface IIndexOptions {
|
|||||||
* index - decorator to mark a field for regular indexing
|
* index - decorator to mark a field for regular indexing
|
||||||
*/
|
*/
|
||||||
export function index(options?: IIndexOptions) {
|
export function index(options?: IIndexOptions) {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||||
logger.log('debug', `called index() on >${target.constructor.name}.${key}<`);
|
if (context.kind !== 'field') {
|
||||||
|
throw new Error('index can only decorate fields');
|
||||||
// Initialize regular indexes array if it doesn't exist
|
|
||||||
if (!target.regularIndexes) {
|
|
||||||
target.regularIndexes = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add this field to regularIndexes with its options
|
const propName = String(context.name);
|
||||||
target.regularIndexes.push({
|
|
||||||
field: key,
|
// Store metadata at class level
|
||||||
|
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||||
|
if (!metadata.regularIndexes) {
|
||||||
|
metadata.regularIndexes = [];
|
||||||
|
}
|
||||||
|
metadata.regularIndexes.push({
|
||||||
|
field: propName,
|
||||||
options: options || {}
|
options: options || {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also ensure it's marked as saveable
|
// Also mark as saveable
|
||||||
if (!target.saveableProperties) {
|
if (!metadata.saveableProperties) {
|
||||||
target.saveableProperties = [];
|
metadata.saveableProperties = [];
|
||||||
}
|
}
|
||||||
|
if (!metadata.saveableProperties.includes(propName)) {
|
||||||
if (!target.saveableProperties.includes(key)) {
|
metadata.saveableProperties.push(propName);
|
||||||
target.saveableProperties.push(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use addInitializer to ensure prototype arrays are set up once
|
||||||
|
context.addInitializer(function(this: any) {
|
||||||
|
const proto = this.constructor.prototype;
|
||||||
|
const metadata = this.constructor[Symbol.metadata];
|
||||||
|
|
||||||
|
if (metadata && metadata.regularIndexes && !proto.regularIndexes) {
|
||||||
|
proto.regularIndexes = [...metadata.regularIndexes];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata && metadata.saveableProperties && !proto.saveableProperties) {
|
||||||
|
proto.saveableProperties = [...metadata.saveableProperties];
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper type to extract element type from arrays or return T itself
|
||||||
|
type ElementOf<T> = T extends ReadonlyArray<infer U> ? U : T;
|
||||||
|
|
||||||
|
// Type for $in/$nin values - arrays of the element type
|
||||||
|
type InValues<T> = ReadonlyArray<ElementOf<T>>;
|
||||||
|
|
||||||
|
// Type that allows MongoDB operators on leaf values while maintaining nested type safety
|
||||||
|
export type MongoFilterCondition<T> = T | {
|
||||||
|
$eq?: T;
|
||||||
|
$ne?: T;
|
||||||
|
$gt?: T;
|
||||||
|
$gte?: T;
|
||||||
|
$lt?: T;
|
||||||
|
$lte?: T;
|
||||||
|
$in?: InValues<T>;
|
||||||
|
$nin?: InValues<T>;
|
||||||
|
$exists?: boolean;
|
||||||
|
$type?: string | number;
|
||||||
|
$regex?: string | RegExp;
|
||||||
|
$options?: string;
|
||||||
|
$all?: T extends ReadonlyArray<infer U> ? ReadonlyArray<U> : never;
|
||||||
|
$elemMatch?: T extends ReadonlyArray<infer U> ? MongoFilter<U> : never;
|
||||||
|
$size?: T extends ReadonlyArray<any> ? number : never;
|
||||||
|
$not?: MongoFilterCondition<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MongoFilter<T> = {
|
||||||
|
[K in keyof T]?: T[K] extends object
|
||||||
|
? T[K] extends any[]
|
||||||
|
? MongoFilterCondition<T[K]> // Arrays can have operators
|
||||||
|
: MongoFilter<T[K]> | MongoFilterCondition<T[K]> // Objects can be nested or have operators
|
||||||
|
: MongoFilterCondition<T[K]>; // Primitives get operators
|
||||||
|
} & {
|
||||||
|
// Logical operators
|
||||||
|
$and?: MongoFilter<T>[];
|
||||||
|
$or?: MongoFilter<T>[];
|
||||||
|
$nor?: MongoFilter<T>[];
|
||||||
|
$not?: MongoFilter<T>;
|
||||||
|
// Allow any string key for dot notation (we lose type safety here but maintain flexibility)
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
||||||
// Special case: detect MongoDB operators and pass them through directly
|
// SECURITY: Block $where to prevent server-side JS execution
|
||||||
// SECURITY: Removed $where to prevent server-side JS execution
|
if (filterArg.$where !== undefined) {
|
||||||
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$regex'];
|
throw new Error('$where operator is not allowed for security reasons');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle logical operators recursively
|
||||||
|
const logicalOperators = ['$and', '$or', '$nor', '$not'];
|
||||||
|
const processedFilter: { [key: string]: any } = {};
|
||||||
|
|
||||||
for (const key of Object.keys(filterArg)) {
|
for (const key of Object.keys(filterArg)) {
|
||||||
if (topLevelOperators.includes(key)) {
|
if (logicalOperators.includes(key)) {
|
||||||
return filterArg; // Return the filter as-is for MongoDB operators
|
if (key === '$not') {
|
||||||
|
processedFilter[key] = convertFilterForMongoDb(filterArg[key]);
|
||||||
|
} else if (Array.isArray(filterArg[key])) {
|
||||||
|
processedFilter[key] = filterArg[key].map((subFilter: any) => convertFilterForMongoDb(subFilter));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If only logical operators, return them
|
||||||
|
const hasOnlyLogicalOperators = Object.keys(filterArg).every(key => logicalOperators.includes(key));
|
||||||
|
if (hasOnlyLogicalOperators) {
|
||||||
|
return processedFilter;
|
||||||
|
}
|
||||||
|
|
||||||
// Original conversion logic for non-MongoDB query objects
|
// Original conversion logic for non-MongoDB query objects
|
||||||
const convertedFilter: { [key: string]: any } = {};
|
const convertedFilter: { [key: string]: any } = {};
|
||||||
|
|
||||||
|
// Helper to merge operator objects
|
||||||
|
const mergeIntoConverted = (path: string, value: any) => {
|
||||||
|
const existing = convertedFilter[path];
|
||||||
|
if (!existing) {
|
||||||
|
convertedFilter[path] = value;
|
||||||
|
} else if (
|
||||||
|
typeof existing === 'object' && !Array.isArray(existing) &&
|
||||||
|
typeof value === 'object' && !Array.isArray(value) &&
|
||||||
|
(Object.keys(existing).some(k => k.startsWith('$')) || Object.keys(value).some(k => k.startsWith('$')))
|
||||||
|
) {
|
||||||
|
// Both have operators, merge them
|
||||||
|
convertedFilter[path] = { ...existing, ...value };
|
||||||
|
} else {
|
||||||
|
// Otherwise later wins
|
||||||
|
convertedFilter[path] = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => {
|
const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => {
|
||||||
if (Array.isArray(filterArg2)) {
|
if (Array.isArray(filterArg2)) {
|
||||||
// FIX: Properly handle arrays for operators like $in, $all, or plain equality
|
// Arrays are typically used as values for operators like $in or as direct equality matches
|
||||||
convertedFilter[keyPathArg2] = filterArg2;
|
mergeIntoConverted(keyPathArg2, filterArg2);
|
||||||
return;
|
return;
|
||||||
} else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
|
} else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
|
||||||
for (const key of Object.keys(filterArg2)) {
|
// Check if this is an object with MongoDB operators
|
||||||
if (key.startsWith('$')) {
|
const keys = Object.keys(filterArg2);
|
||||||
// Prevent dangerous operators
|
const hasOperators = keys.some(key => key.startsWith('$'));
|
||||||
if (key === '$where') {
|
|
||||||
throw new Error('$where operator is not allowed for security reasons');
|
if (hasOperators) {
|
||||||
}
|
// This object contains MongoDB operators
|
||||||
convertedFilter[keyPathArg2] = filterArg2;
|
// Validate and pass through allowed operators
|
||||||
return;
|
const allowedOperators = [
|
||||||
} else if (key.includes('.')) {
|
// Comparison operators
|
||||||
|
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte',
|
||||||
|
// Array operators
|
||||||
|
'$in', '$nin', '$all', '$elemMatch', '$size',
|
||||||
|
// Element operators
|
||||||
|
'$exists', '$type',
|
||||||
|
// Evaluation operators (safe ones only)
|
||||||
|
'$regex', '$options', '$text', '$mod',
|
||||||
|
// Logical operators (nested)
|
||||||
|
'$and', '$or', '$nor', '$not'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check for dangerous operators
|
||||||
|
if (keys.includes('$where')) {
|
||||||
|
throw new Error('$where operator is not allowed for security reasons');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all operators are in the allowed list
|
||||||
|
const invalidOperators = keys.filter(key =>
|
||||||
|
key.startsWith('$') && !allowedOperators.includes(key)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidOperators.length > 0) {
|
||||||
|
console.warn(`Warning: Unknown MongoDB operators detected: ${invalidOperators.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For array operators, ensure the values are appropriate
|
||||||
|
if (filterArg2.$in && !Array.isArray(filterArg2.$in)) {
|
||||||
|
throw new Error('$in operator requires an array value');
|
||||||
|
}
|
||||||
|
if (filterArg2.$nin && !Array.isArray(filterArg2.$nin)) {
|
||||||
|
throw new Error('$nin operator requires an array value');
|
||||||
|
}
|
||||||
|
if (filterArg2.$all && !Array.isArray(filterArg2.$all)) {
|
||||||
|
throw new Error('$all operator requires an array value');
|
||||||
|
}
|
||||||
|
if (filterArg2.$size && typeof filterArg2.$size !== 'number') {
|
||||||
|
throw new Error('$size operator requires a numeric value');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use merge helper to handle duplicate paths
|
||||||
|
mergeIntoConverted(keyPathArg2, filterArg2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No operators, check for dots in keys
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key.includes('.')) {
|
||||||
throw new Error('keys cannot contain dots');
|
throw new Error('keys cannot contain dots');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const key of Object.keys(filterArg2)) {
|
|
||||||
|
// Recursively process nested objects
|
||||||
|
for (const key of keys) {
|
||||||
convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]);
|
convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
convertedFilter[keyPathArg2] = filterArg2;
|
// Primitive values
|
||||||
|
mergeIntoConverted(keyPathArg2, filterArg2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of Object.keys(filterArg)) {
|
for (const key of Object.keys(filterArg)) {
|
||||||
convertFilterArgument(key, filterArg[key]);
|
// Skip logical operators, they were already processed
|
||||||
|
if (!logicalOperators.includes(key)) {
|
||||||
|
convertFilterArgument(key, filterArg[key]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add back processed logical operators
|
||||||
|
Object.assign(convertedFilter, processedFilter);
|
||||||
|
|
||||||
return convertedFilter;
|
return convertedFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,10 +436,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
* the collection object an Doc belongs to
|
* the collection object an Doc belongs to
|
||||||
*/
|
*/
|
||||||
public static collection: SmartdataCollection<any>;
|
public static collection: SmartdataCollection<any>;
|
||||||
public collection: SmartdataCollection<any>;
|
declare public collection: SmartdataCollection<any>;
|
||||||
public static defaultManager;
|
public static defaultManager;
|
||||||
public static manager;
|
public static manager;
|
||||||
public manager: TManager;
|
declare public manager: TManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get collection with fallback to static for Deno compatibility
|
||||||
|
*/
|
||||||
|
private getCollectionSafe(): SmartdataCollection<any> {
|
||||||
|
return this.collection || (this.constructor as any).collection;
|
||||||
|
}
|
||||||
|
|
||||||
// STATIC
|
// STATIC
|
||||||
public static createInstanceFromMongoDbNativeDoc<T>(
|
public static createInstanceFromMongoDbNativeDoc<T>(
|
||||||
@@ -227,12 +469,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
/**
|
/**
|
||||||
* gets all instances as array
|
* gets all instances as array
|
||||||
* @param this
|
* @param this
|
||||||
* @param filterArg
|
* @param filterArg - Type-safe MongoDB filter with nested object support and operators
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
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: MongoFilter<T>,
|
||||||
opts?: { session?: plugins.mongodb.ClientSession }
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
// Pass session through to findAll for transactional queries
|
// Pass session through to findAll for transactional queries
|
||||||
@@ -240,7 +482,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
convertFilterForMongoDb(filterArg),
|
convertFilterForMongoDb(filterArg),
|
||||||
{ session: opts?.session },
|
{ session: opts?.session },
|
||||||
);
|
);
|
||||||
const returnArray = [];
|
const returnArray: T[] = [];
|
||||||
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);
|
||||||
returnArray.push(newInstance);
|
returnArray.push(newInstance);
|
||||||
@@ -256,7 +498,7 @@ 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: MongoFilter<T>,
|
||||||
opts?: { session?: plugins.mongodb.ClientSession }
|
opts?: { session?: plugins.mongodb.ClientSession }
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
// Retrieve one document, with optional session for transactions
|
// Retrieve one document, with optional session for transactions
|
||||||
@@ -268,7 +510,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
||||||
return newInstance;
|
return newInstance;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +531,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
*/
|
*/
|
||||||
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: MongoFilter<T>,
|
||||||
opts?: {
|
opts?: {
|
||||||
session?: plugins.mongodb.ClientSession;
|
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>>;
|
modifier?: (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>;
|
||||||
@@ -319,7 +561,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
*/
|
*/
|
||||||
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: MongoFilter<T>,
|
||||||
opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number },
|
opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number },
|
||||||
): Promise<SmartdataDbWatcher<T>> {
|
): Promise<SmartdataDbWatcher<T>> {
|
||||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||||
@@ -337,7 +579,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
*/
|
*/
|
||||||
public static async forEach<T>(
|
public static async forEach<T>(
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
filterArg: MongoFilter<T>,
|
||||||
forEachFunction: (itemArg: T) => Promise<any>,
|
forEachFunction: (itemArg: T) => Promise<any>,
|
||||||
) {
|
) {
|
||||||
const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg);
|
const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg);
|
||||||
@@ -349,12 +591,23 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
*/
|
*/
|
||||||
public static async getCount<T>(
|
public static async getCount<T>(
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T> = {} as any,
|
filterArg: MongoFilter<T> = {} as any,
|
||||||
) {
|
) {
|
||||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||||
return await collection.getCount(filterArg);
|
return await collection.getCount(filterArg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an integrity check on this collection.
|
||||||
|
* Returns a summary with estimated vs actual counts and any duplicate unique fields.
|
||||||
|
*/
|
||||||
|
public static async checkCollectionIntegrity<T>(
|
||||||
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
|
) {
|
||||||
|
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||||
|
return await collection.checkCollectionIntegrity();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a MongoDB filter from a Lucene query string
|
* Create a MongoDB filter from a Lucene query string
|
||||||
* @param luceneQuery Lucene query string
|
* @param luceneQuery Lucene query string
|
||||||
@@ -565,33 +818,38 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* an array of saveable properties of ALL doc
|
* an array of saveable properties of ALL doc
|
||||||
|
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
||||||
|
* Declared with definite assignment assertion to satisfy TypeScript without creating instance property
|
||||||
*/
|
*/
|
||||||
public globalSaveableProperties: string[];
|
declare globalSaveableProperties: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* unique indexes
|
* unique indexes
|
||||||
|
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
||||||
*/
|
*/
|
||||||
public uniqueIndexes: string[];
|
declare uniqueIndexes: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* regular indexes with their options
|
* regular indexes with their options
|
||||||
|
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
||||||
*/
|
*/
|
||||||
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
|
declare regularIndexes: Array<{field: string, options: IIndexOptions}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* an array of saveable properties of a specific doc
|
* an array of saveable properties of a specific doc
|
||||||
|
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
||||||
*/
|
*/
|
||||||
public saveableProperties: string[];
|
declare saveableProperties: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* name
|
* name
|
||||||
*/
|
*/
|
||||||
public name: string;
|
public name!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* primary id in the database
|
* primary id in the database
|
||||||
*/
|
*/
|
||||||
public dbDocUniqueId: string;
|
public dbDocUniqueId!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* class constructor
|
* class constructor
|
||||||
@@ -614,10 +872,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
// perform insert or update
|
// perform insert or update
|
||||||
switch (this.creationStatus) {
|
switch (this.creationStatus) {
|
||||||
case 'db':
|
case 'db':
|
||||||
dbResult = await this.collection.update(self, { session: opts?.session });
|
dbResult = await this.getCollectionSafe().update(self, { session: opts?.session });
|
||||||
break;
|
break;
|
||||||
case 'new':
|
case 'new':
|
||||||
dbResult = await this.collection.insert(self, { session: opts?.session });
|
dbResult = await this.getCollectionSafe().insert(self, { session: opts?.session });
|
||||||
this.creationStatus = 'db';
|
this.creationStatus = 'db';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -639,7 +897,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
await (this as any).beforeDelete();
|
await (this as any).beforeDelete();
|
||||||
}
|
}
|
||||||
// perform deletion
|
// perform deletion
|
||||||
const result = await this.collection.delete(this, { session: opts?.session });
|
const result = await this.getCollectionSafe().delete(this, { session: opts?.session });
|
||||||
// allow hook after delete
|
// allow hook after delete
|
||||||
if (typeof (this as any).afterDelete === 'function') {
|
if (typeof (this as any).afterDelete === 'function') {
|
||||||
await (this as any).afterDelete();
|
await (this as any).afterDelete();
|
||||||
@@ -651,7 +909,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
* also store any referenced objects to DB
|
* also store any referenced objects to DB
|
||||||
* better for data consistency
|
* better for data consistency
|
||||||
*/
|
*/
|
||||||
public saveDeep(savedMapArg: plugins.lik.ObjectMap<SmartDataDbDoc<any, any>> = null) {
|
public saveDeep(savedMapArg?: plugins.lik.ObjectMap<SmartDataDbDoc<any, any>>) {
|
||||||
if (!savedMapArg) {
|
if (!savedMapArg) {
|
||||||
savedMapArg = new plugins.lik.ObjectMap<SmartDataDbDoc<any, any>>();
|
savedMapArg = new plugins.lik.ObjectMap<SmartDataDbDoc<any, any>>();
|
||||||
}
|
}
|
||||||
@@ -669,7 +927,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
* updates an object from db
|
* updates an object from db
|
||||||
*/
|
*/
|
||||||
public async updateFromDb(): Promise<boolean> {
|
public async updateFromDb(): Promise<boolean> {
|
||||||
const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject());
|
const mongoDbNativeDoc = await this.getCollectionSafe().findOne(await this.createIdentifiableObject());
|
||||||
if (!mongoDbNativeDoc) {
|
if (!mongoDbNativeDoc) {
|
||||||
return false; // Document not found in database
|
return false; // Document not found in database
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,19 +15,19 @@ export class EasyStore<T> {
|
|||||||
@Collection(() => this.smartdataDbRef)
|
@Collection(() => this.smartdataDbRef)
|
||||||
class SmartdataEasyStore extends SmartDataDbDoc<SmartdataEasyStore, SmartdataEasyStore> {
|
class SmartdataEasyStore extends SmartDataDbDoc<SmartdataEasyStore, SmartdataEasyStore> {
|
||||||
@unI()
|
@unI()
|
||||||
public nameId: string;
|
public nameId!: string;
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
public ephemeral: {
|
public ephemeral!: {
|
||||||
activated: boolean;
|
activated: boolean;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
lastEdit: number;
|
lastEdit!: number;
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
public data: Partial<T>;
|
public data!: Partial<T>;
|
||||||
}
|
}
|
||||||
return SmartdataEasyStore;
|
return SmartdataEasyStore;
|
||||||
})();
|
})();
|
||||||
@@ -37,7 +37,7 @@ export class EasyStore<T> {
|
|||||||
this.nameId = nameIdArg;
|
this.nameId = nameIdArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
private easyStorePromise: Promise<InstanceType<typeof this.easyStoreClass>>;
|
private easyStorePromise!: Promise<InstanceType<typeof this.easyStoreClass>>;
|
||||||
private async getEasyStore(): Promise<InstanceType<typeof this.easyStoreClass>> {
|
private async getEasyStore(): Promise<InstanceType<typeof this.easyStoreClass>> {
|
||||||
if (this.easyStorePromise) {
|
if (this.easyStorePromise) {
|
||||||
return this.easyStorePromise;
|
return this.easyStorePromise;
|
||||||
@@ -93,7 +93,9 @@ export class EasyStore<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* writes all keyValue pairs in the object argument
|
* merges all keyValue pairs from the object argument into the store.
|
||||||
|
* Existing keys that are not present in `keyValueObject` are preserved.
|
||||||
|
* To overwrite the entire store and drop missing keys, use `replace()`.
|
||||||
*/
|
*/
|
||||||
public async writeAll(keyValueObject: Partial<T>) {
|
public async writeAll(keyValueObject: Partial<T>) {
|
||||||
const easyStore = await this.getEasyStore();
|
const easyStore = await this.getEasyStore();
|
||||||
@@ -101,6 +103,17 @@ export class EasyStore<T> {
|
|||||||
await easyStore.save();
|
await easyStore.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* atomically replaces the entire store with the given object.
|
||||||
|
* Unlike `writeAll` (which merges), `replace` clears any keys not
|
||||||
|
* present in `keyValueObject`. Useful when you need to drop a key.
|
||||||
|
*/
|
||||||
|
public async replace(keyValueObject: Partial<T>) {
|
||||||
|
const easyStore = await this.getEasyStore();
|
||||||
|
easyStore.data = { ...keyValueObject };
|
||||||
|
await easyStore.save();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* wipes a key value store from disk
|
* wipes a key value store from disk
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ export class LuceneToMongoTransformer {
|
|||||||
const searchTerm = rightQuery.$text.$search.replace(/"/g, '');
|
const searchTerm = rightQuery.$text.$search.replace(/"/g, '');
|
||||||
|
|
||||||
// Determine the fields to apply the negation to
|
// Determine the fields to apply the negation to
|
||||||
const notConditions = [];
|
const notConditions: any[] = [];
|
||||||
|
|
||||||
for (const field in leftQuery) {
|
for (const field in leftQuery) {
|
||||||
if (field !== '$or' && field !== '$and') {
|
if (field !== '$or' && field !== '$and') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a wrapper for the native mongodb cursor. Exposes better
|
* a wrapper for the native mongodb cursor. Exposes better
|
||||||
@@ -13,7 +13,7 @@ export class SmartdataDbWatcher<T = any> extends EventEmitter {
|
|||||||
public readyDeferred = plugins.smartpromise.defer();
|
public readyDeferred = plugins.smartpromise.defer();
|
||||||
|
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
private changeStream: plugins.mongodb.ChangeStream<T>;
|
private changeStream: plugins.mongodb.ChangeStream<any>;
|
||||||
private rawSubject: plugins.smartrx.rxjs.Subject<T>;
|
private rawSubject: plugins.smartrx.rxjs.Subject<T>;
|
||||||
/** Emits change documents (or arrays of documents if buffered) */
|
/** Emits change documents (or arrays of documents if buffered) */
|
||||||
public changeSubject: any;
|
public changeSubject: any;
|
||||||
@@ -23,7 +23,7 @@ export class SmartdataDbWatcher<T = any> extends EventEmitter {
|
|||||||
* @param opts.bufferTimeMs optional milliseconds to buffer events via RxJS
|
* @param opts.bufferTimeMs optional milliseconds to buffer events via RxJS
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
changeStreamArg: plugins.mongodb.ChangeStream<T>,
|
changeStreamArg: plugins.mongodb.ChangeStream<any>,
|
||||||
smartdataDbDocArg: typeof SmartDataDbDoc,
|
smartdataDbDocArg: typeof SmartDataDbDoc,
|
||||||
opts?: { bufferTimeMs?: number },
|
opts?: { bufferTimeMs?: number },
|
||||||
) {
|
) {
|
||||||
@@ -37,14 +37,14 @@ export class SmartdataDbWatcher<T = any> extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.changeStream = changeStreamArg;
|
this.changeStream = changeStreamArg;
|
||||||
this.changeStream.on('change', async (item: any) => {
|
this.changeStream.on('change', async (item: any) => {
|
||||||
let docInstance: T = null;
|
let docInstance: T | null = null;
|
||||||
if (item.fullDocument) {
|
if (item.fullDocument) {
|
||||||
docInstance = smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(
|
docInstance = smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(
|
||||||
item.fullDocument
|
item.fullDocument
|
||||||
) as any as T;
|
) as any as T;
|
||||||
}
|
}
|
||||||
// Notify subscribers
|
// Notify subscribers
|
||||||
this.rawSubject.next(docInstance);
|
this.rawSubject.next(docInstance as T);
|
||||||
this.emit('change', docInstance);
|
this.emit('change', docInstance);
|
||||||
});
|
});
|
||||||
// Signal readiness after one tick
|
// Signal readiness after one tick
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Polyfill must be imported first - ES modules hoist exports before code runs
|
||||||
|
import './shim.js';
|
||||||
|
|
||||||
export * from './classes.db.js';
|
export * from './classes.db.js';
|
||||||
export * from './classes.collection.js';
|
export * from './classes.collection.js';
|
||||||
export * from './classes.doc.js';
|
export * from './classes.doc.js';
|
||||||
|
|||||||
6
ts/shim.ts
Normal file
6
ts/shim.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Polyfill for Symbol.metadata required by TC39 Stage 3 decorators.
|
||||||
|
* Must be imported before any decorator code loads.
|
||||||
|
* @see https://github.com/tc39/proposal-decorator-metadata
|
||||||
|
*/
|
||||||
|
(Symbol as any).metadata ??= Symbol.for('Symbol.metadata');
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"experimentalDecorators": true,
|
"target": "ES2024",
|
||||||
"useDefineForClassFields": false,
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"baseUrl": ".",
|
"types": [
|
||||||
"paths": {}
|
"node"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["dist_*/**/*.d.ts"]
|
||||||
"dist_*/**/*.d.ts"
|
}
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user