Compare commits

..

54 Commits

Author SHA1 Message Date
a91fac450a 5.16.0 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 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 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 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
0806d3749b 5.14.0 2025-04-23 09:03:15 +00:00
f5d5e20a97 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. 2025-04-23 09:03:15 +00:00
db2767010d 5.13.1 2025-04-22 20:42:11 +00:00
e2dc094afd 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. 2025-04-22 20:42:11 +00:00
39d2957b7d 5.13.0 2025-04-22 20:34:23 +00:00
490524516e feat(search): Improve search query handling and update documentation 2025-04-22 20:34:23 +00:00
ccd4b9e1ec 5.12.2 2025-04-22 20:09:21 +00:00
9c6d6d9f2c fix(search): Fix handling of quoted wildcard patterns in field-specific search queries and add tests for location-based wildcard phrase searches 2025-04-22 20:09:21 +00:00
e4d787096e 5.12.1 2025-04-22 19:37:50 +00:00
2bf923b4f1 fix(search): Improve implicit AND logic for mixed free term and field queries in search and enhance wildcard field handling. 2025-04-22 19:37:50 +00:00
0ca1d452b4 5.12.0 2025-04-22 19:13:17 +00:00
436311ab06 feat(doc/search): Enhance search functionality with filter and validate options for advanced query control 2025-04-22 19:13:17 +00:00
498f586ddb 5.11.4 2025-04-22 18:36:47 +00:00
6c50bd23ec fix(search): Implement implicit AND logic for mixed simple term and field:value queries in search 2025-04-22 18:36:47 +00:00
419eb163f4 5.11.3 2025-04-22 18:24:27 +00:00
75aeb12e81 fix(lucene adapter and search tests): Improve range query parsing in Lucene adapter and expand search test coverage 2025-04-22 18:24:26 +00:00
c5a44da975 5.11.2 2025-04-21 17:31:30 +00:00
969b073939 fix(readme): Update readme to clarify usage of searchable fields retrieval 2025-04-21 17:31:30 +00:00
ac80f90ae0 5.11.1 2025-04-21 16:35:29 +00:00
d0e769622e fix(doc): Refactor searchable fields API and improve collection registration. 2025-04-21 16:35:29 +00:00
eef758cabb 5.11.0 2025-04-21 15:29:11 +00:00
d0cc2a0ed2 feat(ts/classes.lucene.adapter): Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities. 2025-04-21 15:29:11 +00:00
87c930121c 5.10.0 2025-04-21 15:27:55 +00:00
23b499b3a8 feat(search): Improve search functionality: update documentation, refine Lucene query transformation, and add advanced search tests 2025-04-21 15:27:55 +00:00
0834ec5c91 5.9.2 2025-04-18 15:10:04 +00:00
6a2a708ea1 fix(documentation): Update search API documentation to replace deprecated searchWithLucene examples with the unified search(query) API and clarify its behavior. 2025-04-18 15:10:03 +00:00
1d977986f1 5.9.1 2025-04-18 14:56:11 +00:00
e325b42906 fix(search): Refactor search tests to use unified search API and update text index type casting 2025-04-18 14:56:11 +00:00
1a359d355a 5.9.0 2025-04-18 11:25:39 +00:00
b5a9449d5e feat(collections/search): Improve text index creation and search fallback mechanisms in collections and document search methods 2025-04-18 11:25:39 +00:00
558f83a3d9 5.8.4 2025-04-17 11:47:38 +00:00
76ae454221 fix(core): Update commit metadata with no functional code changes 2025-04-17 11:47:38 +00:00
90cfc4644d 5.8.3 2025-04-17 11:21:35 +00:00
0be279e5f5 fix(readme): Improve readme documentation on data models and connection management 2025-04-17 11:21:35 +00:00
9755522bba 5.8.2 2025-04-14 18:13:10 +00:00
de8736e99e fix(classes.doc.ts): Ensure collection initialization before creating a cursor in getCursorExtended 2025-04-14 18:13:10 +00:00
c430627a21 5.8.1 2025-04-14 18:06:29 +00:00
0bfebaf5b9 fix(cursor, doc): Add explicit return types and casts to SmartdataDbCursor methods and update getCursorExtended signature in SmartDataDbDoc. 2025-04-14 18:06:29 +00:00
4733982d03 5.8.0 2025-04-14 17:58:54 +00:00
368dc27607 feat(cursor): Add toArray method to SmartdataDbCursor to convert raw MongoDB documents into initialized class instances 2025-04-14 17:58:54 +00:00
938b25c925 5.7.0 2025-04-14 17:49:07 +00:00
ab251858ba feat(SmartDataDbDoc): Add extended cursor method getCursorExtended for flexible cursor modifications 2025-04-14 17:49:07 +00:00
24371ccf78 5.6.0 2025-04-07 16:47:16 +00:00
ed1eecbab8 feat(indexing): Add support for regular index creation in documents and collections 2025-04-07 16:47:16 +00:00
0d2dcec3e2 5.5.1 2025-04-06 18:18:40 +00:00
9426a21a2a fix(ci & formatting): Minor fixes: update CI workflow image and npmci package references, adjust package.json and readme URLs, and apply consistent code formatting. 2025-04-06 18:18:39 +00:00
25 changed files with 2349 additions and 764 deletions

@ -6,8 +6,8 @@ on:
- '**' - '**'
env: env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}} NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}} NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}} NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
@ -26,7 +26,7 @@ jobs:
- name: Install pnpm and npmci - name: Install pnpm and npmci
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
- name: Run npm prepare - name: Run npm prepare
run: npmci npm prepare run: npmci npm prepare

@ -6,8 +6,8 @@ on:
- '*' - '*'
env: env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}} NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}} NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}} NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
@ -26,7 +26,7 @@ jobs:
- name: Prepare - name: Prepare
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
npmci npm prepare npmci npm prepare
- name: Audit production dependencies - name: Audit production dependencies
@ -54,7 +54,7 @@ jobs:
- name: Prepare - name: Prepare
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
npmci npm prepare npmci npm prepare
- name: Test stable - name: Test stable
@ -82,7 +82,7 @@ jobs:
- name: Prepare - name: Prepare
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
npmci npm prepare npmci npm prepare
- name: Release - name: Release
@ -104,7 +104,7 @@ jobs:
- name: Prepare - name: Prepare
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
npmci npm prepare npmci npm prepare
- name: Code quality - name: Code quality

3
.gitignore vendored

@ -3,7 +3,6 @@
# artifacts # artifacts
coverage/ coverage/
public/ public/
pages/
# installs # installs
node_modules/ node_modules/
@ -17,4 +16,4 @@ node_modules/
dist/ dist/
dist_*/ dist_*/
# custom #------# custom

@ -1,5 +1,197 @@
# Changelog # Changelog
## 2025-04-25 - 5.16.0 - feat(watcher)
Enhance change stream watchers with buffering and EventEmitter support; update dependency versions
- Bumped smartmongo from ^2.0.11 to ^2.0.12 and smartrx from ^3.0.7 to ^3.0.10
- Upgraded @tsclass/tsclass to ^9.0.0 and mongodb to ^6.16.0
- Refactored the watch API to accept additional options (bufferTimeMs, fullDocument) for improved change stream handling
- Modified SmartdataDbWatcher to extend EventEmitter and support event notifications
## 2025-04-24 - 5.15.1 - fix(cursor)
Improve cursor usage documentation and refactor getCursor API to support native cursor modifiers
- Updated examples in readme.md to demonstrate manual iteration using cursor.next() and proper cursor closing.
- Refactored the getCursor method in classes.doc.ts to accept session and modifier options, consolidating cursor handling.
- Added new tests in test/test.cursor.ts to verify cursor operations, including limits, sorting, and skipping.
## 2025-04-24 - 5.15.0 - feat(svDb)
Enhance svDb decorator to support custom serialization and deserialization options
- Added an optional options parameter to the svDb decorator to accept serialize/deserialize functions
- Updated instance creation logic (updateFromDb) to apply custom deserialization if provided
- Updated createSavableObject to use custom serialization when available
## 2025-04-23 - 5.14.1 - fix(db operations)
Update transaction API to consistently pass optional session parameters across database operations
- Revised transaction support in readme to use startSession without await and showcased session usage in getInstance and save calls
- Updated methods in classes.collection.ts to accept an optional session parameter for findOne, getCursor, findAll, insert, update, delete, and getCount
- Enhanced SmartDataDbDoc save and delete methods to propagate session parameters
- Improved overall consistency of transactional APIs across the library
## 2025-04-23 - 5.14.0 - feat(doc)
Implement support for beforeSave, afterSave, beforeDelete, and afterDelete lifecycle hooks in document save and delete operations to allow custom logic execution during these critical moments.
- Calls beforeSave hook if defined before performing insert or update.
- Calls afterSave hook after a document is saved.
- Calls beforeDelete hook before deletion and afterDelete hook afterward.
- Ensures _updatedAt timestamp is refreshed during save operations.
## 2025-04-22 - 5.13.1 - fix(search)
Improve search query parsing for implicit AND queries by preserving quoted substrings and better handling free terms, quoted phrases, and field:value tokens.
- Replace previous implicit AND logic with tokenization that preserves quoted substrings
- Support both free term and field:value tokens with wildcards inside quotes
- Ensure errors are thrown for non-searchable fields in field-specific queries
## 2025-04-22 - 5.13.0 - feat(search)
Improve search query handling and update documentation
- Added 'codex.md' providing a high-level project overview and detailed search API documentation.
- Enhanced search parsing in SmartDataDbDoc to support combined free-term and quoted field phrase queries.
- Introduced a new fallback branch in the search method to handle free term with quoted field input.
- Updated tests in test/test.search.ts to cover new combined query scenarios and ensure robust behavior.
## 2025-04-22 - 5.12.2 - fix(search)
Fix handling of quoted wildcard patterns in field-specific search queries and add tests for location-based wildcard phrase searches
- Strip surrounding quotes from wildcard patterns in field queries to correctly transform them to regex
- Introduce new tests in test/test.search.ts to validate exact quoted and unquoted wildcard searches on a location field
## 2025-04-22 - 5.12.1 - fix(search)
Improve implicit AND logic for mixed free term and field queries in search and enhance wildcard field handling.
- Updated regex for field:value parsing to capture full value with wildcards.
- Added explicit handling for free terms by converting to regex across searchable fields.
- Improved error messaging for attempts to search non-searchable fields.
- Extended tests to cover combined free term and wildcard field searches, including error cases.
## 2025-04-22 - 5.12.0 - feat(doc/search)
Enhance search functionality with filter and validate options for advanced query control
- Added 'filter' option to merge additional MongoDB query constraints in search
- Introduced 'validate' hook to post-process and filter fetched documents
- Refactored underlying execQuery function to support additional search options
- Updated tests to cover new search scenarios and fallback mechanisms
## 2025-04-22 - 5.11.4 - fix(search)
Implement implicit AND logic for mixed simple term and field:value queries in search
- Added a new branch to detect and handle search queries that mix field:value pairs with plain terms without explicit operators
- Builds an implicit $and filter when query parts contain colon(s) but lack explicit boolean operators or quotes
- Ensures proper parsing and improved robustness of search filters
## 2025-04-22 - 5.11.3 - fix(lucene adapter and search tests)
Improve range query parsing in Lucene adapter and expand search test coverage
- Added a new 'testSearch' script in package.json to run search tests.
- Introduced advanced search tests for range queries and combined field filters in test/search.advanced.ts.
- Enhanced robustness tests in test/search.ts for wildcard and empty query scenarios.
- Fixed token validation in the parseRange method of the Lucene adapter to ensure proper error handling.
## 2025-04-21 - 5.11.2 - fix(readme)
Update readme to clarify usage of searchable fields retrieval
- Replaced getSearchableFields('Product') with Product.getSearchableFields()
- Updated documentation to reference the static method Class.getSearchableFields()
## 2025-04-21 - 5.11.1 - fix(doc)
Refactor searchable fields API and improve collection registration.
- Removed the standalone getSearchableFields utility in favor of a static method on document classes.
- Updated tests to use the new static method (e.g., Product.getSearchableFields()).
- Ensured the Collection decorator attaches a docCtor property to correctly register searchable fields.
- Added try/catch in test cleanup to gracefully handle dropDatabase errors.
## 2025-04-21 - 5.11.0 - feat(ts/classes.lucene.adapter)
Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities.
- Changed luceneWildcardToRegex from private to public in ts/classes.lucene.adapter.ts.
## 2025-04-21 - 5.10.0 - feat(search)
Improve search functionality: update documentation, refine Lucene query transformation, and add advanced search tests
- Updated readme.md with detailed Lucenestyle search examples and use cases
- Enhanced LuceneToMongoTransformer to properly handle wildcard conversion and regex escaping
- Improved search query parsing in SmartDataDbDoc for field-specific, multi-term, and advanced Lucene syntax
- Added new advanced search tests covering boolean operators, grouping, quoted phrases, and wildcard queries
## 2025-04-18 - 5.9.2 - fix(documentation)
Update search API documentation to replace deprecated searchWithLucene examples with the unified search(query) API and clarify its behavior.
- Replaced 'searchWithLucene' examples with 'search(query)' in the README.
- Updated explanation to detail field-specific exact match, partial word regex search, multi-word literal matching, and handling of empty queries.
- Clarified guidelines for creating MongoDB text indexes on searchable fields for optimized search performance.
## 2025-04-18 - 5.9.1 - fix(search)
Refactor search tests to use unified search API and update text index type casting
- Replaced all calls from searchWithLucene with search in test/search tests
- Updated text index specification in the collection class to use proper type casting
## 2025-04-18 - 5.9.0 - feat(collections/search)
Improve text index creation and search fallback mechanisms in collections and document search methods
- Auto-create a compound text index on all searchable fields in SmartdataCollection with a one-time flag to prevent duplicate index creation.
- Refine the search method in SmartDataDbDoc to support exact field matches and safe regex fallback for non-Lucene queries.
## 2025-04-17 - 5.8.4 - fix(core)
Update commit metadata with no functional code changes
- Commit info and documentation refreshed
- No code or test changes detected in the diff
## 2025-04-17 - 5.8.3 - fix(readme)
Improve readme documentation on data models and connection management
- Clarify that data models use @Collection, @unI, @svDb, @index, and @searchable decorators
- Document that ObjectId and Buffer fields are stored as BSON types natively without extra decorators
- Update connection management section to use 'db.close()' instead of 'db.disconnect()'
- Revise license section to reference the MIT License without including additional legal details
## 2025-04-14 - 5.8.2 - fix(classes.doc.ts)
Ensure collection initialization before creating a cursor in getCursorExtended
- Added 'await collection.init()' to guarantee that the MongoDB collection is initialized before using the cursor
- Prevents potential runtime errors when accessing collection.mongoDbCollection
## 2025-04-14 - 5.8.1 - fix(cursor, doc)
Add explicit return types and casts to SmartdataDbCursor methods and update getCursorExtended signature in SmartDataDbDoc.
- Specify Promise<T> as return type for next() in SmartdataDbCursor and cast return value to T.
- Specify Promise<T[]> as return type for toArray() in SmartdataDbCursor and cast return value to T[].
- Update getCursorExtended to return Promise<SmartdataDbCursor<T>> for clearer type safety.
## 2025-04-14 - 5.8.0 - feat(cursor)
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
- Wraps the modified cursor with SmartdataDbCursor for improved API consistency
- 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
- Update SmartdataCollection to create regular indexes if defined on a document during insert
- 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
- Replace '@shipzone/npmci' with '@ship.zone/npmci' throughout CI scripts
- Adjust homepage and bugs URL in package.json and readme
- Apply trailing commas and consistent formatting in TypeScript source files
- Minor update to .gitignore custom section label
## 2025-04-06 - 5.5.0 - feat(search) ## 2025-04-06 - 5.5.0 - feat(search)
Enhance search functionality with robust Lucene query transformation and reliable fallback mechanisms Enhance search functionality with robust Lucene query transformation and reliable fallback mechanisms

77
codex.md Normal file

@ -0,0 +1,77 @@
# SmartData Project Overview
This document provides a high-level overview of the SmartData library (`@push.rocks/smartdata`), its architecture, core components, and key features—including recent enhancements to the search API.
## 1. Project Purpose
- A 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

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartdata", "name": "@push.rocks/smartdata",
"version": "5.5.0", "version": "5.16.0",
"private": false, "private": false,
"description": "An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.", "description": "An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@ -8,6 +8,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "tstest test/", "test": "tstest test/",
"testSearch": "tsx test/test.search.ts",
"build": "tsbuild --web --allowimplicitany", "build": "tsbuild --web --allowimplicitany",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
@ -18,30 +19,30 @@
"author": "Lossless GmbH", "author": "Lossless GmbH",
"license": "MIT", "license": "MIT",
"bugs": { "bugs": {
"url": "https://gitlab.com/pushrocks/smartdata/issues" "url": "https://code.foss.global/push.rocks/smartdata/issues"
}, },
"homepage": "https://code.foss.global/push.rocks/smartdata", "homepage": "https://code.foss.global/push.rocks/smartdata#readme",
"dependencies": { "dependencies": {
"@push.rocks/lik": "^6.0.14", "@push.rocks/lik": "^6.0.14",
"@push.rocks/smartdelay": "^3.0.1", "@push.rocks/smartdelay": "^3.0.1",
"@push.rocks/smartlog": "^3.0.2", "@push.rocks/smartlog": "^3.0.2",
"@push.rocks/smartmongo": "^2.0.11", "@push.rocks/smartmongo": "^2.0.12",
"@push.rocks/smartpromise": "^4.0.2", "@push.rocks/smartpromise": "^4.0.2",
"@push.rocks/smartrx": "^3.0.7", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstring": "^4.0.15", "@push.rocks/smartstring": "^4.0.15",
"@push.rocks/smarttime": "^4.0.6", "@push.rocks/smarttime": "^4.0.6",
"@push.rocks/smartunique": "^3.0.8", "@push.rocks/smartunique": "^3.0.8",
"@push.rocks/taskbuffer": "^3.1.7", "@push.rocks/taskbuffer": "^3.1.7",
"@tsclass/tsclass": "^8.2.0", "@tsclass/tsclass": "^9.0.0",
"mongodb": "^6.15.0" "mongodb": "^6.16.0"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.3.2", "@git.zone/tsbuild": "^2.3.2",
"@git.zone/tsrun": "^1.2.44", "@git.zone/tsrun": "^1.2.44",
"@git.zone/tstest": "^1.0.77", "@git.zone/tstest": "^1.0.77",
"@push.rocks/qenv": "^6.0.5", "@push.rocks/qenv": "^6.0.5",
"@push.rocks/tapbundle": "^5.6.2", "@push.rocks/tapbundle": "^5.6.3",
"@types/node": "^22.14.0" "@types/node": "^22.15.2"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",
@ -68,5 +69,8 @@
"custom data types", "custom data types",
"ODM" "ODM"
], ],
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6" "packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6",
"pnpm": {
"overrides": {}
}
} }

