Compare commits

...

68 Commits

Author SHA1 Message Date
54fa433d1a v7.1.3
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-26 07:50:29 +00:00
de23b44a23 fix(deps): bump development dependencies for tooling and Node types 2026-03-26 07:50:29 +00:00
1c4f50fbd6 v7.1.2
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 19:51:53 +00:00
3270aa2042 fix(docs): refresh project guidance for TC39 decorators, build configuration, and dependency compatibility 2026-03-24 19:51:52 +00:00
b0dc5f8a60 v7.1.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 19:44:49 +00:00
03431535d7 fix(build): update build and test tooling configuration, migrate project config to .smartconfig.json, and align TypeScript typings 2026-03-24 19:44:49 +00:00
27c1500db5 v7.1.0
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 52s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-26 17:01:37 +00:00
3bbb78add8 feat(config): normalize npmextra.json to namespaced keys and add CI/release configuration 2026-02-26 17:01:37 +00:00
9d779329e1 v7.0.16
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 55s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-26 16:58:04 +00:00
cdc6b029af fix(mongodb): set default socketTimeoutMS to 30000ms in MongoClient options to prevent hung operations from holding connections 2026-02-26 16:58:04 +00:00
39c0ba7bea v7.0.15
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 51s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-01 11:48:28 +00:00
e4faca88ba fix(classes.doc): Avoid emitting instance fields for collection and manager to preserve decorator-defined prototype getters 2025-12-01 11:48:28 +00:00
40bc408d8f v7.0.14
Some checks failed
Default (tags) / security (push) Successful in 52s
Default (tags) / test (push) Failing after 50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 15:45:15 +00:00
3c8308561e fix(classes.collection): Centralize TC39 decorator metadata initialization and use context.metadata in class decorators 2025-11-28 15:45:15 +00:00
49b121aa5b v7.0.13
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 49s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 11:40:52 +00:00
514d3dbd29 fix(classes.doc): Remove noisy debug logging from decorators and serialization logic 2025-11-28 11:40:52 +00:00
2b7316dc46 v7.0.12
Some checks failed
Default (tags) / security (push) Successful in 54s
Default (tags) / test (push) Failing after 1m16s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 11:27:36 +00:00
11a1345891 fix(collection): Ensure TC39 decorator metadata is initialized on both original and decorated constructors/prototypes and add debug logging 2025-11-28 11:27:36 +00:00
2fe3a72eaf 7.0.11
Some checks failed
Default (tags) / security (push) Successful in 56s
Default (tags) / test (push) Failing after 1m18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 11:19:45 +00:00
fb7e82557b chore: Remove debug logging from Collection decorator 2025-11-28 11:19:44 +00:00
8a3425e554 7.0.10
Some checks failed
Default (tags) / security (push) Successful in 58s
Default (tags) / test (push) Failing after 1m20s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 11:17:25 +00:00
d2092cc5f3 chore(debug): Add metadata debug logging to Collection decorator 2025-11-28 11:17:25 +00:00
1a621ca64e v7.0.9
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 11:02:39 +00:00
f6cc07880a fix(classes.collection): Fix closure bug in Collection decorator by defining collection getter on original constructor and prototype 2025-11-28 11:02:39 +00:00
bf4b11f1f5 v7.0.8
Some checks failed
Default (tags) / security (push) Successful in 46s
Default (tags) / test (push) Failing after 51s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 10:55:29 +00:00
181e9da151 fix(classes.collection): Fix closure issue in managed decorator so Class.collection/instance.collection resolve correctly 2025-11-28 10:55:29 +00:00
3013edb2eb v7.0.7
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 49s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 10:17:07 +00:00
604e4ba265 fix(decorators): Fix decorator metadata initialization and Lucene query transformation 2025-11-28 10:17:07 +00:00
477f446c34 v7.0.6
Some checks failed
Default (tags) / security (push) Successful in 49s
Default (tags) / test (push) Failing after 50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 10:03:23 +00:00
fbb8bb685c fix(classes.collection): Guard against missing collection before attaching document constructor in Collection decorator 2025-11-28 10:03:23 +00:00
4cf62fd91c v7.0.5
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 09:37:32 +00:00
8ee45c5646 fix(package): Add package exports entry and remove legacy main/typings fields 2025-11-28 09:37:32 +00:00
12f1630adf v7.0.4
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 09:16:52 +00:00
0a349180b2 fix(decorators): Add Symbol.metadata polyfill and import it at entry to ensure decorator metadata is available 2025-11-28 09:16:52 +00:00
23aa29a5b8 v7.0.3
Some checks failed
Default (tags) / security (push) Successful in 49s
Default (tags) / test (push) Failing after 50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 08:54:48 +00:00
5bf2aae2b9 fix(build): Bump devDependency @git.zone/tsbuild to ^3.1.2 2025-11-28 08:54:48 +00:00
5cf9155205 v7.0.2
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 51s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-28 07:53:15 +00:00
ef5491075f fix(collectionfactory): Simplify CollectionFactory.getCollection: remove unnecessary IIFE and instantiate collection only when dbArg is SmartdataDb 2025-11-28 07:53:15 +00:00
3f5101c061 v7.0.1
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-27 23:07:58 +00:00
4f1d359752 fix(build): Update build tooling and TypeScript compilation target 2025-11-27 23:07:58 +00:00
aead721a58 v7.0.0
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 52s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-27 23:01:25 +00:00
c3a8a15225 BREAKING CHANGE(mongodb): Upgrade dependencies: bump mongodb to ^7.0.0 and @git.zone/tstest to ^3.1.3 2025-11-27 23:01:25 +00:00
026f2acc89 v6.0.0
Some checks failed
Default (tags) / security (push) Successful in 45s
Default (tags) / test (push) Failing after 50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-17 12:51:45 +00:00
1cd0f09598 BREAKING CHANGE(decorators): Migrate to TC39 Stage 3 decorators and refactor decorator metadata handling; update class initialization, lucene adapter fixes and docs 2025-11-17 12:51:45 +00:00
d254f58a05 v5.16.7
Some checks failed
Default (tags) / security (push) Successful in 47s
Default (tags) / test (push) Failing after 49s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-17 08:05:28 +00:00
c5e7b6f982 fix(classes.collection): Improve Deno and TypeScript compatibility: Collection decorator _svDbOptions forwarding and config cleanup 2025-11-17 08:05:28 +00:00
d30c9619c5 v5.16.6
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 49s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-17 04:16:39 +00:00
7344ae2db3 fix(classes): Add Deno compatibility, prototype-safe decorators and safe collection accessor; bump a few deps 2025-11-17 04:16:39 +00:00
3b29a150a8 v5.16.5
Some checks failed
Default (tags) / security (push) Successful in 53s
Default (tags) / test (push) Failing after 7m30s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-16 18:19:57 +00:00
59186d84a9 fix(watcher): Update dependencies, tooling and watcher import; add .serena cache ignore 2025-11-16 18:19:57 +00:00
7fab4e5dd0 5.16.4
Some checks failed
Default (tags) / security (push) Successful in 49s
Default (tags) / test (push) Successful in 3m12s
Default (tags) / release (push) Failing after 1m0s
Default (tags) / metadata (push) Successful in 1m11s
2025-08-18 20:24:16 +00:00
0dbaa1bc5d fix(classes.doc (convertFilterForMongoDb)): Improve filter conversion: handle logical operators, merge operator objects, add nested filter tests and docs, and fix test script 2025-08-18 20:24:16 +00:00
8b37ebc8f9 5.16.3
Some checks failed
Default (tags) / security (push) Successful in 1m10s
Default (tags) / test (push) Successful in 3m14s
Default (tags) / release (push) Failing after 1m2s
Default (tags) / metadata (push) Successful in 1m15s
2025-08-18 19:38:44 +00:00
5d757207c8 fix(docs): Add local Claude settings and remove outdated codex.md 2025-08-18 19:38:44 +00:00
c80df05fdf 5.16.2
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Successful in 3m16s
Default (tags) / release (push) Failing after 5m2s
Default (tags) / metadata (push) Successful in 7m2s
2025-08-18 11:50:37 +00:00
9be43a85ef fix(readme): Update README: clarify examples, expand search/cursor/docs and add local Claude settings 2025-08-18 11:50:37 +00:00
bf66209d3e feat: Enhance type safety for MongoDB filter conditions by introducing helper types for $in and $nin values 2025-08-18 11:42:41 +00:00
cdd1ae2c9b feat: Add comprehensive query filters guide and enhance type safety for MongoDB queries
- Introduced a detailed guide on query filters in the README, covering basic filtering, comparison operators, array operators, logical operators, element operators, and advanced filtering patterns.
- Implemented a type-safe filtering system in `classes.doc.ts` with `MongoFilterCondition` and `MongoFilter` types to support MongoDB operators while maintaining nested type safety.
- Enhanced error handling for invalid operators and conditions in the filtering logic.
- Added extensive tests for various filtering scenarios, including basic, comparison, array, logical, and complex filters, ensuring robust functionality and performance.
- Implemented security measures to prevent the use of dangerous operators like `$where` and validate operator usage.
2025-08-18 11:29:15 +00:00
f4290ae7f7 5.16.1
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 4m4s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-12 11:25:42 +00:00
e58c0fd215 fix(core): Improve error handling and logging; enhance search query sanitization; update dependency versions and documentation 2025-08-12 11:25:42 +00:00
a91fac450a 5.16.0
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 3m29s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-25 09:35:51 +00:00
5cb043009c feat(watcher): Enhance change stream watchers with buffering and EventEmitter support; update dependency versions 2025-04-25 09:35:51 +00:00
4a1f11b885 5.15.1
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Successful in 3m7s
Default (tags) / release (push) Failing after 50s
Default (tags) / metadata (push) Successful in 56s
2025-04-24 11:34:49 +00:00
43f9033ccc fix(cursor): Improve cursor usage documentation and refactor getCursor API to support native cursor modifiers 2025-04-24 11:34:49 +00:00
e7c0951786 5.15.0
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Successful in 3m10s
Default (tags) / release (push) Failing after 51s
Default (tags) / metadata (push) Successful in 57s
2025-04-24 11:08:19 +00:00
efc107907c feat(svDb): Enhance svDb decorator to support custom serialization and deserialization options 2025-04-24 11:08:19 +00:00
2b8b0e5bdd 5.14.1
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Successful in 3m3s
Default (tags) / release (push) Failing after 51s
Default (tags) / metadata (push) Successful in 58s
2025-04-23 17:28:49 +00:00
3ae2a7fcf5 fix(db operations): Update transaction API to consistently pass optional session parameters across database operations 2025-04-23 17:28:49 +00:00
36 changed files with 13986 additions and 5766 deletions

