Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f4290ae7f7 | |||
| e58c0fd215 | |||
| a91fac450a | |||
| 5cb043009c |
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
env:
|
||||
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_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
env:
|
||||
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_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -16,4 +16,12 @@ node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
#------# custom
|
||||
# rust
|
||||
rust/target/
|
||||
dist_rust/
|
||||
|
||||
# AI
|
||||
.claude/
|
||||
.serena/
|
||||
|
||||
#------# custom
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
{
|
||||
"npmdocker": {
|
||||
"baseImage": "hosttoday/ht-docker-node:mongo",
|
||||
"command": "npmci test stable",
|
||||
"dockerSock": false
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public",
|
||||
"npmRegistryUrl": "registry.npmjs.org"
|
||||
},
|
||||
"gitzone": {
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -28,9 +18,25 @@
|
||||
"custom data types",
|
||||
"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"
|
||||
},
|
||||
"@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": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
306
changelog.md
306
changelog.md
@@ -1,6 +1,246 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
|
||||
Improve error handling and logging; enhance search query sanitization; update dependency versions and documentation
|
||||
|
||||
- Replaced console.log and console.warn with structured logger.log calls throughout the core modules
|
||||
- Enhanced database initialization with try/catch and proper URI credential encoding
|
||||
- Improved search query conversion by disallowing dangerous operators (e.g. $where) and securely escaping regex patterns
|
||||
- Bumped dependency versions (smartlog, @tsclass/tsclass, mongodb, etc.) in package.json
|
||||
- Added detailed project memories including code style, project overview, and suggested commands for developers
|
||||
- Updated README with improved instructions, feature highlights, and quick start sections
|
||||
|
||||
## 2025-04-25 - 5.16.0 - feat(watcher)
|
||||
|
||||
Enhance change stream watchers with buffering and EventEmitter support; update dependency versions
|
||||
|
||||
- Bumped smartmongo from ^2.0.11 to ^2.0.12 and smartrx from ^3.0.7 to ^3.0.10
|
||||
- Upgraded @tsclass/tsclass to ^9.0.0 and mongodb to ^6.16.0
|
||||
- Refactored the watch API to accept additional options (bufferTimeMs, fullDocument) for improved change stream handling
|
||||
- Modified SmartdataDbWatcher to extend EventEmitter and support event notifications
|
||||
|
||||
## 2025-04-24 - 5.15.1 - fix(cursor)
|
||||
|
||||
Improve cursor usage documentation and refactor getCursor API to support native cursor modifiers
|
||||
|
||||
- Updated examples in readme.md to demonstrate manual iteration using cursor.next() and proper cursor closing.
|
||||
@@ -8,6 +248,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.
|
||||
|
||||
## 2025-04-24 - 5.15.0 - feat(svDb)
|
||||
|
||||
Enhance svDb decorator to support custom serialization and deserialization options
|
||||
|
||||
- Added an optional options parameter to the svDb decorator to accept serialize/deserialize functions
|
||||
@@ -15,6 +256,7 @@ Enhance svDb decorator to support custom serialization and deserialization optio
|
||||
- Updated createSavableObject to use custom serialization when available
|
||||
|
||||
## 2025-04-23 - 5.14.1 - fix(db operations)
|
||||
|
||||
Update transaction API to consistently pass optional session parameters across database operations
|
||||
|
||||
- Revised transaction support in readme to use startSession without await and showcased session usage in getInstance and save calls
|
||||
@@ -23,14 +265,16 @@ Update transaction API to consistently pass optional session parameters across d
|
||||
- Improved overall consistency of transactional APIs across the library
|
||||
|
||||
## 2025-04-23 - 5.14.0 - feat(doc)
|
||||
|
||||
Implement support for beforeSave, afterSave, beforeDelete, and afterDelete lifecycle hooks in document save and delete operations to allow custom logic execution during these critical moments.
|
||||
|
||||
- Calls beforeSave hook if defined before performing insert or update.
|
||||
- Calls afterSave hook after a document is saved.
|
||||
- Calls beforeDelete hook before deletion and afterDelete hook afterward.
|
||||
- Ensures _updatedAt timestamp is refreshed during save operations.
|
||||
- Ensures \_updatedAt timestamp is refreshed during save operations.
|
||||
|
||||
## 2025-04-22 - 5.13.1 - fix(search)
|
||||
|
||||
Improve search query parsing for implicit AND queries by preserving quoted substrings and better handling free terms, quoted phrases, and field:value tokens.
|
||||
|
||||
- Replace previous implicit AND logic with tokenization that preserves quoted substrings
|
||||
@@ -38,6 +282,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
|
||||
|
||||
## 2025-04-22 - 5.13.0 - feat(search)
|
||||
|
||||
Improve search query handling and update documentation
|
||||
|
||||
- Added 'codex.md' providing a high-level project overview and detailed search API documentation.
|
||||
@@ -46,12 +291,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.
|
||||
|
||||
## 2025-04-22 - 5.12.2 - fix(search)
|
||||
|
||||
Fix handling of quoted wildcard patterns in field-specific search queries and add tests for location-based wildcard phrase searches
|
||||
|
||||
- Strip surrounding quotes from wildcard patterns in field queries to correctly transform them to regex
|
||||
- Introduce new tests in test/test.search.ts to validate exact quoted and unquoted wildcard searches on a location field
|
||||
|
||||
## 2025-04-22 - 5.12.1 - fix(search)
|
||||
|
||||
Improve implicit AND logic for mixed free term and field queries in search and enhance wildcard field handling.
|
||||
|
||||
- Updated regex for field:value parsing to capture full value with wildcards.
|
||||
@@ -60,6 +307,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.
|
||||
|
||||
## 2025-04-22 - 5.12.0 - feat(doc/search)
|
||||
|
||||
Enhance search functionality with filter and validate options for advanced query control
|
||||
|
||||
- Added 'filter' option to merge additional MongoDB query constraints in search
|
||||
@@ -68,6 +316,7 @@ Enhance search functionality with filter and validate options for advanced query
|
||||
- Updated tests to cover new search scenarios and fallback mechanisms
|
||||
|
||||
## 2025-04-22 - 5.11.4 - fix(search)
|
||||
|
||||
Implement implicit AND logic for mixed simple term and field:value queries in search
|
||||
|
||||
- Added a new branch to detect and handle search queries that mix field:value pairs with plain terms without explicit operators
|
||||
@@ -75,6 +324,7 @@ Implement implicit AND logic for mixed simple term and field:value queries in se
|
||||
- Ensures proper parsing and improved robustness of search filters
|
||||
|
||||
## 2025-04-22 - 5.11.3 - fix(lucene adapter and search tests)
|
||||
|
||||
Improve range query parsing in Lucene adapter and expand search test coverage
|
||||
|
||||
- Added a new 'testSearch' script in package.json to run search tests.
|
||||
@@ -83,12 +333,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.
|
||||
|
||||
## 2025-04-21 - 5.11.2 - fix(readme)
|
||||
|
||||
Update readme to clarify usage of searchable fields retrieval
|
||||
|
||||
- Replaced getSearchableFields('Product') with Product.getSearchableFields()
|
||||
- Updated documentation to reference the static method Class.getSearchableFields()
|
||||
|
||||
## 2025-04-21 - 5.11.1 - fix(doc)
|
||||
|
||||
Refactor searchable fields API and improve collection registration.
|
||||
|
||||
- Removed the standalone getSearchableFields utility in favor of a static method on document classes.
|
||||
@@ -97,11 +349,13 @@ Refactor searchable fields API and improve collection registration.
|
||||
- Added try/catch in test cleanup to gracefully handle dropDatabase errors.
|
||||
|
||||
## 2025-04-21 - 5.11.0 - feat(ts/classes.lucene.adapter)
|
||||
|
||||
Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities.
|
||||
|
||||
- Changed luceneWildcardToRegex from private to public in ts/classes.lucene.adapter.ts.
|
||||
|
||||
## 2025-04-21 - 5.10.0 - feat(search)
|
||||
|
||||
Improve search functionality: update documentation, refine Lucene query transformation, and add advanced search tests
|
||||
|
||||
- Updated readme.md with detailed Lucene‑style search examples and use cases
|
||||
@@ -110,6 +364,7 @@ Improve search functionality: update documentation, refine Lucene query transfor
|
||||
- Added new advanced search tests covering boolean operators, grouping, quoted phrases, and wildcard queries
|
||||
|
||||
## 2025-04-18 - 5.9.2 - fix(documentation)
|
||||
|
||||
Update search API documentation to replace deprecated searchWithLucene examples with the unified search(query) API and clarify its behavior.
|
||||
|
||||
- Replaced 'searchWithLucene' examples with 'search(query)' in the README.
|
||||
@@ -117,24 +372,28 @@ Update search API documentation to replace deprecated searchWithLucene examples
|
||||
- Clarified guidelines for creating MongoDB text indexes on searchable fields for optimized search performance.
|
||||
|
||||
## 2025-04-18 - 5.9.1 - fix(search)
|
||||
|
||||
Refactor search tests to use unified search API and update text index type casting
|
||||
|
||||
- Replaced all calls from searchWithLucene with search in test/search tests
|
||||
- Updated text index specification in the collection class to use proper type casting
|
||||
|
||||
## 2025-04-18 - 5.9.0 - feat(collections/search)
|
||||
|
||||
Improve text index creation and search fallback mechanisms in collections and document search methods
|
||||
|
||||
- Auto-create a compound text index on all searchable fields in SmartdataCollection with a one-time flag to prevent duplicate index creation.
|
||||
- Refine the search method in SmartDataDbDoc to support exact field matches and safe regex fallback for non-Lucene queries.
|
||||
|
||||
## 2025-04-17 - 5.8.4 - fix(core)
|
||||
|
||||
Update commit metadata with no functional code changes
|
||||
|
||||
- Commit info and documentation refreshed
|
||||
- No code or test changes detected in the diff
|
||||
|
||||
## 2025-04-17 - 5.8.3 - fix(readme)
|
||||
|
||||
Improve readme documentation on data models and connection management
|
||||
|
||||
- Clarify that data models use @Collection, @unI, @svDb, @index, and @searchable decorators
|
||||
@@ -143,12 +402,14 @@ Improve readme documentation on data models and connection management
|
||||
- Revise license section to reference the MIT License without including additional legal details
|
||||
|
||||
## 2025-04-14 - 5.8.2 - fix(classes.doc.ts)
|
||||
|
||||
Ensure collection initialization before creating a cursor in getCursorExtended
|
||||
|
||||
- Added 'await collection.init()' to guarantee that the MongoDB collection is initialized before using the cursor
|
||||
- Prevents potential runtime errors when accessing collection.mongoDbCollection
|
||||
|
||||
## 2025-04-14 - 5.8.1 - fix(cursor, doc)
|
||||
|
||||
Add explicit return types and casts to SmartdataDbCursor methods and update getCursorExtended signature in SmartDataDbDoc.
|
||||
|
||||
- Specify Promise<T> as return type for next() in SmartdataDbCursor and cast return value to T.
|
||||
@@ -156,12 +417,14 @@ Add explicit return types and casts to SmartdataDbCursor methods and update getC
|
||||
- Update getCursorExtended to return Promise<SmartdataDbCursor<T>> for clearer type safety.
|
||||
|
||||
## 2025-04-14 - 5.8.0 - feat(cursor)
|
||||
|
||||
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
|
||||
- Maps each native document to a SmartDataDbDoc instance using createInstanceFromMongoDbNativeDoc for consistent API usage
|
||||
|
||||
## 2025-04-14 - 5.7.0 - feat(SmartDataDbDoc)
|
||||
|
||||
Add extended cursor method getCursorExtended for flexible cursor modifications
|
||||
|
||||
- Introduces getCursorExtended in classes.doc.ts to allow modifier functions for MongoDB cursors
|
||||
@@ -169,6 +432,7 @@ Add extended cursor method getCursorExtended for flexible cursor modifications
|
||||
- Enhances querying capabilities by enabling customized cursor transformations
|
||||
|
||||
## 2025-04-07 - 5.6.0 - feat(indexing)
|
||||
|
||||
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
|
||||
@@ -176,6 +440,7 @@ Add support for regular index creation in documents and collections
|
||||
- Enhance document structure to store and utilize regular index configurations
|
||||
|
||||
## 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.
|
||||
|
||||
- Update image and repo URL in Gitea workflows from GitLab to code.foss.global
|
||||
@@ -185,6 +450,7 @@ Minor fixes: update CI workflow image and npmci package references, adjust packa
|
||||
- Minor update to .gitignore custom section label
|
||||
|
||||
## 2025-04-06 - 5.5.0 - feat(search)
|
||||
|
||||
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
|
||||
@@ -192,15 +458,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
|
||||
|
||||
## 2025-04-06 - 5.4.0 - feat(core)
|
||||
|
||||
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.
|
||||
- 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'.
|
||||
- Added package manager configuration and pnpm-workspace.yaml for built dependencies.
|
||||
|
||||
## 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.
|
||||
|
||||
- Added pnpm installation command
|
||||
@@ -210,11 +478,13 @@ Enhance documentation with updated installation instructions and comprehensive u
|
||||
- Included detailed examples for transactions, deep object queries, and document lifecycle hooks
|
||||
|
||||
## 2025-02-03 - 5.2.12 - fix(documentation)
|
||||
|
||||
Remove license badge from README
|
||||
|
||||
- Removed the license badge from the README file, ensuring compliance with branding guidelines.
|
||||
|
||||
## 2025-02-03 - 5.2.11 - fix(documentation)
|
||||
|
||||
Updated project documentation for accuracy and added advanced feature details
|
||||
|
||||
- Added details for EasyStore, Distributed Coordination, and Real-time Data Watching features.
|
||||
@@ -222,158 +492,188 @@ Updated project documentation for accuracy and added advanced feature details
|
||||
- Re-organized advanced usage section to showcase additional features separately.
|
||||
|
||||
## 2024-09-05 - 5.2.10 - fix(smartdata.classes.doc)
|
||||
|
||||
Fix issue with array handling in convertFilterForMongoDb function
|
||||
|
||||
- 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)
|
||||
|
||||
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.
|
||||
|
||||
## 2024-09-05 - 5.2.8 - fix(smartdata.classes.doc)
|
||||
|
||||
Fix key handling in convertFilterForMongoDb function
|
||||
|
||||
- Fixed an issue in convertFilterForMongoDb that allowed keys with dots which could cause errors.
|
||||
|
||||
## 2024-09-05 - 5.2.7 - fix(core)
|
||||
|
||||
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.
|
||||
|
||||
## 2024-06-18 - 5.2.6 - Chore
|
||||
|
||||
Maintenance Release
|
||||
|
||||
- Release version 5.2.6
|
||||
|
||||
## 2024-05-31 - 5.2.2 - Bug Fixes
|
||||
|
||||
Fixes and Maintenance
|
||||
|
||||
- Fixed issue where `_createdAt` and `_updatedAt` registered saveableProperties for all document types
|
||||
|
||||
## 2024-04-15 - 5.1.2 - New Feature
|
||||
|
||||
Enhancements and Bug Fixes
|
||||
|
||||
- Added static `.getCount({})` method to `SmartDataDbDoc`
|
||||
- Changed fields `_createdAt` and `_updatedAt` to ISO format
|
||||
|
||||
## 2024-04-14 - 5.0.43 - New Feature
|
||||
|
||||
New Feature Addition
|
||||
|
||||
- Added default `_createdAt` and `_updatedAt` fields, fixes #1
|
||||
|
||||
## 2024-03-30 - 5.0.41 - Bug Fixes
|
||||
|
||||
Improvements and Fixes
|
||||
|
||||
- Improved `tsconfig.json` for ES Module use
|
||||
|
||||
## 2023-07-10 - 5.0.20 - Chore
|
||||
|
||||
Organizational Changes
|
||||
|
||||
- Switched to new org scheme
|
||||
|
||||
## 2023-07-21 - 5.0.21 to 5.0.26 - Fixes
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2023-07-21 - 5.0.20 - Chore
|
||||
|
||||
Organizational Changes
|
||||
|
||||
- Switch to the new org scheme
|
||||
|
||||
## 2023-06-25 - 5.0.14 to 5.0.19 - Fixes
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2022-05-17 - 5.0.0 - Major Update
|
||||
|
||||
Breaking Changes
|
||||
|
||||
- Switched to ESM
|
||||
|
||||
## 2022-05-18 - 5.0.2 - Bug Fixes
|
||||
|
||||
Bug Fixes
|
||||
|
||||
- The `watcher.changeSubject` now emits the correct type into observer functions
|
||||
|
||||
## 2022-05-17 - 5.0.1 - Chore
|
||||
|
||||
Testing Improvements
|
||||
|
||||
- Tests now use `@pushrocks/smartmongo` backed by `wiredTiger`
|
||||
|
||||
## 2022-05-17 to 2022-11-08 - 5.0.8 to 5.0.10
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2021-11-12 - 4.0.17 to 4.0.20
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2021-09-17 - 4.0.10 to 4.0.16
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2021-06-09 - 4.0.1 to 4.0.9
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2021-06-06 - 4.0.0 - Major Update
|
||||
|
||||
Major Release
|
||||
|
||||
- Maintenance and core updates
|
||||
|
||||
## 2021-05-17 - 3.1.56 - Chore
|
||||
|
||||
Maintenance Release
|
||||
|
||||
- Release version 3.1.56
|
||||
|
||||
## 2020-09-09 - 3.1.44 to 3.1.52
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2020-06-12 - 3.1.26 to 3.1.28
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2020-02-18 - 3.1.23 to 3.1.25
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2019-09-11 - 3.1.20 to 3.1.22
|
||||
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2018-07-10 - 3.0.5 - New Feature
|
||||
|
||||
Added Feature
|
||||
|
||||
- Added custom unique indexes to `SmartdataDoc`
|
||||
|
||||
## 2018-07-08 - 3.0.1 - Chore
|
||||
|
||||
Dependencies Update
|
||||
|
||||
- Updated mongodb dependencies
|
||||
|
||||
## 2018-07-08 - 3.0.0 - Major Update
|
||||
|
||||
Refactor and Cleanup
|
||||
|
||||
- Cleaned project structure
|
||||
|
||||
## 2018-01-16 - 2.0.7 - Breaking Change
|
||||
|
||||
Big Changes
|
||||
|
||||
- Switched to `@pushrocks` scope and moved from `rethinkdb` to `mongodb`
|
||||
|
||||
## 2018-01-12 - 2.0.0 - Major Release
|
||||
|
||||
Core Updates
|
||||
|
||||
- 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.
|
||||
48
package.json
48
package.json
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@push.rocks/smartdata",
|
||||
"version": "5.15.1",
|
||||
"version": "7.1.3",
|
||||
"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.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tstest test/",
|
||||
"test": "tstest test/ --verbose --logfile --timeout 120",
|
||||
"testSearch": "tsx test/test.search.ts",
|
||||
"build": "tsbuild --web --allowimplicitany",
|
||||
"build": "tsbuild tsfolders",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"repository": {
|
||||
@@ -23,26 +24,25 @@
|
||||
},
|
||||
"homepage": "https://code.foss.global/push.rocks/smartdata#readme",
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.0.14",
|
||||
"@push.rocks/smartdelay": "^3.0.1",
|
||||
"@push.rocks/smartlog": "^3.0.2",
|
||||
"@push.rocks/smartmongo": "^2.0.11",
|
||||
"@push.rocks/smartpromise": "^4.0.2",
|
||||
"@push.rocks/smartrx": "^3.0.7",
|
||||
"@push.rocks/smartstring": "^4.0.15",
|
||||
"@push.rocks/smarttime": "^4.0.6",
|
||||
"@push.rocks/smartunique": "^3.0.8",
|
||||
"@push.rocks/taskbuffer": "^3.1.7",
|
||||
"@tsclass/tsclass": "^8.2.0",
|
||||
"mongodb": "^6.15.0"
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstring": "^4.1.0",
|
||||
"@push.rocks/smarttime": "^4.2.3",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"mongodb": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.3.2",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^1.0.77",
|
||||
"@push.rocks/qenv": "^6.0.5",
|
||||
"@push.rocks/tapbundle": "^5.6.2",
|
||||
"@types/node": "^22.14.0"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
@@ -53,7 +53,7 @@
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
|
||||
9258
pnpm-lock.yaml
generated
9258
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
|
||||
|
||||
741
readme.md
741
readme.md
@@ -1,72 +1,57 @@
|
||||
# @push.rocks/smartdata
|
||||
# @push.rocks/smartdata 🚀
|
||||
|
||||
[](https://www.npmjs.com/package/@push.rocks/smartdata)
|
||||
|
||||
A powerful TypeScript-first MongoDB wrapper that provides advanced features for distributed systems, real-time data synchronization, and easy data management.
|
||||
**The ultimate TypeScript-first MongoDB 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.
|
||||
|
||||
## Features
|
||||
## Issue Reporting and Security
|
||||
|
||||
- **Type-Safe MongoDB Integration**: Full TypeScript support with decorators for schema definition
|
||||
- **Document Management**: Type-safe CRUD operations with automatic timestamp tracking
|
||||
- **EasyStore**: Simple key-value storage with automatic persistence and sharing between instances
|
||||
- **Distributed Coordination**: Built-in support for leader election and distributed task management
|
||||
- **Real-time Data Sync**: Watchers for real-time data changes with RxJS integration
|
||||
- **Connection Management**: Automatic connection handling with connection pooling
|
||||
- **Collection Management**: Type-safe collection operations with automatic indexing
|
||||
- **Deep Query Type Safety**: Fully type-safe queries for nested object properties with `DeepQuery<T>`
|
||||
- **MongoDB Compatibility**: Support for all MongoDB query operators and advanced features
|
||||
- **Enhanced Cursors**: Chainable, type-safe cursor API with memory efficient data processing
|
||||
- **Type Conversion**: Automatic handling of MongoDB types like ObjectId and Binary data
|
||||
- **Serialization Hooks**: Custom serialization and deserialization of document properties
|
||||
- **Powerful Search Capabilities**: Unified `search(query)` API supporting field:value exact matches, multi-field regex searches, case-insensitive matching, and automatic escaping to prevent regex injection
|
||||
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.
|
||||
|
||||
## Requirements
|
||||
## 🌟 Why SmartData?
|
||||
|
||||
- Node.js >= 16.x
|
||||
- MongoDB >= 4.4
|
||||
- TypeScript >= 4.x (for development)
|
||||
- 🔒 **100% Type-Safe** — TC39 Stage 3 decorators, generic filters, and compile-time query validation
|
||||
- ⚡ **High Performance** — Connection pooling, cursor streaming, and automatic indexing
|
||||
- 🔄 **Real-time Ready** — MongoDB Change Streams with RxJS for reactive applications
|
||||
- 🌍 **Distributed Systems** — Built-in leader election and task coordination via `@push.rocks/taskbuffer`
|
||||
- 🛡️ **Security First** — `$where` injection prevention, operator allow-listing, and input sanitization
|
||||
- 🔎 **Lucene Search** — Full-text, wildcard, boolean, and range queries out of the box
|
||||
- 🎯 **Great DX** — Intuitive API, IntelliSense that just works, and lifecycle hooks
|
||||
|
||||
## Install
|
||||
|
||||
To install `@push.rocks/smartdata`, use npm:
|
||||
|
||||
```bash
|
||||
npm install @push.rocks/smartdata --save
|
||||
```
|
||||
|
||||
Or with pnpm:
|
||||
## 📦 Installation
|
||||
|
||||
```bash
|
||||
pnpm add @push.rocks/smartdata
|
||||
```
|
||||
|
||||
## Usage
|
||||
## 🚦 Requirements
|
||||
|
||||
`@push.rocks/smartdata` enables efficient data handling and operation management with a focus on using MongoDB. It leverages TypeScript for strong typing and ESM syntax for modern JavaScript usage.
|
||||
- **Node.js** >= 20.x
|
||||
- **Deno** >= 2.0 (for Deno projects)
|
||||
- **MongoDB** >= 5.0
|
||||
- **TypeScript** >= 5.2 (for TC39 decorator support)
|
||||
|
||||
### Setting Up and Connecting to the Database
|
||||
> **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.
|
||||
|
||||
Before interacting with the database, you need to set up and establish a connection. The `SmartdataDb` class handles connection pooling and automatic reconnection.
|
||||
## 🎯 Quick Start
|
||||
|
||||
### 1️⃣ Connect to Your Database
|
||||
|
||||
```typescript
|
||||
import { SmartdataDb } from '@push.rocks/smartdata';
|
||||
|
||||
// Create a new instance of SmartdataDb with MongoDB connection details
|
||||
const db = new SmartdataDb({
|
||||
mongoDbUrl: 'mongodb://<USERNAME>:<PASSWORD>@localhost:27017/<DBNAME>',
|
||||
mongoDbName: 'your-database-name',
|
||||
mongoDbUser: 'your-username',
|
||||
mongoDbPass: 'your-password',
|
||||
mongoDbUrl: 'mongodb://localhost:27017/myapp',
|
||||
mongoDbName: 'myapp',
|
||||
mongoDbUser: 'username',
|
||||
mongoDbPass: 'password',
|
||||
});
|
||||
|
||||
// Initialize and connect to the database
|
||||
// This sets up a connection pool with max 100 connections
|
||||
await db.init();
|
||||
console.log(db.status); // 'connected'
|
||||
```
|
||||
|
||||
### Defining Data Models
|
||||
|
||||
Data models in `@push.rocks/smartdata` are classes that represent collections and documents in your MongoDB database. Use decorators such as `@Collection`, `@unI`, `@svDb`, `@index`, and `@searchable` to define your data models. Fields of type `ObjectId` or `Buffer` decorated with `@svDb()` will be stored as BSON ObjectId and Binary, respectively; no separate `@oid()` or `@bin()` decorators are required.
|
||||
### 2️⃣ Define Your Data Models
|
||||
|
||||
```typescript
|
||||
import {
|
||||
@@ -77,33 +62,29 @@ import {
|
||||
index,
|
||||
searchable,
|
||||
} from '@push.rocks/smartdata';
|
||||
import { ObjectId } from 'mongodb';
|
||||
|
||||
@Collection(() => db) // Associate this model with the database instance
|
||||
@Collection(() => db)
|
||||
class User extends SmartDataDbDoc<User, User> {
|
||||
@unI()
|
||||
public id: string = 'unique-user-id'; // Mark 'id' as a unique index
|
||||
public id!: string;
|
||||
|
||||
@svDb()
|
||||
@searchable() // Mark 'username' as searchable
|
||||
public username: string; // Mark 'username' to be saved in DB
|
||||
@searchable()
|
||||
public username!: string;
|
||||
|
||||
@svDb()
|
||||
@searchable() // Mark 'email' as searchable
|
||||
@index() // Create a regular index for this field
|
||||
public email: string; // Mark 'email' to be saved in DB
|
||||
@searchable()
|
||||
@index({ unique: false })
|
||||
public email!: string;
|
||||
|
||||
@svDb()
|
||||
public organizationId: ObjectId; // Stored as BSON ObjectId
|
||||
public status!: 'active' | 'inactive' | 'pending';
|
||||
|
||||
@svDb()
|
||||
public profilePicture: Buffer; // Stored as BSON Binary
|
||||
public tags!: string[];
|
||||
|
||||
@svDb({
|
||||
serialize: (data) => JSON.stringify(data), // Custom serialization
|
||||
deserialize: (data) => JSON.parse(data), // Custom deserialization
|
||||
})
|
||||
public preferences: Record<string, any>;
|
||||
@svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
constructor(username: string, email: string) {
|
||||
super();
|
||||
@@ -113,493 +94,403 @@ class User extends SmartDataDbDoc<User, User> {
|
||||
}
|
||||
```
|
||||
|
||||
### CRUD Operations
|
||||
|
||||
`@push.rocks/smartdata` simplifies CRUD operations with intuitive methods on model instances.
|
||||
|
||||
#### Create
|
||||
### 3️⃣ CRUD Operations
|
||||
|
||||
```typescript
|
||||
const newUser = new User('myUsername', 'myEmail@example.com');
|
||||
await newUser.save(); // Save the new user to the database
|
||||
// ✨ Create
|
||||
const user = new User('johndoe', 'john@example.com');
|
||||
user.status = 'active';
|
||||
user.tags = ['developer', 'typescript'];
|
||||
await user.save();
|
||||
|
||||
// 🔍 Read — fully type-safe filters
|
||||
const foundUser = await User.getInstance({ username: 'johndoe' });
|
||||
const activeUsers = await User.getInstances({ status: 'active' });
|
||||
|
||||
// ✏️ Update
|
||||
foundUser.email = 'newemail@example.com';
|
||||
await foundUser.save();
|
||||
|
||||
// 🗑️ Delete
|
||||
await foundUser.delete();
|
||||
```
|
||||
|
||||
#### Read
|
||||
## 🔥 Features
|
||||
|
||||
### 🎯 Type-Safe Query Filters
|
||||
|
||||
SmartData provides a rich, type-safe filtering system supporting all MongoDB operators with full IntelliSense:
|
||||
|
||||
```typescript
|
||||
// Fetch a single user by a unique attribute
|
||||
const user = await User.getInstance({ username: 'myUsername' });
|
||||
|
||||
// Fetch multiple users that match criteria
|
||||
const users = await User.getInstances({ email: 'myEmail@example.com' });
|
||||
|
||||
// Obtain a cursor for large result sets
|
||||
const cursor = await User.getCursor({ active: true });
|
||||
|
||||
// Stream each document efficiently
|
||||
await cursor.forEach(async (user) => {
|
||||
console.log(`Processing user: ${user.username}`);
|
||||
// Comparison operators
|
||||
const adults = await User.getInstances({
|
||||
age: { $gte: 18, $lt: 65 },
|
||||
});
|
||||
|
||||
// Manually iterate using next()
|
||||
let nextUser;
|
||||
while ((nextUser = await cursor.next())) {
|
||||
console.log(`Next user: ${nextUser.username}`);
|
||||
}
|
||||
|
||||
// Convert to array when the result set is small
|
||||
const userArray = await cursor.toArray();
|
||||
|
||||
// Close the cursor to free resources
|
||||
await cursor.close();
|
||||
|
||||
// For native cursor modifiers (sort, skip, limit), use getCursor with modifier option:
|
||||
const paginatedCursor = await User.getCursor(
|
||||
{ active: true },
|
||||
{ modifier: (c) => c.sort({ createdAt: -1 }).skip(20).limit(10) }
|
||||
);
|
||||
await paginatedCursor.forEach((user) => {
|
||||
console.log(`Paginated user: ${user.username}`);
|
||||
// Array operators
|
||||
const experts = await User.getInstances({
|
||||
tags: { $all: ['typescript', 'mongodb'] },
|
||||
skills: { $size: 5 },
|
||||
});
|
||||
```
|
||||
|
||||
#### Update
|
||||
// Logical operators
|
||||
const complex = await Order.getInstances({
|
||||
$and: [
|
||||
{ status: 'active' },
|
||||
{ $or: [{ priority: 'high' }, { value: { $gte: 1000 } }] },
|
||||
],
|
||||
});
|
||||
|
||||
```typescript
|
||||
// Assuming 'user' is an instance of User
|
||||
user.email = 'newEmail@example.com';
|
||||
await user.save(); // Update the user in the database
|
||||
|
||||
// Upsert operations (insert if not exists, update if exists)
|
||||
const upsertedUser = await User.upsert(
|
||||
{ id: 'user-123' }, // Query to find the user
|
||||
{
|
||||
// Fields to update or insert
|
||||
username: 'newUsername',
|
||||
email: 'newEmail@example.com',
|
||||
// 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' },
|
||||
});
|
||||
```
|
||||
|
||||
#### Delete
|
||||
**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
|
||||
// Assuming 'user' is an instance of User
|
||||
await user.delete(); // Delete the user from the database
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Search Functionality
|
||||
|
||||
SmartData provides powerful, Lucene‑style search capabilities with robust fallback mechanisms:
|
||||
|
||||
```typescript
|
||||
// Define a model with searchable fields
|
||||
@Collection(() => db)
|
||||
class Product extends SmartDataDbDoc<Product, Product> {
|
||||
@unI() public id: string = 'product-id';
|
||||
@svDb() @searchable() public name: string;
|
||||
@svDb() @searchable() public description: string;
|
||||
@svDb() @searchable() public category: string;
|
||||
@svDb() public price: number;
|
||||
@unI() public id!: string;
|
||||
@svDb() @searchable() public name!: string;
|
||||
@svDb() @searchable() public description!: string;
|
||||
@svDb() @searchable() public category!: string;
|
||||
@svDb() public price!: number;
|
||||
}
|
||||
|
||||
// List searchable fields
|
||||
const searchableFields = Product.getSearchableFields();
|
||||
// Simple text search across all @searchable fields
|
||||
const results = await Product.search('laptop');
|
||||
|
||||
// 1: Exact phrase across all fields
|
||||
await Product.search('"Kindle Paperwhite"');
|
||||
// Field-specific search
|
||||
const electronics = await Product.search('category:Electronics');
|
||||
|
||||
// 2: Wildcard search across all fields
|
||||
await Product.search('Air*');
|
||||
// Wildcard
|
||||
const matches = await Product.search('Mac*');
|
||||
|
||||
// 3: Field‑scoped wildcard
|
||||
await Product.search('name:Air*');
|
||||
// Boolean operators (AND, OR, NOT)
|
||||
const query = await Product.search('laptop AND NOT gaming');
|
||||
|
||||
// 4: Boolean AND/OR/NOT
|
||||
await Product.search('category:Electronics AND name:iPhone');
|
||||
// Phrase search
|
||||
const exact = await Product.search('"MacBook Pro"');
|
||||
|
||||
// 5: Grouping with parentheses
|
||||
await Product.search('(Furniture OR Electronics) AND Chair');
|
||||
// Range queries
|
||||
const midRange = await Product.search('price:[100 TO 500]');
|
||||
|
||||
// 6: Multi‑term unquoted (terms AND’d across fields)
|
||||
await Product.search('TypeScript Aufgabe');
|
||||
|
||||
// 7: Empty query returns all documents
|
||||
await Product.search('');
|
||||
// 8: Scoped search with additional filter (e.g. multi-tenant isolation)
|
||||
await Product.search('book', { filter: { ownerId: currentUserId } });
|
||||
// 9: Post-search validation hook to drop unwanted results (e.g. price check)
|
||||
await Product.search('', { validate: (p) => p.price < 100 });
|
||||
```
|
||||
|
||||
The search functionality includes:
|
||||
|
||||
- `@searchable()` decorator for marking fields as searchable
|
||||
- `Class.getSearchableFields()` static method to list searchable fields for a model
|
||||
- `search(query: string)` method supporting:
|
||||
- Exact phrase matches (`"my exact string"` or `'my exact string'`)
|
||||
- Field‑scoped exact & wildcard searches (`field:value`, `field:Air*`)
|
||||
- Wildcard searches across all fields (`Air*`, `?Pods`)
|
||||
- Boolean operators (`AND`, `OR`, `NOT`) with grouping (`(...)`)
|
||||
- Multi‑term unquoted queries AND’d across fields (`TypeScript Aufgabe`)
|
||||
- Single/multi‑term regex searches across fields
|
||||
- Empty queries returning all documents
|
||||
- Automatic escaping & wildcard conversion to prevent regex injection
|
||||
|
||||
### EasyStore
|
||||
|
||||
EasyStore provides a simple key-value storage system with automatic persistence:
|
||||
|
||||
```typescript
|
||||
// Create an EasyStore instance with a specific type
|
||||
interface ConfigStore {
|
||||
apiKey: string;
|
||||
settings: {
|
||||
theme: string;
|
||||
notifications: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Create a type-safe EasyStore
|
||||
const store = await db.createEasyStore<ConfigStore>('app-config');
|
||||
|
||||
// Write and read data with full type safety
|
||||
await store.writeKey('apiKey', 'secret-api-key-123');
|
||||
await store.writeKey('settings', { theme: 'dark', notifications: true });
|
||||
|
||||
const apiKey = await store.readKey('apiKey'); // Type: string
|
||||
const settings = await store.readKey('settings'); // Type: { theme: string, notifications: boolean }
|
||||
|
||||
// Check if a key exists
|
||||
const hasKey = await store.hasKey('apiKey'); // true
|
||||
|
||||
// Delete a key
|
||||
await store.deleteKey('apiKey');
|
||||
```
|
||||
|
||||
### Distributed Coordination
|
||||
|
||||
Built-in support for distributed systems with leader election:
|
||||
|
||||
```typescript
|
||||
// Create a distributed coordinator
|
||||
const coordinator = new SmartdataDistributedCoordinator(db);
|
||||
|
||||
// Start coordination
|
||||
await coordinator.start();
|
||||
|
||||
// Handle leadership changes
|
||||
coordinator.on('leadershipChange', (isLeader) => {
|
||||
if (isLeader) {
|
||||
// This instance is now the leader
|
||||
// Run leader-specific tasks
|
||||
startPeriodicJobs();
|
||||
} else {
|
||||
// This instance is no longer the leader
|
||||
stopPeriodicJobs();
|
||||
}
|
||||
// Combined with MongoDB filters and post-fetch validation
|
||||
const affordable = await Product.search('laptop', {
|
||||
filter: { price: { $lte: 1500 } },
|
||||
validate: async (p) => p.price > 0,
|
||||
});
|
||||
|
||||
// Access leadership status anytime
|
||||
if (coordinator.isLeader) {
|
||||
// Run leader-only operations
|
||||
}
|
||||
|
||||
// Execute a task only on the leader
|
||||
await coordinator.executeIfLeader(async () => {
|
||||
// This code only runs on the leader instance
|
||||
await runImportantTask();
|
||||
});
|
||||
|
||||
// Stop coordination when shutting down
|
||||
await coordinator.stop();
|
||||
```
|
||||
|
||||
### Real-time Data Watching
|
||||
### 📡 Real-Time Change Streams
|
||||
|
||||
Watch for changes in your collections with RxJS integration using MongoDB Change Streams:
|
||||
Watch for database changes with RxJS subjects and EventEmitter support:
|
||||
|
||||
```typescript
|
||||
// Create a watcher for a specific collection with a query filter
|
||||
const watcher = await User.watch(
|
||||
{ status: 'active' },
|
||||
{
|
||||
active: true, // Only watch for changes to active users
|
||||
},
|
||||
{
|
||||
fullDocument: true, // Include the full document in change notifications
|
||||
bufferTimeMs: 100, // Buffer changes for 100ms to reduce notification frequency
|
||||
fullDocument: 'updateLookup',
|
||||
bufferTimeMs: 100, // optional: buffer changes via RxJS
|
||||
},
|
||||
);
|
||||
|
||||
// Subscribe to changes using RxJS
|
||||
watcher.changeSubject.subscribe((change) => {
|
||||
console.log('Change operation:', change.operationType); // 'insert', 'update', 'delete', etc.
|
||||
console.log('Document changed:', change.docInstance); // The full document instance
|
||||
|
||||
// Handle different types of changes
|
||||
if (change.operationType === 'insert') {
|
||||
console.log('New user created:', change.docInstance.username);
|
||||
} else if (change.operationType === 'update') {
|
||||
console.log('User updated:', change.docInstance.username);
|
||||
} else if (change.operationType === 'delete') {
|
||||
console.log('User deleted');
|
||||
}
|
||||
// RxJS subscription
|
||||
watcher.changeSubject.subscribe((user) => {
|
||||
console.log('User changed:', user);
|
||||
});
|
||||
|
||||
// Manual observation with event emitter pattern
|
||||
watcher.on('change', (change) => {
|
||||
console.log('Document changed:', change);
|
||||
// Or EventEmitter style
|
||||
watcher.on('change', (user) => {
|
||||
console.log('User changed:', user);
|
||||
});
|
||||
|
||||
// Stop watching when no longer needed
|
||||
await watcher.stop();
|
||||
// Clean up
|
||||
await watcher.close();
|
||||
```
|
||||
|
||||
### Managed Collections
|
||||
### 🔄 Cursor Streaming
|
||||
|
||||
For more complex data models that require additional context:
|
||||
Process large datasets without memory pressure:
|
||||
|
||||
```typescript
|
||||
@Collection(() => db)
|
||||
class ManagedDoc extends SmartDataDbDoc<ManagedDoc, ManagedDoc> {
|
||||
@unI()
|
||||
public id: string = 'unique-id';
|
||||
const cursor = await User.getCursor(
|
||||
{ status: 'active' },
|
||||
{
|
||||
modifier: (c) => c.sort({ createdAt: -1 }).limit(10000),
|
||||
},
|
||||
);
|
||||
|
||||
@svDb()
|
||||
public data: string;
|
||||
// Iterate one-by-one
|
||||
await cursor.forEach(async (user) => {
|
||||
await processUser(user);
|
||||
});
|
||||
|
||||
@managed()
|
||||
public manager: YourCustomManager;
|
||||
// Or collect into an array
|
||||
const users = await cursor.toArray();
|
||||
|
||||
// The manager can provide additional functionality
|
||||
async specialOperation() {
|
||||
return this.manager.doSomethingSpecial(this);
|
||||
}
|
||||
}
|
||||
// Always close when done
|
||||
await cursor.close();
|
||||
```
|
||||
|
||||
### Automatic Indexing
|
||||
### 🔐 Transactions
|
||||
|
||||
Define indexes directly in your model class:
|
||||
Ensure atomic consistency across multiple operations:
|
||||
|
||||
```typescript
|
||||
@Collection(() => db)
|
||||
class Product extends SmartDataDbDoc<Product, Product> {
|
||||
@unI() // Unique index
|
||||
public id: string = 'product-id';
|
||||
|
||||
@svDb()
|
||||
@index() // Regular index for faster queries
|
||||
public category: string;
|
||||
|
||||
@svDb()
|
||||
@index({ sparse: true }) // Sparse index with options
|
||||
public optionalField?: string;
|
||||
|
||||
// Compound indexes can be defined in the collection decorator
|
||||
@Collection(() => db, {
|
||||
indexMap: {
|
||||
compoundIndex: {
|
||||
fields: { category: 1, name: 1 },
|
||||
options: { background: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Transaction Support
|
||||
|
||||
Use MongoDB transactions for atomic operations. SmartData now exposes `startSession()` and accepts an optional session in all fetch and write APIs:
|
||||
|
||||
```typescript
|
||||
// start a client session (no await)
|
||||
const session = db.startSession();
|
||||
try {
|
||||
// wrap operations in a transaction
|
||||
await session.withTransaction(async () => {
|
||||
// pass session as second arg to getInstance
|
||||
const user = await User.getInstance({ id: 'user-id' }, session);
|
||||
user.balance -= 100;
|
||||
// pass session in save opts
|
||||
await user.save({ session });
|
||||
|
||||
const recipient = await User.getInstance({ id: 'recipient-id' }, session);
|
||||
recipient.balance += 100;
|
||||
await recipient.save({ session });
|
||||
try {
|
||||
await session.withTransaction(async () => {
|
||||
const sender = await User.getInstance({ id: 'user-1' }, { session });
|
||||
sender.balance -= 100;
|
||||
await sender.save({ session });
|
||||
|
||||
const receiver = await User.getInstance({ id: 'user-2' }, { session });
|
||||
receiver.balance += 100;
|
||||
await receiver.save({ session });
|
||||
});
|
||||
} finally {
|
||||
await session.endSession();
|
||||
}
|
||||
```
|
||||
|
||||
### Deep Object Queries
|
||||
### 💾 EasyStore — Type-Safe Key-Value Storage
|
||||
|
||||
SmartData provides fully type-safe deep property queries with the `DeepQuery` type:
|
||||
Built on top of SmartData collections, EasyStore provides simple key-value persistence:
|
||||
|
||||
```typescript
|
||||
// If your document has nested objects
|
||||
class UserProfile extends SmartDataDbDoc<UserProfile, UserProfile> {
|
||||
@unI()
|
||||
public id: string = 'profile-id';
|
||||
|
||||
@svDb()
|
||||
public user: {
|
||||
details: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
address: {
|
||||
city: string;
|
||||
country: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
interface AppConfig {
|
||||
apiKey: string;
|
||||
features: { darkMode: boolean; notifications: boolean };
|
||||
limits: { maxUsers: number };
|
||||
}
|
||||
|
||||
// Type-safe string literals for dot notation
|
||||
const usersInUSA = await UserProfile.getInstances({
|
||||
'user.details.address.country': 'USA',
|
||||
});
|
||||
const config = await db.createEasyStore<AppConfig>('app-config');
|
||||
|
||||
// Fully typed deep queries with the DeepQuery type
|
||||
import { DeepQuery } from '@push.rocks/smartdata';
|
||||
// Write
|
||||
await config.writeKey('features', { darkMode: true, notifications: false });
|
||||
|
||||
const typedQuery: DeepQuery<UserProfile> = {
|
||||
id: 'profile-id',
|
||||
'user.details.firstName': 'John',
|
||||
'user.details.address.country': 'USA',
|
||||
};
|
||||
// Read
|
||||
const features = await config.readKey('features');
|
||||
// TypeScript knows: features.darkMode is boolean ✅
|
||||
|
||||
// TypeScript will error if paths are incorrect
|
||||
const results = await UserProfile.getInstances(typedQuery);
|
||||
// Read all
|
||||
const all = await config.readAll();
|
||||
|
||||
// MongoDB query operators are supported
|
||||
const operatorQuery: DeepQuery<UserProfile> = {
|
||||
'user.details.address.country': 'USA',
|
||||
'user.details.address.city': { $in: ['New York', 'Los Angeles'] },
|
||||
};
|
||||
// Write multiple keys
|
||||
await config.writeAll({ apiKey: 'new-key', limits: { maxUsers: 500 } });
|
||||
|
||||
const filteredResults = await UserProfile.getInstances(operatorQuery);
|
||||
// Delete a key
|
||||
await config.deleteKey('features');
|
||||
|
||||
// Wipe the store
|
||||
await config.wipe();
|
||||
```
|
||||
|
||||
### Document Lifecycle Hooks
|
||||
### 🌐 Distributed Coordination
|
||||
|
||||
Implement custom logic at different stages of a document's lifecycle:
|
||||
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({
|
||||
serialize: (set) => Array.from(set),
|
||||
deserialize: (arr) => new Set(arr),
|
||||
})
|
||||
public tags!: Set<string>;
|
||||
|
||||
@svDb({
|
||||
serialize: (date) => date?.toISOString(),
|
||||
deserialize: (str) => (str ? new Date(str) : null),
|
||||
})
|
||||
public scheduledAt!: Date | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 🎣 Lifecycle Hooks
|
||||
|
||||
Add custom logic before and after save/delete:
|
||||
|
||||
```typescript
|
||||
@Collection(() => db)
|
||||
class Order extends SmartDataDbDoc<Order, Order> {
|
||||
@unI()
|
||||
public id: string = 'order-id';
|
||||
@unI() public id!: string;
|
||||
@svDb() public items!: Array<{ product: string; quantity: number; price: number }>;
|
||||
@svDb() public totalAmount!: number;
|
||||
|
||||
@svDb()
|
||||
public total: number;
|
||||
|
||||
@svDb()
|
||||
public items: string[];
|
||||
|
||||
// Called before saving the document
|
||||
async beforeSave() {
|
||||
// Calculate total based on items
|
||||
this.total = await calculateTotal(this.items);
|
||||
|
||||
// Validate the document
|
||||
if (this.items.length === 0) {
|
||||
throw new Error('Order must have at least one item');
|
||||
}
|
||||
this.totalAmount = this.items.reduce((s, i) => s + i.price * i.quantity, 0);
|
||||
}
|
||||
|
||||
// Called after the document is saved
|
||||
async afterSave() {
|
||||
// Notify other systems about the saved order
|
||||
await notifyExternalSystems(this);
|
||||
await notificationService.orderUpdated(this.id);
|
||||
}
|
||||
|
||||
// Called before deleting the document
|
||||
async beforeDelete() {
|
||||
// Check if order can be deleted
|
||||
const canDelete = await checkOrderDeletable(this.id);
|
||||
if (!canDelete) {
|
||||
throw new Error('Order cannot be deleted');
|
||||
}
|
||||
if (this.totalAmount > 0) throw new Error('Cannot delete non-zero orders');
|
||||
}
|
||||
// Called after deleting the document
|
||||
|
||||
async afterDelete() {
|
||||
// Cleanup or audit actions
|
||||
await auditLogDeletion(this.id);
|
||||
await cache.delete(`order:${this.id}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
### 🏗️ Indexing
|
||||
|
||||
### Connection Management
|
||||
```typescript
|
||||
@Collection(() => db)
|
||||
class HighPerformanceDoc extends SmartDataDbDoc<HighPerformanceDoc, HighPerformanceDoc> {
|
||||
@unI()
|
||||
public id!: string; // Unique index
|
||||
|
||||
- Always call `db.init()` before using any database features
|
||||
- Use `db.close()` when shutting down your application
|
||||
- Set appropriate connection pool sizes based on your application's needs
|
||||
@index()
|
||||
public userId!: string; // Regular index
|
||||
|
||||
### Document Design
|
||||
@index({ sparse: true })
|
||||
public deletedAt?: Date; // Sparse index — only indexes docs where field exists
|
||||
|
||||
- Use appropriate decorators (`@svDb`, `@unI`, `@index`, `@searchable`) to optimize database operations
|
||||
- Implement type-safe models by properly extending `SmartDataDbDoc`
|
||||
- Consider using interfaces to define document structures separately from implementation
|
||||
- Mark fields that need to be searched with the `@searchable()` decorator
|
||||
@index({ expireAfterSeconds: 86400 })
|
||||
public sessionToken!: string; // TTL index — auto-expires after 24h
|
||||
}
|
||||
```
|
||||
|
||||
### Search Optimization
|
||||
### 🔧 Connection Options
|
||||
|
||||
- (Optional) Create MongoDB text indexes on searchable fields to speed up full-text search
|
||||
- Use `search(query)` for all search operations (field:value, partial matches, multi-word)
|
||||
- Prefer field-specific exact matches when possible for optimal performance
|
||||
- Avoid unnecessary complexity in query strings to keep regex searches efficient
|
||||
```typescript
|
||||
const db = new SmartdataDb({
|
||||
mongoDbUrl: 'mongodb://localhost:27017',
|
||||
mongoDbName: 'myapp',
|
||||
mongoDbUser: 'admin',
|
||||
mongoDbPass: 's3cret',
|
||||
|
||||
### Performance Optimization
|
||||
// Connection pool tuning (all optional)
|
||||
maxPoolSize: 100, // Max connections (default: 100)
|
||||
maxIdleTimeMS: 300000, // Close idle connections after 5min (default)
|
||||
serverSelectionTimeoutMS: 30000, // Timeout for server selection
|
||||
socketTimeoutMS: 30000, // Socket timeout to prevent hung operations
|
||||
});
|
||||
```
|
||||
|
||||
- Use cursors for large datasets instead of loading all documents into memory
|
||||
- Create appropriate indexes for frequent query patterns
|
||||
- Use projections to limit the fields returned when you don't need the entire document
|
||||
## 📚 Decorators Reference
|
||||
|
||||
### Distributed Systems
|
||||
| Decorator | Target | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `@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 |
|
||||
|
||||
- Implement proper error handling for leader election events
|
||||
- Ensure all instances have synchronized clocks when using time-based coordination
|
||||
- Use the distributed coordinator's task management features for coordinated operations
|
||||
## 📚 API Reference
|
||||
|
||||
### Type Safety
|
||||
### Core Classes
|
||||
|
||||
- Take advantage of the `DeepQuery<T>` type for fully type-safe queries
|
||||
- Define proper types for your document models to enhance IDE auto-completion
|
||||
- Use generic type parameters to specify exact document types when working with collections
|
||||
| Class | Description |
|
||||
|-------|-------------|
|
||||
| `SmartdataDb` | Database connection, session management, EasyStore factory |
|
||||
| `SmartDataDbDoc<T, TImpl>` | Base class for all document models |
|
||||
| `SmartdataCollection<T>` | Underlying collection manager (usually accessed indirectly) |
|
||||
| `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 |
|
||||
|
||||
## Contributing
|
||||
### Key Static Methods on `SmartDataDbDoc`
|
||||
|
||||
We welcome contributions to @push.rocks/smartdata! Here's how you can help:
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `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 |
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
### Key Instance Methods on `SmartDataDbDoc`
|
||||
|
||||
Please make sure to update tests as appropriate and follow our coding standards.
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `save(opts?)` | Insert or update the document |
|
||||
| `delete(opts?)` | Delete the document |
|
||||
| `updateFromDb()` | Refresh fields from the database |
|
||||
| `saveDeep(savedMap?)` | Recursively save referenced documents |
|
||||
| `createSavableObject()` | Serialize to a plain object for persistence |
|
||||
| `createIdentifiableObject()` | Extract unique index fields for filtering |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository is licensed under the MIT License. For details, see [MIT License](https://opensource.org/licenses/MIT).
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Task Venture Capital GmbH
|
||||
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.
|
||||
|
||||
@@ -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 { smartunique } from '../ts/plugins.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 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 * as smartmongo from '@push.rocks/smartmongo';
|
||||
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 * as smartmongo from '@push.rocks/smartmongo';
|
||||
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 smartdata from '../ts/index.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 { 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 * as smartmongo from '@push.rocks/smartmongo';
|
||||
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 * as smartmongo from '@push.rocks/smartmongo';
|
||||
import { smartunique } from '../ts/plugins.js';
|
||||
@@ -60,6 +60,43 @@ tap.test('should watch a collection', async (toolsArg) => {
|
||||
await done.promise;
|
||||
});
|
||||
|
||||
// ======= New tests for EventEmitter and buffering support =======
|
||||
tap.test('should emit change via EventEmitter', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const watcher = await House.watch({});
|
||||
watcher.on('change', async (houseArg) => {
|
||||
// Expect a House instance
|
||||
expect(houseArg).toBeDefined();
|
||||
// Clean up
|
||||
await watcher.stop();
|
||||
done.resolve();
|
||||
});
|
||||
// Trigger an insert to generate a change event
|
||||
const h = new House();
|
||||
await h.save();
|
||||
await done.promise;
|
||||
});
|
||||
|
||||
tap.test('should buffer change events when bufferTimeMs is set', async (tools) => {
|
||||
const done = tools.defer();
|
||||
// bufferTimeMs collects events into arrays every 50ms
|
||||
const watcher = await House.watch({}, { bufferTimeMs: 50 });
|
||||
let received: House[];
|
||||
watcher.changeSubject.subscribe(async (batch: House[]) => {
|
||||
if (batch && batch.length > 0) {
|
||||
received = batch;
|
||||
await watcher.stop();
|
||||
done.resolve();
|
||||
}
|
||||
});
|
||||
// Rapidly insert multiple docs
|
||||
const docs = [new House(), new House(), new House()];
|
||||
for (const doc of docs) await doc.save();
|
||||
await done.promise;
|
||||
// All inserts should be in one buffered batch
|
||||
expect(received.length).toEqual(docs.length);
|
||||
});
|
||||
|
||||
// =======================================
|
||||
// close the database connection
|
||||
// =======================================
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartdata',
|
||||
version: '5.15.1',
|
||||
version: '7.1.3',
|
||||
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SmartdataDbCursor } from './classes.cursor.js';
|
||||
import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js';
|
||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||
import { CollectionFactory } from './classes.collectionfactory.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
export interface IFindOptions {
|
||||
limit?: number;
|
||||
@@ -20,37 +21,85 @@ export type TDelayed<TDelayedArg> = () => TDelayedArg;
|
||||
|
||||
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
|
||||
* @param dbArg
|
||||
*/
|
||||
export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) {
|
||||
return function classDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
|
||||
const decoratedClass = class extends constructor {
|
||||
public static className = constructor.name;
|
||||
public static get collection() {
|
||||
if (!(dbArg instanceof SmartdataDb)) {
|
||||
dbArg = dbArg();
|
||||
}
|
||||
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||
// Attach document constructor for searchableFields lookup
|
||||
if (!(coll as any).docCtor) {
|
||||
(coll as any).docCtor = decoratedClass;
|
||||
}
|
||||
return coll;
|
||||
return function classDecorator(value: Function, context: ClassDecoratorContext) {
|
||||
if (context.kind !== 'class') {
|
||||
throw new Error('Collection can only decorate classes');
|
||||
}
|
||||
|
||||
const constructor = value as { new (...args: any[]): any } & { className?: string };
|
||||
|
||||
const getCollection = () => {
|
||||
if (!(dbArg instanceof SmartdataDb)) {
|
||||
dbArg = dbArg();
|
||||
}
|
||||
public get collection() {
|
||||
if (!(dbArg instanceof SmartdataDb)) {
|
||||
dbArg = dbArg();
|
||||
}
|
||||
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||
if (!(coll as any).docCtor) {
|
||||
(coll as any).docCtor = decoratedClass;
|
||||
}
|
||||
return coll;
|
||||
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||
// Attach document constructor for searchableFields lookup
|
||||
if (coll && !(coll as any).docCtor) {
|
||||
(coll as any).docCtor = constructor;
|
||||
}
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,57 +117,51 @@ export const setDefaultManagerForDoc = <T,>(managerArg: IManager, dbDocArg: T):
|
||||
* @param dbArg
|
||||
*/
|
||||
export function managed<TManager extends IManager>(managerArg?: TManager | TDelayed<TManager>) {
|
||||
return function classDecorator<T extends { new (...args: any[]): any }>(constructor: T) {
|
||||
const decoratedClass = class extends constructor {
|
||||
public static className = constructor.name;
|
||||
public static get collection() {
|
||||
let dbArg: SmartdataDb;
|
||||
if (!managerArg) {
|
||||
dbArg = this.prototype.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 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 function classDecorator(value: Function, context: ClassDecoratorContext) {
|
||||
if (context.kind !== 'class') {
|
||||
throw new Error('managed can only decorate classes');
|
||||
}
|
||||
|
||||
const constructor = value as { new (...args: any[]): any } & { className?: string };
|
||||
(constructor as any).className = constructor.name;
|
||||
|
||||
// Resolution helpers (capture managerArg via closure)
|
||||
const getManager = (defaultManagerFn: () => TManager): TManager => {
|
||||
if (!managerArg) return defaultManagerFn();
|
||||
if (managerArg['db']) return managerArg as TManager;
|
||||
return (managerArg as TDelayed<TManager>)();
|
||||
};
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,8 +174,8 @@ export class SmartdataCollection<T> {
|
||||
/**
|
||||
* the collection that is used
|
||||
*/
|
||||
public mongoDbCollection: plugins.mongodb.Collection;
|
||||
public objectValidation: IDocValidationFunc<T> = null;
|
||||
public mongoDbCollection!: plugins.mongodb.Collection;
|
||||
public objectValidation: IDocValidationFunc<T> | null = null;
|
||||
public collectionName: string;
|
||||
public smartdataDb: SmartdataDb;
|
||||
public uniqueIndexes: string[] = [];
|
||||
@@ -161,7 +204,7 @@ export class SmartdataCollection<T> {
|
||||
});
|
||||
if (!wantedCollection) {
|
||||
await this.smartdataDb.mongoDb.createCollection(this.collectionName);
|
||||
console.log(`Successfully initiated Collection ${this.collectionName}`);
|
||||
logger.log('info', `Successfully initiated Collection ${this.collectionName}`);
|
||||
}
|
||||
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
|
||||
// Auto-create a compound text index on all searchable fields
|
||||
@@ -182,10 +225,10 @@ export class SmartdataCollection<T> {
|
||||
/**
|
||||
* mark unique index
|
||||
*/
|
||||
public markUniqueIndexes(keyArrayArg: string[] = []) {
|
||||
public async markUniqueIndexes(keyArrayArg: string[] = []) {
|
||||
for (const key of keyArrayArg) {
|
||||
if (!this.uniqueIndexes.includes(key)) {
|
||||
this.mongoDbCollection.createIndex(key, {
|
||||
await this.mongoDbCollection.createIndex({ [key]: 1 }, {
|
||||
unique: true,
|
||||
});
|
||||
// make sure we only call this once and not for every doc we create
|
||||
@@ -197,12 +240,12 @@ export class SmartdataCollection<T> {
|
||||
/**
|
||||
* creates regular indexes for the collection
|
||||
*/
|
||||
public createRegularIndexes(indexesArg: Array<{field: string, options: IIndexOptions}> = []) {
|
||||
public async createRegularIndexes(indexesArg: Array<{field: string, options: IIndexOptions}> = []) {
|
||||
for (const indexDef of indexesArg) {
|
||||
// Check if we've already created this index
|
||||
const indexKey = indexDef.field;
|
||||
if (!this.regularIndexes.some(i => i.field === indexKey)) {
|
||||
this.mongoDbCollection.createIndex(
|
||||
await this.mongoDbCollection.createIndex(
|
||||
{ [indexDef.field]: 1 }, // Simple single-field index
|
||||
indexDef.options
|
||||
);
|
||||
@@ -256,24 +299,40 @@ export class SmartdataCollection<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* watches the collection while applying a filter
|
||||
* Watches the collection, returning a SmartdataDbWatcher with RxJS and EventEmitter support.
|
||||
* @param filterObject match filter for change stream
|
||||
* @param opts optional MongoDB ChangeStreamOptions & { bufferTimeMs } to buffer events
|
||||
* @param smartdataDbDocArg document class for instance creation
|
||||
*/
|
||||
public async watch(
|
||||
filterObject: any,
|
||||
smartdataDbDocArg: typeof SmartDataDbDoc,
|
||||
opts: (plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number }) = {},
|
||||
smartdataDbDocArg?: typeof SmartDataDbDoc,
|
||||
): Promise<SmartdataDbWatcher> {
|
||||
await this.init();
|
||||
// Extract bufferTimeMs from options
|
||||
const { bufferTimeMs, fullDocument, ...otherOptions } = opts || {};
|
||||
// Determine fullDocument behavior: default to 'updateLookup'
|
||||
const changeStreamOptions: plugins.mongodb.ChangeStreamOptions = {
|
||||
...otherOptions,
|
||||
fullDocument:
|
||||
fullDocument === undefined
|
||||
? 'updateLookup'
|
||||
: (fullDocument as any) === true
|
||||
? 'updateLookup'
|
||||
: fullDocument,
|
||||
} as any;
|
||||
// Build pipeline with match if provided
|
||||
const pipeline = filterObject ? [{ $match: filterObject }] : [];
|
||||
const changeStream = this.mongoDbCollection.watch(
|
||||
[
|
||||
{
|
||||
$match: filterObject,
|
||||
},
|
||||
],
|
||||
{
|
||||
fullDocument: 'updateLookup',
|
||||
},
|
||||
pipeline,
|
||||
changeStreamOptions,
|
||||
);
|
||||
const smartdataWatcher = new SmartdataDbWatcher(
|
||||
changeStream,
|
||||
smartdataDbDocArg!,
|
||||
{ bufferTimeMs },
|
||||
);
|
||||
const smartdataWatcher = new SmartdataDbWatcher(changeStream, smartdataDbDocArg);
|
||||
await smartdataWatcher.readyDeferred.promise;
|
||||
return smartdataWatcher;
|
||||
}
|
||||
@@ -294,7 +353,7 @@ export class SmartdataCollection<T> {
|
||||
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 });
|
||||
return result;
|
||||
}
|
||||
@@ -309,7 +368,7 @@ export class SmartdataCollection<T> {
|
||||
await this.init();
|
||||
await this.checkDoc(dbDocArg);
|
||||
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
||||
const saveableObject = await dbDocArg.createSavableObject();
|
||||
const saveableObject = await dbDocArg.createSavableObject() as any;
|
||||
const updateableObject: any = {};
|
||||
for (const key of Object.keys(saveableObject)) {
|
||||
if (identifiableObject[key]) {
|
||||
|
||||
@@ -6,13 +6,8 @@ export class CollectionFactory {
|
||||
public collections: { [key: string]: SmartdataCollection<any> } = {};
|
||||
|
||||
public getCollection = (nameArg: string, dbArg: SmartdataDb): SmartdataCollection<any> => {
|
||||
if (!this.collections[nameArg]) {
|
||||
this.collections[nameArg] = (() => {
|
||||
if (dbArg instanceof SmartdataDb) {
|
||||
// tslint:disable-next-line: no-string-literal
|
||||
return new SmartdataCollection(nameArg, dbArg);
|
||||
}
|
||||
})();
|
||||
if (!this.collections[nameArg] && dbArg instanceof SmartdataDb) {
|
||||
this.collections[nameArg] = new SmartdataCollection(nameArg, dbArg);
|
||||
}
|
||||
return this.collections[nameArg];
|
||||
};
|
||||
|
||||
@@ -12,8 +12,8 @@ export type TConnectionStatus = 'initial' | 'disconnected' | 'connected' | 'fail
|
||||
|
||||
export class SmartdataDb {
|
||||
smartdataOptions: plugins.tsclass.database.IMongoDescriptor;
|
||||
mongoDbClient: plugins.mongodb.MongoClient;
|
||||
mongoDb: plugins.mongodb.Db;
|
||||
mongoDbClient!: plugins.mongodb.MongoClient;
|
||||
mongoDb!: plugins.mongodb.Db;
|
||||
status: TConnectionStatus;
|
||||
statusConnectedDeferred = plugins.smartpromise.defer();
|
||||
smartdataCollectionMap = new plugins.lik.ObjectMap<SmartdataCollection<any>>();
|
||||
@@ -35,24 +35,44 @@ export class SmartdataDb {
|
||||
* connects to the database that was specified during instance creation
|
||||
*/
|
||||
public async init(): Promise<any> {
|
||||
const finalConnectionUrl = this.smartdataOptions.mongoDbUrl
|
||||
.replace('<USERNAME>', this.smartdataOptions.mongoDbUser)
|
||||
.replace('<username>', this.smartdataOptions.mongoDbUser)
|
||||
.replace('<USER>', this.smartdataOptions.mongoDbUser)
|
||||
.replace('<user>', this.smartdataOptions.mongoDbUser)
|
||||
.replace('<PASSWORD>', this.smartdataOptions.mongoDbPass)
|
||||
.replace('<password>', this.smartdataOptions.mongoDbPass)
|
||||
.replace('<DBNAME>', this.smartdataOptions.mongoDbName)
|
||||
.replace('<dbname>', this.smartdataOptions.mongoDbName);
|
||||
try {
|
||||
// Safely encode credentials to handle special characters
|
||||
const encodedUser = this.smartdataOptions.mongoDbUser
|
||||
? encodeURIComponent(this.smartdataOptions.mongoDbUser)
|
||||
: '';
|
||||
const encodedPass = this.smartdataOptions.mongoDbPass
|
||||
? encodeURIComponent(this.smartdataOptions.mongoDbPass)
|
||||
: '';
|
||||
|
||||
const finalConnectionUrl = this.smartdataOptions.mongoDbUrl
|
||||
.replace('<USERNAME>', encodedUser)
|
||||
.replace('<username>', encodedUser)
|
||||
.replace('<USER>', encodedUser)
|
||||
.replace('<user>', encodedUser)
|
||||
.replace('<PASSWORD>', encodedPass)
|
||||
.replace('<password>', encodedPass)
|
||||
.replace('<DBNAME>', this.smartdataOptions.mongoDbName || '')
|
||||
.replace('<dbname>', this.smartdataOptions.mongoDbName || '');
|
||||
|
||||
this.mongoDbClient = await plugins.mongodb.MongoClient.connect(finalConnectionUrl, {
|
||||
maxPoolSize: 100,
|
||||
maxIdleTimeMS: 10,
|
||||
});
|
||||
this.mongoDb = this.mongoDbClient.db(this.smartdataOptions.mongoDbName);
|
||||
this.status = 'connected';
|
||||
this.statusConnectedDeferred.resolve();
|
||||
console.log(`Connected to database ${this.smartdataOptions.mongoDbName}`);
|
||||
const clientOptions: plugins.mongodb.MongoClientOptions = {
|
||||
maxPoolSize: (this.smartdataOptions as any).maxPoolSize ?? 100,
|
||||
maxIdleTimeMS: (this.smartdataOptions as any).maxIdleTimeMS ?? 300000, // 5 minutes default
|
||||
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,
|
||||
};
|
||||
|
||||
this.mongoDbClient = await plugins.mongodb.MongoClient.connect(finalConnectionUrl, clientOptions);
|
||||
this.mongoDb = this.mongoDbClient.db(this.smartdataOptions.mongoDbName);
|
||||
this.status = 'connected';
|
||||
this.statusConnectedDeferred.resolve();
|
||||
logger.log('info', `Connected to database ${this.smartdataOptions.mongoDbName}`);
|
||||
} catch (error) {
|
||||
this.status = 'disconnected';
|
||||
this.statusConnectedDeferred.reject(error);
|
||||
logger.log('error', `Failed to connect to database ${this.smartdataOptions.mongoDbName}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,15 +3,16 @@ import { SmartdataDb } from './classes.db.js';
|
||||
import { managed, setDefaultManagerForDoc } from './classes.collection.js';
|
||||
import { SmartDataDbDoc, svDb, unI } from './classes.doc.js';
|
||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
@managed()
|
||||
export class DistributedClass extends SmartDataDbDoc<DistributedClass, DistributedClass> {
|
||||
// INSTANCE
|
||||
@unI()
|
||||
public id: string;
|
||||
public id!: string;
|
||||
|
||||
@svDb()
|
||||
public data: {
|
||||
public data!: {
|
||||
status: 'initializing' | 'bidding' | 'settled' | 'stopped';
|
||||
biddingShortcode?: string;
|
||||
biddingStartTime?: number;
|
||||
@@ -39,8 +40,8 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
public readyPromise: Promise<any>;
|
||||
public db: SmartdataDb;
|
||||
private asyncExecutionStack = new plugins.lik.AsyncExecutionStack();
|
||||
public ownInstance: DistributedClass;
|
||||
public distributedWatcher: SmartdataDbWatcher<DistributedClass>;
|
||||
public ownInstance!: DistributedClass;
|
||||
public distributedWatcher!: SmartdataDbWatcher<DistributedClass>;
|
||||
|
||||
constructor(dbArg: SmartdataDb) {
|
||||
super();
|
||||
@@ -63,11 +64,11 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
this.ownInstance.data.elected = false;
|
||||
}
|
||||
if (this.ownInstance?.data.status === 'stopped') {
|
||||
console.log(`stopping a distributed instance that has not been started yet.`);
|
||||
logger.log('warn', `stopping a distributed instance that has not been started yet.`);
|
||||
}
|
||||
this.ownInstance.data.status = 'stopped';
|
||||
await this.ownInstance.save();
|
||||
console.log(`stopped ${this.ownInstance.id}`);
|
||||
logger.log('info', `stopped ${this.ownInstance.id}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,17 +84,17 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
public async sendHeartbeat() {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
if (this.ownInstance.data.status === 'stopped') {
|
||||
console.log(`aborted sending heartbeat because status is stopped`);
|
||||
logger.log('debug', `aborted sending heartbeat because status is stopped`);
|
||||
return;
|
||||
}
|
||||
await this.ownInstance.updateFromDb();
|
||||
this.ownInstance.data.lastUpdated = Date.now();
|
||||
await this.ownInstance.save();
|
||||
console.log(`sent heartbeat for ${this.ownInstance.id}`);
|
||||
logger.log('debug', `sent heartbeat for ${this.ownInstance.id}`);
|
||||
const allInstances = DistributedClass.getInstances({});
|
||||
});
|
||||
if (this.ownInstance.data.status === 'stopped') {
|
||||
console.log(`aborted sending heartbeat because status is stopped`);
|
||||
logger.log('info', `aborted sending heartbeat because status is stopped`);
|
||||
return;
|
||||
}
|
||||
const eligibleLeader = await this.getEligibleLeader();
|
||||
@@ -120,7 +121,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
await this.ownInstance.save();
|
||||
});
|
||||
} else {
|
||||
console.warn(`distributed instance already initialized`);
|
||||
logger.log('warn', `distributed instance already initialized`);
|
||||
}
|
||||
|
||||
// lets enable the heartbeat
|
||||
@@ -149,24 +150,24 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
public async checkAndMaybeLead() {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
this.ownInstance.data.status = 'initializing';
|
||||
this.ownInstance.save();
|
||||
await this.ownInstance.save();
|
||||
});
|
||||
if (await this.getEligibleLeader()) {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
await this.ownInstance.updateFromDb();
|
||||
this.ownInstance.data.status = 'settled';
|
||||
await this.ownInstance.save();
|
||||
console.log(`${this.ownInstance.id} settled as follower`);
|
||||
logger.log('info', `${this.ownInstance.id} settled as follower`);
|
||||
});
|
||||
return;
|
||||
} else if (
|
||||
(await DistributedClass.getInstances({})).find((instanceArg) => {
|
||||
instanceArg.data.status === 'bidding' &&
|
||||
instanceArg.data.biddingStartTime <= Date.now() - 4000 &&
|
||||
instanceArg.data.biddingStartTime >= Date.now() - 30000;
|
||||
return instanceArg.data.status === 'bidding' &&
|
||||
instanceArg.data.biddingStartTime! <= Date.now() - 4000 &&
|
||||
instanceArg.data.biddingStartTime! >= Date.now() - 30000;
|
||||
})
|
||||
) {
|
||||
console.log('too late to the bidding party... waiting for next round.');
|
||||
logger.log('info', 'too late to the bidding party... waiting for next round.');
|
||||
return;
|
||||
} else {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
@@ -175,9 +176,9 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
this.ownInstance.data.biddingStartTime = Date.now();
|
||||
this.ownInstance.data.biddingShortcode = plugins.smartunique.shortId();
|
||||
await this.ownInstance.save();
|
||||
console.log('bidding code stored.');
|
||||
logger.log('info', 'bidding code stored.');
|
||||
});
|
||||
console.log(`bidding for leadership...`);
|
||||
logger.log('info', `bidding for leadership...`);
|
||||
await plugins.smartdelay.delayFor(plugins.smarttime.getMilliSecondsFromUnits({ seconds: 5 }));
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
let biddingInstances = await DistributedClass.getInstances({});
|
||||
@@ -187,15 +188,15 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
instanceArg.data.lastUpdated >=
|
||||
Date.now() - plugins.smarttime.getMilliSecondsFromUnits({ seconds: 10 }),
|
||||
);
|
||||
console.log(`found ${biddingInstances.length} bidding instances...`);
|
||||
logger.log('info', `found ${biddingInstances.length} bidding instances...`);
|
||||
this.ownInstance.data.elected = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
await plugins.smartdelay.delayFor(5000);
|
||||
console.log(`settling with status elected = ${this.ownInstance.data.elected}`);
|
||||
logger.log('info', `settling with status elected = ${this.ownInstance.data.elected}`);
|
||||
this.ownInstance.data.status = 'settled';
|
||||
await this.ownInstance.save();
|
||||
});
|
||||
@@ -226,11 +227,11 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
this.distributedWatcher.changeSubject.subscribe({
|
||||
next: async (distributedDoc) => {
|
||||
if (!distributedDoc) {
|
||||
console.log(`registered deletion of instance...`);
|
||||
logger.log('info', `registered deletion of instance...`);
|
||||
return;
|
||||
}
|
||||
console.log(distributedDoc);
|
||||
console.log(`registered change for ${distributedDoc.id}`);
|
||||
logger.log('info', distributedDoc);
|
||||
logger.log('info', `registered change for ${distributedDoc.id}`);
|
||||
distributedDoc;
|
||||
},
|
||||
});
|
||||
@@ -252,7 +253,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
): Promise<plugins.taskbuffer.distributedCoordination.IDistributedTaskRequestResult> {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
if (!this.ownInstance) {
|
||||
console.error('instance need to be started first...');
|
||||
logger.log('error', 'instance need to be started first...');
|
||||
return;
|
||||
}
|
||||
await this.ownInstance.updateFromDb();
|
||||
@@ -268,8 +269,8 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
return taskRequestResult;
|
||||
});
|
||||
if (!result) {
|
||||
console.warn('no result found for task request...');
|
||||
return null;
|
||||
logger.log('warn', 'no result found for task request...');
|
||||
return null as any;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -285,7 +286,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
);
|
||||
});
|
||||
if (!existingInfoBasis) {
|
||||
console.warn('trying to update a non existing task request... aborting!');
|
||||
logger.log('warn', 'trying to update a non existing task request... aborting!');
|
||||
return;
|
||||
}
|
||||
Object.assign(existingInfoBasis, infoBasisArg);
|
||||
@@ -293,8 +294,10 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
plugins.smartdelay.delayFor(60000).then(() => {
|
||||
this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
const indexToRemove = this.ownInstance.data.taskRequests.indexOf(existingInfoBasis);
|
||||
this.ownInstance.data.taskRequests.splice(indexToRemove, indexToRemove);
|
||||
await this.ownInstance.save();
|
||||
if (indexToRemove >= 0) {
|
||||
this.ownInstance.data.taskRequests.splice(indexToRemove, 1);
|
||||
await this.ownInstance.save();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
import { SmartdataDb } from './classes.db.js';
|
||||
import { logger } from './logging.js';
|
||||
import { SmartdataDbCursor } from './classes.cursor.js';
|
||||
import { type IManager, SmartdataCollection } from './classes.collection.js';
|
||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||
@@ -27,15 +28,39 @@ export interface SearchOptions<T> {
|
||||
|
||||
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() {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
||||
if (!target.globalSaveableProperties) {
|
||||
target.globalSaveableProperties = [];
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('globalSvDb can only decorate fields');
|
||||
}
|
||||
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];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,20 +78,44 @@ export interface SvDbOptions {
|
||||
* saveable - saveable decorator to be used on class properties
|
||||
*/
|
||||
export function svDb(options?: SvDbOptions) {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
||||
if (!target.saveableProperties) {
|
||||
target.saveableProperties = [];
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('svDb can only decorate fields');
|
||||
}
|
||||
target.saveableProperties.push(key);
|
||||
// attach custom serializer/deserializer options to the class constructor
|
||||
const ctor = target.constructor as any;
|
||||
if (!ctor._svDbOptions) {
|
||||
ctor._svDbOptions = {};
|
||||
|
||||
const propName = String(context.name);
|
||||
|
||||
// Store metadata at class level using Symbol.metadata
|
||||
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||
if (!metadata.saveableProperties) {
|
||||
metadata.saveableProperties = [];
|
||||
}
|
||||
metadata.saveableProperties.push(propName);
|
||||
|
||||
// Store options in metadata
|
||||
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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,13 +123,30 @@ export function svDb(options?: SvDbOptions) {
|
||||
* searchable - marks a property as searchable with Lucene query syntax
|
||||
*/
|
||||
export function searchable() {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
// Attach to class constructor for direct access
|
||||
const ctor = target.constructor as any;
|
||||
if (!Array.isArray(ctor.searchableFields)) {
|
||||
ctor.searchableFields = [];
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('searchable can only decorate fields');
|
||||
}
|
||||
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];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,20 +159,41 @@ function escapeForRegex(input: string): string {
|
||||
* unique index - decorator to mark a unique index
|
||||
*/
|
||||
export function unI() {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called unI on >>${target.constructor.name}.${key}<<`);
|
||||
|
||||
// mark the index as unique
|
||||
if (!target.uniqueIndexes) {
|
||||
target.uniqueIndexes = [];
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('unI can only decorate fields');
|
||||
}
|
||||
target.uniqueIndexes.push(key);
|
||||
|
||||
// and also save it
|
||||
if (!target.saveableProperties) {
|
||||
target.saveableProperties = [];
|
||||
const propName = String(context.name);
|
||||
|
||||
// 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];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,67 +212,222 @@ export interface IIndexOptions {
|
||||
* index - decorator to mark a field for regular indexing
|
||||
*/
|
||||
export function index(options?: IIndexOptions) {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called index() on >${target.constructor.name}.${key}<`);
|
||||
|
||||
// Initialize regular indexes array if it doesn't exist
|
||||
if (!target.regularIndexes) {
|
||||
target.regularIndexes = [];
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('index can only decorate fields');
|
||||
}
|
||||
|
||||
// Add this field to regularIndexes with its options
|
||||
target.regularIndexes.push({
|
||||
field: key,
|
||||
|
||||
const propName = String(context.name);
|
||||
|
||||
// Store metadata at class level
|
||||
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||
if (!metadata.regularIndexes) {
|
||||
metadata.regularIndexes = [];
|
||||
}
|
||||
metadata.regularIndexes.push({
|
||||
field: propName,
|
||||
options: options || {}
|
||||
});
|
||||
|
||||
// Also ensure it's marked as saveable
|
||||
if (!target.saveableProperties) {
|
||||
target.saveableProperties = [];
|
||||
|
||||
// Also mark as saveable
|
||||
if (!metadata.saveableProperties) {
|
||||
metadata.saveableProperties = [];
|
||||
}
|
||||
|
||||
if (!target.saveableProperties.includes(key)) {
|
||||
target.saveableProperties.push(key);
|
||||
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.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 }) => {
|
||||
// Special case: detect MongoDB operators and pass them through directly
|
||||
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$where', '$regex'];
|
||||
// SECURITY: Block $where to prevent server-side JS execution
|
||||
if (filterArg.$where !== undefined) {
|
||||
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)) {
|
||||
if (topLevelOperators.includes(key)) {
|
||||
return filterArg; // Return the filter as-is for MongoDB operators
|
||||
if (logicalOperators.includes(key)) {
|
||||
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
|
||||
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) => {
|
||||
if (Array.isArray(filterArg2)) {
|
||||
// Directly assign arrays (they might be using operators like $in or $all)
|
||||
convertFilterArgument(keyPathArg2, filterArg2[0]);
|
||||
// Arrays are typically used as values for operators like $in or as direct equality matches
|
||||
mergeIntoConverted(keyPathArg2, filterArg2);
|
||||
return;
|
||||
} else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
|
||||
for (const key of Object.keys(filterArg2)) {
|
||||
if (key.startsWith('$')) {
|
||||
convertedFilter[keyPathArg2] = filterArg2;
|
||||
return;
|
||||
} else if (key.includes('.')) {
|
||||
// Check if this is an object with MongoDB operators
|
||||
const keys = Object.keys(filterArg2);
|
||||
const hasOperators = keys.some(key => key.startsWith('$'));
|
||||
|
||||
if (hasOperators) {
|
||||
// This object contains MongoDB operators
|
||||
// Validate and pass through allowed operators
|
||||
const allowedOperators = [
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(filterArg2)) {
|
||||
|
||||
// Recursively process nested objects
|
||||
for (const key of keys) {
|
||||
convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]);
|
||||
}
|
||||
} else {
|
||||
convertedFilter[keyPathArg2] = filterArg2;
|
||||
// Primitive values
|
||||
mergeIntoConverted(keyPathArg2, filterArg2);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -194,10 +436,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
* the collection object an Doc belongs to
|
||||
*/
|
||||
public static collection: SmartdataCollection<any>;
|
||||
public collection: SmartdataCollection<any>;
|
||||
declare public collection: SmartdataCollection<any>;
|
||||
public static defaultManager;
|
||||
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
|
||||
public static createInstanceFromMongoDbNativeDoc<T>(
|
||||
@@ -220,12 +469,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
/**
|
||||
* gets all instances as array
|
||||
* @param this
|
||||
* @param filterArg
|
||||
* @param filterArg - Type-safe MongoDB filter with nested object support and operators
|
||||
* @returns
|
||||
*/
|
||||
public static async getInstances<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<T[]> {
|
||||
// Pass session through to findAll for transactional queries
|
||||
@@ -233,7 +482,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
convertFilterForMongoDb(filterArg),
|
||||
{ session: opts?.session },
|
||||
);
|
||||
const returnArray = [];
|
||||
const returnArray: T[] = [];
|
||||
for (const foundDoc of foundDocs) {
|
||||
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
||||
returnArray.push(newInstance);
|
||||
@@ -249,7 +498,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public static async getInstance<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<T> {
|
||||
// Retrieve one document, with optional session for transactions
|
||||
@@ -261,7 +510,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
||||
return newInstance;
|
||||
} else {
|
||||
return null;
|
||||
return null as any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +531,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public static async getCursor<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: {
|
||||
session?: plugins.mongodb.ClientSession;
|
||||
modifier?: (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>;
|
||||
@@ -305,13 +554,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
* @param filterArg
|
||||
* @param forEachFunction
|
||||
*/
|
||||
/**
|
||||
* Watch the collection for changes, with optional buffering and change stream options.
|
||||
* @param filterArg MongoDB filter to select which changes to observe
|
||||
* @param opts optional ChangeStreamOptions plus bufferTimeMs
|
||||
*/
|
||||
public static async watch<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
) {
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number },
|
||||
): Promise<SmartdataDbWatcher<T>> {
|
||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||
const watcher: SmartdataDbWatcher<T> = await collection.watch(
|
||||
convertFilterForMongoDb(filterArg),
|
||||
opts || {},
|
||||
this as any,
|
||||
);
|
||||
return watcher;
|
||||
@@ -323,7 +579,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public static async forEach<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
forEachFunction: (itemArg: T) => Promise<any>,
|
||||
) {
|
||||
const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg);
|
||||
@@ -335,7 +591,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public static async getCount<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;
|
||||
return await collection.getCount(filterArg);
|
||||
@@ -551,33 +807,38 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
||||
*/
|
||||
public saveableProperties: string[];
|
||||
declare saveableProperties: string[];
|
||||
|
||||
/**
|
||||
* name
|
||||
*/
|
||||
public name: string;
|
||||
public name!: string;
|
||||
|
||||
/**
|
||||
* primary id in the database
|
||||
*/
|
||||
public dbDocUniqueId: string;
|
||||
public dbDocUniqueId!: string;
|
||||
|
||||
/**
|
||||
* class constructor
|
||||
@@ -600,14 +861,14 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
// perform insert or update
|
||||
switch (this.creationStatus) {
|
||||
case 'db':
|
||||
dbResult = await this.collection.update(self, { session: opts?.session });
|
||||
dbResult = await this.getCollectionSafe().update(self, { session: opts?.session });
|
||||
break;
|
||||
case 'new':
|
||||
dbResult = await this.collection.insert(self, { session: opts?.session });
|
||||
dbResult = await this.getCollectionSafe().insert(self, { session: opts?.session });
|
||||
this.creationStatus = 'db';
|
||||
break;
|
||||
default:
|
||||
console.error('neither new nor in db?');
|
||||
logger.log('error', 'neither new nor in db?');
|
||||
}
|
||||
// allow hook after saving
|
||||
if (typeof (this as any).afterSave === 'function') {
|
||||
@@ -625,7 +886,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
await (this as any).beforeDelete();
|
||||
}
|
||||
// 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
|
||||
if (typeof (this as any).afterDelete === 'function') {
|
||||
await (this as any).afterDelete();
|
||||
@@ -637,7 +898,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
* also store any referenced objects to DB
|
||||
* 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) {
|
||||
savedMapArg = new plugins.lik.ObjectMap<SmartDataDbDoc<any, any>>();
|
||||
}
|
||||
@@ -654,8 +915,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
/**
|
||||
* updates an object from db
|
||||
*/
|
||||
public async updateFromDb() {
|
||||
const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject());
|
||||
public async updateFromDb(): Promise<boolean> {
|
||||
const mongoDbNativeDoc = await this.getCollectionSafe().findOne(await this.createIdentifiableObject());
|
||||
if (!mongoDbNativeDoc) {
|
||||
return false; // Document not found in database
|
||||
}
|
||||
for (const key of Object.keys(mongoDbNativeDoc)) {
|
||||
const rawValue = mongoDbNativeDoc[key];
|
||||
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
||||
@@ -664,6 +928,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
? opts.deserialize(rawValue)
|
||||
: rawValue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -671,7 +936,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public async createSavableObject(): Promise<TImplements> {
|
||||
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
|
||||
const saveableProperties = [...this.globalSaveableProperties, ...this.saveableProperties];
|
||||
const globalProps = this.globalSaveableProperties || [];
|
||||
const specificProps = this.saveableProperties || [];
|
||||
const saveableProperties = [...globalProps, ...specificProps];
|
||||
// apply custom serialization if configured
|
||||
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
||||
for (const propertyNameString of saveableProperties) {
|
||||
|
||||
@@ -15,29 +15,29 @@ export class EasyStore<T> {
|
||||
@Collection(() => this.smartdataDbRef)
|
||||
class SmartdataEasyStore extends SmartDataDbDoc<SmartdataEasyStore, SmartdataEasyStore> {
|
||||
@unI()
|
||||
public nameId: string;
|
||||
public nameId!: string;
|
||||
|
||||
@svDb()
|
||||
public ephermal: {
|
||||
public ephemeral!: {
|
||||
activated: boolean;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
@svDb()
|
||||
lastEdit: number;
|
||||
lastEdit!: number;
|
||||
|
||||
@svDb()
|
||||
public data: Partial<T>;
|
||||
public data!: Partial<T>;
|
||||
}
|
||||
return SmartdataEasyStore;
|
||||
})();
|
||||
|
||||
constructor(nameIdArg: string, smnartdataDbRefArg: SmartdataDb) {
|
||||
this.smartdataDbRef = smnartdataDbRefArg;
|
||||
constructor(nameIdArg: string, smartdataDbRefArg: SmartdataDb) {
|
||||
this.smartdataDbRef = smartdataDbRefArg;
|
||||
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>> {
|
||||
if (this.easyStorePromise) {
|
||||
return this.easyStorePromise;
|
||||
@@ -110,10 +110,12 @@ export class EasyStore<T> {
|
||||
await easyStore.save();
|
||||
}
|
||||
|
||||
public async cleanUpEphermal() {
|
||||
while (
|
||||
(await this.smartdataDbRef.statusConnectedDeferred.promise) &&
|
||||
this.smartdataDbRef.status === 'connected'
|
||||
) {}
|
||||
public async cleanUpEphemeral() {
|
||||
// Clean up ephemeral data periodically while connected
|
||||
while (this.smartdataDbRef.status === 'connected') {
|
||||
await plugins.smartdelay.delayFor(60000); // Check every minute
|
||||
// TODO: Implement actual cleanup logic for ephemeral data
|
||||
// For now, this prevents the infinite CPU loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Lucene to MongoDB query adapter for SmartData
|
||||
*/
|
||||
import * as plugins from './plugins.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
// Types
|
||||
type NodeType =
|
||||
@@ -535,7 +536,7 @@ export class LuceneToMongoTransformer {
|
||||
const searchTerm = rightQuery.$text.$search.replace(/"/g, '');
|
||||
|
||||
// Determine the fields to apply the negation to
|
||||
const notConditions = [];
|
||||
const notConditions: any[] = [];
|
||||
|
||||
for (const field in leftQuery) {
|
||||
if (field !== '$or' && field !== '$and') {
|
||||
@@ -754,7 +755,7 @@ export class SmartdataLuceneAdapter {
|
||||
// Transform the AST to a MongoDB query
|
||||
return this.transformWithFields(ast, fieldsToSearch);
|
||||
} catch (error) {
|
||||
console.error(`Failed to convert Lucene query "${luceneQuery}":`, error);
|
||||
logger.log('error', `Failed to convert Lucene query "${luceneQuery}":`, error);
|
||||
throw new Error(`Failed to convert Lucene query: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,73 @@
|
||||
import { SmartDataDbDoc } from './classes.doc.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
/**
|
||||
* a wrapper for the native mongodb cursor. Exposes better
|
||||
*/
|
||||
export class SmartdataDbWatcher<T = any> {
|
||||
/**
|
||||
* Wraps a MongoDB ChangeStream with RxJS and EventEmitter support.
|
||||
*/
|
||||
export class SmartdataDbWatcher<T = any> extends EventEmitter {
|
||||
// STATIC
|
||||
public readyDeferred = plugins.smartpromise.defer();
|
||||
|
||||
// INSTANCE
|
||||
private changeStream: plugins.mongodb.ChangeStream<T>;
|
||||
|
||||
public changeSubject = new plugins.smartrx.rxjs.Subject<T>();
|
||||
private changeStream: plugins.mongodb.ChangeStream<any>;
|
||||
private rawSubject: plugins.smartrx.rxjs.Subject<T>;
|
||||
/** Emits change documents (or arrays of documents if buffered) */
|
||||
public changeSubject: any;
|
||||
/**
|
||||
* @param changeStreamArg native MongoDB ChangeStream
|
||||
* @param smartdataDbDocArg document class for instance creation
|
||||
* @param opts.bufferTimeMs optional milliseconds to buffer events via RxJS
|
||||
*/
|
||||
constructor(
|
||||
changeStreamArg: plugins.mongodb.ChangeStream<T>,
|
||||
changeStreamArg: plugins.mongodb.ChangeStream<any>,
|
||||
smartdataDbDocArg: typeof SmartDataDbDoc,
|
||||
opts?: { bufferTimeMs?: number },
|
||||
) {
|
||||
super();
|
||||
this.rawSubject = new plugins.smartrx.rxjs.Subject<T>();
|
||||
// Apply buffering if requested
|
||||
if (opts && opts.bufferTimeMs) {
|
||||
this.changeSubject = this.rawSubject.pipe(plugins.smartrx.rxjs.ops.bufferTime(opts.bufferTimeMs));
|
||||
} else {
|
||||
this.changeSubject = this.rawSubject;
|
||||
}
|
||||
this.changeStream = changeStreamArg;
|
||||
this.changeStream.on('change', async (item: any) => {
|
||||
if (!item.fullDocument) {
|
||||
this.changeSubject.next(null);
|
||||
return;
|
||||
let docInstance: T | null = null;
|
||||
if (item.fullDocument) {
|
||||
docInstance = smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(
|
||||
item.fullDocument
|
||||
) as any as T;
|
||||
}
|
||||
this.changeSubject.next(
|
||||
smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(item.fullDocument) as any as T,
|
||||
);
|
||||
// Notify subscribers
|
||||
this.rawSubject.next(docInstance as T);
|
||||
this.emit('change', docInstance);
|
||||
});
|
||||
// Signal readiness after one tick
|
||||
plugins.smartdelay.delayFor(0).then(() => {
|
||||
this.readyDeferred.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
public async close() {
|
||||
/**
|
||||
* Close the change stream, complete the RxJS subject, and remove listeners.
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
// Close MongoDB ChangeStream
|
||||
await this.changeStream.close();
|
||||
// Complete the subject to teardown any buffering operators
|
||||
this.rawSubject.complete();
|
||||
// Remove all EventEmitter listeners
|
||||
this.removeAllListeners();
|
||||
}
|
||||
/**
|
||||
* Alias for close(), matching README usage
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
return this.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.collection.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": {
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"target": "ES2024",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
"exclude": ["dist_*/**/*.d.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user