647
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

299
readme.md

@ -13,12 +13,12 @@ A powerful TypeScript-first MongoDB wrapper that provides advanced features for
- **Real-time Data Sync**: Watchers for real-time data changes with RxJS integration - **Real-time Data Sync**: Watchers for real-time data changes with RxJS integration
- **Connection Management**: Automatic connection handling with connection pooling - **Connection Management**: Automatic connection handling with connection pooling
- **Collection Management**: Type-safe collection operations with automatic indexing - **Collection Management**: Type-safe collection operations with automatic indexing
- **Deep Query Type Safety**: Fully type-safe queries for nested object properties with `DeepQuery<T>` - **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 - **MongoDB Compatibility**: Support for all MongoDB query operators and advanced features
- **Enhanced Cursors**: Chainable, type-safe cursor API with memory efficient data processing - **Enhanced Cursors**: Chainable, type-safe cursor API with memory efficient data processing
- **Type Conversion**: Automatic handling of MongoDB types like ObjectId and Binary data - **Type Conversion**: Automatic handling of MongoDB types like ObjectId and Binary data
- **Serialization Hooks**: Custom serialization and deserialization of document properties - **Serialization Hooks**: Custom serialization and deserialization of document properties
- **Powerful Search Capabilities**: Lucene-like query syntax with field-specific search, advanced operators, and fallback mechanisms - **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
## Requirements ## Requirements
@ -27,6 +27,7 @@ A powerful TypeScript-first MongoDB wrapper that provides advanced features for
- TypeScript >= 4.x (for development) - TypeScript >= 4.x (for development)
## Install ## Install
To install `@push.rocks/smartdata`, use npm: To install `@push.rocks/smartdata`, use npm:
```bash ```bash
@ -40,9 +41,11 @@ pnpm add @push.rocks/smartdata
``` ```
## Usage ## Usage
`@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. `@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.
### Setting Up and Connecting to the Database ### Setting Up and Connecting to the Database
Before interacting with the database, you need to set up and establish a connection. The `SmartdataDb` class handles connection pooling and automatic reconnection. Before interacting with the database, you need to set up and establish a connection. The `SmartdataDb` class handles connection pooling and automatic reconnection.
```typescript ```typescript
@ -62,40 +65,46 @@ await db.init();
``` ```
### Defining Data Models ### 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`, and `@svDb` to define your data models.
Data models in `@push.rocks/smartdata` are classes that represent collections and documents in your MongoDB database. Use decorators such as `@Collection`, `@unI`, `@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.
```typescript ```typescript
import { SmartDataDbDoc, Collection, unI, svDb, oid, bin, index, searchable } from '@push.rocks/smartdata'; import {
SmartDataDbDoc,
Collection,
unI,
svDb,
index,
searchable,
} from '@push.rocks/smartdata';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
@Collection(() => db) // Associate this model with the database instance @Collection(() => db) // Associate this model with the database instance
class User extends SmartDataDbDoc<User, User> { class User extends SmartDataDbDoc<User, User> {
@unI() @unI()
public id: string = 'unique-user-id'; // Mark 'id' as a unique index public id: string = 'unique-user-id'; // Mark 'id' as a unique index
@svDb() @svDb()
@searchable() // Mark 'username' as searchable @searchable() // Mark 'username' as searchable
public username: string; // Mark 'username' to be saved in DB public username: string; // Mark 'username' to be saved in DB
@svDb() @svDb()
@searchable() // Mark 'email' as searchable @searchable() // Mark 'email' as searchable
@index() // Create a regular index for this field @index() // Create a regular index for this field
public email: string; // Mark 'email' to be saved in DB public email: string; // Mark 'email' to be saved in DB
@svDb() @svDb()
@oid() // Automatically handle as ObjectId type public organizationId: ObjectId; // Stored as BSON ObjectId
public organizationId: ObjectId; // Will be automatically converted to/from ObjectId
@svDb() @svDb()
@bin() // Automatically handle as Binary data public profilePicture: Buffer; // Stored as BSON Binary
public profilePicture: Buffer; // Will be automatically converted to/from Binary
@svDb({
@svDb({
serialize: (data) => JSON.stringify(data), // Custom serialization serialize: (data) => JSON.stringify(data), // Custom serialization
deserialize: (data) => JSON.parse(data) // Custom deserialization deserialize: (data) => JSON.parse(data), // Custom deserialization
}) })
public preferences: Record<string, any>; public preferences: Record<string, any>;
constructor(username: string, email: string) { constructor(username: string, email: string) {
super(); super();
this.username = username; this.username = username;
@ -105,15 +114,18 @@ class User extends SmartDataDbDoc<User, User> {
``` ```
### CRUD Operations ### CRUD Operations
`@push.rocks/smartdata` simplifies CRUD operations with intuitive methods on model instances. `@push.rocks/smartdata` simplifies CRUD operations with intuitive methods on model instances.
#### Create #### Create
```typescript ```typescript
const newUser = new User('myUsername', 'myEmail@example.com'); const newUser = new User('myUsername', 'myEmail@example.com');
await newUser.save(); // Save the new user to the database await newUser.save(); // Save the new user to the database
``` ```
#### Read #### Read
```typescript ```typescript
// Fetch a single user by a unique attribute // Fetch a single user by a unique attribute
const user = await User.getInstance({ username: 'myUsername' }); const user = await User.getInstance({ username: 'myUsername' });
@ -121,125 +133,123 @@ const user = await User.getInstance({ username: 'myUsername' });
// Fetch multiple users that match criteria // Fetch multiple users that match criteria
const users = await User.getInstances({ email: 'myEmail@example.com' }); const users = await User.getInstances({ email: 'myEmail@example.com' });
// Using a cursor for large collections // Obtain a cursor for large result sets
const cursor = await User.getCursor({ active: true }); const cursor = await User.getCursor({ active: true });
// Process documents one at a time (memory efficient) // Stream each document efficiently
await cursor.forEach(async (user, index) => { await cursor.forEach(async (user) => {
// Process each user with its position console.log(`Processing user: ${user.username}`);
console.log(`Processing user ${index}: ${user.username}`);
}); });
// Chain cursor methods like in the MongoDB native driver // Manually iterate using next()
const paginatedCursor = await User.getCursor({ active: true }) let nextUser;
.limit(10) // Limit results while ((nextUser = await cursor.next())) {
.skip(20) // Skip first 20 results console.log(`Next user: ${nextUser.username}`);
.sort({ createdAt: -1 }); // Sort by creation date descending }
// Convert cursor to array (when you know the result set is small) // Convert to array when the result set is small
const userArray = await paginatedCursor.toArray(); const userArray = await cursor.toArray();
// Other cursor operations // Close the cursor to free resources
const nextUser = await cursor.next(); // Get the next document
const hasMoreUsers = await cursor.hasNext(); // Check if more documents exist
const count = await cursor.count(); // Get the count of documents in the cursor
// Always close cursors when done with them
await cursor.close(); await cursor.close();
// For native cursor modifiers (sort, skip, limit), use getCursor with modifier option:
const paginatedCursor = await User.getCursor(
{ active: true },
{ modifier: (c) => c.sort({ createdAt: -1 }).skip(20).limit(10) }
);
await paginatedCursor.forEach((user) => {
console.log(`Paginated user: ${user.username}`);
});
``` ```
#### Update #### Update
```typescript ```typescript
// Assuming 'user' is an instance of User // Assuming 'user' is an instance of User
user.email = 'newEmail@example.com'; user.email = 'newEmail@example.com';
await user.save(); // Update the user in the database await user.save(); // Update the user in the database
// Upsert operations (insert if not exists, update if exists) // Upsert operations (insert if not exists, update if exists)
const upsertedUser = await User.upsert( const upsertedUser = await User.upsert(
{ id: 'user-123' }, // Query to find the user { id: 'user-123' }, // Query to find the user
{ // Fields to update or insert {
username: 'newUsername', // Fields to update or insert
email: 'newEmail@example.com' username: 'newUsername',
} email: 'newEmail@example.com',
},
); );
``` ```
#### Delete #### Delete
```typescript ```typescript
// Assuming 'user' is an instance of User // Assuming 'user' is an instance of User
await user.delete(); // Delete the user from the database await user.delete(); // Delete the user from the database
``` ```
## Advanced Features ## Advanced Features
### Search Functionality ### Search Functionality
SmartData provides powerful search capabilities with a Lucene-like query syntax and robust fallback mechanisms:
SmartData provides powerful, Lucenestyle search capabilities with robust fallback mechanisms:
```typescript ```typescript
// Define a model with searchable fields // Define a model with searchable fields
@Collection(() => db) @Collection(() => db)
class Product extends SmartDataDbDoc<Product, Product> { class Product extends SmartDataDbDoc<Product, Product> {
@unI() @unI() public id: string = 'product-id';
public id: string = 'product-id'; @svDb() @searchable() public name: string;
@svDb() @searchable() public description: string;
@svDb() @svDb() @searchable() public category: string;
@searchable() // Mark this field as searchable @svDb() public price: number;
public name: string;
@svDb()
@searchable() // Mark this field as searchable
public description: string;
@svDb()
@searchable() // Mark this field as searchable
public category: string;
@svDb()
public price: number;
} }
// Get all fields marked as searchable for a class // List searchable fields
const searchableFields = getSearchableFields('Product'); // ['name', 'description', 'category'] const searchableFields = Product.getSearchableFields();
// Basic search across all searchable fields // 1: Exact phrase across all fields
const iPhoneProducts = await Product.searchWithLucene('iPhone'); await Product.search('"Kindle Paperwhite"');
// Field-specific search // 2: Wildcard search across all fields
const electronicsProducts = await Product.searchWithLucene('category:Electronics'); await Product.search('Air*');
// Search with wildcards // 3: Fieldscoped wildcard
const macProducts = await Product.searchWithLucene('Mac*'); await Product.search('name:Air*');
// Search in specific fields with partial words // 4: Boolean AND/OR/NOT
const laptopResults = await Product.searchWithLucene('description:laptop'); await Product.search('category:Electronics AND name:iPhone');
// Search is case-insensitive // 5: Grouping with parentheses
const results1 = await Product.searchWithLucene('electronics'); await Product.search('(Furniture OR Electronics) AND Chair');
const results2 = await Product.searchWithLucene('Electronics');
// results1 and results2 will contain the same documents
// Using boolean operators (requires text index in MongoDB) // 6: Multiterm unquoted (terms ANDd across fields)
const wirelessOrLaptop = await Product.searchWithLucene('wireless OR laptop'); await Product.search('TypeScript Aufgabe');
// Negative searches // 7: Empty query returns all documents
const electronicsNotSamsung = await Product.searchWithLucene('Electronics NOT Samsung'); await Product.search('');
// 8: Scoped search with additional filter (e.g. multi-tenant isolation)
// Phrase searches await Product.search('book', { filter: { ownerId: currentUserId } });
const exactPhrase = await Product.searchWithLucene('"high-speed blender"'); // 9: Post-search validation hook to drop unwanted results (e.g. price check)
await Product.search('', { validate: (p) => p.price < 100 });
// Grouping with parentheses
const complexQuery = await Product.searchWithLucene('(wireless OR bluetooth) AND Electronics');
``` ```
The search functionality includes: The search functionality includes:
- `@searchable()` decorator for marking fields as searchable - `@searchable()` decorator for marking fields as searchable
- `getSearchableFields()` to retrieve all searchable fields for a class - `Class.getSearchableFields()` static method to list searchable fields for a model
- `search()` method for basic search (requires MongoDB text index) - `search(query: string)` method supporting:
- `searchWithLucene()` method with robust fallback mechanisms - Exact phrase matches (`"my exact string"` or `'my exact string'`)
- Support for field-specific searches, wildcards, and boolean operators - Fieldscoped exact & wildcard searches (`field:value`, `field:Air*`)
- Automatic fallback to regex-based search if MongoDB text search fails - 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
EasyStore provides a simple key-value storage system with automatic persistence: EasyStore provides a simple key-value storage system with automatic persistence:
```typescript ```typescript
@ -270,6 +280,7 @@ await store.deleteKey('apiKey');
``` ```
### Distributed Coordination ### Distributed Coordination
Built-in support for distributed systems with leader election: Built-in support for distributed systems with leader election:
```typescript ```typescript
@ -307,22 +318,26 @@ await coordinator.stop();
``` ```
### Real-time Data Watching ### Real-time Data Watching
Watch for changes in your collections with RxJS integration using MongoDB Change Streams: Watch for changes in your collections with RxJS integration using MongoDB Change Streams:
```typescript ```typescript
// Create a watcher for a specific collection with a query filter // Create a watcher for a specific collection with a query filter
const watcher = await User.watch({ const watcher = await User.watch(
active: true // Only watch for changes to active users {
}, { active: true, // Only watch for changes to active users
fullDocument: true, // Include the full document in change notifications },
bufferTimeMs: 100 // Buffer changes for 100ms to reduce notification frequency {
}); fullDocument: true, // Include the full document in change notifications
bufferTimeMs: 100, // Buffer changes for 100ms to reduce notification frequency
},
);
// Subscribe to changes using RxJS // Subscribe to changes using RxJS
watcher.changeSubject.subscribe((change) => { watcher.changeSubject.subscribe((change) => {
console.log('Change operation:', change.operationType); // 'insert', 'update', 'delete', etc. console.log('Change operation:', change.operationType); // 'insert', 'update', 'delete', etc.
console.log('Document changed:', change.docInstance); // The full document instance console.log('Document changed:', change.docInstance); // The full document instance
// Handle different types of changes // Handle different types of changes
if (change.operationType === 'insert') { if (change.operationType === 'insert') {
console.log('New user created:', change.docInstance.username); console.log('New user created:', change.docInstance.username);
@ -343,6 +358,7 @@ await watcher.stop();
``` ```
### Managed Collections ### Managed Collections
For more complex data models that require additional context: For more complex data models that require additional context:
```typescript ```typescript
@ -350,13 +366,13 @@ For more complex data models that require additional context:
class ManagedDoc extends SmartDataDbDoc<ManagedDoc, ManagedDoc> { class ManagedDoc extends SmartDataDbDoc<ManagedDoc, ManagedDoc> {
@unI() @unI()
public id: string = 'unique-id'; public id: string = 'unique-id';
@svDb() @svDb()
public data: string; public data: string;
@managed() @managed()
public manager: YourCustomManager; public manager: YourCustomManager;
// The manager can provide additional functionality // The manager can provide additional functionality
async specialOperation() { async specialOperation() {
return this.manager.doSomethingSpecial(this); return this.manager.doSomethingSpecial(this);
@ -365,6 +381,7 @@ class ManagedDoc extends SmartDataDbDoc<ManagedDoc, ManagedDoc> {
``` ```
### Automatic Indexing ### Automatic Indexing
Define indexes directly in your model class: Define indexes directly in your model class:
```typescript ```typescript
@ -372,19 +389,19 @@ Define indexes directly in your model class:
class Product extends SmartDataDbDoc<Product, Product> { class Product extends SmartDataDbDoc<Product, Product> {
@unI() // Unique index @unI() // Unique index
public id: string = 'product-id'; public id: string = 'product-id';
@svDb() @svDb()
@index() // Regular index for faster queries @index() // Regular index for faster queries
public category: string; public category: string;
@svDb() @svDb()
@index({ sparse: true }) // Sparse index with options @index({ sparse: true }) // Sparse index with options
public optionalField?: string; public optionalField?: string;
// Compound indexes can be defined in the collection decorator // Compound indexes can be defined in the collection decorator
@Collection(() => db, { @Collection(() => db, {
indexMap: { indexMap: {
compoundIndex: { compoundIndex: {
fields: { category: 1, name: 1 }, fields: { category: 1, name: 1 },
options: { background: true } options: { background: true }
} }
@ -394,19 +411,24 @@ class Product extends SmartDataDbDoc<Product, Product> {
``` ```
### Transaction Support ### Transaction Support
Use MongoDB transactions for atomic operations:
Use MongoDB transactions for atomic operations. SmartData now exposes `startSession()` and accepts an optional session in all fetch and write APIs:
```typescript ```typescript
const session = await db.startSession(); // start a client session (no await)
const session = db.startSession();
try { try {
// wrap operations in a transaction
await session.withTransaction(async () => { await session.withTransaction(async () => {
const user = await User.getInstance({ id: 'user-id' }, { session }); // pass session as second arg to getInstance
const user = await User.getInstance({ id: 'user-id' }, session);
user.balance -= 100; user.balance -= 100;
// pass session in save opts
await user.save({ session }); await user.save({ session });
const recipient = await User.getInstance({ id: 'recipient-id' }, { session }); const recipient = await User.getInstance({ id: 'recipient-id' }, session);
recipient.balance += 100; recipient.balance += 100;
await user.save({ session }); await recipient.save({ session });
}); });
} finally { } finally {
await session.endSession(); await session.endSession();
@ -414,6 +436,7 @@ try {
``` ```
### Deep Object Queries ### Deep Object Queries
SmartData provides fully type-safe deep property queries with the `DeepQuery` type: SmartData provides fully type-safe deep property queries with the `DeepQuery` type:
```typescript ```typescript
@ -421,7 +444,7 @@ SmartData provides fully type-safe deep property queries with the `DeepQuery` ty
class UserProfile extends SmartDataDbDoc<UserProfile, UserProfile> { class UserProfile extends SmartDataDbDoc<UserProfile, UserProfile> {
@unI() @unI()
public id: string = 'profile-id'; public id: string = 'profile-id';
@svDb() @svDb()
public user: { public user: {
details: { details: {
@ -430,14 +453,14 @@ class UserProfile extends SmartDataDbDoc<UserProfile, UserProfile> {
address: { address: {
city: string; city: string;
country: string; country: string;
} };
} };
}; };
} }
// Type-safe string literals for dot notation // Type-safe string literals for dot notation
const usersInUSA = await UserProfile.getInstances({ const usersInUSA = await UserProfile.getInstances({
'user.details.address.country': 'USA' 'user.details.address.country': 'USA',
}); });
// Fully typed deep queries with the DeepQuery type // Fully typed deep queries with the DeepQuery type
@ -446,7 +469,7 @@ import { DeepQuery } from '@push.rocks/smartdata';
const typedQuery: DeepQuery<UserProfile> = { const typedQuery: DeepQuery<UserProfile> = {
id: 'profile-id', id: 'profile-id',
'user.details.firstName': 'John', 'user.details.firstName': 'John',
'user.details.address.country': 'USA' 'user.details.address.country': 'USA',
}; };
// TypeScript will error if paths are incorrect // TypeScript will error if paths are incorrect
@ -455,13 +478,14 @@ const results = await UserProfile.getInstances(typedQuery);
// MongoDB query operators are supported // MongoDB query operators are supported
const operatorQuery: DeepQuery<UserProfile> = { const operatorQuery: DeepQuery<UserProfile> = {
'user.details.address.country': 'USA', 'user.details.address.country': 'USA',
'user.details.address.city': { $in: ['New York', 'Los Angeles'] } 'user.details.address.city': { $in: ['New York', 'Los Angeles'] },
}; };
const filteredResults = await UserProfile.getInstances(operatorQuery); const filteredResults = await UserProfile.getInstances(operatorQuery);
``` ```
### Document Lifecycle Hooks ### Document Lifecycle Hooks
Implement custom logic at different stages of a document's lifecycle: Implement custom logic at different stages of a document's lifecycle:
```typescript ```typescript
@ -469,30 +493,30 @@ Implement custom logic at different stages of a document's lifecycle:
class Order extends SmartDataDbDoc<Order, Order> { class Order extends SmartDataDbDoc<Order, Order> {
@unI() @unI()
public id: string = 'order-id'; public id: string = 'order-id';
@svDb() @svDb()
public total: number; public total: number;
@svDb() @svDb()
public items: string[]; public items: string[];
// Called before saving the document // Called before saving the document
async beforeSave() { async beforeSave() {
// Calculate total based on items // Calculate total based on items
this.total = await calculateTotal(this.items); this.total = await calculateTotal(this.items);
// Validate the document // Validate the document
if (this.items.length === 0) { if (this.items.length === 0) {
throw new Error('Order must have at least one item'); throw new Error('Order must have at least one item');
} }
} }
// Called after the document is saved // Called after the document is saved
async afterSave() { async afterSave() {
// Notify other systems about the saved order // Notify other systems about the saved order
await notifyExternalSystems(this); await notifyExternalSystems(this);
} }
// Called before deleting the document // Called before deleting the document
async beforeDelete() { async beforeDelete() {
// Check if order can be deleted // Check if order can be deleted
@ -501,39 +525,50 @@ class Order extends SmartDataDbDoc<Order, Order> {
throw new Error('Order cannot be deleted'); throw new Error('Order cannot be deleted');
} }
} }
// Called after deleting the document
async afterDelete() {
// Cleanup or audit actions
await auditLogDeletion(this.id);
}
} }
``` ```
## Best Practices ## Best Practices
### Connection Management ### Connection Management
- Always call `db.init()` before using any database features - Always call `db.init()` before using any database features
- Use `db.disconnect()` when shutting down your application - Use `db.close()` when shutting down your application
- Set appropriate connection pool sizes based on your application's needs - Set appropriate connection pool sizes based on your application's needs
### Document Design ### Document Design
- Use appropriate decorators (`@svDb`, `@unI`, `@index`, `@searchable`) to optimize database operations - Use appropriate decorators (`@svDb`, `@unI`, `@index`, `@searchable`) to optimize database operations
- Implement type-safe models by properly extending `SmartDataDbDoc` - Implement type-safe models by properly extending `SmartDataDbDoc`
- Consider using interfaces to define document structures separately from implementation - Consider using interfaces to define document structures separately from implementation
- Mark fields that need to be searched with the `@searchable()` decorator - Mark fields that need to be searched with the `@searchable()` decorator
### Search Optimization ### Search Optimization
- Create MongoDB text indexes for collections that need advanced search operations
- Use `searchWithLucene()` for robust searches with fallback mechanisms - (Optional) Create MongoDB text indexes on searchable fields to speed up full-text search
- Prefer field-specific searches when possible for better performance - Use `search(query)` for all search operations (field:value, partial matches, multi-word)
- Use simple term queries instead of boolean operators if you don't have text indexes - Prefer field-specific exact matches when possible for optimal performance
- Avoid unnecessary complexity in query strings to keep regex searches efficient
### Performance Optimization ### Performance Optimization
- Use cursors for large datasets instead of loading all documents into memory - Use cursors for large datasets instead of loading all documents into memory
- Create appropriate indexes for frequent query patterns - Create appropriate indexes for frequent query patterns
- Use projections to limit the fields returned when you don't need the entire document - Use projections to limit the fields returned when you don't need the entire document
### Distributed Systems ### Distributed Systems
- Implement proper error handling for leader election events - Implement proper error handling for leader election events
- Ensure all instances have synchronized clocks when using time-based coordination - Ensure all instances have synchronized clocks when using time-based coordination
- Use the distributed coordinator's task management features for coordinated operations - Use the distributed coordinator's task management features for coordinated operations
### Type Safety ### Type Safety
- Take advantage of the `DeepQuery<T>` type for fully type-safe queries - Take advantage of the `DeepQuery<T>` type for fully type-safe queries
- Define proper types for your document models to enhance IDE auto-completion - 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 - Use generic type parameters to specify exact document types when working with collections
@ -552,7 +587,7 @@ Please make sure to update tests as appropriate and follow our coding standards.
## License and Legal Information ## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. This repository is licensed under the MIT License. For details, see [MIT License](https://opensource.org/licenses/MIT).
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
@ -567,4 +602,4 @@ Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc. For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

97
test/test.cursor.ts Normal file

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

@ -3,7 +3,10 @@ import * as smartmongo from '@push.rocks/smartmongo';
import type * as taskbuffer from '@push.rocks/taskbuffer'; import type * as taskbuffer from '@push.rocks/taskbuffer';
import * as smartdata from '../ts/index.js'; import * as smartdata from '../ts/index.js';
import { SmartdataDistributedCoordinator, DistributedClass } from '../ts/classes.distributedcoordinator.js'; // path might need adjusting import {
SmartdataDistributedCoordinator,
DistributedClass,
} from '../ts/classes.distributedcoordinator.js'; // path might need adjusting
const totalInstances = 10; const totalInstances = 10;
// ======================================= // =======================================
@ -20,93 +23,100 @@ tap.test('should create a testinstance as database', async () => {
}); });
tap.test('should instantiate DistributedClass', async (tools) => { tap.test('should instantiate DistributedClass', async (tools) => {
const instance = new DistributedClass(); const instance = new DistributedClass();
expect(instance).toBeInstanceOf(DistributedClass); expect(instance).toBeInstanceOf(DistributedClass);
}); });
tap.test('DistributedClass should update the time', async (tools) => { tap.test('DistributedClass should update the time', async (tools) => {
const distributedCoordinator = new SmartdataDistributedCoordinator(testDb); const distributedCoordinator = new SmartdataDistributedCoordinator(testDb);
await distributedCoordinator.start(); await distributedCoordinator.start();
const initialTime = distributedCoordinator.ownInstance.data.lastUpdated; const initialTime = distributedCoordinator.ownInstance.data.lastUpdated;
await distributedCoordinator.sendHeartbeat(); await distributedCoordinator.sendHeartbeat();
const updatedTime = distributedCoordinator.ownInstance.data.lastUpdated; const updatedTime = distributedCoordinator.ownInstance.data.lastUpdated;
expect(updatedTime).toBeGreaterThan(initialTime); expect(updatedTime).toBeGreaterThan(initialTime);
await distributedCoordinator.stop(); await distributedCoordinator.stop();
}); });
tap.test('should instantiate SmartdataDistributedCoordinator', async (tools) => { tap.test('should instantiate SmartdataDistributedCoordinator', async (tools) => {
const distributedCoordinator = new SmartdataDistributedCoordinator(testDb); const distributedCoordinator = new SmartdataDistributedCoordinator(testDb);
await distributedCoordinator.start(); await distributedCoordinator.start();
expect(distributedCoordinator).toBeInstanceOf(SmartdataDistributedCoordinator); expect(distributedCoordinator).toBeInstanceOf(SmartdataDistributedCoordinator);
await distributedCoordinator.stop(); await distributedCoordinator.stop();
}); });
tap.test('SmartdataDistributedCoordinator should update leader status', async (tools) => { tap.test('SmartdataDistributedCoordinator should update leader status', async (tools) => {
const distributedCoordinator = new SmartdataDistributedCoordinator(testDb); const distributedCoordinator = new SmartdataDistributedCoordinator(testDb);
await distributedCoordinator.start(); await distributedCoordinator.start();
await distributedCoordinator.checkAndMaybeLead(); await distributedCoordinator.checkAndMaybeLead();
expect(distributedCoordinator.ownInstance.data.elected).toBeOneOf([true, false]); expect(distributedCoordinator.ownInstance.data.elected).toBeOneOf([true, false]);
await distributedCoordinator.stop(); await distributedCoordinator.stop();
}); });
tap.test('SmartdataDistributedCoordinator should handle distributed task requests', async (tools) => { tap.test(
'SmartdataDistributedCoordinator should handle distributed task requests',
async (tools) => {
const distributedCoordinator = new SmartdataDistributedCoordinator(testDb); const distributedCoordinator = new SmartdataDistributedCoordinator(testDb);
await distributedCoordinator.start(); await distributedCoordinator.start();
const mockTaskRequest: taskbuffer.distributedCoordination.IDistributedTaskRequest = { const mockTaskRequest: taskbuffer.distributedCoordination.IDistributedTaskRequest = {
submitterId: "mockSubmitter12345", // Some unique mock submitter ID submitterId: 'mockSubmitter12345', // Some unique mock submitter ID
requestResponseId: 'uni879873462hjhfkjhsdf', // Some unique ID for the request-response requestResponseId: 'uni879873462hjhfkjhsdf', // Some unique ID for the request-response
taskName: "SampleTask", taskName: 'SampleTask',
taskVersion: "1.0.0", // Assuming it's a version string taskVersion: '1.0.0', // Assuming it's a version string
taskExecutionTime: Date.now(), taskExecutionTime: Date.now(),
taskExecutionTimeout: 60000, // Let's say the timeout is 1 minute (60000 ms) taskExecutionTimeout: 60000, // Let's say the timeout is 1 minute (60000 ms)
taskExecutionParallel: 5, // Let's assume max 5 parallel executions taskExecutionParallel: 5, // Let's assume max 5 parallel executions
status: 'requesting' status: 'requesting',
}; };
const response = await distributedCoordinator.fireDistributedTaskRequest(mockTaskRequest); const response = await distributedCoordinator.fireDistributedTaskRequest(mockTaskRequest);
console.log(response) // based on your expected structure for the response console.log(response); // based on your expected structure for the response
await distributedCoordinator.stop(); await distributedCoordinator.stop();
}); },
);
tap.test('SmartdataDistributedCoordinator should update distributed task requests', async (tools) => { tap.test(
'SmartdataDistributedCoordinator should update distributed task requests',
async (tools) => {
const distributedCoordinator = new SmartdataDistributedCoordinator(testDb); const distributedCoordinator = new SmartdataDistributedCoordinator(testDb);
await distributedCoordinator.start(); await distributedCoordinator.start();
const mockTaskRequest: taskbuffer.distributedCoordination.IDistributedTaskRequest = { const mockTaskRequest: taskbuffer.distributedCoordination.IDistributedTaskRequest = {
submitterId: "mockSubmitter12345", // Some unique mock submitter ID submitterId: 'mockSubmitter12345', // Some unique mock submitter ID
requestResponseId: 'uni879873462hjhfkjhsdf', // Some unique ID for the request-response requestResponseId: 'uni879873462hjhfkjhsdf', // Some unique ID for the request-response
taskName: "SampleTask", taskName: 'SampleTask',
taskVersion: "1.0.0", // Assuming it's a version string taskVersion: '1.0.0', // Assuming it's a version string
taskExecutionTime: Date.now(), taskExecutionTime: Date.now(),
taskExecutionTimeout: 60000, // Let's say the timeout is 1 minute (60000 ms) taskExecutionTimeout: 60000, // Let's say the timeout is 1 minute (60000 ms)
taskExecutionParallel: 5, // Let's assume max 5 parallel executions taskExecutionParallel: 5, // Let's assume max 5 parallel executions
status: 'requesting' status: 'requesting',
}; };
await distributedCoordinator.updateDistributedTaskRequest(mockTaskRequest); await distributedCoordinator.updateDistributedTaskRequest(mockTaskRequest);
// Here, we can potentially check if a DB entry got updated or some other side-effect of the update method. // Here, we can potentially check if a DB entry got updated or some other side-effect of the update method.
await distributedCoordinator.stop(); await distributedCoordinator.stop();
}); },
);
tap.test('should elect only one leader amongst multiple instances', async (tools) => { tap.test('should elect only one leader amongst multiple instances', async (tools) => {
const coordinators = Array.from({ length: totalInstances }).map(() => new SmartdataDistributedCoordinator(testDb)); const coordinators = Array.from({ length: totalInstances }).map(
await Promise.all(coordinators.map(coordinator => coordinator.start())); () => new SmartdataDistributedCoordinator(testDb),
const leaders = coordinators.filter(coordinator => coordinator.ownInstance.data.elected); );
for (const leader of leaders) { await Promise.all(coordinators.map((coordinator) => coordinator.start()));
console.log(leader.ownInstance); const leaders = coordinators.filter((coordinator) => coordinator.ownInstance.data.elected);
} for (const leader of leaders) {
expect(leaders.length).toEqual(1); console.log(leader.ownInstance);
}
expect(leaders.length).toEqual(1);
// stopping clears a coordinator from being elected. // stopping clears a coordinator from being elected.
await Promise.all(coordinators.map(coordinator => coordinator.stop())); await Promise.all(coordinators.map((coordinator) => coordinator.stop()));
}); });
tap.test('should clean up', async () => { tap.test('should clean up', async () => {
await smartmongoInstance.stopAndDumpToDir(`.nogit/dbdump/test.distributedcoordinator.ts`); await smartmongoInstance.stopAndDumpToDir(`.nogit/dbdump/test.distributedcoordinator.ts`);
setTimeout(() => process.exit(), 2000); setTimeout(() => process.exit(), 2000);
}) });
tap.start({ throwOnError: true }); tap.start({ throwOnError: true });

@ -0,0 +1,202 @@
import { tap, expect } from '@push.rocks/tapbundle';
import * as smartmongo from '@push.rocks/smartmongo';
import * as smartdata from '../ts/index.js';
import { searchable } from '../ts/classes.doc.js';
import { smartunique } from '../ts/plugins.js';
// Set up database connection
let smartmongoInstance: smartmongo.SmartMongo;
let testDb: smartdata.SmartdataDb;
// Define a test class for advanced search scenarios
@smartdata.Collection(() => testDb)
class Product extends smartdata.SmartDataDbDoc<Product, Product> {
@smartdata.unI()
public id: string = smartunique.shortId();
@smartdata.svDb()
@searchable()
public name: string;
@smartdata.svDb()
@searchable()
public description: string;
@smartdata.svDb()
@searchable()
public category: string;
@smartdata.svDb()
public price: number;
constructor(
nameArg: string,
descriptionArg: string,
categoryArg: string,
priceArg: number,
) {
super();
this.name = nameArg;
this.description = descriptionArg;
this.category = categoryArg;
this.price = priceArg;
}
}
// Initialize DB and insert sample products
tap.test('setup advanced search database', async () => {
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
testDb = new smartdata.SmartdataDb(
await smartmongoInstance.getMongoDescriptor(),
);
await testDb.init();
});
tap.test('insert products for advanced search', async () => {
const products = [
new Product(
'Night Owl Lamp',
'Bright lamp for night reading',
'Lighting',
29,
),
new Product(
'Day Light Lamp',
'Daytime lamp with adjustable brightness',
'Lighting',
39,
),
new Product(
'Office Chair',
'Ergonomic chair for office',
'Furniture',
199,
),
new Product(
'Gaming Chair',
'Comfortable for long gaming sessions',
'Furniture',
299,
),
new Product(
'iPhone 12',
'Latest iPhone with A14 Bionic chip',
'Electronics',
999,
),
new Product(
'AirPods',
'Wireless earbuds with noise cancellation',
'Electronics',
249,
),
];
for (const p of products) {
await p.save();
}
const all = await Product.getInstances({});
expect(all.length).toEqual(products.length);
});
// Simple exact field:value matching
tap.test('simpleExact: category:Furniture returns chairs', async () => {
const res = await Product.search('category:Furniture');
expect(res.length).toEqual(2);
const names = res.map((r) => r.name).sort();
expect(names).toEqual(['Gaming Chair', 'Office Chair']);
});
// simpleExact invalid field should throw
tap.test('simpleExact invalid field errors', async () => {
let error: Error;
try {
await Product.search('price:29');
} catch (e) {
error = e as Error;
}
expect(error).toBeTruthy();
expect(error.message).toMatch(/not searchable/);
});
// Quoted phrase search
tap.test('quoted phrase "Bright lamp" matches Night Owl Lamp', async () => {
const res = await Product.search('"Bright lamp"');
expect(res.length).toEqual(1);
expect(res[0].name).toEqual('Night Owl Lamp');
});
tap.test("quoted phrase 'night reading' matches Night Owl Lamp", async () => {
const res = await Product.search("'night reading'");
expect(res.length).toEqual(1);
expect(res[0].name).toEqual('Night Owl Lamp');
});
tap.test('wildcard description:*gaming* matches Gaming Chair', async () => {
const res = await Product.search('description:*gaming*');
expect(res.length).toEqual(1);
expect(res[0].name).toEqual('Gaming Chair');
});
// Boolean AND and OR
tap.test('boolean AND: category:Lighting AND lamp', async () => {
const res = await Product.search('category:Lighting AND lamp');
expect(res.length).toEqual(2);
});
tap.test('boolean OR: Furniture OR Electronics', async () => {
const res = await Product.search('Furniture OR Electronics');
expect(res.length).toEqual(4);
});
// Multi-term unquoted -> AND across terms
tap.test('multi-term unquoted adjustable brightness', async () => {
const res = await Product.search('adjustable brightness');
expect(res.length).toEqual(1);
expect(res[0].name).toEqual('Day Light Lamp');
});
tap.test('multi-term unquoted Night Lamp', async () => {
const res = await Product.search('Night Lamp');
expect(res.length).toEqual(1);
expect(res[0].name).toEqual('Night Owl Lamp');
});
// Grouping with parentheses
tap.test('grouping: (Furniture OR Electronics) AND Chair', async () => {
const res = await Product.search(
'(Furniture OR Electronics) AND Chair',
);
expect(res.length).toEqual(2);
const names = res.map((r) => r.name).sort();
expect(names).toEqual(['Gaming Chair', 'Office Chair']);
});
// Additional range and combined query tests
tap.test('range query price:[30 TO 300] returns expected products', async () => {
const res = await Product.search('price:[30 TO 300]');
// Expect products with price between 30 and 300 inclusive: Day Light Lamp, Gaming Chair, Office Chair, AirPods
expect(res.length).toEqual(4);
const names = res.map((r) => r.name).sort();
expect(names).toEqual(['AirPods', 'Day Light Lamp', 'Gaming Chair', 'Office Chair']);
});
tap.test('should filter category and price range', async () => {
const res = await Product.search('category:Lighting AND price:[30 TO 40]');
expect(res.length).toEqual(1);
expect(res[0].name).toEqual('Day Light Lamp');
});
// Teardown
tap.test('cleanup advanced search database', async () => {
await testDb.mongoDb.dropDatabase();
await testDb.close();
if (smartmongoInstance) {
await smartmongoInstance.stopAndDumpToDir(
`.nogit/dbdump/test.search.advanced.ts`,
);
}
setTimeout(() => process.exit(), 2000);
});
tap.start({ throwOnError: true });

@ -4,11 +4,13 @@ import { smartunique } from '../ts/plugins.js';
// Import the smartdata library // Import the smartdata library
import * as smartdata from '../ts/index.js'; import * as smartdata from '../ts/index.js';
import { searchable, getSearchableFields } from '../ts/classes.doc.js'; import { searchable } from '../ts/classes.doc.js';
// Set up database connection // Set up database connection
let smartmongoInstance: smartmongo.SmartMongo; let smartmongoInstance: smartmongo.SmartMongo;
let testDb: smartdata.SmartdataDb; let testDb: smartdata.SmartdataDb;
// Class for location-based wildcard/phrase tests
let LocationDoc: any;
// Define a test class with searchable fields using the standard SmartDataDbDoc // Define a test class with searchable fields using the standard SmartDataDbDoc
@smartdata.Collection(() => testDb) @smartdata.Collection(() => testDb)
@ -56,14 +58,14 @@ tap.test('should create test products with searchable fields', async () => {
new Product('Kindle Paperwhite', 'E-reader with built-in light', 'Books', 129), new Product('Kindle Paperwhite', 'E-reader with built-in light', 'Books', 129),
new Product('Harry Potter', 'Fantasy book series about wizards', 'Books', 49), new Product('Harry Potter', 'Fantasy book series about wizards', 'Books', 49),
new Product('Coffee Maker', 'Automatic drip coffee machine', 'Kitchen', 89), new Product('Coffee Maker', 'Automatic drip coffee machine', 'Kitchen', 89),
new Product('Blender', 'High-speed blender for smoothies', 'Kitchen', 129) new Product('Blender', 'High-speed blender for smoothies', 'Kitchen', 129),
]; ];
// Save all products to the database // Save all products to the database
for (const product of products) { for (const product of products) {
await product.save(); await product.save();
} }
// Verify that we can get all products // Verify that we can get all products
const allProducts = await Product.getInstances({}); const allProducts = await Product.getInstances({});
expect(allProducts.length).toEqual(products.length); expect(allProducts.length).toEqual(products.length);
@ -72,9 +74,9 @@ tap.test('should create test products with searchable fields', async () => {
tap.test('should retrieve searchable fields for a class', async () => { tap.test('should retrieve searchable fields for a class', async () => {
// Use the getSearchableFields function to verify our searchable fields // Use the getSearchableFields function to verify our searchable fields
const searchableFields = getSearchableFields('Product'); const searchableFields = Product.getSearchableFields();
console.log('Searchable fields:', searchableFields); console.log('Searchable fields:', searchableFields);
expect(searchableFields.length).toEqual(3); expect(searchableFields.length).toEqual(3);
expect(searchableFields).toContain('name'); expect(searchableFields).toContain('name');
expect(searchableFields).toContain('description'); expect(searchableFields).toContain('description');
@ -85,7 +87,7 @@ tap.test('should search products by exact field match', async () => {
// Basic field exact match search // Basic field exact match search
const electronicsProducts = await Product.getInstances({ category: 'Electronics' }); const electronicsProducts = await Product.getInstances({ category: 'Electronics' });
console.log(`Found ${electronicsProducts.length} products in Electronics category`); console.log(`Found ${electronicsProducts.length} products in Electronics category`);
expect(electronicsProducts.length).toEqual(4); expect(electronicsProducts.length).toEqual(4);
}); });
@ -94,7 +96,7 @@ tap.test('should search products by basic search method', async () => {
try { try {
const iPhoneResults = await Product.search('iPhone'); const iPhoneResults = await Product.search('iPhone');
console.log(`Found ${iPhoneResults.length} products matching 'iPhone' using basic search`); console.log(`Found ${iPhoneResults.length} products matching 'iPhone' using basic search`);
expect(iPhoneResults.length).toEqual(1); expect(iPhoneResults.length).toEqual(1);
expect(iPhoneResults[0].name).toEqual('iPhone 12'); expect(iPhoneResults[0].name).toEqual('iPhone 12');
} catch (error) { } catch (error) {
@ -104,20 +106,22 @@ tap.test('should search products by basic search method', async () => {
} }
}); });
tap.test('should search products with searchWithLucene method', async () => { tap.test('should search products with search method', async () => {
// Using the robust searchWithLucene method // Using the robust searchWithLucene method
const wirelessResults = await Product.searchWithLucene('wireless'); const wirelessResults = await Product.search('wireless');
console.log(`Found ${wirelessResults.length} products matching 'wireless' using searchWithLucene`); console.log(
`Found ${wirelessResults.length} products matching 'wireless' using search`,
);
expect(wirelessResults.length).toEqual(1); expect(wirelessResults.length).toEqual(1);
expect(wirelessResults[0].name).toEqual('AirPods'); expect(wirelessResults[0].name).toEqual('AirPods');
}); });
tap.test('should search products by category with searchWithLucene', async () => { tap.test('should search products by category with search', async () => {
// Using field-specific search with searchWithLucene // Using field-specific search with searchWithLucene
const kitchenResults = await Product.searchWithLucene('category:Kitchen'); const kitchenResults = await Product.search('category:Kitchen');
console.log(`Found ${kitchenResults.length} products in Kitchen category using searchWithLucene`); console.log(`Found ${kitchenResults.length} products in Kitchen category using search`);
expect(kitchenResults.length).toEqual(2); expect(kitchenResults.length).toEqual(2);
expect(kitchenResults[0].category).toEqual('Kitchen'); expect(kitchenResults[0].category).toEqual('Kitchen');
expect(kitchenResults[1].category).toEqual('Kitchen'); expect(kitchenResults[1].category).toEqual('Kitchen');
@ -125,30 +129,30 @@ tap.test('should search products by category with searchWithLucene', async () =>
tap.test('should search products with partial word matches', async () => { tap.test('should search products with partial word matches', async () => {
// Testing partial word matches // Testing partial word matches
const proResults = await Product.searchWithLucene('Pro'); const proResults = await Product.search('Pro');
console.log(`Found ${proResults.length} products matching 'Pro'`); console.log(`Found ${proResults.length} products matching 'Pro'`);
// Should match both "MacBook Pro" and "professionals" in description // Should match both "MacBook Pro" and "professionals" in description
expect(proResults.length).toBeGreaterThan(0); expect(proResults.length).toBeGreaterThan(0);
}); });
tap.test('should search across multiple searchable fields', async () => { tap.test('should search across multiple searchable fields', async () => {
// Test searching across all searchable fields // Test searching across all searchable fields
const bookResults = await Product.searchWithLucene('book'); const bookResults = await Product.search('book');
console.log(`Found ${bookResults.length} products matching 'book' across all fields`); console.log(`Found ${bookResults.length} products matching 'book' across all fields`);
// Should match "MacBook" in name and "Books" in category // Should match "MacBook" in name and "Books" in category
expect(bookResults.length).toBeGreaterThan(1); expect(bookResults.length).toBeGreaterThan(1);
}); });
tap.test('should handle case insensitive searches', async () => { tap.test('should handle case insensitive searches', async () => {
// Test case insensitivity // Test case insensitivity
const electronicsResults = await Product.searchWithLucene('electronics'); const electronicsResults = await Product.search('electronics');
const ElectronicsResults = await Product.searchWithLucene('Electronics'); const ElectronicsResults = await Product.search('Electronics');
console.log(`Found ${electronicsResults.length} products matching lowercase 'electronics'`); console.log(`Found ${electronicsResults.length} products matching lowercase 'electronics'`);
console.log(`Found ${ElectronicsResults.length} products matching capitalized 'Electronics'`); console.log(`Found ${ElectronicsResults.length} products matching capitalized 'Electronics'`);
// Both searches should return the same results // Both searches should return the same results
expect(electronicsResults.length).toEqual(ElectronicsResults.length); expect(electronicsResults.length).toEqual(ElectronicsResults.length);
}); });
@ -161,19 +165,19 @@ tap.test('should demonstrate search fallback mechanisms', async () => {
console.log('3. As last resort, perform in-memory filtering'); console.log('3. As last resort, perform in-memory filtering');
console.log('This ensures robust search even with complex queries'); console.log('This ensures robust search even with complex queries');
console.log('==============================================\n'); console.log('==============================================\n');
// Use a simpler term that should be found in descriptions // Use a simpler term that should be found in descriptions
// Avoid using "OR" operator which requires a text index // Avoid using "OR" operator which requires a text index
const results = await Product.searchWithLucene('high'); const results = await Product.search('high');
console.log(`Found ${results.length} products matching 'high'`); console.log(`Found ${results.length} products matching 'high'`);
// "High-speed blender" contains "high" // "High-speed blender" contains "high"
expect(results.length).toBeGreaterThan(0); expect(results.length).toBeGreaterThan(0);
// Try another fallback example that won't need $text // Try another fallback example that won't need $text
const powerfulResults = await Product.searchWithLucene('powerful'); const powerfulResults = await Product.search('powerful');
console.log(`Found ${powerfulResults.length} products matching 'powerful'`); console.log(`Found ${powerfulResults.length} products matching 'powerful'`);
// "Powerful laptop for professionals" contains "powerful" // "Powerful laptop for professionals" contains "powerful"
expect(powerfulResults.length).toBeGreaterThan(0); expect(powerfulResults.length).toBeGreaterThan(0);
}); });
@ -186,10 +190,212 @@ tap.test('should explain the advantages of the integrated approach', async () =>
console.log('4. searchWithLucene provides powerful search capabilities'); console.log('4. searchWithLucene provides powerful search capabilities');
console.log('5. Backwards compatible with existing code'); console.log('5. Backwards compatible with existing code');
console.log('================================================\n'); console.log('================================================\n');
expect(true).toEqual(true); expect(true).toEqual(true);
}); });
// Additional robustness tests
tap.test('should search exact name using field:value', async () => {
const nameResults = await Product.search('name:AirPods');
expect(nameResults.length).toEqual(1);
expect(nameResults[0].name).toEqual('AirPods');
});
tap.test('should throw when searching non-searchable field', async () => {
let error: Error;
try {
await Product.search('price:129');
} catch (err) {
error = err as Error;
}
expect(error).toBeTruthy();
expect(error.message).toMatch(/not searchable/);
});
tap.test('empty query should return all products', async () => {
const allResults = await Product.search('');
expect(allResults.length).toEqual(8);
});
tap.test('should search multi-word term across fields', async () => {
const termResults = await Product.search('iPhone 12');
expect(termResults.length).toEqual(1);
expect(termResults[0].name).toEqual('iPhone 12');
});
// Additional search scenarios
tap.test('should return zero results for non-existent terms', async () => {
const noResults = await Product.search('NonexistentTerm');
expect(noResults.length).toEqual(0);
});
tap.test('should search products by description term "noise"', async () => {
const noiseResults = await Product.search('noise');
expect(noiseResults.length).toEqual(1);
expect(noiseResults[0].name).toEqual('AirPods');
});
tap.test('should search products by description term "flagship"', async () => {
const flagshipResults = await Product.search('flagship');
expect(flagshipResults.length).toEqual(1);
expect(flagshipResults[0].name).toEqual('Galaxy S21');
});
tap.test('should search numeric strings "12"', async () => {
const twelveResults = await Product.search('12');
expect(twelveResults.length).toEqual(1);
expect(twelveResults[0].name).toEqual('iPhone 12');
});
tap.test('should search hyphenated terms "high-speed"', async () => {
const hyphenResults = await Product.search('high-speed');
expect(hyphenResults.length).toEqual(1);
expect(hyphenResults[0].name).toEqual('Blender');
});
tap.test('should search hyphenated terms "E-reader"', async () => {
const ereaderResults = await Product.search('E-reader');
expect(ereaderResults.length).toEqual(1);
expect(ereaderResults[0].name).toEqual('Kindle Paperwhite');
});
// Additional robustness tests
tap.test('should return all products for empty search', async () => {
const searchResults = await Product.search('');
const allProducts = await Product.getInstances({});
expect(searchResults.length).toEqual(allProducts.length);
});
tap.test('should support wildcard plain term across all fields', async () => {
const results = await Product.search('*book*');
const names = results.map((r) => r.name).sort();
expect(names).toEqual(['Harry Potter', 'Kindle Paperwhite', 'MacBook Pro']);
});
tap.test('should support wildcard plain term with question mark pattern', async () => {
const results = await Product.search('?one?');
const names = results.map((r) => r.name).sort();
expect(names).toEqual(['Galaxy S21', 'iPhone 12']);
});
// Filter and Validation tests
tap.test('should apply filter option to restrict results', async () => {
// search term 'book' across all fields but restrict to Books category
const bookFiltered = await Product.search('book', { filter: { category: 'Books' } });
expect(bookFiltered.length).toEqual(2);
bookFiltered.forEach((p) => expect(p.category).toEqual('Books'));
});
tap.test('should apply validate hook to post-filter results', async () => {
// return only products with price > 500
const expensive = await Product.search('', { validate: (p) => p.price > 500 });
expect(expensive.length).toBeGreaterThan(0);
expensive.forEach((p) => expect(p.price).toBeGreaterThan(500));
});
// Tests for quoted and wildcard field-specific phrases
tap.test('setup location test products', async () => {
@smartdata.Collection(() => testDb)
class LD extends smartdata.SmartDataDbDoc<LD, LD> {
@smartdata.unI() public id: string = smartunique.shortId();
@smartdata.svDb() @searchable() public location: string;
constructor(loc: string) { super(); this.location = loc; }
}
// Assign to outer variable for subsequent tests
LocationDoc = LD;
const locations = ['Berlin', 'Frankfurt am Main', 'Frankfurt am Oder', 'London'];
for (const loc of locations) {
await new LocationDoc(loc).save();
}
});
tap.test('should search exact quoted field phrase', async () => {
const results = await (LocationDoc as any).search('location:"Frankfurt am Main"');
expect(results.length).toEqual(1);
expect(results[0].location).toEqual('Frankfurt am Main');
});
tap.test('should search wildcard quoted field phrase', async () => {
const results = await (LocationDoc as any).search('location:"Frankfurt am *"');
const names = results.map((d: any) => d.location).sort();
expect(names).toEqual(['Frankfurt am Main', 'Frankfurt am Oder']);
});
tap.test('should search unquoted wildcard field', async () => {
const results = await (LocationDoc as any).search('location:Frankfurt*');
const names = results.map((d: any) => d.location).sort();
expect(names).toEqual(['Frankfurt am Main', 'Frankfurt am Oder']);
});
// Combined free-term + field phrase/wildcard tests
let CombinedDoc: any;
tap.test('setup combined docs for free-term and location tests', async () => {
@smartdata.Collection(() => testDb)
class CD extends smartdata.SmartDataDbDoc<CD, CD> {
@smartdata.unI() public id: string = smartunique.shortId();
@smartdata.svDb() @searchable() public name: string;
@smartdata.svDb() @searchable() public location: string;
constructor(name: string, location: string) { super(); this.name = name; this.location = location; }
}
CombinedDoc = CD;
const docs = [
new CombinedDoc('TypeScript', 'Berlin'),
new CombinedDoc('TypeScript', 'Frankfurt am Main'),
new CombinedDoc('TypeScript', 'Frankfurt am Oder'),
new CombinedDoc('JavaScript', 'Berlin'),
];
for (const d of docs) await d.save();
});
tap.test('should search free term and exact quoted field phrase', async () => {
const res = await CombinedDoc.search('TypeScript location:"Berlin"');
expect(res.length).toEqual(1);
expect(res[0].location).toEqual('Berlin');
});
tap.test('should not match free term with non-matching quoted field phrase', async () => {
const res = await CombinedDoc.search('TypeScript location:"Frankfurt d"');
expect(res.length).toEqual(0);
});
tap.test('should search free term with quoted wildcard field phrase', async () => {
const res = await CombinedDoc.search('TypeScript location:"Frankfurt am *"');
const locs = res.map((r: any) => r.location).sort();
expect(locs).toEqual(['Frankfurt am Main', 'Frankfurt am Oder']);
});
// Quoted exact field phrase without wildcard should return no matches if no exact match
tap.test('should not match location:"Frankfurt d"', async () => {
const results = await (LocationDoc as any).search('location:"Frankfurt d"');
expect(results.length).toEqual(0);
});
// Combined free-term and field wildcard tests
tap.test('should combine free term and wildcard field search', async () => {
const results = await Product.search('book category:Book*');
expect(results.length).toEqual(2);
results.forEach((p) => expect(p.category).toEqual('Books'));
});
tap.test('should not match when free term matches but wildcard field does not', async () => {
const results = await Product.search('book category:Kitchen*');
expect(results.length).toEqual(0);
});
// Non-searchable field should cause an error for combined queries
tap.test('should throw when combining term with non-searchable field', async () => {
let error: Error;
try {
await Product.search('book location:Berlin');
} catch (e) {
error = e as Error;
}
expect(error).toBeTruthy();
expect(error.message).toMatch(/not searchable/);
});
tap.test('should throw when combining term with non-searchable wildcard field', async () => {
let error: Error;
try {
await Product.search('book location:Berlin*');
} catch (e) {
error = e as Error;
}
expect(error).toBeTruthy();
expect(error.message).toMatch(/not searchable/);
});
// Close database connection
tap.test('close database connection', async () => { tap.test('close database connection', async () => {
await testDb.mongoDb.dropDatabase(); await testDb.mongoDb.dropDatabase();
await testDb.close(); await testDb.close();
@ -199,4 +405,4 @@ tap.test('close database connection', async () => {
setTimeout(() => process.exit(), 2000); setTimeout(() => process.exit(), 2000);
}); });
tap.start({ throwOnError: true }); tap.start({ throwOnError: true });

@ -97,7 +97,7 @@ tap.test('should save the car to the db', async (toolsArg) => {
console.log( console.log(
`Filled database with ${counter} of ${totalCars} Cars and memory usage ${ `Filled database with ${counter} of ${totalCars} Cars and memory usage ${
process.memoryUsage().rss / 1e6 process.memoryUsage().rss / 1e6
} MB` } MB`,
); );
} }
} while (counter < totalCars); } while (counter < totalCars);
@ -116,7 +116,7 @@ tap.test('expect to get instance of Car with shallow match', async () => {
console.log( console.log(
`performed ${counter} of ${totalQueryCycles} total query cycles: took ${ `performed ${counter} of ${totalQueryCycles} total query cycles: took ${
Date.now() - timeStart Date.now() - timeStart
}ms to query a set of 2000 with memory footprint ${process.memoryUsage().rss / 1e6} MB` }ms to query a set of 2000 with memory footprint ${process.memoryUsage().rss / 1e6} MB`,
); );
} }
expect(myCars[0].deepData.sodeep).toEqual('yes'); expect(myCars[0].deepData.sodeep).toEqual('yes');
@ -139,7 +139,7 @@ tap.test('expect to get instance of Car with deep match', async () => {
console.log( console.log(
`performed ${counter} of ${totalQueryCycles} total query cycles: took ${ `performed ${counter} of ${totalQueryCycles} total query cycles: took ${
Date.now() - timeStart Date.now() - timeStart
}ms to deep query a set of 2000 with memory footprint ${process.memoryUsage().rss / 1e6} MB` }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].deepData.sodeep).toEqual('yes');
@ -209,7 +209,7 @@ tap.test('should store a new Truck', async () => {
tap.test('should return a count', async () => { tap.test('should return a count', async () => {
const truckCount = await Truck.getCount(); const truckCount = await Truck.getCount();
expect(truckCount).toEqual(1); expect(truckCount).toEqual(1);
}) });
tap.test('should use a cursor', async () => { tap.test('should use a cursor', async () => {
const cursor = await Car.getCursor({}); const cursor = await Car.getCursor({});

@ -60,11 +60,52 @@ tap.test('should watch a collection', async (toolsArg) => {
await done.promise; await done.promise;
}); });
// ======= New tests for EventEmitter and buffering support =======
tap.test('should emit change via EventEmitter', async (tools) => {
const done = tools.defer();
const watcher = await House.watch({});
watcher.on('change', async (houseArg) => {
// Expect a House instance
expect(houseArg).toBeDefined();
// Clean up
await watcher.stop();
done.resolve();
});
// Trigger an insert to generate a change event
const h = new House();
await h.save();
await done.promise;
});
tap.test('should buffer change events when bufferTimeMs is set', async (tools) => {
const done = tools.defer();
// bufferTimeMs collects events into arrays every 50ms
const watcher = await House.watch({}, { bufferTimeMs: 50 });
let received: House[];
watcher.changeSubject.subscribe(async (batch: House[]) => {
if (batch && batch.length > 0) {
received = batch;
await watcher.stop();
done.resolve();
}
});
// Rapidly insert multiple docs
const docs = [new House(), new House(), new House()];
for (const doc of docs) await doc.save();
await done.promise;
// All inserts should be in one buffered batch
expect(received.length).toEqual(docs.length);
});
// ======================================= // =======================================
// close the database connection // close the database connection
// ======================================= // =======================================
tap.test('close', async () => { tap.test('close', async () => {
await testDb.mongoDb.dropDatabase(); try {
await testDb.mongoDb.dropDatabase();
} catch (err) {
console.warn('dropDatabase error ignored in cleanup:', err.message || err);
}
await testDb.close(); await testDb.close();
if (smartmongoInstance) { if (smartmongoInstance) {
await smartmongoInstance.stop(); await smartmongoInstance.stop();

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartdata', name: '@push.rocks/smartdata',
version: '5.5.0', version: '5.16.0',
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.' description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
} }

@ -1,7 +1,7 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { SmartdataDb } from './classes.db.js'; import { SmartdataDb } from './classes.db.js';
import { SmartdataDbCursor } from './classes.cursor.js'; import { SmartdataDbCursor } from './classes.cursor.js';
import { SmartDataDbDoc } from './classes.doc.js'; import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js';
import { SmartdataDbWatcher } from './classes.watcher.js'; import { SmartdataDbWatcher } from './classes.watcher.js';
import { CollectionFactory } from './classes.collectionfactory.js'; import { CollectionFactory } from './classes.collectionfactory.js';
@ -32,13 +32,22 @@ export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) {
if (!(dbArg instanceof SmartdataDb)) { if (!(dbArg instanceof SmartdataDb)) {
dbArg = dbArg(); dbArg = dbArg();
} }
return collectionFactory.getCollection(constructor.name, dbArg); const coll = collectionFactory.getCollection(constructor.name, dbArg);
// Attach document constructor for searchableFields lookup
if (!(coll as any).docCtor) {
(coll as any).docCtor = decoratedClass;
}
return coll;
} }
public get collection() { public get collection() {
if (!(dbArg instanceof SmartdataDb)) { if (!(dbArg instanceof SmartdataDb)) {
dbArg = dbArg(); dbArg = dbArg();
} }
return collectionFactory.getCollection(constructor.name, dbArg); const coll = collectionFactory.getCollection(constructor.name, dbArg);
if (!(coll as any).docCtor) {
(coll as any).docCtor = decoratedClass;
}
return coll;
} }
}; };
return decoratedClass; return decoratedClass;
@ -49,7 +58,7 @@ export interface IManager {
db: SmartdataDb; db: SmartdataDb;
} }
export const setDefaultManagerForDoc = <T>(managerArg: IManager, dbDocArg: T): T => { export const setDefaultManagerForDoc = <T,>(managerArg: IManager, dbDocArg: T): T => {
(dbDocArg as any).prototype.defaultManager = managerArg; (dbDocArg as any).prototype.defaultManager = managerArg;
return dbDocArg; return dbDocArg;
}; };
@ -127,6 +136,9 @@ export class SmartdataCollection<T> {
public collectionName: string; public collectionName: string;
public smartdataDb: SmartdataDb; public smartdataDb: SmartdataDb;
public uniqueIndexes: string[] = []; public uniqueIndexes: string[] = [];
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
// flag to ensure text index is created only once
private textIndexCreated: boolean = false;
constructor(classNameArg: string, smartDataDbArg: SmartdataDb) { constructor(classNameArg: string, smartDataDbArg: SmartdataDb) {
// tell the collection where it belongs // tell the collection where it belongs
@ -152,6 +164,18 @@ export class SmartdataCollection<T> {
console.log(`Successfully initiated Collection ${this.collectionName}`); console.log(`Successfully initiated Collection ${this.collectionName}`);
} }
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName); this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
// Auto-create a compound text index on all searchable fields
// Use document constructor's searchableFields registered via decorator
const docCtor = (this as any).docCtor;
const searchableFields: string[] = docCtor?.searchableFields || [];
if (searchableFields.length > 0 && !this.textIndexCreated) {
// Build a compound text index spec
const indexSpec: Record<string, 'text'> = {};
searchableFields.forEach(f => { indexSpec[f] = 'text'; });
// Cast to any to satisfy TypeScript IndexSpecification typing
await this.mongoDbCollection.createIndex(indexSpec as any, { name: 'smartdata_text_index' });
this.textIndexCreated = true;
}
} }
} }
@ -170,6 +194,24 @@ export class SmartdataCollection<T> {
} }
} }
/**
* creates regular indexes for the collection
*/
public 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(
{ [indexDef.field]: 1 }, // Simple single-field index
indexDef.options
);
// Track that we've created this index
this.regularIndexes.push(indexDef);
}
}
}
/** /**
* adds a validation function that all newly inserted and updated objects have to pass * adds a validation function that all newly inserted and updated objects have to pass
*/ */
@ -180,53 +222,74 @@ export class SmartdataCollection<T> {
/** /**
* finds an object in the DbCollection * finds an object in the DbCollection
*/ */
public async findOne(filterObject: any): Promise<any> { public async findOne(
filterObject: any,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init(); await this.init();
const cursor = this.mongoDbCollection.find(filterObject); // Use MongoDB driver's findOne with optional session
const result = await cursor.next(); return this.mongoDbCollection.findOne(filterObject, { session: opts?.session });
cursor.close();
return result;
} }
public async getCursor( public async getCursor(
filterObjectArg: any, filterObjectArg: any,
dbDocArg: typeof SmartDataDbDoc dbDocArg: typeof SmartDataDbDoc,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<SmartdataDbCursor<any>> { ): Promise<SmartdataDbCursor<any>> {
await this.init(); await this.init();
const cursor = this.mongoDbCollection.find(filterObjectArg); const cursor = this.mongoDbCollection.find(filterObjectArg, { session: opts?.session });
return new SmartdataDbCursor(cursor, dbDocArg); return new SmartdataDbCursor(cursor, dbDocArg);
} }
/** /**
* finds an object in the DbCollection * finds an object in the DbCollection
*/ */
public async findAll(filterObject: any): Promise<any[]> { public async findAll(
filterObject: any,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any[]> {
await this.init(); await this.init();
const cursor = this.mongoDbCollection.find(filterObject); const cursor = this.mongoDbCollection.find(filterObject, { session: opts?.session });
const result = await cursor.toArray(); const result = await cursor.toArray();
cursor.close(); cursor.close();
return result; return result;
} }
/** /**
* watches the collection while applying a filter * Watches the collection, returning a SmartdataDbWatcher with RxJS and EventEmitter support.
* @param filterObject match filter for change stream
* @param opts optional MongoDB ChangeStreamOptions & { bufferTimeMs } to buffer events
* @param smartdataDbDocArg document class for instance creation
*/ */
public async watch( public async watch(
filterObject: any, filterObject: any,
smartdataDbDocArg: typeof SmartDataDbDoc opts: (plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number }) = {},
smartdataDbDocArg?: typeof SmartDataDbDoc,
): Promise<SmartdataDbWatcher> { ): Promise<SmartdataDbWatcher> {
await this.init(); await this.init();
// Extract bufferTimeMs from options
const { bufferTimeMs, fullDocument, ...otherOptions } = opts || {};
// Determine fullDocument behavior: default to 'updateLookup'
const changeStreamOptions: plugins.mongodb.ChangeStreamOptions = {
...otherOptions,
fullDocument:
fullDocument === undefined
? 'updateLookup'
: fullDocument === true
? 'updateLookup'
: fullDocument,
} as any;
// Build pipeline with match if provided
const pipeline = filterObject ? [{ $match: filterObject }] : [];
const changeStream = this.mongoDbCollection.watch( const changeStream = this.mongoDbCollection.watch(
[ pipeline,
{ changeStreamOptions,
$match: filterObject, );
}, const smartdataWatcher = new SmartdataDbWatcher(
], changeStream,
{ smartdataDbDocArg,
fullDocument: 'updateLookup', { bufferTimeMs },
}
); );
const smartdataWatcher = new SmartdataDbWatcher(changeStream, smartdataDbDocArg);
await smartdataWatcher.readyDeferred.promise; await smartdataWatcher.readyDeferred.promise;
return smartdataWatcher; return smartdataWatcher;
} }
@ -234,19 +297,31 @@ export class SmartdataCollection<T> {
/** /**
* create an object in the database * create an object in the database
*/ */
public async insert(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> { public async insert(
dbDocArg: T & SmartDataDbDoc<T, unknown>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init(); await this.init();
await this.checkDoc(dbDocArg); await this.checkDoc(dbDocArg);
this.markUniqueIndexes(dbDocArg.uniqueIndexes); this.markUniqueIndexes(dbDocArg.uniqueIndexes);
// Create regular indexes if available
if (dbDocArg.regularIndexes && dbDocArg.regularIndexes.length > 0) {
this.createRegularIndexes(dbDocArg.regularIndexes);
}
const saveableObject = await dbDocArg.createSavableObject(); const saveableObject = await dbDocArg.createSavableObject();
const result = await this.mongoDbCollection.insertOne(saveableObject); const result = await this.mongoDbCollection.insertOne(saveableObject, { session: opts?.session });
return result; return result;
} }
/** /**
* inserts object into the DbCollection * inserts object into the DbCollection
*/ */
public async update(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> { public async update(
dbDocArg: T & SmartDataDbDoc<T, unknown>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init(); await this.init();
await this.checkDoc(dbDocArg); await this.checkDoc(dbDocArg);
const identifiableObject = await dbDocArg.createIdentifiableObject(); const identifiableObject = await dbDocArg.createIdentifiableObject();
@ -261,21 +336,27 @@ export class SmartdataCollection<T> {
const result = await this.mongoDbCollection.updateOne( const result = await this.mongoDbCollection.updateOne(
identifiableObject, identifiableObject,
{ $set: updateableObject }, { $set: updateableObject },
{ upsert: true } { upsert: true, session: opts?.session },
); );
return result; return result;
} }
public async delete(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> { public async delete(
dbDocArg: T & SmartDataDbDoc<T, unknown>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init(); await this.init();
await this.checkDoc(dbDocArg); await this.checkDoc(dbDocArg);
const identifiableObject = await dbDocArg.createIdentifiableObject(); const identifiableObject = await dbDocArg.createIdentifiableObject();
await this.mongoDbCollection.deleteOne(identifiableObject); await this.mongoDbCollection.deleteOne(identifiableObject, { session: opts?.session });
} }
public async getCount(filterObject: any) { public async getCount(
filterObject: any,
opts?: { session?: plugins.mongodb.ClientSession }
) {
await this.init(); await this.init();
return this.mongoDbCollection.countDocuments(filterObject); return this.mongoDbCollection.countDocuments(filterObject, { session: opts?.session });
} }
/** /**
@ -295,4 +376,4 @@ export class SmartdataCollection<T> {
} }
return done.promise; return done.promise;
} }
} }

@ -15,14 +15,14 @@ export class SmartdataDbCursor<T = any> {
this.smartdataDbDoc = dbDocArg; this.smartdataDbDoc = dbDocArg;
} }
public async next(closeAtEnd = true) { public async next(closeAtEnd = true): Promise<T> {
const result = this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc( const result = this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc(
await this.mongodbCursor.next() await this.mongodbCursor.next(),
); );
if (!result && closeAtEnd) { if (!result && closeAtEnd) {
await this.close(); await this.close();
} }
return result; return result as T;
} }
public async forEach(forEachFuncArg: (itemArg: T) => Promise<any>, closeCursorAtEnd = true) { public async forEach(forEachFuncArg: (itemArg: T) => Promise<any>, closeCursorAtEnd = true) {
@ -40,6 +40,11 @@ export class SmartdataDbCursor<T = any> {
} }
} }
public async toArray(): Promise<T[]> {
const result = await this.mongodbCursor.toArray();
return result.map((itemArg) => this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc(itemArg)) as T[];
}
public async close() { public async close() {
await this.mongodbCursor.close(); await this.mongodbCursor.close();
} }

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

@ -139,7 +139,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
const eligibleLeader = leaders.find( const eligibleLeader = leaders.find(
(leader) => (leader) =>
leader.data.lastUpdated >= leader.data.lastUpdated >=
Date.now() - plugins.smarttime.getMilliSecondsFromUnits({ seconds: 20 }) Date.now() - plugins.smarttime.getMilliSecondsFromUnits({ seconds: 20 }),
); );
return eligibleLeader; return eligibleLeader;
}); });
@ -178,16 +178,14 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
console.log('bidding code stored.'); console.log('bidding code stored.');
}); });
console.log(`bidding for leadership...`); console.log(`bidding for leadership...`);
await plugins.smartdelay.delayFor( await plugins.smartdelay.delayFor(plugins.smarttime.getMilliSecondsFromUnits({ seconds: 5 }));
plugins.smarttime.getMilliSecondsFromUnits({ seconds: 5 })
);
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => { await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
let biddingInstances = await DistributedClass.getInstances({}); let biddingInstances = await DistributedClass.getInstances({});
biddingInstances = biddingInstances.filter( biddingInstances = biddingInstances.filter(
(instanceArg) => (instanceArg) =>
instanceArg.data.status === 'bidding' && instanceArg.data.status === 'bidding' &&
instanceArg.data.lastUpdated >= instanceArg.data.lastUpdated >=
Date.now() - plugins.smarttime.getMilliSecondsFromUnits({ seconds: 10 }) Date.now() - plugins.smarttime.getMilliSecondsFromUnits({ seconds: 10 }),
); );
console.log(`found ${biddingInstances.length} bidding instances...`); console.log(`found ${biddingInstances.length} bidding instances...`);
this.ownInstance.data.elected = true; this.ownInstance.data.elected = true;
@ -242,7 +240,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
for (const instance of allInstances) { for (const instance of allInstances) {
if (instance.data.status === 'stopped') { if (instance.data.status === 'stopped') {
await instance.delete(); await instance.delete();
}; }
} }
await plugins.smartdelay.delayFor(10000); await plugins.smartdelay.delayFor(10000);
} }
@ -250,7 +248,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
// abstract implemented methods // abstract implemented methods
public async fireDistributedTaskRequest( public async fireDistributedTaskRequest(
taskRequestArg: plugins.taskbuffer.distributedCoordination.IDistributedTaskRequest taskRequestArg: plugins.taskbuffer.distributedCoordination.IDistributedTaskRequest,
): Promise<plugins.taskbuffer.distributedCoordination.IDistributedTaskRequestResult> { ): Promise<plugins.taskbuffer.distributedCoordination.IDistributedTaskRequestResult> {
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => { await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
if (!this.ownInstance) { if (!this.ownInstance) {
@ -277,7 +275,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
} }
public async updateDistributedTaskRequest( public async updateDistributedTaskRequest(
infoBasisArg: plugins.taskbuffer.distributedCoordination.IDistributedTaskRequest infoBasisArg: plugins.taskbuffer.distributedCoordination.IDistributedTaskRequest,
): Promise<void> { ): Promise<void> {
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => { await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
const existingInfoBasis = this.ownInstance.data.taskRequests.find((infoBasisItem) => { const existingInfoBasis = this.ownInstance.data.taskRequests.find((infoBasisItem) => {

@ -5,11 +5,29 @@ import { SmartdataDbCursor } from './classes.cursor.js';
import { type IManager, SmartdataCollection } from './classes.collection.js'; import { type IManager, SmartdataCollection } from './classes.collection.js';
import { SmartdataDbWatcher } from './classes.watcher.js'; import { SmartdataDbWatcher } from './classes.watcher.js';
import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js'; import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
/**
* Search options for `.search()`:
* - filter: additional MongoDB query to AND-merge
* - validate: post-fetch validator, return true to keep a doc
*/
export interface SearchOptions<T> {
/**
* Additional MongoDB filter to 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'; export type TDocCreation = 'db' | 'new' | 'mixed';
// Set of searchable fields for each class
const searchableFieldsMap = new Map<string, Set<string>>();
export function globalSvDb() { export function globalSvDb() {
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => { return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
@ -21,16 +39,34 @@ export function globalSvDb() {
}; };
} }
/**
* Options for custom serialization/deserialization of a field.
*/
export interface SvDbOptions {
/** Function to serialize the field value before saving to DB */
serialize?: (value: any) => any;
/** Function to deserialize the field value after reading from DB */
deserialize?: (value: any) => any;
}
/** /**
* saveable - saveable decorator to be used on class properties * saveable - saveable decorator to be used on class properties
*/ */
export function svDb() { export function svDb(options?: SvDbOptions) {
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => { return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
console.log(`called svDb() on >${target.constructor.name}.${key}<`); console.log(`called svDb() on >${target.constructor.name}.${key}<`);
if (!target.saveableProperties) { if (!target.saveableProperties) {
target.saveableProperties = []; target.saveableProperties = [];
} }
target.saveableProperties.push(key); target.saveableProperties.push(key);
// attach custom serializer/deserializer options to the class constructor
const ctor = target.constructor as any;
if (!ctor._svDbOptions) {
ctor._svDbOptions = {};
}
if (options) {
ctor._svDbOptions[key] = options;
}
}; };
} }
@ -39,27 +75,18 @@ export function svDb() {
*/ */
export function searchable() { export function searchable() {
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => { return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
console.log(`called searchable() on >${target.constructor.name}.${key}<`); // Attach to class constructor for direct access
const ctor = target.constructor as any;
// Initialize the set for this class if it doesn't exist if (!Array.isArray(ctor.searchableFields)) {
const className = target.constructor.name; ctor.searchableFields = [];
if (!searchableFieldsMap.has(className)) {
searchableFieldsMap.set(className, new Set<string>());
} }
ctor.searchableFields.push(key);
// Add the property to the searchable fields set
searchableFieldsMap.get(className).add(key);
}; };
} }
/** // Escape user input for safe use in MongoDB regular expressions
* Get searchable fields for a class function escapeForRegex(input: string): string {
*/ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export function getSearchableFields(className: string): string[] {
if (!searchableFieldsMap.has(className)) {
return [];
}
return Array.from(searchableFieldsMap.get(className));
} }
/** /**
@ -83,6 +110,46 @@ export function unI() {
}; };
} }
/**
* Options for MongoDB indexes
*/
export interface IIndexOptions {
background?: boolean;
unique?: boolean;
sparse?: boolean;
expireAfterSeconds?: number;
[key: string]: any;
}
/**
* 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 = [];
}
// Add this field to regularIndexes with its options
target.regularIndexes.push({
field: key,
options: options || {}
});
// Also ensure it's marked as saveable
if (!target.saveableProperties) {
target.saveableProperties = [];
}
if (!target.saveableProperties.includes(key)) {
target.saveableProperties.push(key);
}
};
}
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => { export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
// Special case: detect MongoDB operators and pass them through directly // Special case: detect MongoDB operators and pass them through directly
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$where', '$regex']; const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$where', '$regex'];
@ -135,12 +202,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
// STATIC // STATIC
public static createInstanceFromMongoDbNativeDoc<T>( public static createInstanceFromMongoDbNativeDoc<T>(
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
mongoDbNativeDocArg: any mongoDbNativeDocArg: any,
): T { ): T {
const newInstance = new this(); const newInstance = new this();
(newInstance as any).creationStatus = 'db'; (newInstance as any).creationStatus = 'db';
for (const key of Object.keys(mongoDbNativeDocArg)) { for (const key of Object.keys(mongoDbNativeDocArg)) {
newInstance[key] = mongoDbNativeDocArg[key]; const rawValue = mongoDbNativeDocArg[key];
const optionsMap = (this as any)._svDbOptions || {};
const opts = optionsMap[key];
newInstance[key] = opts && typeof opts.deserialize === 'function'
? opts.deserialize(rawValue)
: rawValue;
} }
return newInstance; return newInstance;
} }
@ -153,9 +225,14 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
*/ */
public static async getInstances<T>( public static async getInstances<T>(
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T> filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<T[]> { ): Promise<T[]> {
const foundDocs = await (this as any).collection.findAll(convertFilterForMongoDb(filterArg)); // Pass session through to findAll for transactional queries
const foundDocs = await (this as any).collection.findAll(
convertFilterForMongoDb(filterArg),
{ session: opts?.session },
);
const returnArray = []; const returnArray = [];
for (const foundDoc of foundDocs) { for (const foundDoc of foundDocs) {
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc); const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
@ -172,9 +249,14 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
*/ */
public static async getInstance<T>( public static async getInstance<T>(
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T> filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<T> { ): Promise<T> {
const foundDoc = await (this as any).collection.findOne(convertFilterForMongoDb(filterArg)); // Retrieve one document, with optional session for transactions
const foundDoc = await (this as any).collection.findOne(
convertFilterForMongoDb(filterArg),
{ session: opts?.session },
);
if (foundDoc) { if (foundDoc) {
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc); const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
return newInstance; return newInstance;
@ -186,24 +268,35 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
/** /**
* get a unique id prefixed with the class name * get a unique id prefixed with the class name
*/ */
public static async getNewId<T = any>(this: plugins.tsclass.typeFest.Class<T>, lengthArg: number = 20) { public static async getNewId<T = any>(
this: plugins.tsclass.typeFest.Class<T>,
lengthArg: number = 20,
) {
return `${(this as any).className}:${plugins.smartunique.shortId(lengthArg)}`; return `${(this as any).className}:${plugins.smartunique.shortId(lengthArg)}`;
} }
/** /**
* get cursor * Get a cursor for streaming results, with optional session and native cursor modifiers.
* @returns * @param filterArg Partial filter to apply
* @param opts Optional session and modifier for the raw MongoDB cursor
*/ */
public static async getCursor<T>( public static async getCursor<T>(
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T> filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
) { opts?: {
session?: plugins.mongodb.ClientSession;
modifier?: (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>;
}
): Promise<SmartdataDbCursor<T>> {
const collection: SmartdataCollection<T> = (this as any).collection; const collection: SmartdataCollection<T> = (this as any).collection;
const cursor: SmartdataDbCursor<T> = await collection.getCursor( const { session, modifier } = opts || {};
convertFilterForMongoDb(filterArg), await collection.init();
this as any as typeof SmartDataDbDoc let rawCursor: plugins.mongodb.FindCursor<any> =
); collection.mongoDbCollection.find(convertFilterForMongoDb(filterArg), { session });
return cursor; if (modifier) {
rawCursor = modifier(rawCursor);
}
return new SmartdataDbCursor<T>(rawCursor, this as any as typeof SmartDataDbDoc);
} }
/** /**
@ -212,14 +305,21 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
* @param filterArg * @param filterArg
* @param forEachFunction * @param forEachFunction
*/ */
/**
* Watch the collection for changes, with optional buffering and change stream options.
* @param filterArg MongoDB filter to select which changes to observe
* @param opts optional ChangeStreamOptions plus bufferTimeMs
*/
public static async watch<T>( public static async watch<T>(
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T> filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
) { opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number },
): Promise<SmartdataDbWatcher<T>> {
const collection: SmartdataCollection<T> = (this as any).collection; const collection: SmartdataCollection<T> = (this as any).collection;
const watcher: SmartdataDbWatcher<T> = await collection.watch( const watcher: SmartdataDbWatcher<T> = await collection.watch(
convertFilterForMongoDb(filterArg), convertFilterForMongoDb(filterArg),
this as any opts || {},
this as any,
); );
return watcher; return watcher;
} }
@ -231,7 +331,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
public static async forEach<T>( public static async forEach<T>(
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>, filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
forEachFunction: (itemArg: T) => Promise<any> forEachFunction: (itemArg: T) => Promise<any>,
) { ) {
const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg); const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg);
await cursor.forEach(forEachFunction); await cursor.forEach(forEachFunction);
@ -242,7 +342,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
*/ */
public static async getCount<T>( public static async getCount<T>(
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T> = ({} as any) filterArg: plugins.tsclass.typeFest.PartialDeep<T> = {} as any,
) { ) {
const collection: SmartdataCollection<T> = (this as any).collection; const collection: SmartdataCollection<T> = (this as any).collection;
return await collection.getCount(filterArg); return await collection.getCount(filterArg);
@ -255,117 +355,188 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
*/ */
public static createSearchFilter<T>( public static createSearchFilter<T>(
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
luceneQuery: string luceneQuery: string,
): any { ): any {
const className = (this as any).className || this.name; const searchableFields = (this as any).getSearchableFields();
const searchableFields = getSearchableFields(className);
if (searchableFields.length === 0) { if (searchableFields.length === 0) {
throw new Error(`No searchable fields defined for class ${className}`); throw new Error(`No searchable fields defined for class ${this.name}`);
} }
const adapter = new SmartdataLuceneAdapter(searchableFields); const adapter = new SmartdataLuceneAdapter(searchableFields);
return adapter.convert(luceneQuery); return adapter.convert(luceneQuery);
} }
/**
* List all searchable fields defined on this class
*/
public static getSearchableFields(): string[] {
const ctor = this as any;
return Array.isArray(ctor.searchableFields) ? ctor.searchableFields : [];
}
/**
* Execute a query with optional hard filter and post-fetch validation
*/
private static async execQuery<T>(
this: plugins.tsclass.typeFest.Class<T>,
baseFilter: Record<string, any>,
opts?: SearchOptions<T>
): Promise<T[]> {
let mongoFilter = baseFilter || {};
if (opts?.filter) {
mongoFilter = { $and: [mongoFilter, opts.filter] };
}
// Fetch with optional session for transactions
// Fetch within optional session
let docs: T[] = await (this as any).getInstances(mongoFilter, { session: opts?.session });
if (opts?.validate) {
const out: T[] = [];
for (const d of docs) {
if (await opts.validate(d)) out.push(d);
}
docs = out;
}
return docs;
}
/** /**
* Search documents using Lucene query syntax * Search documents by text or field:value syntax, with safe regex fallback
* @param luceneQuery Lucene query string * Supports additional filtering and post-fetch validation via opts
* @param query A search term or field:value expression
* @param opts Optional filter and validate hooks
* @returns Array of matching documents * @returns Array of matching documents
*/ */
public static async search<T>( public static async search<T>(
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
luceneQuery: string query: string,
opts?: SearchOptions<T>,
): Promise<T[]> { ): Promise<T[]> {
const filter = (this as any).createSearchFilter(luceneQuery); const searchableFields = (this as any).getSearchableFields();
return await (this as any).getInstances(filter); if (searchableFields.length === 0) {
} throw new Error(`No searchable fields defined for class ${this.name}`);
/**
* Search documents using Lucene query syntax with robust error handling
* @param luceneQuery The Lucene query string to search with
* @returns Array of matching documents
*/
public static async searchWithLucene<T>(
this: plugins.tsclass.typeFest.Class<T>,
luceneQuery: string
): Promise<T[]> {
try {
const className = (this as any).className || this.name;
const searchableFields = getSearchableFields(className);
if (searchableFields.length === 0) {
console.warn(`No searchable fields defined for class ${className}, falling back to simple search`);
return (this as any).searchByTextAcrossFields(luceneQuery);
}
// Simple term search optimization
if (!luceneQuery.includes(':') &&
!luceneQuery.includes(' AND ') &&
!luceneQuery.includes(' OR ') &&
!luceneQuery.includes(' NOT ')) {
return (this as any).searchByTextAcrossFields(luceneQuery);
}
// Try to use the Lucene-to-MongoDB conversion
const filter = (this as any).createSearchFilter(luceneQuery);
return await (this as any).getInstances(filter);
} catch (error) {
console.error(`Error in searchWithLucene: ${error.message}`);
return (this as any).searchByTextAcrossFields(luceneQuery);
} }
} // empty query -> return all
const q = query.trim();
/** if (!q) {
* Search by text across all searchable fields (fallback method) // empty query: fetch all, apply opts
* @param searchText The text to search for in all searchable fields return await (this as any).execQuery({}, opts);
* @returns Array of matching documents }
*/ // simple exact field:value (no spaces, no wildcards, no quotes)
private static async searchByTextAcrossFields<T>( // simple exact field:value (no spaces, wildcards, quotes)
this: plugins.tsclass.typeFest.Class<T>, const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
searchText: string if (simpleExact) {
): Promise<T[]> { const field = simpleExact[1];
try { const value = simpleExact[2];
const className = (this as any).className || this.name; if (!searchableFields.includes(field)) {
const searchableFields = getSearchableFields(className); throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
// Fallback to direct filter if we have searchable fields
if (searchableFields.length > 0) {
// Create a simple $or query with regex for each field
const orConditions = searchableFields.map(field => ({
[field]: { $regex: searchText, $options: 'i' }
}));
const filter = { $or: orConditions };
try {
// Try with MongoDB filter first
return await (this as any).getInstances(filter);
} catch (error) {
console.warn('MongoDB filter failed, falling back to in-memory search');
}
} }
// simple field:value search
// Last resort: get all and filter in memory return await (this as any).execQuery({ [field]: value }, opts);
const allDocs = await (this as any).getInstances({}); }
const lowerSearchText = searchText.toLowerCase(); // quoted phrase across all searchable fields: exact match of phrase
const quoted = q.match(/^"(.+)"$|^'(.+)'$/);
return allDocs.filter((doc: any) => { if (quoted) {
for (const field of searchableFields) { const phrase = quoted[1] || quoted[2] || '';
const value = doc[field]; const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
if (value && typeof value === 'string' && const pattern = parts.join('\\s+');
value.toLowerCase().includes(lowerSearchText)) { const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
return true; return await (this as any).execQuery({ $or: orConds }, opts);
}
// wildcard field:value (supports * and ?) -> direct regex on that field
const wildcardField = q.match(/^(\w+):(.+[*?].*)$/);
if (wildcardField) {
const field = wildcardField[1];
// Support quoted wildcard patterns: strip surrounding quotes
let pattern = wildcardField[2];
if ((pattern.startsWith('"') && pattern.endsWith('"')) ||
(pattern.startsWith("'") && pattern.endsWith("'"))) {
pattern = pattern.slice(1, -1);
}
if (!searchableFields.includes(field)) {
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
}
// escape regex special chars except * and ?, then convert wildcards
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
return await (this as any).execQuery({ [field]: { $regex: regexPattern, $options: 'i' } }, opts);
}
// wildcard plain term across all fields (supports * and ?)
if (!q.includes(':') && (q.includes('*') || q.includes('?'))) {
// build wildcard regex pattern: escape all except * and ? then convert
const escaped = q.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
return await (this as any).execQuery({ $or: orConds }, opts);
}
// implicit AND for multiple tokens: free terms, quoted phrases, and field:values
{
// Split query into tokens, preserving quoted substrings
const rawTokens = q.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
// Only apply when more than one token and no boolean operators or grouping
if (
rawTokens.length > 1 &&
!/(\bAND\b|\bOR\b|\bNOT\b|\(|\))/i.test(q) &&
!/\[|\]/.test(q)
) {
const andConds: any[] = [];
for (let token of rawTokens) {
// field:value token
const fv = token.match(/^(\w+):(.+)$/);
if (fv) {
const field = fv[1];
let value = fv[2];
if (!searchableFields.includes(field)) {
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
}
// Strip surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Wildcard search?
if (value.includes('*') || value.includes('?')) {
const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
andConds.push({ [field]: { $regex: pattern, $options: 'i' } });
} else {
andConds.push({ [field]: value });
}
} else if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
// Quoted free phrase across all fields
const phrase = token.slice(1, -1);
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
const pattern = parts.join('\\s+');
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })) });
} else {
// Free term across all fields
const esc = escapeForRegex(token);
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) });
} }
} }
return false; return await (this as any).execQuery({ $and: andConds }, opts);
}); }
} catch (error) {
console.error(`Error in searchByTextAcrossFields: ${error.message}`);
return [];
} }
// detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
if (luceneSyntax.test(q)) {
const filter = (this as any).createSearchFilter(q);
return await (this as any).execQuery(filter, opts);
}
// multi-term unquoted -> AND of regex across fields for each term
const terms = q.split(/\s+/);
if (terms.length > 1) {
const andConds = terms.map((term) => {
const esc = escapeForRegex(term);
const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
return { $or: ors };
});
return await (this as any).execQuery({ $and: andConds }, opts);
}
// single term -> regex across all searchable fields
const esc = escapeForRegex(q);
const orConds = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
return await (this as any).execQuery({ $or: orConds }, opts);
} }
// INSTANCE
// INSTANCE // INSTANCE
/** /**
@ -377,13 +548,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
* updated from db in any case where doc comes from db * updated from db in any case where doc comes from db
*/ */
@globalSvDb() @globalSvDb()
_createdAt: string = (new Date()).toISOString(); _createdAt: string = new Date().toISOString();
/** /**
* will be updated everytime the doc is saved * will be updated everytime the doc is saved
*/ */
@globalSvDb() @globalSvDb()
_updatedAt: string = (new Date()).toISOString(); _updatedAt: string = new Date().toISOString();
/** /**
* an array of saveable properties of ALL doc * an array of saveable properties of ALL doc
@ -395,6 +566,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
*/ */
public uniqueIndexes: string[]; public uniqueIndexes: string[];
/**
* regular indexes with their options
*/
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
/** /**
* an array of saveable properties of a specific doc * an array of saveable properties of a specific doc
*/ */
@ -416,35 +592,52 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
constructor() {} constructor() {}
/** /**
* saves this instance but not any connected items * saves this instance (optionally within a transaction)
* may lead to data inconsistencies, but is faster
*/ */
public async save() { public async save(opts?: { session?: plugins.mongodb.ClientSession }) {
// allow hook before saving
if (typeof (this as any).beforeSave === 'function') {
await (this as any).beforeSave();
}
// tslint:disable-next-line: no-this-assignment // tslint:disable-next-line: no-this-assignment
const self: any = this; const self: any = this;
let dbResult: any; let dbResult: any;
// update timestamp
this._updatedAt = (new Date()).toISOString(); this._updatedAt = new Date().toISOString();
// perform insert or update
switch (this.creationStatus) { switch (this.creationStatus) {
case 'db': case 'db':
dbResult = await this.collection.update(self); dbResult = await this.collection.update(self, { session: opts?.session });
break; break;
case 'new': case 'new':
dbResult = await this.collection.insert(self); dbResult = await this.collection.insert(self, { session: opts?.session });
this.creationStatus = 'db'; this.creationStatus = 'db';
break; break;
default: default:
console.error('neither new nor in db?'); console.error('neither new nor in db?');
} }
// allow hook after saving
if (typeof (this as any).afterSave === 'function') {
await (this as any).afterSave();
}
return dbResult; return dbResult;
} }
/** /**
* deletes a document from the database * deletes a document from the database (optionally within a transaction)
*/ */
public async delete() { public async delete(opts?: { session?: plugins.mongodb.ClientSession }) {
await this.collection.delete(this); // allow hook before deleting
if (typeof (this as any).beforeDelete === 'function') {
await (this as any).beforeDelete();
}
// perform deletion
const result = await this.collection.delete(this, { session: opts?.session });
// allow hook after delete
if (typeof (this as any).afterDelete === 'function') {
await (this as any).afterDelete();
}
return result;
} }
/** /**
@ -471,7 +664,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
public async updateFromDb() { public async updateFromDb() {
const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject()); const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject());
for (const key of Object.keys(mongoDbNativeDoc)) { for (const key of Object.keys(mongoDbNativeDoc)) {
this[key] = mongoDbNativeDoc[key]; 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;
} }
} }
@ -480,12 +678,15 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
*/ */
public async createSavableObject(): Promise<TImplements> { public async createSavableObject(): Promise<TImplements> {
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
const saveableProperties = [ const saveableProperties = [...this.globalSaveableProperties, ...this.saveableProperties];
...this.globalSaveableProperties, // apply custom serialization if configured
...this.saveableProperties const optionsMap = (this.constructor as any)._svDbOptions || {};
]
for (const propertyNameString of saveableProperties) { for (const propertyNameString of saveableProperties) {
saveableObject[propertyNameString] = this[propertyNameString]; const rawValue = (this as any)[propertyNameString];
const opts = optionsMap[propertyNameString];
(saveableObject as any)[propertyNameString] = opts && typeof opts.serialize === 'function'
? opts.serialize(rawValue)
: rawValue;
} }
return saveableObject as TImplements; return saveableObject as TImplements;
} }

@ -41,7 +41,7 @@ export class EasyStore<T> {
private async getEasyStore(): Promise<InstanceType<typeof this.easyStoreClass>> { private async getEasyStore(): Promise<InstanceType<typeof this.easyStoreClass>> {
if (this.easyStorePromise) { if (this.easyStorePromise) {
return this.easyStorePromise; return this.easyStorePromise;
}; }
// first run from here // first run from here
const deferred = plugins.smartpromise.defer<InstanceType<typeof this.easyStoreClass>>(); const deferred = plugins.smartpromise.defer<InstanceType<typeof this.easyStoreClass>>();

@ -4,7 +4,17 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
// Types // Types
type NodeType = 'TERM' | 'PHRASE' | 'FIELD' | 'AND' | 'OR' | 'NOT' | 'RANGE' | 'WILDCARD' | 'FUZZY' | 'GROUP'; type NodeType =
| 'TERM'
| 'PHRASE'
| 'FIELD'
| 'AND'
| 'OR'
| 'NOT'
| 'RANGE'
| 'WILDCARD'
| 'FUZZY'
| 'GROUP';
interface QueryNode { interface QueryNode {
type: NodeType; type: NodeType;
@ -59,7 +69,15 @@ interface GroupNode extends QueryNode {
value: AnyQueryNode; value: AnyQueryNode;
} }
type AnyQueryNode = TermNode | PhraseNode | FieldNode | BooleanNode | RangeNode | WildcardNode | FuzzyNode | GroupNode; type AnyQueryNode =
| TermNode
| PhraseNode
| FieldNode
| BooleanNode
| RangeNode
| WildcardNode
| FuzzyNode
| GroupNode;
/** /**
* Lucene query parser * Lucene query parser
@ -68,9 +86,9 @@ export class LuceneParser {
private pos: number = 0; private pos: number = 0;
private input: string = ''; private input: string = '';
private tokens: string[] = []; private tokens: string[] = [];
constructor() {} constructor() {}
/** /**
* Parse a Lucene query string into an AST * Parse a Lucene query string into an AST
*/ */
@ -78,24 +96,24 @@ export class LuceneParser {
this.input = query.trim(); this.input = query.trim();
this.pos = 0; this.pos = 0;
this.tokens = this.tokenize(this.input); this.tokens = this.tokenize(this.input);
return this.parseQuery(); return this.parseQuery();
} }
/** /**
* Tokenize the input string into tokens * Tokenize the input string into tokens
*/ */
private tokenize(input: string): string[] { private tokenize(input: string): string[] {
const specialChars = /[()\[\]{}"~^:]/; const specialChars = /[()\[\]{}"~^:]/;
const operators = /AND|OR|NOT|TO/; const operators = /AND|OR|NOT|TO/;
let tokens: string[] = []; let tokens: string[] = [];
let current = ''; let current = '';
let inQuote = false; let inQuote = false;
for (let i = 0; i < input.length; i++) { for (let i = 0; i < input.length; i++) {
const char = input[i]; const char = input[i];
// Handle quoted strings // Handle quoted strings
if (char === '"') { if (char === '"') {
if (inQuote) { if (inQuote) {
@ -109,12 +127,12 @@ export class LuceneParser {
} }
continue; continue;
} }
if (inQuote) { if (inQuote) {
current += char; current += char;
continue; continue;
} }
// Handle whitespace // Handle whitespace
if (char === ' ' || char === '\t' || char === '\n') { if (char === ' ' || char === '\t' || char === '\n') {
if (current) { if (current) {
@ -123,7 +141,7 @@ export class LuceneParser {
} }
continue; continue;
} }
// Handle special characters // Handle special characters
if (specialChars.test(char)) { if (specialChars.test(char)) {
if (current) { if (current) {
@ -133,38 +151,37 @@ export class LuceneParser {
tokens.push(char); tokens.push(char);
continue; continue;
} }
current += char; current += char;
// Check if current is an operator // Check if current is an operator
if (operators.test(current) && if (operators.test(current) && (i + 1 === input.length || /\s/.test(input[i + 1]))) {
(i + 1 === input.length || /\s/.test(input[i + 1]))) {
tokens.push(current); tokens.push(current);
current = ''; current = '';
} }
} }
if (current) tokens.push(current); if (current) tokens.push(current);
return tokens; return tokens;
} }
/** /**
* Parse the main query expression * Parse the main query expression
*/ */
private parseQuery(): AnyQueryNode { private parseQuery(): AnyQueryNode {
const left = this.parseBooleanOperand(); const left = this.parseBooleanOperand();
if (this.pos < this.tokens.length) { if (this.pos < this.tokens.length) {
const token = this.tokens[this.pos]; const token = this.tokens[this.pos];
if (token === 'AND' || token === 'OR') { if (token === 'AND' || token === 'OR') {
this.pos++; this.pos++;
const right = this.parseQuery(); const right = this.parseQuery();
return { return {
type: token as 'AND' | 'OR', type: token as 'AND' | 'OR',
left, left,
right right,
}; };
} else if (token === 'NOT' || token === '-') { } else if (token === 'NOT' || token === '-') {
this.pos++; this.pos++;
@ -172,14 +189,14 @@ export class LuceneParser {
return { return {
type: 'NOT', type: 'NOT',
left, left,
right right,
}; };
} }
} }
return left; return left;
} }
/** /**
* Parse boolean operands (terms, phrases, fields, groups) * Parse boolean operands (terms, phrases, fields, groups)
*/ */
@ -187,14 +204,14 @@ export class LuceneParser {
if (this.pos >= this.tokens.length) { if (this.pos >= this.tokens.length) {
throw new Error('Unexpected end of input'); throw new Error('Unexpected end of input');
} }
const token = this.tokens[this.pos]; const token = this.tokens[this.pos];
// Handle grouping with parentheses // Handle grouping with parentheses
if (token === '(') { if (token === '(') {
this.pos++; this.pos++;
const group = this.parseQuery(); const group = this.parseQuery();
if (this.pos < this.tokens.length && this.tokens[this.pos] === ')') { if (this.pos < this.tokens.length && this.tokens[this.pos] === ')') {
this.pos++; this.pos++;
return { type: 'GROUP', value: group } as GroupNode; return { type: 'GROUP', value: group } as GroupNode;
@ -202,12 +219,12 @@ export class LuceneParser {
throw new Error('Unclosed group'); throw new Error('Unclosed group');
} }
} }
// Handle fields (field:value) // Handle fields (field:value)
if (this.pos + 1 < this.tokens.length && this.tokens[this.pos + 1] === ':') { if (this.pos + 1 < this.tokens.length && this.tokens[this.pos + 1] === ':') {
const field = token; const field = token;
this.pos += 2; // Skip field and colon this.pos += 2; // Skip field and colon
if (this.pos < this.tokens.length) { if (this.pos < this.tokens.length) {
const value = this.parseBooleanOperand(); const value = this.parseBooleanOperand();
return { type: 'FIELD', field, value } as FieldNode; return { type: 'FIELD', field, value } as FieldNode;
@ -215,17 +232,17 @@ export class LuceneParser {
throw new Error('Expected value after field'); throw new Error('Expected value after field');
} }
} }
// Handle range queries // Handle range queries
if (token === '[' || token === '{') { if (token === '[' || token === '{') {
return this.parseRange(); return this.parseRange();
} }
// Handle phrases ("term term") // Handle phrases ("term term")
if (token.startsWith('"') && token.endsWith('"')) { if (token.startsWith('"') && token.endsWith('"')) {
const phrase = token.slice(1, -1); const phrase = token.slice(1, -1);
this.pos++; this.pos++;
// Check for proximity operator // Check for proximity operator
let proximity: number | undefined; let proximity: number | undefined;
if (this.pos < this.tokens.length && this.tokens[this.pos] === '~') { if (this.pos < this.tokens.length && this.tokens[this.pos] === '~') {
@ -237,64 +254,64 @@ export class LuceneParser {
throw new Error('Expected number after proximity operator'); throw new Error('Expected number after proximity operator');
} }
} }
return { type: 'PHRASE', value: phrase, proximity } as PhraseNode; return { type: 'PHRASE', value: phrase, proximity } as PhraseNode;
} }
// Handle wildcards // Handle wildcards
if (token.includes('*') || token.includes('?')) { if (token.includes('*') || token.includes('?')) {
this.pos++; this.pos++;
return { type: 'WILDCARD', value: token } as WildcardNode; return { type: 'WILDCARD', value: token } as WildcardNode;
} }
// Handle fuzzy searches // Handle fuzzy searches
if (this.pos + 1 < this.tokens.length && this.tokens[this.pos + 1] === '~') { if (this.pos + 1 < this.tokens.length && this.tokens[this.pos + 1] === '~') {
const term = token; const term = token;
this.pos += 2; // Skip term and tilde this.pos += 2; // Skip term and tilde
let maxEdits = 2; // Default let maxEdits = 2; // Default
if (this.pos < this.tokens.length && /^\d+$/.test(this.tokens[this.pos])) { if (this.pos < this.tokens.length && /^\d+$/.test(this.tokens[this.pos])) {
maxEdits = parseInt(this.tokens[this.pos], 10); maxEdits = parseInt(this.tokens[this.pos], 10);
this.pos++; this.pos++;
} }
return { type: 'FUZZY', value: term, maxEdits } as FuzzyNode; return { type: 'FUZZY', value: term, maxEdits } as FuzzyNode;
} }
// Simple term // Simple term
this.pos++; this.pos++;
return { type: 'TERM', value: token } as TermNode; return { type: 'TERM', value: token } as TermNode;
} }
/** /**
* Parse range queries * Parse range queries
*/ */
private parseRange(): RangeNode { private parseRange(): RangeNode {
const includeLower = this.tokens[this.pos] === '['; const includeLower = this.tokens[this.pos] === '[';
const includeUpper = this.tokens[this.pos + 4] === ']'; const includeUpper = this.tokens[this.pos + 4] === ']';
this.pos++; // Skip open bracket // Ensure tokens for lower, TO, upper, and closing bracket exist
if (this.pos + 4 >= this.tokens.length) { if (this.pos + 4 >= this.tokens.length) {
throw new Error('Invalid range query syntax'); throw new Error('Invalid range query syntax');
} }
this.pos++; // Skip open bracket
const lower = this.tokens[this.pos]; const lower = this.tokens[this.pos];
this.pos++; this.pos++;
if (this.tokens[this.pos] !== 'TO') { if (this.tokens[this.pos] !== 'TO') {
throw new Error('Expected TO in range query'); throw new Error('Expected TO in range query');
} }
this.pos++; this.pos++;
const upper = this.tokens[this.pos]; const upper = this.tokens[this.pos];
this.pos++; this.pos++;
if (this.tokens[this.pos] !== (includeLower ? ']' : '}')) { if (this.tokens[this.pos] !== (includeLower ? ']' : '}')) {
throw new Error('Invalid range query closing bracket'); throw new Error('Invalid range query closing bracket');
} }
this.pos++; this.pos++;
// For simplicity, assuming the field is handled separately // For simplicity, assuming the field is handled separately
return { return {
type: 'RANGE', type: 'RANGE',
@ -302,7 +319,7 @@ export class LuceneParser {
lower, lower,
upper, upper,
includeLower, includeLower,
includeUpper includeUpper,
}; };
} }
} }
@ -312,8 +329,17 @@ export class LuceneParser {
* FIXED VERSION - proper MongoDB query structure * FIXED VERSION - proper MongoDB query structure
*/ */
export class LuceneToMongoTransformer { export class LuceneToMongoTransformer {
constructor() {} private defaultFields: string[];
constructor(defaultFields: string[] = []) {
this.defaultFields = defaultFields;
}
/**
* Escape special characters for use in RegExp patterns
*/
private escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** /**
* Transform a Lucene AST node to a MongoDB query * Transform a Lucene AST node to a MongoDB query
*/ */
@ -343,44 +369,44 @@ export class LuceneToMongoTransformer {
throw new Error(`Unsupported node type: ${(node as any).type}`); throw new Error(`Unsupported node type: ${(node as any).type}`);
} }
} }
/** /**
* Transform a term to MongoDB query * Transform a term to MongoDB query
* FIXED: properly structured $or query for multiple fields * FIXED: properly structured $or query for multiple fields
*/ */
private transformTerm(node: TermNode, searchFields?: string[]): any { private transformTerm(node: TermNode, searchFields?: string[]): any {
// If specific fields are provided, search across those fields // Build regex pattern, support wildcard (*) and fuzzy (?) if present
if (searchFields && searchFields.length > 0) { const term = node.value;
// Create an $or query to search across multiple fields // Determine regex pattern: wildcard conversion or exact escape
const orConditions = searchFields.map(field => ({ let pattern: string;
[field]: { $regex: node.value, $options: 'i' } if (term.includes('*') || term.includes('?')) {
})); pattern = this.luceneWildcardToRegex(term);
} else {
return { $or: orConditions }; pattern = this.escapeRegex(term);
} }
// Search across provided fields or default fields
// Otherwise, use text search (requires a text index on desired fields) const fields = searchFields && searchFields.length > 0 ? searchFields : this.defaultFields;
return { $text: { $search: node.value } }; const orConditions = fields.map((field) => ({
[field]: { $regex: pattern, $options: 'i' },
}));
return { $or: orConditions };
} }
/** /**
* Transform a phrase to MongoDB query * Transform a phrase to MongoDB query
* FIXED: properly structured $or query for multiple fields * FIXED: properly structured $or query for multiple fields
*/ */
private transformPhrase(node: PhraseNode, searchFields?: string[]): any { private transformPhrase(node: PhraseNode, searchFields?: string[]): any {
// If specific fields are provided, search phrase across those fields // Use regex across provided fields or default fields, respecting word boundaries
if (searchFields && searchFields.length > 0) { const parts = node.value.split(/\s+/).map((t) => this.escapeRegex(t));
const orConditions = searchFields.map(field => ({ const pattern = parts.join('\\s+');
[field]: { $regex: `${node.value.replace(/\s+/g, '\\s+')}`, $options: 'i' } const fields = searchFields && searchFields.length > 0 ? searchFields : this.defaultFields;
})); const orConditions = fields.map((field) => ({
[field]: { $regex: pattern, $options: 'i' },
return { $or: orConditions }; }));
} return { $or: orConditions };
// For phrases, we use a regex to ensure exact matches
return { $text: { $search: `"${node.value}"` } };
} }
/** /**
* Transform a field query to MongoDB query * Transform a field query to MongoDB query
*/ */
@ -391,50 +417,55 @@ export class LuceneToMongoTransformer {
rangeNode.field = node.field; rangeNode.field = node.field;
return this.transformRange(rangeNode); return this.transformRange(rangeNode);
} }
// Handle special case for wildcards on fields // Handle special case for wildcards on fields
if (node.value.type === 'WILDCARD') { if (node.value.type === 'WILDCARD') {
return { return {
[node.field]: { [node.field]: {
$regex: this.luceneWildcardToRegex((node.value as WildcardNode).value), $regex: this.luceneWildcardToRegex((node.value as WildcardNode).value),
$options: 'i' $options: 'i',
} },
}; };
} }
// Handle special case for fuzzy searches on fields // Handle special case for fuzzy searches on fields
if (node.value.type === 'FUZZY') { if (node.value.type === 'FUZZY') {
return { return {
[node.field]: { [node.field]: {
$regex: this.createFuzzyRegex((node.value as FuzzyNode).value), $regex: this.createFuzzyRegex((node.value as FuzzyNode).value),
$options: 'i' $options: 'i',
} },
}; };
} }
// Special case for exact term matches on fields // Special case for exact term matches on fields (supporting wildcard characters)
if (node.value.type === 'TERM') { if (node.value.type === 'TERM') {
return { [node.field]: { $regex: (node.value as TermNode).value, $options: 'i' } }; const val = (node.value as TermNode).value;
if (val.includes('*') || val.includes('?')) {
const regex = this.luceneWildcardToRegex(val);
return { [node.field]: { $regex: regex, $options: 'i' } };
}
return { [node.field]: { $regex: val, $options: 'i' } };
} }
// Special case for phrase matches on fields // Special case for phrase matches on fields
if (node.value.type === 'PHRASE') { if (node.value.type === 'PHRASE') {
return { return {
[node.field]: { [node.field]: {
$regex: `${(node.value as PhraseNode).value.replace(/\s+/g, '\\s+')}`, $regex: `${(node.value as PhraseNode).value.replace(/\s+/g, '\\s+')}`,
$options: 'i' $options: 'i',
} },
}; };
} }
// For other cases, we'll transform the value and apply it to the field // For other cases, we'll transform the value and apply it to the field
const transformedValue = this.transform(node.value); const transformedValue = this.transform(node.value);
// If the transformed value uses $text, we need to adapt it for the field // If the transformed value uses $text, we need to adapt it for the field
if (transformedValue.$text) { if (transformedValue.$text) {
return { [node.field]: { $regex: transformedValue.$text.$search, $options: 'i' } }; return { [node.field]: { $regex: transformedValue.$text.$search, $options: 'i' } };
} }
// Handle $or and $and cases // Handle $or and $and cases
if (transformedValue.$or || transformedValue.$and) { if (transformedValue.$or || transformedValue.$and) {
// This is a bit complex - we need to restructure the query to apply the field // This is a bit complex - we need to restructure the query to apply the field
@ -444,10 +475,10 @@ export class LuceneToMongoTransformer {
return { [node.field]: { $regex: term, $options: 'i' } }; return { [node.field]: { $regex: term, $options: 'i' } };
} }
} }
return { [node.field]: transformedValue }; return { [node.field]: transformedValue };
} }
/** /**
* Extract a term from a boolean query (simplification) * Extract a term from a boolean query (simplification)
*/ */
@ -460,7 +491,7 @@ export class LuceneToMongoTransformer {
} }
} }
} }
if (query.$and && Array.isArray(query.$and) && query.$and.length > 0) { if (query.$and && Array.isArray(query.$and) && query.$and.length > 0) {
const firstClause = query.$and[0]; const firstClause = query.$and[0];
for (const field in firstClause) { for (const field in firstClause) {
@ -469,10 +500,10 @@ export class LuceneToMongoTransformer {
} }
} }
} }
return null; return null;
} }
/** /**
* Transform AND operator to MongoDB query * Transform AND operator to MongoDB query
* FIXED: $and must be an array * FIXED: $and must be an array
@ -480,7 +511,7 @@ export class LuceneToMongoTransformer {
private transformAnd(node: BooleanNode): any { private transformAnd(node: BooleanNode): any {
return { $and: [this.transform(node.left), this.transform(node.right)] }; return { $and: [this.transform(node.left), this.transform(node.right)] };
} }
/** /**
* Transform OR operator to MongoDB query * Transform OR operator to MongoDB query
* FIXED: $or must be an array * FIXED: $or must be an array
@ -488,7 +519,7 @@ export class LuceneToMongoTransformer {
private transformOr(node: BooleanNode): any { private transformOr(node: BooleanNode): any {
return { $or: [this.transform(node.left), this.transform(node.right)] }; return { $or: [this.transform(node.left), this.transform(node.right)] };
} }
/** /**
* Transform NOT operator to MongoDB query * Transform NOT operator to MongoDB query
* FIXED: $and must be an array and $not usage * FIXED: $and must be an array and $not usage
@ -496,43 +527,40 @@ export class LuceneToMongoTransformer {
private transformNot(node: BooleanNode): any { private transformNot(node: BooleanNode): any {
const leftQuery = this.transform(node.left); const leftQuery = this.transform(node.left);
const rightQuery = this.transform(node.right); const rightQuery = this.transform(node.right);
// Create a query that includes left but excludes right // Create a query that includes left but excludes right
if (rightQuery.$text) { if (rightQuery.$text) {
// For text searches, we need a different approach // For text searches, we need a different approach
// We'll use a negated regex instead // We'll use a negated regex instead
const searchTerm = rightQuery.$text.$search.replace(/"/g, ''); const searchTerm = rightQuery.$text.$search.replace(/"/g, '');
// Determine the fields to apply the negation to // Determine the fields to apply the negation to
const notConditions = []; const notConditions = [];
for (const field in leftQuery) { for (const field in leftQuery) {
if (field !== '$or' && field !== '$and') { if (field !== '$or' && field !== '$and') {
notConditions.push({ notConditions.push({
[field]: { $not: { $regex: searchTerm, $options: 'i' } } [field]: { $not: { $regex: searchTerm, $options: 'i' } },
}); });
} }
} }
// If left query has $or or $and, we need to handle it differently // If left query has $or or $and, we need to handle it differently
if (leftQuery.$or) { if (leftQuery.$or) {
return { return {
$and: [ $and: [leftQuery, { $nor: [{ $or: notConditions }] }],
leftQuery,
{ $nor: [{ $or: notConditions }] }
]
}; };
} else { } else {
// Simple case - just add $not to each field // Simple case - just add $not to each field
return { return {
$and: [leftQuery, { $and: notConditions }] $and: [leftQuery, { $and: notConditions }],
}; };
} }
} else { } else {
// For other queries, we can use $not directly // For other queries, we can use $not directly
// We need to handle different structures based on the rightQuery // We need to handle different structures based on the rightQuery
let notQuery = {}; let notQuery = {};
if (rightQuery.$or) { if (rightQuery.$or) {
notQuery = { $nor: rightQuery.$or }; notQuery = { $nor: rightQuery.$or };
} else if (rightQuery.$and) { } else if (rightQuery.$and) {
@ -544,28 +572,28 @@ export class LuceneToMongoTransformer {
notQuery[field] = { $not: rightQuery[field] }; notQuery[field] = { $not: rightQuery[field] };
} }
} }
return { $and: [leftQuery, notQuery] }; return { $and: [leftQuery, notQuery] };
} }
} }
/** /**
* Transform range query to MongoDB query * Transform range query to MongoDB query
*/ */
private transformRange(node: RangeNode): any { private transformRange(node: RangeNode): any {
const range: any = {}; const range: any = {};
if (node.lower !== '*') { if (node.lower !== '*') {
range[node.includeLower ? '$gte' : '$gt'] = this.parseValue(node.lower); range[node.includeLower ? '$gte' : '$gt'] = this.parseValue(node.lower);
} }
if (node.upper !== '*') { if (node.upper !== '*') {
range[node.includeUpper ? '$lte' : '$lt'] = this.parseValue(node.upper); range[node.includeUpper ? '$lte' : '$lt'] = this.parseValue(node.upper);
} }
return { [node.field]: range }; return { [node.field]: range };
} }
/** /**
* Transform wildcard query to MongoDB query * Transform wildcard query to MongoDB query
* FIXED: properly structured for multiple fields * FIXED: properly structured for multiple fields
@ -573,20 +601,20 @@ export class LuceneToMongoTransformer {
private transformWildcard(node: WildcardNode, searchFields?: string[]): any { private transformWildcard(node: WildcardNode, searchFields?: string[]): any {
// Convert Lucene wildcards to MongoDB regex // Convert Lucene wildcards to MongoDB regex
const regex = this.luceneWildcardToRegex(node.value); const regex = this.luceneWildcardToRegex(node.value);
// If specific fields are provided, search wildcard across those fields // If specific fields are provided, search wildcard across those fields
if (searchFields && searchFields.length > 0) { if (searchFields && searchFields.length > 0) {
const orConditions = searchFields.map(field => ({ const orConditions = searchFields.map((field) => ({
[field]: { $regex: regex, $options: 'i' } [field]: { $regex: regex, $options: 'i' },
})); }));
return { $or: orConditions }; return { $or: orConditions };
} }
// By default, apply to the default field // By default, apply to the default field
return { $regex: regex, $options: 'i' }; return { $regex: regex, $options: 'i' };
} }
/** /**
* Transform fuzzy query to MongoDB query * Transform fuzzy query to MongoDB query
* FIXED: properly structured for multiple fields * FIXED: properly structured for multiple fields
@ -595,24 +623,24 @@ export class LuceneToMongoTransformer {
// MongoDB doesn't have built-in fuzzy search // MongoDB doesn't have built-in fuzzy search
// This is a very basic approach using regex // This is a very basic approach using regex
const regex = this.createFuzzyRegex(node.value); const regex = this.createFuzzyRegex(node.value);
// If specific fields are provided, search fuzzy term across those fields // If specific fields are provided, search fuzzy term across those fields
if (searchFields && searchFields.length > 0) { if (searchFields && searchFields.length > 0) {
const orConditions = searchFields.map(field => ({ const orConditions = searchFields.map((field) => ({
[field]: { $regex: regex, $options: 'i' } [field]: { $regex: regex, $options: 'i' },
})); }));
return { $or: orConditions }; return { $or: orConditions };
} }
// By default, apply to the default field // By default, apply to the default field
return { $regex: regex, $options: 'i' }; return { $regex: regex, $options: 'i' };
} }
/** /**
* Convert Lucene wildcards to MongoDB regex patterns * Convert Lucene wildcards to MongoDB regex patterns
*/ */
private luceneWildcardToRegex(wildcardPattern: string): string { public luceneWildcardToRegex(wildcardPattern: string): string {
// Replace Lucene wildcards with regex equivalents // Replace Lucene wildcards with regex equivalents
// * => .* // * => .*
// ? => . // ? => .
@ -622,7 +650,7 @@ export class LuceneToMongoTransformer {
.replace(/\*/g, '.*') .replace(/\*/g, '.*')
.replace(/\?/g, '.'); .replace(/\?/g, '.');
} }
/** /**
* Create a simplified fuzzy search regex * Create a simplified fuzzy search regex
*/ */
@ -639,7 +667,7 @@ export class LuceneToMongoTransformer {
} }
return regex; return regex;
} }
/** /**
* Parse string values to appropriate types (numbers, dates, etc.) * Parse string values to appropriate types (numbers, dates, etc.)
*/ */
@ -648,17 +676,17 @@ export class LuceneToMongoTransformer {
if (/^-?\d+$/.test(value)) { if (/^-?\d+$/.test(value)) {
return parseInt(value, 10); return parseInt(value, 10);
} }
if (/^-?\d+\.\d+$/.test(value)) { if (/^-?\d+\.\d+$/.test(value)) {
return parseFloat(value); return parseFloat(value);
} }
// Try to parse as date (simplified) // Try to parse as date (simplified)
const date = new Date(value); const date = new Date(value);
if (!isNaN(date.getTime())) { if (!isNaN(date.getTime())) {
return date; return date;
} }
// Default to string // Default to string
return value; return value;
} }
@ -671,18 +699,19 @@ export class SmartdataLuceneAdapter {
private parser: LuceneParser; private parser: LuceneParser;
private transformer: LuceneToMongoTransformer; private transformer: LuceneToMongoTransformer;
private defaultSearchFields: string[] = []; private defaultSearchFields: string[] = [];
/** /**
* @param defaultSearchFields - Optional array of field names to search across when no field is specified * @param defaultSearchFields - Optional array of field names to search across when no field is specified
*/ */
constructor(defaultSearchFields?: string[]) { constructor(defaultSearchFields?: string[]) {
this.parser = new LuceneParser(); this.parser = new LuceneParser();
this.transformer = new LuceneToMongoTransformer(); // Pass default searchable fields into transformer
this.transformer = new LuceneToMongoTransformer(defaultSearchFields || []);
if (defaultSearchFields) { if (defaultSearchFields) {
this.defaultSearchFields = defaultSearchFields; this.defaultSearchFields = defaultSearchFields;
} }
} }
/** /**
* Convert a Lucene query string to a MongoDB query object * Convert a Lucene query string to a MongoDB query object
* @param luceneQuery - The Lucene query string to convert * @param luceneQuery - The Lucene query string to convert
@ -690,33 +719,38 @@ export class SmartdataLuceneAdapter {
*/ */
convert(luceneQuery: string, searchFields?: string[]): any { convert(luceneQuery: string, searchFields?: string[]): any {
try { try {
// For simple single term queries, create a simpler query structure // For simple single-term queries (no field:, boolean, grouping), use simpler regex
if (!luceneQuery.includes(':') && if (
!luceneQuery.includes(' AND ') && !luceneQuery.includes(':') &&
!luceneQuery.includes(' OR ') && !luceneQuery.includes(' AND ') &&
!luceneQuery.includes(' NOT ') && !luceneQuery.includes(' OR ') &&
!luceneQuery.includes('(') && !luceneQuery.includes(' NOT ') &&
!luceneQuery.includes('[')) { !luceneQuery.includes('(') &&
!luceneQuery.includes('[')
// This is a simple term, use a more direct approach ) {
const fieldsToSearch = searchFields || this.defaultSearchFields; const fieldsToSearch = searchFields || this.defaultSearchFields;
if (fieldsToSearch && fieldsToSearch.length > 0) { if (fieldsToSearch && fieldsToSearch.length > 0) {
// Handle wildcard characters in query
let pattern = luceneQuery;
if (luceneQuery.includes('*') || luceneQuery.includes('?')) {
// Use transformer to convert wildcard pattern
pattern = this.transformer.luceneWildcardToRegex(luceneQuery);
}
return { return {
$or: fieldsToSearch.map(field => ({ $or: fieldsToSearch.map((field) => ({
[field]: { $regex: luceneQuery, $options: 'i' } [field]: { $regex: pattern, $options: 'i' },
})) })),
}; };
} }
} }
// For more complex queries, use the full parser // For more complex queries, use the full parser
// Parse the Lucene query into an AST // Parse the Lucene query into an AST
const ast = this.parser.parse(luceneQuery); const ast = this.parser.parse(luceneQuery);
// Use provided searchFields, fall back to defaultSearchFields // Use provided searchFields, fall back to defaultSearchFields
const fieldsToSearch = searchFields || this.defaultSearchFields; const fieldsToSearch = searchFields || this.defaultSearchFields;
// Transform the AST to a MongoDB query // Transform the AST to a MongoDB query
return this.transformWithFields(ast, fieldsToSearch); return this.transformWithFields(ast, fieldsToSearch);
} catch (error) { } catch (error) {
@ -724,18 +758,22 @@ export class SmartdataLuceneAdapter {
throw new Error(`Failed to convert Lucene query: ${error}`); throw new Error(`Failed to convert Lucene query: ${error}`);
} }
} }
/** /**
* Helper method to transform the AST with field information * Helper method to transform the AST with field information
*/ */
private transformWithFields(node: AnyQueryNode, searchFields: string[]): any { private transformWithFields(node: AnyQueryNode, searchFields: string[]): any {
// Special case for term nodes without a specific field // Special case for term nodes without a specific field
if (node.type === 'TERM' || node.type === 'PHRASE' || if (
node.type === 'WILDCARD' || node.type === 'FUZZY') { node.type === 'TERM' ||
node.type === 'PHRASE' ||
node.type === 'WILDCARD' ||
node.type === 'FUZZY'
) {
return this.transformer.transform(node, searchFields); return this.transformer.transform(node, searchFields);
} }
// For other node types, use the standard transformation // For other node types, use the standard transformation
return this.transformer.transform(node); return this.transformer.transform(node);
} }
} }