View File

@@ -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}}

View File

@@ -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
View File

@@ -16,4 +16,12 @@ node_modules/
dist/
dist_*/
#------# custom
# rust
rust/target/
dist_rust/
# AI
.claude/
.serena/
#------# custom

View File

@@ -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"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"fileMatch": ["/.smartconfig.json"],
"schema": {
"type": "object",
"properties": {

View File

@@ -1,14 +1,280 @@
# 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.
- Refactored the getCursor method in classes.doc.ts to accept session and modifier options, consolidating cursor handling.
- Added new tests in test/test.cursor.ts to verify cursor operations, including limits, sorting, and skipping.
## 2025-04-24 - 5.15.0 - feat(svDb)
Enhance svDb decorator to support custom serialization and deserialization options
- Added an optional options parameter to the svDb decorator to accept serialize/deserialize functions
- Updated instance creation logic (updateFromDb) to apply custom deserialization if provided
- Updated createSavableObject to use custom serialization when available
## 2025-04-23 - 5.14.1 - fix(db operations)
Update transaction API to consistently pass optional session parameters across database operations
- Revised transaction support in readme to use startSession without await and showcased session usage in getInstance and save calls
- Updated methods in classes.collection.ts to accept an optional session parameter for findOne, getCursor, findAll, insert, update, delete, and getCount
- Enhanced SmartDataDbDoc save and delete methods to propagate session parameters
- Improved overall consistency of transactional APIs across the library
## 2025-04-23 - 5.14.0 - feat(doc)
Implement support for beforeSave, afterSave, beforeDelete, and afterDelete lifecycle hooks in document save and delete operations to allow custom logic execution during these critical moments.
- Calls beforeSave hook if defined before performing insert or update.
- Calls afterSave hook after a document is saved.
- Calls beforeDelete hook before deletion and afterDelete hook afterward.
- Ensures _updatedAt timestamp is refreshed during save operations.
- 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
@@ -16,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.
@@ -24,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.
@@ -38,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
@@ -46,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
@@ -53,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.
@@ -61,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.
@@ -75,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 Lucenestyle search examples and use cases
@@ -88,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.
@@ -95,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
@@ -121,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.
@@ -134,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
@@ -147,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
@@ -154,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
@@ -163,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
@@ -170,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
@@ -188,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.
@@ -200,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

View File

@@ -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 TypeScriptfirst wrapper around MongoDB that supplies:
- Stronglytyped document & collection classes
- Decoratorbased schema definition (no external schema files)
- Advanced search capabilities with Lucenestyle queries
- Builtin support for realtime data sync, distributed coordination, and keyvalue 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; autocreates 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, schemaless keyvalue store built on top of MongoDB for sharing ephemeral data.
- **Distributed Coordinator**: Leader election and taskdistribution API for building resilient, multiinstance systems.
- **Watcher**: Listens to change streams for realtime 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. Multiterm unquoted: terms ANDd across all searchable fields
8. Empty query returns all documents
- **Fallback Mechanisms**:
1. Text index based `$text` search (if supported)
2. Fieldscoped and multifield regex queries
3. Inmemory 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 ANDmerged
validate?: (doc: T) => boolean; // Postfetch hook to drop results
}
```
- **filter**: Enforces mandatory constraints (e.g. multitenant 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 freeterm + field searches
- Edge cases (nonsearchable 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 organizations items)
const myItems = await Product.search('book', { filter: { ownerId } });
// Postsearch validation (only cheap items)
const cheapItems = await Product.search('', { validate: p => p.price < 50 });
```
---
Last updated: 2025-04-22

6811
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
license Normal file
View 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.

View File

@@ -1,15 +1,16 @@
{
"name": "@push.rocks/smartdata",
"version": "5.14.0",
"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

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
onlyBuiltDependencies:
- esbuild
- mongodb-memory-server
- puppeteer

View File

@@ -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

805
readme.md
View File

@@ -1,72 +1,57 @@
# @push.rocks/smartdata
# @push.rocks/smartdata 🚀
[![npm version](https://badge.fury.io/js/@push.rocks%2Fsmartdata.svg)](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,481 +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' });
// Using a cursor for large collections
const cursor = await User.getCursor({ active: true });
// Process documents one at a time (memory efficient)
await cursor.forEach(async (user, index) => {
// Process each user with its position
console.log(`Processing user ${index}: ${user.username}`);
// Comparison operators
const adults = await User.getInstances({
age: { $gte: 18, $lt: 65 },
});
// Chain cursor methods like in the MongoDB native driver
const paginatedCursor = await User.getCursor({ active: true })
.limit(10) // Limit results
.skip(20) // Skip first 20 results
.sort({ createdAt: -1 }); // Sort by creation date descending
// Array operators
const experts = await User.getInstances({
tags: { $all: ['typescript', 'mongodb'] },
skills: { $size: 5 },
});
// Convert cursor to array (when you know the result set is small)
const userArray = await paginatedCursor.toArray();
// Logical operators
const complex = await Order.getInstances({
$and: [
{ status: 'active' },
{ $or: [{ priority: 'high' }, { value: { $gte: 1000 } }] },
],
});
// Other cursor operations
const nextUser = await cursor.next(); // Get the next document
const hasMoreUsers = await cursor.hasNext(); // Check if more documents exist
const count = await cursor.count(); // Get the count of documents in the cursor
// Deep nested object queries
const users = await User.getInstances({
profile: {
settings: {
notifications: { email: true },
},
},
});
// Always close cursors when done with them
// Dot notation
const sameUsers = await User.getInstances({
'profile.settings.notifications.email': true,
});
// Regex patterns
const gmailUsers = await User.getInstances({
email: { $regex: '@gmail\\.com$', $options: 'i' },
});
```
**Security**: The `$where` operator is automatically blocked to prevent NoSQL injection. Unknown operators trigger warnings.
### 🔎 Lucene-Powered Search
Mark fields with `@searchable()` to enable a built-in search engine with automatic compound text indexing:
```typescript
@Collection(() => db)
class Product extends SmartDataDbDoc<Product, Product> {
@unI() public id!: string;
@svDb() @searchable() public name!: string;
@svDb() @searchable() public description!: string;
@svDb() @searchable() public category!: string;
@svDb() public price!: number;
}
// Simple text search across all @searchable fields
const results = await Product.search('laptop');
// Field-specific search
const electronics = await Product.search('category:Electronics');
// Wildcard
const matches = await Product.search('Mac*');
// Boolean operators (AND, OR, NOT)
const query = await Product.search('laptop AND NOT gaming');
// Phrase search
const exact = await Product.search('"MacBook Pro"');
// Range queries
const midRange = await Product.search('price:[100 TO 500]');
// Combined with MongoDB filters and post-fetch validation
const affordable = await Product.search('laptop', {
filter: { price: { $lte: 1500 } },
validate: async (p) => p.price > 0,
});
```
### 📡 Real-Time Change Streams
Watch for database changes with RxJS subjects and EventEmitter support:
```typescript
const watcher = await User.watch(
{ status: 'active' },
{
fullDocument: 'updateLookup',
bufferTimeMs: 100, // optional: buffer changes via RxJS
},
);
// RxJS subscription
watcher.changeSubject.subscribe((user) => {
console.log('User changed:', user);
});
// Or EventEmitter style
watcher.on('change', (user) => {
console.log('User changed:', user);
});
// Clean up
await watcher.close();
```
### 🔄 Cursor Streaming
Process large datasets without memory pressure:
```typescript
const cursor = await User.getCursor(
{ status: 'active' },
{
modifier: (c) => c.sort({ createdAt: -1 }).limit(10000),
},
);
// Iterate one-by-one
await cursor.forEach(async (user) => {
await processUser(user);
});
// Or collect into an array
const users = await cursor.toArray();
// Always close when done
await cursor.close();
```
#### Update
### 🔐 Transactions
Ensure atomic consistency across multiple operations:
```typescript
// Assuming 'user' is an instance of User
user.email = 'newEmail@example.com';
await user.save(); // Update the user in the database
const session = db.startSession();
// Upsert operations (insert if not exists, update if exists)
const upsertedUser = await User.upsert(
{ id: 'user-123' }, // Query to find the user
{
// Fields to update or insert
username: 'newUsername',
email: 'newEmail@example.com',
},
);
```
#### Delete
```typescript
// Assuming 'user' is an instance of User
await user.delete(); // Delete the user from the database
```
## Advanced Features
### Search Functionality
SmartData provides powerful, Lucenestyle 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;
}
// List searchable fields
const searchableFields = Product.getSearchableFields();
// 1: Exact phrase across all fields
await Product.search('"Kindle Paperwhite"');
// 2: Wildcard search across all fields
await Product.search('Air*');
// 3: Fieldscoped wildcard
await Product.search('name:Air*');
// 4: Boolean AND/OR/NOT
await Product.search('category:Electronics AND name:iPhone');
// 5: Grouping with parentheses
await Product.search('(Furniture OR Electronics) AND Chair');
// 6: Multiterm unquoted (terms ANDd 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'`)
- Fieldscoped exact & wildcard searches (`field:value`, `field:Air*`)
- Wildcard searches across all fields (`Air*`, `?Pods`)
- Boolean operators (`AND`, `OR`, `NOT`) with grouping (`(...)`)
- Multiterm unquoted queries ANDd across fields (`TypeScript Aufgabe`)
- Single/multiterm 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();
}
});
// 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
Watch for changes in your collections with RxJS integration using MongoDB Change Streams:
```typescript
// Create a watcher for a specific collection with a query filter
const watcher = await User.watch(
{
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
},
);
// 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');
}
});
// Manual observation with event emitter pattern
watcher.on('change', (change) => {
console.log('Document changed:', change);
});
// Stop watching when no longer needed
await watcher.stop();
```
### Managed Collections
For more complex data models that require additional context:
```typescript
@Collection(() => db)
class ManagedDoc extends SmartDataDbDoc<ManagedDoc, ManagedDoc> {
@unI()
public id: string = 'unique-id';
@svDb()
public data: string;
@managed()
public manager: YourCustomManager;
// The manager can provide additional functionality
async specialOperation() {
return this.manager.doSomethingSpecial(this);
}
}
```
### Automatic Indexing
Define indexes directly in your model class:
```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:
```typescript
const session = await db.startSession();
try {
await session.withTransaction(async () => {
const user = await User.getInstance({ id: 'user-id' }, { session });
user.balance -= 100;
await user.save({ session });
const sender = await User.getInstance({ id: 'user-1' }, { session });
sender.balance -= 100;
await sender.save({ session });
const recipient = await User.getInstance({ id: 'recipient-id' }, { session });
recipient.balance += 100;
await user.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');
}
async afterDelete() {
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.

97
test/test.cursor.ts Normal file
View File

@@ -0,0 +1,97 @@
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';
// Set up database connection
let smartmongoInstance: smartmongo.SmartMongo;
let testDb: smartdata.SmartdataDb;
// Define a simple document model for cursor tests
@smartdata.Collection(() => testDb)
class CursorTest extends smartdata.SmartDataDbDoc<CursorTest, CursorTest> {
@smartdata.unI()
public id: string = smartunique.shortId();
@smartdata.svDb()
public name: string;
@smartdata.svDb()
public order: number;
constructor(name: string, order: number) {
super();
this.name = name;
this.order = order;
}
}
// Initialize the in-memory MongoDB and SmartdataDB
tap.test('cursor init: start Mongo and SmartdataDb', async () => {
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
testDb = new smartdata.SmartdataDb(
await smartmongoInstance.getMongoDescriptor(),
);
await testDb.init();
});
// Insert sample documents
tap.test('cursor insert: save 5 test documents', async () => {
for (let i = 1; i <= 5; i++) {
const doc = new CursorTest(`item${i}`, i);
await doc.save();
}
const count = await CursorTest.getCount({});
expect(count).toEqual(5);
});
// Test that toArray returns all documents
tap.test('cursor toArray: retrieves all documents', async () => {
const cursor = await CursorTest.getCursor({});
const all = await cursor.toArray();
expect(all.length).toEqual(5);
});
// Test iteration via forEach
tap.test('cursor forEach: iterates through all documents', async () => {
const names: string[] = [];
const cursor = await CursorTest.getCursor({});
await cursor.forEach(async (item) => {
names.push(item.name);
});
expect(names.length).toEqual(5);
expect(names).toContain('item3');
});
// Test native cursor modifiers: limit
tap.test('cursor modifier limit: only two documents', async () => {
const cursor = await CursorTest.getCursor({}, { modifier: (c) => c.limit(2) });
const limited = await cursor.toArray();
expect(limited.length).toEqual(2);
});
// Test native cursor modifiers: sort and skip
tap.test('cursor modifier sort & skip: returns correct order', async () => {
const cursor = await CursorTest.getCursor({}, {
modifier: (c) => c.sort({ order: -1 }).skip(1),
});
const results = await cursor.toArray();
// Skipped the first (order 5), next should be 4,3,2,1
expect(results.length).toEqual(4);
expect(results[0].order).toEqual(4);
});
// Cleanup: drop database, close connections, stop Mongo
tap.test('cursor cleanup: drop DB and stop', async () => {
await testDb.mongoDb.dropDatabase();
await testDb.close();
if (smartmongoInstance) {
await smartmongoInstance.stopAndDumpToDir(
`.nogit/dbdump/test.cursor.ts`,
);
}
// Ensure process exits after cleanup
setTimeout(() => process.exit(), 2000);
});
export default tap.start();

255
test/test.deno.ts Normal file
View 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 });

View File

@@ -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';

View File

@@ -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
View 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();

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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
// =======================================

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartdata',
version: '5.14.0',
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.'
}

View File

@@ -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
);
@@ -222,53 +265,74 @@ export class SmartdataCollection<T> {
/**
* finds an object in the DbCollection
*/
public async findOne(filterObject: any): Promise<any> {
public async findOne(
filterObject: any,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init();
const cursor = this.mongoDbCollection.find(filterObject);
const result = await cursor.next();
cursor.close();
return result;
// Use MongoDB driver's findOne with optional session
return this.mongoDbCollection.findOne(filterObject, { session: opts?.session });
}
public async getCursor(
filterObjectArg: any,
dbDocArg: typeof SmartDataDbDoc,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<SmartdataDbCursor<any>> {
await this.init();
const cursor = this.mongoDbCollection.find(filterObjectArg);
const cursor = this.mongoDbCollection.find(filterObjectArg, { session: opts?.session });
return new SmartdataDbCursor(cursor, dbDocArg);
}
/**
* finds an object in the DbCollection
*/
public async findAll(filterObject: any): Promise<any[]> {
public async findAll(
filterObject: any,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any[]> {
await this.init();
const cursor = this.mongoDbCollection.find(filterObject);
const cursor = this.mongoDbCollection.find(filterObject, { session: opts?.session });
const result = await cursor.toArray();
cursor.close();
return result;
}
/**
* watches the collection while applying a filter
* Watches the collection, returning a SmartdataDbWatcher with RxJS and EventEmitter support.
* @param filterObject match filter for change stream
* @param opts optional MongoDB ChangeStreamOptions & { bufferTimeMs } to buffer events
* @param smartdataDbDocArg document class for instance creation
*/
public async watch(
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;
}
@@ -276,7 +340,10 @@ export class SmartdataCollection<T> {
/**
* create an object in the database
*/
public async insert(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
public async insert(
dbDocArg: T & SmartDataDbDoc<T, unknown>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init();
await this.checkDoc(dbDocArg);
this.markUniqueIndexes(dbDocArg.uniqueIndexes);
@@ -286,19 +353,22 @@ export class SmartdataCollection<T> {
this.createRegularIndexes(dbDocArg.regularIndexes);
}
const saveableObject = await dbDocArg.createSavableObject();
const result = await this.mongoDbCollection.insertOne(saveableObject);
const saveableObject = await dbDocArg.createSavableObject() as any;
const result = await this.mongoDbCollection.insertOne(saveableObject, { session: opts?.session });
return result;
}
/**
* inserts object into the DbCollection
*/
public async update(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
public async update(
dbDocArg: T & SmartDataDbDoc<T, unknown>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init();
await this.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]) {
@@ -309,21 +379,27 @@ export class SmartdataCollection<T> {
const result = await this.mongoDbCollection.updateOne(
identifiableObject,
{ $set: updateableObject },
{ upsert: true },
{ upsert: true, session: opts?.session },
);
return result;
}
public async delete(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
public async delete(
dbDocArg: T & SmartDataDbDoc<T, unknown>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init();
await this.checkDoc(dbDocArg);
const identifiableObject = await dbDocArg.createIdentifiableObject();
await this.mongoDbCollection.deleteOne(identifiableObject);
await this.mongoDbCollection.deleteOne(identifiableObject, { session: opts?.session });
}
public async getCount(filterObject: any) {
public async getCount(
filterObject: any,
opts?: { session?: plugins.mongodb.ClientSession }
) {
await this.init();
return this.mongoDbCollection.countDocuments(filterObject);
return this.mongoDbCollection.countDocuments(filterObject, { session: opts?.session });
}
/**

View File

@@ -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];
};

View File

@@ -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;
}
}
/**
@@ -63,6 +83,12 @@ export class SmartdataDb {
this.status = 'disconnected';
logger.log('info', `disconnected from database ${this.smartdataOptions.mongoDbName}`);
}
/**
* Start a MongoDB client session for transactions
*/
public startSession(): plugins.mongodb.ClientSession {
return this.mongoDbClient.startSession();
}
// handle table to class distribution

View File

@@ -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();
}
});
});
});

View File

@@ -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';
@@ -11,34 +12,110 @@ import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
* - validate: post-fetch validator, return true to keep a doc
*/
export interface SearchOptions<T> {
/**
* Additional MongoDB filter to ANDmerge into the query
*/
filter?: Record<string, any>;
/**
* Postfetch validator; return true to keep each doc
*/
validate?: (doc: T) => Promise<boolean> | boolean;
/**
* Optional MongoDB session for transactional operations
*/
session?: plugins.mongodb.ClientSession;
}
export type TDocCreation = 'db' | 'new' | 'mixed';
// 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];
}
});
};
}
/**
* Options for custom serialization/deserialization of a field.
*/
export interface SvDbOptions {
/** Function to serialize the field value before saving to DB */
serialize?: (value: any) => any;
/** Function to deserialize the field value after reading from DB */
deserialize?: (value: any) => any;
}
/**
* saveable - saveable decorator to be used on class properties
*/
export function svDb() {
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
if (!target.saveableProperties) {
target.saveableProperties = [];
export function svDb(options?: SvDbOptions) {
return (value: undefined, context: ClassFieldDecoratorContext) => {
if (context.kind !== 'field') {
throw new Error('svDb can only decorate fields');
}
target.saveableProperties.push(key);
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) {
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 };
}
});
};
}
@@ -46,13 +123,30 @@ export function svDb() {
* 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];
}
});
};
}
@@ -65,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];
}
});
};
}
@@ -97,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;
};
@@ -166,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>(
@@ -179,7 +456,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
const newInstance = new this();
(newInstance as any).creationStatus = 'db';
for (const key of Object.keys(mongoDbNativeDocArg)) {
newInstance[key] = mongoDbNativeDocArg[key];
const rawValue = mongoDbNativeDocArg[key];
const optionsMap = (this as any)._svDbOptions || {};
const opts = optionsMap[key];
newInstance[key] = opts && typeof opts.deserialize === 'function'
? opts.deserialize(rawValue)
: rawValue;
}
return newInstance;
}
@@ -187,15 +469,20 @@ 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[]> {
const foundDocs = await (this as any).collection.findAll(convertFilterForMongoDb(filterArg));
const returnArray = [];
// Pass session through to findAll for transactional queries
const foundDocs = await (this as any).collection.findAll(
convertFilterForMongoDb(filterArg),
{ session: opts?.session },
);
const returnArray: T[] = [];
for (const foundDoc of foundDocs) {
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
returnArray.push(newInstance);
@@ -211,14 +498,19 @@ 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> {
const foundDoc = await (this as any).collection.findOne(convertFilterForMongoDb(filterArg));
// Retrieve one document, with optional session for transactions
const foundDoc = await (this as any).collection.findOne(
convertFilterForMongoDb(filterArg),
{ session: opts?.session },
);
if (foundDoc) {
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
return newInstance;
} else {
return null;
return null as any;
}
}
@@ -233,33 +525,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
}
/**
* get cursor
* @returns
* Get a cursor for streaming results, with optional session and native cursor modifiers.
* @param filterArg Partial filter to apply
* @param opts Optional session and modifier for the raw MongoDB cursor
*/
public static async getCursor<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
) {
const collection: SmartdataCollection<T> = (this as any).collection;
const cursor: SmartdataDbCursor<T> = await collection.getCursor(
convertFilterForMongoDb(filterArg),
this as any as typeof SmartDataDbDoc,
);
return cursor;
}
public static async getCursorExtended<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
modifierFunction = (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => cursorArg,
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>>;
}
): Promise<SmartdataDbCursor<T>> {
const collection: SmartdataCollection<T> = (this as any).collection;
const { session, modifier } = opts || {};
await collection.init();
let cursor: plugins.mongodb.FindCursor<any> = collection.mongoDbCollection.find(
convertFilterForMongoDb(filterArg),
);
cursor = modifierFunction(cursor);
return new SmartdataDbCursor<T>(cursor, this as any as typeof SmartDataDbDoc);
let rawCursor: plugins.mongodb.FindCursor<any> =
collection.mongoDbCollection.find(convertFilterForMongoDb(filterArg), { session });
if (modifier) {
rawCursor = modifier(rawCursor);
}
return new SmartdataDbCursor<T>(rawCursor, this as any as typeof SmartDataDbDoc);
}
/**
@@ -268,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;
@@ -286,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);
@@ -298,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);
@@ -339,7 +632,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
if (opts?.filter) {
mongoFilter = { $and: [mongoFilter, opts.filter] };
}
let docs: T[] = await (this as any).getInstances(mongoFilter);
// Fetch with optional session for transactions
// Fetch within optional session
let docs: T[] = await (this as any).getInstances(mongoFilter, { session: opts?.session });
if (opts?.validate) {
const out: T[] = [];
for (const d of docs) {
@@ -512,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
@@ -546,10 +846,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
constructor() {}
/**
* saves this instance but not any connected items
* may lead to data inconsistencies, but is faster
* saves this instance (optionally within a transaction)
*/
public async save() {
public async save(opts?: { session?: plugins.mongodb.ClientSession }) {
// allow hook before saving
if (typeof (this as any).beforeSave === 'function') {
await (this as any).beforeSave();
@@ -562,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);
dbResult = await this.getCollectionSafe().update(self, { session: opts?.session });
break;
case 'new':
dbResult = await this.collection.insert(self);
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') {
@@ -579,15 +878,15 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
}
/**
* deletes a document from the database
* deletes a document from the database (optionally within a transaction)
*/
public async delete() {
public async delete(opts?: { session?: plugins.mongodb.ClientSession }) {
// allow hook before deleting
if (typeof (this as any).beforeDelete === 'function') {
await (this as any).beforeDelete();
}
// perform deletion
const result = await this.collection.delete(this);
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();
@@ -599,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>>();
}
@@ -616,11 +915,20 @@ 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());
for (const key of Object.keys(mongoDbNativeDoc)) {
this[key] = mongoDbNativeDoc[key];
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 || {};
const opts = optionsMap[key];
this[key] = opts && typeof opts.deserialize === 'function'
? opts.deserialize(rawValue)
: rawValue;
}
return true;
}
/**
@@ -628,9 +936,17 @@ 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) {
saveableObject[propertyNameString] = this[propertyNameString];
const rawValue = (this as any)[propertyNameString];
const opts = optionsMap[propertyNameString];
(saveableObject as any)[propertyNameString] = opts && typeof opts.serialize === 'function'
? opts.serialize(rawValue)
: rawValue;
}
return saveableObject as TImplements;
}

View File

@@ -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
}
}
}

View File

@@ -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}`);
}
}

View File

@@ -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();
}
}

View File

@@ -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
View 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');

View File

@@ -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"]
}