@ -1,37 +1,73 @@
import { SmartDataDbDoc } from './classes.doc.js'; import { SmartDataDbDoc } from './classes.doc.js';
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { EventEmitter } from 'events';
/** /**
* a wrapper for the native mongodb cursor. Exposes better * a wrapper for the native mongodb cursor. Exposes better
*/ */
export class SmartdataDbWatcher<T = any> { /**
* Wraps a MongoDB ChangeStream with RxJS and EventEmitter support.
*/
export class SmartdataDbWatcher<T = any> extends EventEmitter {
// STATIC // STATIC
public readyDeferred = plugins.smartpromise.defer(); public readyDeferred = plugins.smartpromise.defer();
// INSTANCE // INSTANCE
private changeStream: plugins.mongodb.ChangeStream<T>; private changeStream: plugins.mongodb.ChangeStream<T>;
private rawSubject: plugins.smartrx.rxjs.Subject<T>;
public changeSubject = new plugins.smartrx.rxjs.Subject<T>(); /** Emits change documents (or arrays of documents if buffered) */
public changeSubject: any;
/**
* @param changeStreamArg native MongoDB ChangeStream
* @param smartdataDbDocArg document class for instance creation
* @param opts.bufferTimeMs optional milliseconds to buffer events via RxJS
*/
constructor( constructor(
changeStreamArg: plugins.mongodb.ChangeStream<T>, changeStreamArg: plugins.mongodb.ChangeStream<T>,
smartdataDbDocArg: typeof SmartDataDbDoc smartdataDbDocArg: typeof SmartDataDbDoc,
opts?: { bufferTimeMs?: number },
) { ) {
super();
this.rawSubject = new plugins.smartrx.rxjs.Subject<T>();
// Apply buffering if requested
if (opts && opts.bufferTimeMs) {
this.changeSubject = this.rawSubject.pipe(plugins.smartrx.rxjs.ops.bufferTime(opts.bufferTimeMs));
} else {
this.changeSubject = this.rawSubject;
}
this.changeStream = changeStreamArg; this.changeStream = changeStreamArg;
this.changeStream.on('change', async (item: any) => { this.changeStream.on('change', async (item: any) => {
if (!item.fullDocument) { let docInstance: T = null;
this.changeSubject.next(null); if (item.fullDocument) {
return; docInstance = smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(
item.fullDocument
) as any as T;
} }
this.changeSubject.next( // Notify subscribers
smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(item.fullDocument) as any as T this.rawSubject.next(docInstance);
); this.emit('change', docInstance);
}); });
// Signal readiness after one tick
plugins.smartdelay.delayFor(0).then(() => { plugins.smartdelay.delayFor(0).then(() => {
this.readyDeferred.resolve(); this.readyDeferred.resolve();
}); });
} }
public async close() { /**
* Close the change stream, complete the RxJS subject, and remove listeners.
*/
public async close(): Promise<void> {
// Close MongoDB ChangeStream
await this.changeStream.close(); await this.changeStream.close();
// Complete the subject to teardown any buffering operators
this.rawSubject.complete();
// Remove all EventEmitter listeners
this.removeAllListeners();
}
/**
* Alias for close(), matching README usage
*/
public async stop(): Promise<void> {
return this.close();
} }
} }

@ -11,4 +11,4 @@ export { convenience };
// to be removed with the next breaking update // to be removed with the next breaking update
import type * as plugins from './plugins.js'; import type * as plugins from './plugins.js';
type IMongoDescriptor = plugins.tsclass.database.IMongoDescriptor; type IMongoDescriptor = plugins.tsclass.database.IMongoDescriptor;
export type { IMongoDescriptor }; export type { IMongoDescriptor };

@ -6,9 +6,11 @@
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
}, },
"exclude": [ "exclude": [
"dist_*/**/*.d.ts" "dist_*/**/*.d.ts"
] ]
} }