Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27c1500db5 | |||
| 3bbb78add8 | |||
| 9d779329e1 | |||
| cdc6b029af | |||
| 39c0ba7bea | |||
| e4faca88ba | |||
| 40bc408d8f | |||
| 3c8308561e | |||
| 49b121aa5b | |||
| 514d3dbd29 | |||
| 2b7316dc46 | |||
| 11a1345891 | |||
| 2fe3a72eaf | |||
| fb7e82557b | |||
| 8a3425e554 | |||
| d2092cc5f3 | |||
| 1a621ca64e | |||
| f6cc07880a | |||
| bf4b11f1f5 | |||
| 181e9da151 | |||
| 3013edb2eb | |||
| 604e4ba265 | |||
| 477f446c34 | |||
| fbb8bb685c | |||
| 4cf62fd91c | |||
| 8ee45c5646 | |||
| 12f1630adf | |||
| 0a349180b2 | |||
| 23aa29a5b8 | |||
| 5bf2aae2b9 | |||
| 5cf9155205 | |||
| ef5491075f | |||
| 3f5101c061 | |||
| 4f1d359752 | |||
| aead721a58 | |||
| c3a8a15225 | |||
| 026f2acc89 | |||
| 1cd0f09598 | |||
| d254f58a05 | |||
| c5e7b6f982 | |||
| d30c9619c5 | |||
| 7344ae2db3 | |||
| 3b29a150a8 | |||
| 59186d84a9 | |||
| 7fab4e5dd0 | |||
| 0dbaa1bc5d | |||
| 8b37ebc8f9 | |||
| 5d757207c8 | |||
| c80df05fdf | |||
| 9be43a85ef | |||
| bf66209d3e | |||
| cdd1ae2c9b | |||
| f4290ae7f7 | |||
| e58c0fd215 | |||
| a91fac450a | |||
| 5cb043009c | |||
| 4a1f11b885 | |||
| 43f9033ccc | |||
| e7c0951786 | |||
| efc107907c | |||
| 2b8b0e5bdd | |||
| 3ae2a7fcf5 | |||
| 0806d3749b | |||
| f5d5e20a97 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -22,5 +22,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"deno.enable": false
|
||||
}
|
||||
|
||||
221
changelog.md
221
changelog.md
@@ -1,5 +1,226 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-26 - 7.1.0 - feat(config)
|
||||
normalize npmextra.json to namespaced keys and add CI/release configuration
|
||||
|
||||
- Replaced legacy keys (npmdocker, npmci, gitzone, tsdoc) with namespaced package keys (@git.zone/cli, @git.zone/tsdoc, @git.zone/tsdocker, @ship.zone/szci).
|
||||
- Moved tsdoc legal text under @git.zone/tsdoc.
|
||||
- Added release configuration with registries (https://verdaccio.lossless.digital and https://registry.npmjs.org) and accessLevel public under @git.zone/cli.
|
||||
- Added @git.zone/tsdocker CI/docker settings and @ship.zone/szci npm registry/tooling settings.
|
||||
- Removed old top-level entries to consolidate tooling configuration under scoped keys.
|
||||
|
||||
## 2026-02-26 - 7.0.16 - fix(mongodb)
|
||||
set default socketTimeoutMS to 30000ms in MongoClient options to prevent hung operations from holding connections
|
||||
|
||||
- Adds socketTimeoutMS: 30000 to MongoClient clientOptions in ts/classes.db.ts
|
||||
- Helps prevent hung operations from indefinitely holding connections by enforcing a 30s socket timeout
|
||||
- Non-breaking change (defaults only)
|
||||
|
||||
## 2025-12-01 - 7.0.15 - fix(classes.doc)
|
||||
Avoid emitting instance fields for collection and manager to preserve decorator-defined prototype getters
|
||||
|
||||
- ts/classes.doc.ts: changed instance properties `collection` and `manager` to `declare` so TypeScript does not emit them as own properties — prevents ES2022 class fields from shadowing prototype getters created by @Collection and @managed decorators.
|
||||
- readme.hints.md: added documentation explaining the ES2022 class fields issue and recommending use of `declare` for type-only instance properties; marks the fix as v7.0.15.
|
||||
|
||||
## 2025-11-28 - 7.0.14 - fix(classes.collection)
|
||||
Centralize TC39 decorator metadata initialization and use context.metadata in class decorators
|
||||
|
||||
- Add initializeDecoratorMetadata helper to initialize prototype and constructor properties from TC39 decorator metadata
|
||||
- Refactor Collection and managed decorators to call initializeDecoratorMetadata with context.metadata
|
||||
- Remove direct reliance on constructor[Symbol.metadata] in class decorators to avoid read-only assignment issues
|
||||
- Ensure consistent initialization of saveableProperties, globalSaveableProperties, uniqueIndexes, regularIndexes, searchableFields and _svDbOptions
|
||||
|
||||
## 2025-11-28 - 7.0.13 - fix(classes.doc)
|
||||
Remove noisy debug logging from decorators and serialization logic
|
||||
|
||||
- Removed debug logger calls from globalSvDb decorator initialization
|
||||
- Removed debug logger calls from svDb decorator initialization and svDb options handling
|
||||
- Removed debug logger calls from unI and index decorator initializers
|
||||
- Removed debug logging in createSavableObject to reduce console noise; no functional changes
|
||||
|
||||
## 2025-11-28 - 7.0.12 - fix(collection)
|
||||
Ensure TC39 decorator metadata is initialized on both original and decorated constructors/prototypes and add debug logging
|
||||
|
||||
- Initialize metadata-driven prototype properties (globalSaveableProperties, saveableProperties, uniqueIndexes, regularIndexes) on both the decorated class prototype and the original constructor prototype to avoid closure/compatibility issues
|
||||
- Initialize searchableFields on both the decorated constructor and the original constructor so text-index creation and searches see the fields correctly
|
||||
- Forward and initialize _svDbOptions from decorator metadata onto the original constructor to preserve custom serialization options
|
||||
- Add debug logging in the Collection decorator and in createSavableObject to surface metadata and saveable-property counts for easier troubleshooting
|
||||
|
||||
## 2025-11-28 - 7.0.9 - fix(classes.collection)
|
||||
Fix closure bug in Collection decorator by defining collection getter on original constructor and prototype
|
||||
|
||||
- Define the collection getter on the original constructor so class-level references (e.g. `User.collection`) resolve to the decorated collection instead of the original constructor's closure value.
|
||||
- Also define the getter on the original constructor's prototype to ensure instance access works consistently across runtimes (Deno/Node).
|
||||
|
||||
## 2025-11-28 - 7.0.8 - fix(classes.collection)
|
||||
Fix closure issue in managed decorator so Class.collection/instance.collection resolve correctly
|
||||
|
||||
- Resolve closure bug in the managed() decorator where class methods referencing Class.collection (or instance.collection) could receive the original constructor's captured value and thus the wrong collection/manager.
|
||||
- Define dynamic getters on the original constructor and its prototype that compute the collection from the proper manager/db at access time (supports direct manager objects, delayed manager factory functions, and fallback to defaultManager).
|
||||
- Getters are defined as non-enumerable and configurable to preserve compatibility with existing consumers.
|
||||
|
||||
## 2025-11-28 - 7.0.7 - fix(decorators)
|
||||
Fix decorator metadata initialization and Lucene query transformation
|
||||
|
||||
- Ensure TC39 decorator metadata is used to initialize prototype properties so decorators work reliably across runtimes (context.metadata / Symbol.metadata shim imported early).
|
||||
- Field and class decorators now populate and consume metadata for saveable properties, indexes and searchable fields so prototype initialization happens before instance creation.
|
||||
- Fix Lucene -> MongoDB transformer to produce correct $or/$and/$not structures and improve wildcard/fuzzy/range handling for search queries.
|
||||
- Improve collection initialization to auto-create compound text indexes from searchableFields and ensure index creation is idempotent.
|
||||
|
||||
## 2025-11-28 - 7.0.6 - fix(classes.collection)
|
||||
Guard against missing collection before attaching document constructor in Collection decorator
|
||||
|
||||
- Added a truthy check for `coll` before setting `(coll as any).docCtor` in the Collection decorator (ts/classes.collection.ts).
|
||||
- Prevents a potential TypeError when `collectionFactory.getCollection` returns null/undefined during decorator initialization.
|
||||
|
||||
## 2025-11-28 - 7.0.5 - fix(package)
|
||||
Add package exports entry and remove legacy main/typings fields
|
||||
|
||||
- Added an "exports" entry in package.json mapping "." to ./dist_ts/index.js to declare the package's ESM entrypoint.
|
||||
- Removed legacy "main" and "typings" fields from package.json.
|
||||
- Improves Node/module resolution and modern bundler compatibility by using the package exports field.
|
||||
|
||||
## 2025-11-28 - 7.0.4 - fix(decorators)
|
||||
Add Symbol.metadata polyfill and import it at entry to ensure decorator metadata is available
|
||||
|
||||
- Add ts/shim.ts: defines Symbol.metadata when missing (polyfill for TC39 Stage 3 decorator metadata).
|
||||
- Import './shim.js' at the very top of ts/index.ts so the polyfill runs before any decorator code or exports are evaluated.
|
||||
- Prevents runtime errors when decorators rely on Symbol.metadata and improves compatibility across runtimes/environments.
|
||||
|
||||
## 2025-11-28 - 7.0.3 - fix(build)
|
||||
Bump devDependency @git.zone/tsbuild to ^3.1.2
|
||||
|
||||
- Updated @git.zone/tsbuild in devDependencies from ^3.1.1 to ^3.1.2
|
||||
|
||||
## 2025-11-28 - 7.0.2 - fix(collectionfactory)
|
||||
Simplify CollectionFactory.getCollection: remove unnecessary IIFE and instantiate collection only when dbArg is SmartdataDb
|
||||
|
||||
- Remove redundant IIFE wrapper in getCollection for improved readability
|
||||
- Only create and cache a SmartdataCollection when dbArg is an instance of SmartdataDb
|
||||
- Avoid assigning undefined to the collections map by guarding instantiation and returning existing collection
|
||||
|
||||
## 2025-11-27 - 7.0.1 - fix(build)
|
||||
Update build tooling and TypeScript compilation target
|
||||
|
||||
- Bump devDependency @git.zone/tsbuild from ^3.1.0 to ^3.1.1.
|
||||
- Update tsconfig.json compiler target from ES2022 to ES2024 (affects emitted JS language level).
|
||||
|
||||
## 2025-11-27 - 7.0.0 - BREAKING CHANGE(mongodb)
|
||||
Upgrade dependencies: bump mongodb to ^7.0.0 and @git.zone/tstest to ^3.1.3
|
||||
|
||||
- Bump 'mongodb' dependency from ^6.20.0 to ^7.0.0 — major version upgrade; may introduce breaking API changes and require code updates or verification against the new driver.
|
||||
- Update devDependency '@git.zone/tstest' from ^2.8.1 to ^3.1.3 — test tooling updated.
|
||||
|
||||
## 2025-11-17 - 6.0.0 - BREAKING CHANGE(decorators)
|
||||
Migrate to TC39 Stage 3 decorators and refactor decorator metadata handling; update class initialization, lucene adapter fixes and docs
|
||||
|
||||
- Switch all decorators to TC39 Stage 3 signatures and metadata usage (use context.metadata and context.addInitializer) — affects svDb, globalSvDb, searchable, unI, index, Collection and managed.
|
||||
- Refactor Collection/managed decorators to read and initialize prototype/constructor properties from context.metadata to ensure prototype properties are available before instance creation (ts/classes.collection.ts).
|
||||
- Improve search implementation: add a Lucene parser and transformer with safer MongoDB query generation, wildcard/fuzzy handling and properly structured boolean operators (ts/classes.lucene.adapter.ts).
|
||||
- Search integration updated to use the new adapter and handle advanced Lucene syntax and edge cases more robustly.
|
||||
- Bump dev tooling versions: @git.zone/tsbuild -> ^3.1.0 and @git.zone/tsrun -> ^2.0.0.
|
||||
- Documentation: update README and add readme.hints.md describing the TC39 decorator migration, minimum TypeScript (>=5.2) and Deno notes; tests adjusted accordingly.
|
||||
- Clean up project memory/config files related to the previous decorator approach and Deno configuration adjustments.
|
||||
|
||||
## 2025-11-17 - 5.16.7 - fix(classes.collection)
|
||||
Improve Deno and TypeScript compatibility: Collection decorator _svDbOptions forwarding and config cleanup
|
||||
|
||||
- Collection decorator: capture original constructor and forward _svDbOptions to ensure property decorator options (serialize/deserialize) remain accessible in Deno environments.
|
||||
- Collection decorator: keep instance getter defined on prototype for Deno compatibility (no behavior change, clarifies forwarding logic).
|
||||
- Build/config: removed experimentalDecorators and useDefineForClassFields from deno.json and tsconfig.json to avoid Deno/TS build issues and rely on default compilation settings.
|
||||
|
||||
## 2025-11-17 - 5.16.6 - fix(classes)
|
||||
Add Deno compatibility, prototype-safe decorators and safe collection accessor; bump a few deps
|
||||
|
||||
- Add deno.json to enable experimentalDecorators and target ES2022/DOM for Deno builds.
|
||||
- Introduce getCollectionSafe() on SmartDataDbDoc and use it for save/update/delete/findOne to avoid runtime errors when instance 'collection' is not present.
|
||||
- Change several instance properties (globalSaveableProperties, uniqueIndexes, regularIndexes, saveableProperties) to 'declare' so decorator-set prototype properties are not shadowed (Deno compatibility).
|
||||
- Enhance @Collection decorator: capture original constructor/prototype for Deno, define prototype getter for collection on decorated class, attach docCtor for searchableFields, and forward _svDbOptions to the original constructor to preserve serializer metadata.
|
||||
- Improve text/search index handling by relying on docCtor.searchableFields and guarding text index creation.
|
||||
- Bump dependencies/devDependencies: @push.rocks/smartmongo -> ^2.0.14, @git.zone/tsbuild -> ^2.7.1, @git.zone/tstest -> ^2.8.1.
|
||||
- These are non-breaking runtime compatibility and developer-experience fixes; intended as a patch release.
|
||||
|
||||
## 2025-11-16 - 5.16.5 - fix(watcher)
|
||||
Update dependencies, tooling and watcher import; add .serena cache ignore
|
||||
|
||||
- Bump runtime dependencies: @push.rocks/smartlog 3.1.8 → 3.1.10, @push.rocks/smartstring 4.0.15 → 4.1.0, @push.rocks/taskbuffer 3.1.7 → 3.4.0, @tsclass/tsclass 9.2.0 → 9.3.0, mongodb 6.18.0 → 6.20.0
|
||||
- Bump devDependencies: @git.zone/tsbuild 2.6.7 → 2.6.8, @git.zone/tsrun 1.2.44 → 1.6.2, @git.zone/tstest 2.3.5 → 2.6.2
|
||||
- Switch EventEmitter import to node:events in ts/classes.watcher.ts to use the namespaced Node import
|
||||
- Add .serena/.gitignore to ignore /cache
|
||||
|
||||
## 2025-08-18 - 5.16.4 - fix(classes.doc (convertFilterForMongoDb))
|
||||
Improve filter conversion: handle logical operators, merge operator objects, add nested filter tests and docs, and fix test script
|
||||
|
||||
- Fix package.json test script: remove stray dot in tstest --verbose argument to ensure tests run correctly
|
||||
- Enhance convertFilterForMongoDb in ts/classes.doc.ts to properly handle logical operators ($and, $or, $nor, $not) and return them recursively
|
||||
- Merge operator objects for the same field path (e.g. combining $gte and $lte) to avoid overwriting operator clauses when object and dot-notation are mixed
|
||||
- Add validation/guards for operator argument types (e.g. $in, $nin, $all must be arrays; $size must be numeric) and preserve existing behavior blocking $where for security
|
||||
- Add comprehensive nested filter tests in test/test.filters.ts to cover deep nested object queries, $elemMatch, array size, $all, $in on nested fields and more
|
||||
- Expand README filtering section with detailed examples for basic filtering, deep nested filters, comparison operators, array operations, logical and element operators, and advanced patterns
|
||||
|
||||
## 2025-08-18 - 5.16.3 - fix(docs)
|
||||
Add local Claude settings and remove outdated codex.md
|
||||
|
||||
- Added .claude/settings.local.json to store local Claude/assistant permissions and configuration.
|
||||
- Removed codex.md (project overview) — documentation file deleted.
|
||||
- No runtime/library code changes; documentation/configuration-only update, bump patch version.
|
||||
|
||||
## 2025-08-18 - 5.16.2 - fix(readme)
|
||||
Update README: clarify examples, expand search/cursor/docs and add local Claude settings
|
||||
|
||||
- Refined README wording and structure: clearer Quick Start, improved examples and developer-focused phrasing
|
||||
- Expanded documentation for search, cursors, change streams, distributed coordination, transactions and EasyStore with more concrete code examples
|
||||
- Adjusted code examples to show safer defaults (ID generation, status/tags, connection pooling) and improved best-practices guidance
|
||||
- Added .claude/settings.local.json to provide local assistant/CI permission configuration
|
||||
|
||||
## 2025-08-12 - 5.16.1 - fix(core)
|
||||
Improve error handling and logging; enhance search query sanitization; update dependency versions and documentation
|
||||
|
||||
- Replaced console.log and console.warn with structured logger.log calls throughout the core modules
|
||||
- Enhanced database initialization with try/catch and proper URI credential encoding
|
||||
- Improved search query conversion by disallowing dangerous operators (e.g. $where) and securely escaping regex patterns
|
||||
- Bumped dependency versions (smartlog, @tsclass/tsclass, mongodb, etc.) in package.json
|
||||
- Added detailed project memories including code style, project overview, and suggested commands for developers
|
||||
- Updated README with improved instructions, feature highlights, and quick start sections
|
||||
|
||||
## 2025-04-25 - 5.16.0 - feat(watcher)
|
||||
Enhance change stream watchers with buffering and EventEmitter support; update dependency versions
|
||||
|
||||
- Bumped smartmongo from ^2.0.11 to ^2.0.12 and smartrx from ^3.0.7 to ^3.0.10
|
||||
- Upgraded @tsclass/tsclass to ^9.0.0 and mongodb to ^6.16.0
|
||||
- Refactored the watch API to accept additional options (bufferTimeMs, fullDocument) for improved change stream handling
|
||||
- Modified SmartdataDbWatcher to extend EventEmitter and support event notifications
|
||||
|
||||
## 2025-04-24 - 5.15.1 - fix(cursor)
|
||||
Improve cursor usage documentation and refactor getCursor API to support native cursor modifiers
|
||||
|
||||
- Updated examples in readme.md to demonstrate manual iteration using cursor.next() and proper cursor closing.
|
||||
- Refactored the getCursor method in classes.doc.ts to accept session and modifier options, consolidating cursor handling.
|
||||
- Added new tests in test/test.cursor.ts to verify cursor operations, including limits, sorting, and skipping.
|
||||
|
||||
## 2025-04-24 - 5.15.0 - feat(svDb)
|
||||
Enhance svDb decorator to support custom serialization and deserialization options
|
||||
|
||||
- Added an optional options parameter to the svDb decorator to accept serialize/deserialize functions
|
||||
- Updated instance creation logic (updateFromDb) to apply custom deserialization if provided
|
||||
- Updated createSavableObject to use custom serialization when available
|
||||
|
||||
## 2025-04-23 - 5.14.1 - fix(db operations)
|
||||
Update transaction API to consistently pass optional session parameters across database operations
|
||||
|
||||
- Revised transaction support in readme to use startSession without await and showcased session usage in getInstance and save calls
|
||||
- Updated methods in classes.collection.ts to accept an optional session parameter for findOne, getCursor, findAll, insert, update, delete, and getCount
|
||||
- Enhanced SmartDataDbDoc save and delete methods to propagate session parameters
|
||||
- Improved overall consistency of transactional APIs across the library
|
||||
|
||||
## 2025-04-23 - 5.14.0 - feat(doc)
|
||||
Implement support for beforeSave, afterSave, beforeDelete, and afterDelete lifecycle hooks in document save and delete operations to allow custom logic execution during these critical moments.
|
||||
|
||||
- Calls beforeSave hook if defined before performing insert or update.
|
||||
- Calls afterSave hook after a document is saved.
|
||||
- Calls beforeDelete hook before deletion and afterDelete hook afterward.
|
||||
- Ensures _updatedAt timestamp is refreshed during save operations.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
77
codex.md
77
codex.md
@@ -1,77 +0,0 @@
|
||||
# SmartData Project Overview
|
||||
|
||||
This document provides a high-level overview of the SmartData library (`@push.rocks/smartdata`), its architecture, core components, and key features—including recent enhancements to the search API.
|
||||
|
||||
## 1. Project Purpose
|
||||
- A TypeScript‑first wrapper around MongoDB that supplies:
|
||||
- Strongly‑typed document & collection classes
|
||||
- Decorator‑based schema definition (no external schema files)
|
||||
- Advanced search capabilities with Lucene‑style queries
|
||||
- Built‑in support for real‑time data sync, distributed coordination, and key‑value EasyStore
|
||||
|
||||
## 2. Core Concepts & Components
|
||||
- **SmartDataDb**: Manages the MongoDB connection, pooling, and initialization of collections.
|
||||
- **SmartDataDbDoc**: Base class for all document models; provides CRUD, upsert, and cursor APIs.
|
||||
- **Decorators**:
|
||||
- `@Collection`: Associates a class with a MongoDB collection
|
||||
- `@svDb()`: Marks a field as persisted to the DB
|
||||
- `@unI()`: Marks a field as a unique index
|
||||
- `@index()`: Adds a regular index
|
||||
- `@searchable()`: Marks a field for inclusion in text searches or regex queries
|
||||
- **SmartdataCollection**: Wraps a MongoDB collection; auto‑creates indexes based on decorators.
|
||||
- **Lucene Adapter**: Parses a Lucene query string into an AST and transforms it to a MongoDB filter object.
|
||||
- **EasyStore**: A simple, schema‑less key‑value store built on top of MongoDB for sharing ephemeral data.
|
||||
- **Distributed Coordinator**: Leader election and task‑distribution API for building resilient, multi‑instance systems.
|
||||
- **Watcher**: Listens to change streams for real‑time updates and integrates with RxJS.
|
||||
|
||||
## 3. Search API
|
||||
SmartData provides a unified `.search(query[, opts])` method on all models with `@searchable()` fields:
|
||||
|
||||
- **Supported Syntax**:
|
||||
1. Exact field:value (e.g. `field:Value`)
|
||||
2. Quoted phrases (e.g. `"exact phrase"` or `'exact phrase'`)
|
||||
3. Wildcards: `*` (zero or more chars) and `?` (single char)
|
||||
4. Boolean operators: `AND`, `OR`, `NOT`
|
||||
5. Grouping: parenthesis `(A OR B) AND C`
|
||||
6. Range queries: `[num TO num]`, `{num TO num}`
|
||||
7. Multi‑term unquoted: terms AND’d across all searchable fields
|
||||
8. Empty query returns all documents
|
||||
|
||||
- **Fallback Mechanisms**:
|
||||
1. Text index based `$text` search (if supported)
|
||||
2. Field‑scoped and multi‑field regex queries
|
||||
3. In‑memory filtering for complex or unsupported cases
|
||||
|
||||
### New Security & Extensibility Hooks
|
||||
The `.search(query, opts?)` signature now accepts a `SearchOptions<T>` object:
|
||||
```ts
|
||||
interface SearchOptions<T> {
|
||||
filter?: Record<string, any>; // Additional MongoDB filter AND‑merged
|
||||
validate?: (doc: T) => boolean; // Post‑fetch hook to drop results
|
||||
}
|
||||
```
|
||||
- **filter**: Enforces mandatory constraints (e.g. multi‑tenant isolation) directly in the Mongo query.
|
||||
- **validate**: An async function that runs after fetching; return `false` to exclude a document.
|
||||
|
||||
## 4. Testing Strategy
|
||||
- Unit tests in `test/test.search.ts` cover basic search functionality and new options:
|
||||
- Exact, wildcard, phrase, boolean and grouping cases
|
||||
- Implicit AND and mixed free‑term + field searches
|
||||
- Edge cases (non‑searchable fields, quoted wildcards, no matches)
|
||||
- `filter` and `validate` tests ensure security hooks work as intended
|
||||
- Advanced search scenarios are covered in `test/test.search.advanced.ts`.
|
||||
|
||||
## 5. Usage Example
|
||||
```ts
|
||||
// Basic search
|
||||
const prods = await Product.search('wireless earbuds');
|
||||
|
||||
// Scoped search (only your organization’s items)
|
||||
const myItems = await Product.search('book', { filter: { ownerId } });
|
||||
|
||||
// Post‑search validation (only cheap items)
|
||||
const cheapItems = await Product.search('', { validate: p => p.price < 50 });
|
||||
```
|
||||
|
||||
---
|
||||
Last updated: 2025-04-22
|
||||
@@ -1,15 +1,5 @@
|
||||
{
|
||||
"npmdocker": {
|
||||
"baseImage": "hosttoday/ht-docker-node:mongo",
|
||||
"command": "npmci test stable",
|
||||
"dockerSock": false
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public",
|
||||
"npmRegistryUrl": "registry.npmjs.org"
|
||||
},
|
||||
"gitzone": {
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -28,9 +18,25 @@
|
||||
"custom data types",
|
||||
"ODM"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"tsdoc": {
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@git.zone/tsdocker": {
|
||||
"baseImage": "hosttoday/ht-docker-node:mongo",
|
||||
"command": "npmci test stable",
|
||||
"dockerSock": false
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmRegistryUrl": "registry.npmjs.org"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
package.json
37
package.json
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "@push.rocks/smartdata",
|
||||
"version": "5.13.1",
|
||||
"version": "7.1.0",
|
||||
"private": false,
|
||||
"description": "An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tstest test/",
|
||||
"test": "tstest test/ --verbose --logfile --timeout 120",
|
||||
"testSearch": "tsx test/test.search.ts",
|
||||
"build": "tsbuild --web --allowimplicitany",
|
||||
"buildDocs": "tsdoc"
|
||||
@@ -23,26 +24,26 @@
|
||||
},
|
||||
"homepage": "https://code.foss.global/push.rocks/smartdata#readme",
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.0.14",
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smartdelay": "^3.0.1",
|
||||
"@push.rocks/smartlog": "^3.0.2",
|
||||
"@push.rocks/smartmongo": "^2.0.11",
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartmongo": "^2.0.14",
|
||||
"@push.rocks/smartpromise": "^4.0.2",
|
||||
"@push.rocks/smartrx": "^3.0.7",
|
||||
"@push.rocks/smartstring": "^4.0.15",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstring": "^4.1.0",
|
||||
"@push.rocks/smarttime": "^4.0.6",
|
||||
"@push.rocks/smartunique": "^3.0.8",
|
||||
"@push.rocks/taskbuffer": "^3.1.7",
|
||||
"@tsclass/tsclass": "^8.2.0",
|
||||
"mongodb": "^6.15.0"
|
||||
"@push.rocks/taskbuffer": "^3.4.0",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"mongodb": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.3.2",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^1.0.77",
|
||||
"@push.rocks/qenv": "^6.0.5",
|
||||
"@push.rocks/tapbundle": "^5.6.2",
|
||||
"@types/node": "^22.14.0"
|
||||
"@git.zone/tsbuild": "^3.1.2",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^22.15.2"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
|
||||
6866
pnpm-lock.yaml
generated
6866
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
# Project Memory - Smartdata
|
||||
|
||||
## TC39 Decorator Migration (v6.0.0) - ✅ COMPLETED
|
||||
|
||||
### Final Status: All Tests Passing (157/157)
|
||||
Migration successfully completed on 2025-11-17.
|
||||
|
||||
### What Changed:
|
||||
- ✅ Removed `experimentalDecorators` from tsconfig.json
|
||||
- ✅ Refactored all 7 decorators to TC39 Stage 3 syntax
|
||||
- 5 property decorators: @globalSvDb, @svDb, @unI, @index, @searchable
|
||||
- 2 class decorators: @Collection, @managed
|
||||
- ✅ Implemented context.metadata pattern for shared decorator state
|
||||
- ✅ All tests passing across Node.js and Deno runtimes
|
||||
|
||||
### Critical Discovery: TC39 Metadata Access Pattern
|
||||
**THE KEY INSIGHT**: In TC39 decorators, metadata is NOT accessed via `constructor[Symbol.metadata]`. Instead:
|
||||
- **Field decorators**: Write to `context.metadata`
|
||||
- **Class decorators**: Read from `context.metadata` (same shared object!)
|
||||
- The `context.metadata` object is shared between all decorators on the same class
|
||||
- Attempting to write to `constructor[Symbol.metadata]` throws: "Cannot assign to read only property"
|
||||
|
||||
### Implementation Pattern:
|
||||
```typescript
|
||||
// Field decorator - stores metadata
|
||||
export function svDb() {
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||
if (!metadata.saveableProperties) {
|
||||
metadata.saveableProperties = [];
|
||||
}
|
||||
metadata.saveableProperties.push(String(context.name));
|
||||
};
|
||||
}
|
||||
|
||||
// Class decorator - reads metadata and initializes prototype
|
||||
export function Collection(dbArg: SmartdataDb) {
|
||||
return function(value: Function, context: ClassDecoratorContext) => {
|
||||
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||
if (metadata?.saveableProperties) {
|
||||
decoratedClass.prototype.saveableProperties = [...metadata.saveableProperties];
|
||||
}
|
||||
return decoratedClass;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Runtime Compatibility:
|
||||
- ✅ **Node.js v23.8.0**: Full TC39 support
|
||||
- ✅ **Deno v2.5.4**: Full TC39 support
|
||||
- ❌ **Bun v1.3.0**: No TC39 support (uses legacy decorators only)
|
||||
- Removed "+bun" from test filenames to skip Bun tests
|
||||
|
||||
### Key Technical Notes:
|
||||
1. **Metadata Initialization Timing**: Class decorators run AFTER field decorators, allowing them to read accumulated metadata and initialize prototypes before any instances are created
|
||||
2. **Prototype vs Instance Properties**: Properties set on prototype are accessible via `this.propertyName` in instances
|
||||
3. **TypeScript Lib Support**: TypeScript 5.9.3 includes built-in decorator types (no custom lib configuration needed)
|
||||
4. **Interface Naming**: Used `ISmartdataDecoratorMetadata` extending `DecoratorMetadataObject` for type safety
|
||||
|
||||
### Files Modified:
|
||||
- ts/classes.doc.ts (property decorators + metadata interface)
|
||||
- ts/classes.collection.ts (class decorators + prototype initialization)
|
||||
- tsconfig.json (removed experimentalDecorators flag)
|
||||
- test/*.ts (renamed files to remove "+bun" suffix)
|
||||
|
||||
### Test Results:
|
||||
All 157 tests passing across 10 test files:
|
||||
- test.cursor.ts: 7/7
|
||||
- test.deno.ts: 11/11 (queries working correctly!)
|
||||
- test.search.advanced.ts: 41/41
|
||||
- test.typescript.ts: 4/4
|
||||
- test.watch.ts: 5/5
|
||||
- And 5 more test files
|
||||
|
||||
### Migration Learnings for Future Reference:
|
||||
1. `context.metadata` is the ONLY way to share state between decorators
|
||||
2. Class decorators must initialize prototypes from metadata immediately
|
||||
3. `Symbol.metadata` on constructors is read-only (managed by runtime)
|
||||
4. Field decorators run before class decorators (guaranteed order)
|
||||
5. TypeScript 5.2+ has built-in TC39 decorator support
|
||||
|
||||
## ES2022 Class Fields & Prototype Getters - Fixed in v7.0.15
|
||||
|
||||
### Issue
|
||||
ES2022 class fields (`useDefineForClassFields: true`) create own properties during construction that shadow prototype getters defined by decorators.
|
||||
|
||||
### Solution
|
||||
Use `declare` keyword for instance properties that are accessed via prototype getters:
|
||||
```typescript
|
||||
// In SmartDataDbDoc (ts/classes.doc.ts):
|
||||
declare public collection: SmartdataCollection<any>; // Type-only, no JS emitted
|
||||
declare public manager: TManager; // Type-only, no JS emitted
|
||||
```
|
||||
|
||||
### Key Insight
|
||||
- `declare` tells TypeScript this is a type-only declaration
|
||||
- No JavaScript code is emitted for `declare` properties
|
||||
- Prototype getters defined by `@Collection` and `@managed` decorators are no longer shadowed
|
||||
|
||||
97
test/test.cursor.ts
Normal file
97
test/test.cursor.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import { smartunique } from '../ts/plugins.js';
|
||||
import * as smartdata from '../ts/index.js';
|
||||
|
||||
// Set up database connection
|
||||
let smartmongoInstance: smartmongo.SmartMongo;
|
||||
let testDb: smartdata.SmartdataDb;
|
||||
|
||||
// Define a simple document model for cursor tests
|
||||
@smartdata.Collection(() => testDb)
|
||||
class CursorTest extends smartdata.SmartDataDbDoc<CursorTest, CursorTest> {
|
||||
@smartdata.unI()
|
||||
public id: string = smartunique.shortId();
|
||||
|
||||
@smartdata.svDb()
|
||||
public name: string;
|
||||
|
||||
@smartdata.svDb()
|
||||
public order: number;
|
||||
|
||||
constructor(name: string, order: number) {
|
||||
super();
|
||||
this.name = name;
|
||||
this.order = order;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the in-memory MongoDB and SmartdataDB
|
||||
tap.test('cursor init: start Mongo and SmartdataDb', async () => {
|
||||
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
|
||||
testDb = new smartdata.SmartdataDb(
|
||||
await smartmongoInstance.getMongoDescriptor(),
|
||||
);
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
// Insert sample documents
|
||||
tap.test('cursor insert: save 5 test documents', async () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const doc = new CursorTest(`item${i}`, i);
|
||||
await doc.save();
|
||||
}
|
||||
const count = await CursorTest.getCount({});
|
||||
expect(count).toEqual(5);
|
||||
});
|
||||
|
||||
// Test that toArray returns all documents
|
||||
tap.test('cursor toArray: retrieves all documents', async () => {
|
||||
const cursor = await CursorTest.getCursor({});
|
||||
const all = await cursor.toArray();
|
||||
expect(all.length).toEqual(5);
|
||||
});
|
||||
|
||||
// Test iteration via forEach
|
||||
tap.test('cursor forEach: iterates through all documents', async () => {
|
||||
const names: string[] = [];
|
||||
const cursor = await CursorTest.getCursor({});
|
||||
await cursor.forEach(async (item) => {
|
||||
names.push(item.name);
|
||||
});
|
||||
expect(names.length).toEqual(5);
|
||||
expect(names).toContain('item3');
|
||||
});
|
||||
|
||||
// Test native cursor modifiers: limit
|
||||
tap.test('cursor modifier limit: only two documents', async () => {
|
||||
const cursor = await CursorTest.getCursor({}, { modifier: (c) => c.limit(2) });
|
||||
const limited = await cursor.toArray();
|
||||
expect(limited.length).toEqual(2);
|
||||
});
|
||||
|
||||
// Test native cursor modifiers: sort and skip
|
||||
tap.test('cursor modifier sort & skip: returns correct order', async () => {
|
||||
const cursor = await CursorTest.getCursor({}, {
|
||||
modifier: (c) => c.sort({ order: -1 }).skip(1),
|
||||
});
|
||||
const results = await cursor.toArray();
|
||||
// Skipped the first (order 5), next should be 4,3,2,1
|
||||
expect(results.length).toEqual(4);
|
||||
expect(results[0].order).toEqual(4);
|
||||
});
|
||||
|
||||
// Cleanup: drop database, close connections, stop Mongo
|
||||
tap.test('cursor cleanup: drop DB and stop', async () => {
|
||||
await testDb.mongoDb.dropDatabase();
|
||||
await testDb.close();
|
||||
if (smartmongoInstance) {
|
||||
await smartmongoInstance.stopAndDumpToDir(
|
||||
`.nogit/dbdump/test.cursor.ts`,
|
||||
);
|
||||
}
|
||||
// Ensure process exits after cleanup
|
||||
setTimeout(() => process.exit(), 2000);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
255
test/test.deno.ts
Normal file
255
test/test.deno.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// TODO: Decorator support during testing for bun and deno in @git.zone/tstest
|
||||
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { Qenv } from '@push.rocks/qenv';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import { smartunique } from '../ts/plugins.js';
|
||||
|
||||
import * as mongodb from 'mongodb';
|
||||
|
||||
const testQenv = new Qenv(process.cwd(), process.cwd() + '/.nogit/');
|
||||
|
||||
console.log(process.memoryUsage());
|
||||
|
||||
// the tested module
|
||||
import * as smartdata from '../ts/index.js';
|
||||
|
||||
// =======================================
|
||||
// Connecting to the database server
|
||||
// =======================================
|
||||
|
||||
let smartmongoInstance: smartmongo.SmartMongo;
|
||||
let testDb: smartdata.SmartdataDb;
|
||||
|
||||
const totalCars = 2000;
|
||||
|
||||
tap.test('should create a testinstance as database', async () => {
|
||||
const databaseName = `test-smartdata-deno-${smartunique.shortId()}`;
|
||||
testDb = new smartdata.SmartdataDb({
|
||||
mongoDbUrl: await testQenv.getEnvVarOnDemand('MONGODB_URL'),
|
||||
mongoDbName: databaseName,
|
||||
});
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
// =======================================
|
||||
// The actual tests
|
||||
// =======================================
|
||||
|
||||
// ------
|
||||
// Collections
|
||||
// ------
|
||||
|
||||
@smartdata.Collection(() => {
|
||||
return testDb;
|
||||
})
|
||||
class Car extends smartdata.SmartDataDbDoc<Car, Car> {
|
||||
@smartdata.unI()
|
||||
public index: string = smartunique.shortId();
|
||||
|
||||
@smartdata.svDb()
|
||||
public color: string;
|
||||
|
||||
@smartdata.svDb()
|
||||
public brand: string;
|
||||
|
||||
@smartdata.svDb()
|
||||
public testBuffer = Buffer.from('hello');
|
||||
|
||||
@smartdata.svDb()
|
||||
deepData = {
|
||||
sodeep: 'yes',
|
||||
};
|
||||
|
||||
constructor(colorArg: string, brandArg: string) {
|
||||
super();
|
||||
this.color = colorArg;
|
||||
this.brand = brandArg;
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('should create a new id', async () => {
|
||||
const newid = await Car.getNewId();
|
||||
console.log(newid);
|
||||
});
|
||||
|
||||
tap.test('should save the car to the db', async (toolsArg) => {
|
||||
const myCar = new Car('red', 'Volvo');
|
||||
console.log('Car.collection.smartdataDb:', (Car.collection as any).smartdataDb?.mongoDb?.databaseName);
|
||||
console.log('Car.collection.collectionName:', (Car.collection as any).collectionName);
|
||||
console.log('testDb.mongoDb.databaseName:', testDb.mongoDb.databaseName);
|
||||
await myCar.save();
|
||||
|
||||
const myCar2 = new Car('red', 'Volvo');
|
||||
await myCar2.save();
|
||||
|
||||
let counter = 0;
|
||||
|
||||
const gottenCarInstance = await Car.getInstance({});
|
||||
console.log(gottenCarInstance.testBuffer instanceof mongodb.Binary);
|
||||
process.memoryUsage();
|
||||
do {
|
||||
const myCar3 = new Car('red', 'Renault');
|
||||
await myCar3.save();
|
||||
counter++;
|
||||
if (counter % 100 === 0) {
|
||||
console.log(
|
||||
`Filled database with ${counter} of ${totalCars} Cars and memory usage ${
|
||||
process.memoryUsage().rss / 1e6
|
||||
} MB`,
|
||||
);
|
||||
}
|
||||
} while (counter < totalCars);
|
||||
console.log(process.memoryUsage());
|
||||
|
||||
// DEBUG: Check what's actually in the database
|
||||
const savedCount = await Car.getCount({});
|
||||
console.log('Total cars saved in DB:', savedCount);
|
||||
const renaultCount = await Car.getCount({ brand: 'Renault' });
|
||||
console.log('Renault cars in DB:', renaultCount);
|
||||
|
||||
// Check what's actually in the first saved car
|
||||
const firstCar = await Car.getInstance({});
|
||||
console.log('First car data:', JSON.stringify({
|
||||
color: firstCar?.color,
|
||||
brand: firstCar?.brand,
|
||||
index: firstCar?.index
|
||||
}));
|
||||
});
|
||||
|
||||
tap.test('expect to get instance of Car with shallow match', async () => {
|
||||
console.log('Before query - testDb.mongoDb.databaseName:', testDb.mongoDb.databaseName);
|
||||
console.log('Before query - Car.collection.smartdataDb:', (Car.collection as any).smartdataDb?.mongoDb?.databaseName);
|
||||
console.log('Before query - Car.collection.collectionName:', (Car.collection as any).collectionName);
|
||||
|
||||
const totalQueryCycles = totalCars / 2;
|
||||
let counter = 0;
|
||||
do {
|
||||
const timeStart = Date.now();
|
||||
const myCars = await Car.getInstances({
|
||||
brand: 'Renault',
|
||||
});
|
||||
if (counter % 10 === 0) {
|
||||
console.log(
|
||||
`performed ${counter} of ${totalQueryCycles} total query cycles: took ${
|
||||
Date.now() - timeStart
|
||||
}ms to query a set of 2000 with memory footprint ${process.memoryUsage().rss / 1e6} MB`,
|
||||
);
|
||||
console.log('myCars.length:', myCars.length);
|
||||
console.log('myCars[0]:', myCars[0]);
|
||||
}
|
||||
expect(myCars[0].deepData.sodeep).toEqual('yes');
|
||||
expect(myCars[0].brand).toEqual('Renault');
|
||||
counter++;
|
||||
} while (counter < totalQueryCycles);
|
||||
});
|
||||
|
||||
tap.test('expect to get instance of Car with deep match', async () => {
|
||||
const totalQueryCycles = totalCars / 6;
|
||||
let counter = 0;
|
||||
do {
|
||||
const timeStart = Date.now();
|
||||
const myCars2 = await Car.getInstances({
|
||||
deepData: {
|
||||
sodeep: 'yes',
|
||||
},
|
||||
});
|
||||
if (counter % 10 === 0) {
|
||||
console.log(
|
||||
`performed ${counter} of ${totalQueryCycles} total query cycles: took ${
|
||||
Date.now() - timeStart
|
||||
}ms to deep query a set of 2000 with memory footprint ${process.memoryUsage().rss / 1e6} MB`,
|
||||
);
|
||||
}
|
||||
expect(myCars2[0].deepData.sodeep).toEqual('yes');
|
||||
expect(myCars2[0].brand).toEqual('Volvo');
|
||||
counter++;
|
||||
} while (counter < totalQueryCycles);
|
||||
});
|
||||
|
||||
tap.test('expect to get instance of Car and update it', async () => {
|
||||
const myCar = await Car.getInstance<Car>({
|
||||
brand: 'Volvo',
|
||||
});
|
||||
expect(myCar.color).toEqual('red');
|
||||
myCar.color = 'blue';
|
||||
await myCar.save();
|
||||
});
|
||||
|
||||
tap.test('should be able to delete an instance of car', async () => {
|
||||
const myCars = await Car.getInstances({
|
||||
brand: 'Volvo',
|
||||
color: 'blue',
|
||||
});
|
||||
console.log(myCars);
|
||||
expect(myCars[0].color).toEqual('blue');
|
||||
for (const myCar of myCars) {
|
||||
await myCar.delete();
|
||||
}
|
||||
|
||||
const myCar2 = await Car.getInstance<Car>({
|
||||
brand: 'Volvo',
|
||||
});
|
||||
expect(myCar2.color).toEqual('red');
|
||||
});
|
||||
|
||||
// tslint:disable-next-line: max-classes-per-file
|
||||
@smartdata.Collection(() => {
|
||||
return testDb;
|
||||
})
|
||||
class Truck extends smartdata.SmartDataDbDoc<Car, Car> {
|
||||
@smartdata.unI()
|
||||
public id: string = smartunique.shortId();
|
||||
|
||||
@smartdata.svDb()
|
||||
public color: string;
|
||||
|
||||
@smartdata.svDb()
|
||||
public brand: string;
|
||||
|
||||
constructor(colorArg: string, brandArg: string) {
|
||||
super();
|
||||
this.color = colorArg;
|
||||
this.brand = brandArg;
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('should store a new Truck', async () => {
|
||||
const truck = new Truck('blue', 'MAN');
|
||||
await truck.save();
|
||||
const myTruck2 = await Truck.getInstance({ color: 'blue' });
|
||||
expect(myTruck2.color).toEqual('blue');
|
||||
myTruck2.color = 'red';
|
||||
await myTruck2.save();
|
||||
const myTruck3 = await Truck.getInstance({ color: 'blue' });
|
||||
expect(myTruck3).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('should return a count', async () => {
|
||||
const truckCount = await Truck.getCount();
|
||||
expect(truckCount).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('should use a cursor', async () => {
|
||||
const cursor = await Car.getCursor({});
|
||||
let counter = 0;
|
||||
await cursor.forEach(async (carArg) => {
|
||||
counter++;
|
||||
counter % 50 === 0 ? console.log(`50 more of ${carArg.color}`) : null;
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================
|
||||
// close the database connection
|
||||
// =======================================
|
||||
tap.test('close', async () => {
|
||||
if (smartmongoInstance) {
|
||||
await smartmongoInstance.stopAndDumpToDir('./.nogit/dbdump/test.ts');
|
||||
} else {
|
||||
await testDb.mongoDb.dropDatabase();
|
||||
await testDb.close();
|
||||
}
|
||||
setTimeout(() => process.exit(), 2000);
|
||||
});
|
||||
|
||||
tap.start({ throwOnError: true });
|
||||
819
test/test.filters.ts
Normal file
819
test/test.filters.ts
Normal file
@@ -0,0 +1,819 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
import * as smartdata from '../ts/index.js';
|
||||
|
||||
const { SmartdataDb, Collection, svDb, unI, index } = smartdata;
|
||||
|
||||
let smartmongoInstance: smartmongo.SmartMongo;
|
||||
let testDb: smartdata.SmartdataDb;
|
||||
|
||||
// Define test document classes
|
||||
@Collection(() => testDb)
|
||||
class TestUser extends smartdata.SmartDataDbDoc<TestUser, TestUser> {
|
||||
@unI()
|
||||
public id: string = smartunique.shortId();
|
||||
|
||||
@svDb()
|
||||
public name: string;
|
||||
|
||||
@svDb()
|
||||
public age: number;
|
||||
|
||||
@svDb()
|
||||
public email: string;
|
||||
|
||||
@svDb()
|
||||
public roles: string[];
|
||||
|
||||
@svDb()
|
||||
public tags: string[];
|
||||
|
||||
@svDb()
|
||||
public status: 'active' | 'inactive' | 'pending';
|
||||
|
||||
@svDb()
|
||||
public metadata: {
|
||||
lastLogin?: Date;
|
||||
loginCount?: number;
|
||||
preferences?: Record<string, any>;
|
||||
};
|
||||
|
||||
@svDb()
|
||||
public scores: number[];
|
||||
|
||||
constructor(data: Partial<TestUser> = {}) {
|
||||
super();
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
@Collection(() => testDb)
|
||||
class TestOrder extends smartdata.SmartDataDbDoc<TestOrder, TestOrder> {
|
||||
@unI()
|
||||
public id: string = smartunique.shortId();
|
||||
|
||||
@svDb()
|
||||
public userId: string;
|
||||
|
||||
@svDb()
|
||||
public items: Array<{
|
||||
product: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}>;
|
||||
|
||||
@svDb()
|
||||
public totalAmount: number;
|
||||
|
||||
@svDb()
|
||||
public status: string;
|
||||
|
||||
@svDb()
|
||||
public tags: string[];
|
||||
|
||||
constructor(data: Partial<TestOrder> = {}) {
|
||||
super();
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup and teardown
|
||||
tap.test('should create a test database instance', async () => {
|
||||
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
|
||||
testDb = new smartdata.SmartdataDb(await smartmongoInstance.getMongoDescriptor());
|
||||
await testDb.init();
|
||||
expect(testDb).toBeInstanceOf(SmartdataDb);
|
||||
});
|
||||
|
||||
tap.test('should create test data', async () => {
|
||||
// Create test users
|
||||
const users = [
|
||||
new TestUser({
|
||||
name: 'John Doe',
|
||||
age: 30,
|
||||
email: 'john@example.com',
|
||||
roles: ['admin', 'user'],
|
||||
tags: ['javascript', 'nodejs', 'mongodb'],
|
||||
status: 'active',
|
||||
metadata: { loginCount: 5, lastLogin: new Date() },
|
||||
scores: [85, 90, 78]
|
||||
}),
|
||||
new TestUser({
|
||||
name: 'Jane Smith',
|
||||
age: 25,
|
||||
email: 'jane@example.com',
|
||||
roles: ['user'],
|
||||
tags: ['python', 'mongodb'],
|
||||
status: 'active',
|
||||
metadata: { loginCount: 3 },
|
||||
scores: [92, 88, 95]
|
||||
}),
|
||||
new TestUser({
|
||||
name: 'Bob Johnson',
|
||||
age: 35,
|
||||
email: 'bob@example.com',
|
||||
roles: ['moderator', 'user'],
|
||||
tags: ['javascript', 'react', 'nodejs'],
|
||||
status: 'inactive',
|
||||
metadata: { loginCount: 0 },
|
||||
scores: [70, 75, 80]
|
||||
}),
|
||||
new TestUser({
|
||||
name: 'Alice Brown',
|
||||
age: 28,
|
||||
email: 'alice@example.com',
|
||||
roles: ['admin'],
|
||||
tags: ['typescript', 'angular', 'mongodb'],
|
||||
status: 'active',
|
||||
metadata: { loginCount: 10 },
|
||||
scores: [95, 98, 100]
|
||||
}),
|
||||
new TestUser({
|
||||
name: 'Charlie Wilson',
|
||||
age: 22,
|
||||
email: 'charlie@example.com',
|
||||
roles: ['user'],
|
||||
tags: ['golang', 'kubernetes'],
|
||||
status: 'pending',
|
||||
metadata: { loginCount: 1 },
|
||||
scores: [60, 65]
|
||||
})
|
||||
];
|
||||
|
||||
for (const user of users) {
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// Create test orders
|
||||
const orders = [
|
||||
new TestOrder({
|
||||
userId: users[0].id,
|
||||
items: [
|
||||
{ product: 'laptop', quantity: 1, price: 1200 },
|
||||
{ product: 'mouse', quantity: 2, price: 25 }
|
||||
],
|
||||
totalAmount: 1250,
|
||||
status: 'completed',
|
||||
tags: ['electronics', 'priority']
|
||||
}),
|
||||
new TestOrder({
|
||||
userId: users[1].id,
|
||||
items: [
|
||||
{ product: 'book', quantity: 3, price: 15 },
|
||||
{ product: 'pen', quantity: 5, price: 2 }
|
||||
],
|
||||
totalAmount: 55,
|
||||
status: 'pending',
|
||||
tags: ['stationery']
|
||||
}),
|
||||
new TestOrder({
|
||||
userId: users[0].id,
|
||||
items: [
|
||||
{ product: 'laptop', quantity: 2, price: 1200 },
|
||||
{ product: 'keyboard', quantity: 2, price: 80 }
|
||||
],
|
||||
totalAmount: 2560,
|
||||
status: 'processing',
|
||||
tags: ['electronics', 'bulk']
|
||||
})
|
||||
];
|
||||
|
||||
for (const order of orders) {
|
||||
await order.save();
|
||||
}
|
||||
|
||||
const savedUsers = await TestUser.getInstances({});
|
||||
const savedOrders = await TestOrder.getInstances({});
|
||||
expect(savedUsers.length).toEqual(5);
|
||||
expect(savedOrders.length).toEqual(3);
|
||||
});
|
||||
|
||||
// ============= BASIC FILTER TESTS =============
|
||||
tap.test('should filter by simple equality', async () => {
|
||||
const users = await TestUser.getInstances({ name: 'John Doe' });
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('John Doe');
|
||||
});
|
||||
|
||||
tap.test('should filter by multiple fields (implicit AND)', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
status: 'active',
|
||||
age: 30
|
||||
});
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('John Doe');
|
||||
});
|
||||
|
||||
tap.test('should filter by nested object fields', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
'metadata.loginCount': 5
|
||||
});
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('John Doe');
|
||||
});
|
||||
|
||||
// ============= COMPREHENSIVE NESTED FILTER TESTS =============
|
||||
tap.test('should filter by nested object with direct object syntax', async () => {
|
||||
// Direct nested object matching (exact match)
|
||||
const users = await TestUser.getInstances({
|
||||
metadata: {
|
||||
loginCount: 5,
|
||||
lastLogin: (await TestUser.getInstances({}))[0].metadata.lastLogin // Get the exact date
|
||||
}
|
||||
});
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('John Doe');
|
||||
});
|
||||
|
||||
tap.test('should filter by partial nested object match', async () => {
|
||||
// When using object syntax, only specified fields must match
|
||||
const users = await TestUser.getInstances({
|
||||
metadata: { loginCount: 5 } // Only checks loginCount, ignores other fields
|
||||
});
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('John Doe');
|
||||
});
|
||||
|
||||
tap.test('should combine nested object and dot notation', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
metadata: { loginCount: { $gte: 3 } }, // Object syntax with operator
|
||||
'metadata.loginCount': { $lte: 10 } // Dot notation with operator
|
||||
});
|
||||
expect(users.length).toEqual(3); // Jane (3), John (5), and Alice (10) have loginCount between 3-10
|
||||
});
|
||||
|
||||
tap.test('should filter nested fields with operators using dot notation', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
'metadata.loginCount': { $gte: 5 }
|
||||
});
|
||||
expect(users.length).toEqual(2); // John (5) and Alice (10)
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Alice Brown', 'John Doe']);
|
||||
});
|
||||
|
||||
tap.test('should filter nested fields with multiple operators', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
'metadata.loginCount': { $gte: 3, $lt: 10 }
|
||||
});
|
||||
expect(users.length).toEqual(2); // Jane (3) and John (5)
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Jane Smith', 'John Doe']);
|
||||
});
|
||||
|
||||
tap.test('should handle deeply nested object structures', async () => {
|
||||
// First, create a user with deep nesting in preferences
|
||||
const deepUser = new TestUser({
|
||||
name: 'Deep Nester',
|
||||
age: 40,
|
||||
email: 'deep@example.com',
|
||||
roles: ['admin'],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
metadata: {
|
||||
loginCount: 1,
|
||||
preferences: {
|
||||
theme: {
|
||||
colors: {
|
||||
primary: '#000000',
|
||||
secondary: '#ffffff'
|
||||
},
|
||||
fonts: {
|
||||
heading: 'Arial',
|
||||
body: 'Helvetica'
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
email: true,
|
||||
push: false
|
||||
}
|
||||
}
|
||||
},
|
||||
scores: []
|
||||
});
|
||||
await deepUser.save();
|
||||
|
||||
// Test deep nesting with dot notation
|
||||
const deepResults = await TestUser.getInstances({
|
||||
'metadata.preferences.theme.colors.primary': '#000000'
|
||||
});
|
||||
expect(deepResults.length).toEqual(1);
|
||||
expect(deepResults[0].name).toEqual('Deep Nester');
|
||||
|
||||
// Test deep nesting with operators
|
||||
const boolResults = await TestUser.getInstances({
|
||||
'metadata.preferences.notifications.email': { $eq: true }
|
||||
});
|
||||
expect(boolResults.length).toEqual(1);
|
||||
expect(boolResults[0].name).toEqual('Deep Nester');
|
||||
|
||||
// Clean up
|
||||
await deepUser.delete();
|
||||
});
|
||||
|
||||
tap.test('should filter arrays of nested objects using $elemMatch', async () => {
|
||||
const orders = await TestOrder.getInstances({
|
||||
items: {
|
||||
$elemMatch: {
|
||||
product: 'laptop',
|
||||
price: { $gte: 1000 }
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(orders.length).toEqual(2); // Both laptop orders have price >= 1000
|
||||
});
|
||||
|
||||
tap.test('should filter nested arrays with dot notation', async () => {
|
||||
// Query for any order that has an item with specific product
|
||||
const orders = await TestOrder.getInstances({
|
||||
'items.product': 'laptop'
|
||||
});
|
||||
expect(orders.length).toEqual(2); // Two orders contain laptops
|
||||
});
|
||||
|
||||
tap.test('should combine nested object filters with logical operators', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
$or: [
|
||||
{ 'metadata.loginCount': { $gte: 10 } }, // Alice has 10
|
||||
{
|
||||
$and: [
|
||||
{ 'metadata.loginCount': { $lt: 5 } }, // Jane has 3, Bob has 0, Charlie has 1
|
||||
{ status: 'active' } // Jane is active, Bob is inactive, Charlie is pending
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(users.length).toEqual(2); // Alice (loginCount >= 10), Jane (loginCount < 5 AND active)
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Alice Brown', 'Jane Smith']);
|
||||
});
|
||||
|
||||
tap.test('should handle null and undefined in nested fields', async () => {
|
||||
// Users without lastLogin
|
||||
const noLastLogin = await TestUser.getInstances({
|
||||
'metadata.lastLogin': { $exists: false }
|
||||
});
|
||||
expect(noLastLogin.length).toEqual(4); // Everyone except John
|
||||
|
||||
// Users with preferences (none have it set)
|
||||
const withPreferences = await TestUser.getInstances({
|
||||
'metadata.preferences': { $exists: true }
|
||||
});
|
||||
expect(withPreferences.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should filter nested arrays by size', async () => {
|
||||
// Create an order with specific number of items
|
||||
const multiItemOrder = new TestOrder({
|
||||
userId: 'test-user',
|
||||
items: [
|
||||
{ product: 'item1', quantity: 1, price: 10 },
|
||||
{ product: 'item2', quantity: 2, price: 20 },
|
||||
{ product: 'item3', quantity: 3, price: 30 },
|
||||
{ product: 'item4', quantity: 4, price: 40 }
|
||||
],
|
||||
totalAmount: 100,
|
||||
status: 'pending',
|
||||
tags: ['test']
|
||||
});
|
||||
await multiItemOrder.save();
|
||||
|
||||
const fourItemOrders = await TestOrder.getInstances({
|
||||
items: { $size: 4 }
|
||||
});
|
||||
expect(fourItemOrders.length).toEqual(1);
|
||||
|
||||
// Clean up
|
||||
await multiItemOrder.delete();
|
||||
});
|
||||
|
||||
tap.test('should handle nested field comparison between documents', async () => {
|
||||
// Find users where loginCount equals their age divided by 6 (John: 30/6=5)
|
||||
const users = await TestUser.getInstances({
|
||||
$and: [
|
||||
{ 'metadata.loginCount': 5 },
|
||||
{ age: 30 }
|
||||
]
|
||||
});
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('John Doe');
|
||||
});
|
||||
|
||||
tap.test('should filter using $in on nested fields', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
'metadata.loginCount': { $in: [0, 1, 5] }
|
||||
});
|
||||
expect(users.length).toEqual(3); // Bob (0), Charlie (1), John (5)
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Bob Johnson', 'Charlie Wilson', 'John Doe']);
|
||||
});
|
||||
|
||||
tap.test('should filter nested arrays with $all', async () => {
|
||||
// Create an order with multiple tags
|
||||
const taggedOrder = new TestOrder({
|
||||
userId: 'test-user',
|
||||
items: [{ product: 'test', quantity: 1, price: 10 }],
|
||||
totalAmount: 10,
|
||||
status: 'completed',
|
||||
tags: ['urgent', 'priority', 'electronics']
|
||||
});
|
||||
await taggedOrder.save();
|
||||
|
||||
const priorityElectronics = await TestOrder.getInstances({
|
||||
tags: { $all: ['priority', 'electronics'] }
|
||||
});
|
||||
expect(priorityElectronics.length).toEqual(2); // Original order and new one
|
||||
|
||||
// Clean up
|
||||
await taggedOrder.delete();
|
||||
});
|
||||
|
||||
// ============= COMPARISON OPERATOR TESTS =============
|
||||
tap.test('should filter using $gt operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
age: { $gt: 30 }
|
||||
});
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('Bob Johnson');
|
||||
});
|
||||
|
||||
tap.test('should filter using $gte operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
age: { $gte: 30 }
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Bob Johnson', 'John Doe']);
|
||||
});
|
||||
|
||||
tap.test('should filter using $lt operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
age: { $lt: 25 }
|
||||
});
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('Charlie Wilson');
|
||||
});
|
||||
|
||||
tap.test('should filter using $lte operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
age: { $lte: 25 }
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Charlie Wilson', 'Jane Smith']);
|
||||
});
|
||||
|
||||
tap.test('should filter using $ne operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
status: { $ne: 'active' }
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const statuses = users.map(u => u.status).sort();
|
||||
expect(statuses).toEqual(['inactive', 'pending']);
|
||||
});
|
||||
|
||||
tap.test('should filter using multiple comparison operators', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
age: { $gte: 25, $lt: 30 }
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Alice Brown', 'Jane Smith']);
|
||||
});
|
||||
|
||||
// ============= ARRAY OPERATOR TESTS =============
|
||||
tap.test('should filter using $in operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
status: { $in: ['active', 'pending'] }
|
||||
});
|
||||
expect(users.length).toEqual(4);
|
||||
expect(users.every(u => ['active', 'pending'].includes(u.status))).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should filter arrays using $in operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
roles: { $in: ['admin'] }
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Alice Brown', 'John Doe']);
|
||||
});
|
||||
|
||||
tap.test('should filter using $nin operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
status: { $nin: ['inactive', 'pending'] }
|
||||
});
|
||||
expect(users.length).toEqual(3);
|
||||
expect(users.every(u => u.status === 'active')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should filter arrays using $all operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
tags: { $all: ['javascript', 'nodejs'] }
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Bob Johnson', 'John Doe']);
|
||||
});
|
||||
|
||||
tap.test('should filter arrays using $size operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
scores: { $size: 2 }
|
||||
});
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('Charlie Wilson');
|
||||
});
|
||||
|
||||
tap.test('should filter arrays using $elemMatch operator', async () => {
|
||||
const orders = await TestOrder.getInstances({
|
||||
items: {
|
||||
$elemMatch: {
|
||||
product: 'laptop',
|
||||
quantity: { $gte: 2 }
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(orders.length).toEqual(1);
|
||||
expect(orders[0].totalAmount).toEqual(2560);
|
||||
});
|
||||
|
||||
tap.test('should filter using $elemMatch with single condition', async () => {
|
||||
const orders = await TestOrder.getInstances({
|
||||
items: {
|
||||
$elemMatch: {
|
||||
price: { $gt: 100 }
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(orders.length).toEqual(2);
|
||||
expect(orders.every(o => o.items.some(i => i.price > 100))).toEqual(true);
|
||||
});
|
||||
|
||||
// ============= LOGICAL OPERATOR TESTS =============
|
||||
tap.test('should filter using $or operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
$or: [
|
||||
{ age: { $lt: 25 } },
|
||||
{ status: 'inactive' }
|
||||
]
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Bob Johnson', 'Charlie Wilson']);
|
||||
});
|
||||
|
||||
tap.test('should filter using $and operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
$and: [
|
||||
{ status: 'active' },
|
||||
{ age: { $gte: 28 } }
|
||||
]
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Alice Brown', 'John Doe']);
|
||||
});
|
||||
|
||||
tap.test('should filter using $nor operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
$nor: [
|
||||
{ status: 'inactive' },
|
||||
{ age: { $lt: 25 } }
|
||||
]
|
||||
});
|
||||
expect(users.length).toEqual(3);
|
||||
expect(users.every(u => u.status !== 'inactive' && u.age >= 25)).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should filter using nested logical operators', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
$or: [
|
||||
{
|
||||
$and: [
|
||||
{ status: 'active' },
|
||||
{ roles: { $in: ['admin'] } }
|
||||
]
|
||||
},
|
||||
{ age: { $lt: 23 } }
|
||||
]
|
||||
});
|
||||
expect(users.length).toEqual(3);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Alice Brown', 'Charlie Wilson', 'John Doe']);
|
||||
});
|
||||
|
||||
// ============= ELEMENT OPERATOR TESTS =============
|
||||
tap.test('should filter using $exists operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
'metadata.lastLogin': { $exists: true }
|
||||
});
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('John Doe');
|
||||
});
|
||||
|
||||
tap.test('should filter using $exists false', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
'metadata.preferences': { $exists: false }
|
||||
});
|
||||
expect(users.length).toEqual(5);
|
||||
});
|
||||
|
||||
// ============= COMPLEX FILTER TESTS =============
|
||||
tap.test('should handle complex nested filters', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
$and: [
|
||||
{ status: 'active' },
|
||||
{
|
||||
$or: [
|
||||
{ age: { $gte: 30 } },
|
||||
{ roles: { $all: ['admin'] } }
|
||||
]
|
||||
},
|
||||
{ tags: { $in: ['mongodb'] } }
|
||||
]
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Alice Brown', 'John Doe']);
|
||||
});
|
||||
|
||||
tap.test('should combine multiple operator types', async () => {
|
||||
const orders = await TestOrder.getInstances({
|
||||
$and: [
|
||||
{ totalAmount: { $gte: 100 } },
|
||||
{ status: { $in: ['completed', 'processing'] } },
|
||||
{ tags: { $in: ['electronics'] } }
|
||||
]
|
||||
});
|
||||
expect(orders.length).toEqual(2);
|
||||
expect(orders.every(o => o.totalAmount >= 100)).toEqual(true);
|
||||
});
|
||||
|
||||
// ============= ERROR HANDLING TESTS =============
|
||||
tap.test('should throw error for $where operator', async () => {
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await TestUser.getInstances({
|
||||
$where: 'this.age > 25'
|
||||
});
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeTruthy();
|
||||
expect(error?.message).toMatch(/\$where.*not allowed/);
|
||||
});
|
||||
|
||||
tap.test('should throw error for invalid $in value', async () => {
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await TestUser.getInstances({
|
||||
status: { $in: 'active' as any } // Should be an array
|
||||
});
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeTruthy();
|
||||
expect(error?.message).toMatch(/\$in.*requires.*array/);
|
||||
});
|
||||
|
||||
tap.test('should throw error for invalid $size value', async () => {
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await TestUser.getInstances({
|
||||
scores: { $size: '3' as any } // Should be a number
|
||||
});
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeTruthy();
|
||||
expect(error?.message).toMatch(/\$size.*requires.*numeric/);
|
||||
});
|
||||
|
||||
tap.test('should throw error for dots in field names', async () => {
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await TestUser.getInstances({
|
||||
'some.nested.field': { 'invalid.key': 'value' }
|
||||
});
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeTruthy();
|
||||
expect(error?.message).toMatch(/keys cannot contain dots/);
|
||||
});
|
||||
|
||||
// ============= EDGE CASE TESTS =============
|
||||
tap.test('should handle empty filter (return all)', async () => {
|
||||
const users = await TestUser.getInstances({});
|
||||
expect(users.length).toEqual(5);
|
||||
});
|
||||
|
||||
tap.test('should handle null values in filter', async () => {
|
||||
// First, create a user with null email
|
||||
const nullUser = new TestUser({
|
||||
name: 'Null User',
|
||||
age: 40,
|
||||
email: null as any,
|
||||
roles: ['user'],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
metadata: {},
|
||||
scores: []
|
||||
});
|
||||
await nullUser.save();
|
||||
|
||||
const users = await TestUser.getInstances({ email: null });
|
||||
expect(users.length).toEqual(1);
|
||||
expect(users[0].name).toEqual('Null User');
|
||||
|
||||
// Clean up
|
||||
await nullUser.delete();
|
||||
});
|
||||
|
||||
tap.test('should handle arrays as direct equality match', async () => {
|
||||
// This tests that arrays without operators are treated as equality matches
|
||||
const users = await TestUser.getInstances({
|
||||
roles: ['user'] // Exact match for array
|
||||
});
|
||||
expect(users.length).toEqual(2); // Both Jane and Charlie have exactly ['user']
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Charlie Wilson', 'Jane Smith']);
|
||||
});
|
||||
|
||||
tap.test('should handle regex operator', async () => {
|
||||
const users = await TestUser.getInstances({
|
||||
name: { $regex: '^J', $options: 'i' }
|
||||
});
|
||||
expect(users.length).toEqual(2);
|
||||
const names = users.map(u => u.name).sort();
|
||||
expect(names).toEqual(['Jane Smith', 'John Doe']);
|
||||
});
|
||||
|
||||
tap.test('should handle unknown operators by letting MongoDB reject them', async () => {
|
||||
// Unknown operators should be passed through to MongoDB, which will reject them
|
||||
let error: Error | null = null;
|
||||
|
||||
try {
|
||||
await TestUser.getInstances({
|
||||
age: { $unknownOp: 30 } as any
|
||||
});
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).toBeTruthy();
|
||||
expect(error?.message).toMatch(/unknown operator.*\$unknownOp/);
|
||||
});
|
||||
|
||||
// ============= PERFORMANCE TESTS =============
|
||||
tap.test('should efficiently filter large result sets', async () => {
|
||||
// Create many test documents
|
||||
const manyUsers = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
manyUsers.push(new TestUser({
|
||||
name: `User ${i}`,
|
||||
age: 20 + (i % 40),
|
||||
email: `user${i}@example.com`,
|
||||
roles: i % 3 === 0 ? ['admin'] : ['user'],
|
||||
tags: i % 2 === 0 ? ['even', 'test'] : ['odd', 'test'],
|
||||
status: i % 4 === 0 ? 'inactive' : 'active',
|
||||
metadata: { loginCount: i },
|
||||
scores: [i, i + 10, i + 20]
|
||||
}));
|
||||
}
|
||||
|
||||
// Save in batches for efficiency
|
||||
for (const user of manyUsers) {
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// Complex filter that should still be fast
|
||||
const startTime = Date.now();
|
||||
const filtered = await TestUser.getInstances({
|
||||
$and: [
|
||||
{ age: { $gte: 30, $lt: 40 } },
|
||||
{ status: 'active' },
|
||||
{ tags: { $in: ['even'] } },
|
||||
{ 'metadata.loginCount': { $gte: 20 } }
|
||||
]
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(`Complex filter on 100+ documents took ${duration}ms`);
|
||||
expect(duration).toBeLessThan(1000); // Should complete in under 1 second
|
||||
expect(filtered.length).toBeGreaterThan(0);
|
||||
|
||||
// Clean up
|
||||
for (const user of manyUsers) {
|
||||
await user.delete();
|
||||
}
|
||||
});
|
||||
|
||||
// ============= CLEANUP =============
|
||||
tap.test('should clean up test database', async () => {
|
||||
await testDb.mongoDb.dropDatabase();
|
||||
await testDb.close();
|
||||
await smartmongoInstance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,3 +1,5 @@
|
||||
// TODO: Decorator support during testing for bun and deno in @git.zone/tstest
|
||||
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { Qenv } from '@push.rocks/qenv';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
@@ -60,6 +60,43 @@ tap.test('should watch a collection', async (toolsArg) => {
|
||||
await done.promise;
|
||||
});
|
||||
|
||||
// ======= New tests for EventEmitter and buffering support =======
|
||||
tap.test('should emit change via EventEmitter', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const watcher = await House.watch({});
|
||||
watcher.on('change', async (houseArg) => {
|
||||
// Expect a House instance
|
||||
expect(houseArg).toBeDefined();
|
||||
// Clean up
|
||||
await watcher.stop();
|
||||
done.resolve();
|
||||
});
|
||||
// Trigger an insert to generate a change event
|
||||
const h = new House();
|
||||
await h.save();
|
||||
await done.promise;
|
||||
});
|
||||
|
||||
tap.test('should buffer change events when bufferTimeMs is set', async (tools) => {
|
||||
const done = tools.defer();
|
||||
// bufferTimeMs collects events into arrays every 50ms
|
||||
const watcher = await House.watch({}, { bufferTimeMs: 50 });
|
||||
let received: House[];
|
||||
watcher.changeSubject.subscribe(async (batch: House[]) => {
|
||||
if (batch && batch.length > 0) {
|
||||
received = batch;
|
||||
await watcher.stop();
|
||||
done.resolve();
|
||||
}
|
||||
});
|
||||
// Rapidly insert multiple docs
|
||||
const docs = [new House(), new House(), new House()];
|
||||
for (const doc of docs) await doc.save();
|
||||
await done.promise;
|
||||
// All inserts should be in one buffered batch
|
||||
expect(received.length).toEqual(docs.length);
|
||||
});
|
||||
|
||||
// =======================================
|
||||
// close the database connection
|
||||
// =======================================
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartdata',
|
||||
version: '5.13.1',
|
||||
version: '7.1.0',
|
||||
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SmartdataDbCursor } from './classes.cursor.js';
|
||||
import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js';
|
||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||
import { CollectionFactory } from './classes.collectionfactory.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
export interface IFindOptions {
|
||||
limit?: number;
|
||||
@@ -20,37 +21,85 @@ export type TDelayed<TDelayedArg> = () => TDelayedArg;
|
||||
|
||||
const collectionFactory = new CollectionFactory();
|
||||
|
||||
/**
|
||||
* Initialize prototype and constructor properties from TC39 decorator metadata.
|
||||
* Shared by both Collection and managed decorators.
|
||||
*/
|
||||
function initializeDecoratorMetadata(
|
||||
constructor: { new (...args: any[]): any; prototype: any },
|
||||
metadata: any
|
||||
): void {
|
||||
if (!metadata) return;
|
||||
|
||||
const proto = constructor.prototype;
|
||||
const ctor = constructor as any;
|
||||
|
||||
// Prototype properties (instance-level)
|
||||
if (metadata.globalSaveableProperties && !proto.globalSaveableProperties) {
|
||||
proto.globalSaveableProperties = [...metadata.globalSaveableProperties];
|
||||
}
|
||||
if (metadata.saveableProperties && !proto.saveableProperties) {
|
||||
proto.saveableProperties = [...metadata.saveableProperties];
|
||||
}
|
||||
if (metadata.uniqueIndexes && !proto.uniqueIndexes) {
|
||||
proto.uniqueIndexes = [...metadata.uniqueIndexes];
|
||||
}
|
||||
if (metadata.regularIndexes && !proto.regularIndexes) {
|
||||
proto.regularIndexes = [...metadata.regularIndexes];
|
||||
}
|
||||
|
||||
// Constructor properties (static-level)
|
||||
if (metadata.searchableFields && !Array.isArray(ctor.searchableFields)) {
|
||||
ctor.searchableFields = [...metadata.searchableFields];
|
||||
}
|
||||
if (metadata._svDbOptions && !ctor._svDbOptions) {
|
||||
ctor._svDbOptions = { ...metadata._svDbOptions };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a decorator that will tell the decorated class what dbTable to use
|
||||
* @param dbArg
|
||||
*/
|
||||
export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) {
|
||||
return function classDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
|
||||
const decoratedClass = class extends constructor {
|
||||
public static className = constructor.name;
|
||||
public static get collection() {
|
||||
if (!(dbArg instanceof SmartdataDb)) {
|
||||
dbArg = dbArg();
|
||||
}
|
||||
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||
// Attach document constructor for searchableFields lookup
|
||||
if (!(coll as any).docCtor) {
|
||||
(coll as any).docCtor = decoratedClass;
|
||||
}
|
||||
return coll;
|
||||
return function classDecorator(value: Function, context: ClassDecoratorContext) {
|
||||
if (context.kind !== 'class') {
|
||||
throw new Error('Collection can only decorate classes');
|
||||
}
|
||||
|
||||
const constructor = value as { new (...args: any[]): any } & { className?: string };
|
||||
|
||||
const getCollection = () => {
|
||||
if (!(dbArg instanceof SmartdataDb)) {
|
||||
dbArg = dbArg();
|
||||
}
|
||||
public get collection() {
|
||||
if (!(dbArg instanceof SmartdataDb)) {
|
||||
dbArg = dbArg();
|
||||
}
|
||||
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||
if (!(coll as any).docCtor) {
|
||||
(coll as any).docCtor = decoratedClass;
|
||||
}
|
||||
return coll;
|
||||
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||
// Attach document constructor for searchableFields lookup
|
||||
if (coll && !(coll as any).docCtor) {
|
||||
(coll as any).docCtor = constructor;
|
||||
}
|
||||
return coll;
|
||||
};
|
||||
return decoratedClass;
|
||||
|
||||
// Add static className property directly on the constructor
|
||||
(constructor as any).className = constructor.name;
|
||||
|
||||
// Define collection getter on constructor (static access)
|
||||
Object.defineProperty(constructor, 'collection', {
|
||||
get: getCollection,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Define collection getter on prototype (instance access)
|
||||
Object.defineProperty(constructor.prototype, 'collection', {
|
||||
get: getCollection,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
initializeDecoratorMetadata(constructor, context.metadata);
|
||||
return constructor as any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,57 +117,51 @@ export const setDefaultManagerForDoc = <T,>(managerArg: IManager, dbDocArg: T):
|
||||
* @param dbArg
|
||||
*/
|
||||
export function managed<TManager extends IManager>(managerArg?: TManager | TDelayed<TManager>) {
|
||||
return function classDecorator<T extends { new (...args: any[]): any }>(constructor: T) {
|
||||
const decoratedClass = class extends constructor {
|
||||
public static className = constructor.name;
|
||||
public static get collection() {
|
||||
let dbArg: SmartdataDb;
|
||||
if (!managerArg) {
|
||||
dbArg = this.prototype.defaultManager.db;
|
||||
} else if (managerArg['db']) {
|
||||
dbArg = (managerArg as TManager).db;
|
||||
} else {
|
||||
dbArg = (managerArg as TDelayed<TManager>)().db;
|
||||
}
|
||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
||||
}
|
||||
public get collection() {
|
||||
let dbArg: SmartdataDb;
|
||||
if (!managerArg) {
|
||||
//console.log(this.defaultManager.db);
|
||||
//process.exit(0)
|
||||
dbArg = this.defaultManager.db;
|
||||
} else if (managerArg['db']) {
|
||||
dbArg = (managerArg as TManager).db;
|
||||
} else {
|
||||
dbArg = (managerArg as TDelayed<TManager>)().db;
|
||||
}
|
||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
||||
}
|
||||
public static get manager() {
|
||||
let manager: TManager;
|
||||
if (!managerArg) {
|
||||
manager = this.prototype.defaultManager;
|
||||
} else if (managerArg['db']) {
|
||||
manager = managerArg as TManager;
|
||||
} else {
|
||||
manager = (managerArg as TDelayed<TManager>)();
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
public get manager() {
|
||||
let manager: TManager;
|
||||
if (!managerArg) {
|
||||
manager = this.defaultManager;
|
||||
} else if (managerArg['db']) {
|
||||
manager = managerArg as TManager;
|
||||
} else {
|
||||
manager = (managerArg as TDelayed<TManager>)();
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
return function classDecorator(value: Function, context: ClassDecoratorContext) {
|
||||
if (context.kind !== 'class') {
|
||||
throw new Error('managed can only decorate classes');
|
||||
}
|
||||
|
||||
const constructor = value as { new (...args: any[]): any } & { className?: string };
|
||||
(constructor as any).className = constructor.name;
|
||||
|
||||
// Resolution helpers (capture managerArg via closure)
|
||||
const getManager = (defaultManagerFn: () => TManager): TManager => {
|
||||
if (!managerArg) return defaultManagerFn();
|
||||
if (managerArg['db']) return managerArg as TManager;
|
||||
return (managerArg as TDelayed<TManager>)();
|
||||
};
|
||||
return decoratedClass;
|
||||
|
||||
const getDb = (defaultManagerFn: () => TManager): SmartdataDb => {
|
||||
return getManager(defaultManagerFn).db;
|
||||
};
|
||||
|
||||
// Static getters
|
||||
Object.defineProperty(constructor, 'collection', {
|
||||
get(this: any) { return collectionFactory.getCollection(constructor.name, getDb(() => this.prototype.defaultManager)); },
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(constructor, 'manager', {
|
||||
get(this: any) { return getManager(() => this.prototype.defaultManager); },
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Instance getters
|
||||
Object.defineProperty(constructor.prototype, 'collection', {
|
||||
get(this: any) { return collectionFactory.getCollection(constructor.name, getDb(() => this.defaultManager)); },
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(constructor.prototype, 'manager', {
|
||||
get(this: any) { return getManager(() => this.defaultManager); },
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
initializeDecoratorMetadata(constructor, context.metadata);
|
||||
return constructor as any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,7 +204,7 @@ export class SmartdataCollection<T> {
|
||||
});
|
||||
if (!wantedCollection) {
|
||||
await this.smartdataDb.mongoDb.createCollection(this.collectionName);
|
||||
console.log(`Successfully initiated Collection ${this.collectionName}`);
|
||||
logger.log('info', `Successfully initiated Collection ${this.collectionName}`);
|
||||
}
|
||||
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
|
||||
// Auto-create a compound text index on all searchable fields
|
||||
@@ -182,10 +225,10 @@ export class SmartdataCollection<T> {
|
||||
/**
|
||||
* mark unique index
|
||||
*/
|
||||
public markUniqueIndexes(keyArrayArg: string[] = []) {
|
||||
public async markUniqueIndexes(keyArrayArg: string[] = []) {
|
||||
for (const key of keyArrayArg) {
|
||||
if (!this.uniqueIndexes.includes(key)) {
|
||||
this.mongoDbCollection.createIndex(key, {
|
||||
await this.mongoDbCollection.createIndex({ [key]: 1 }, {
|
||||
unique: true,
|
||||
});
|
||||
// make sure we only call this once and not for every doc we create
|
||||
@@ -197,12 +240,12 @@ export class SmartdataCollection<T> {
|
||||
/**
|
||||
* creates regular indexes for the collection
|
||||
*/
|
||||
public createRegularIndexes(indexesArg: Array<{field: string, options: IIndexOptions}> = []) {
|
||||
public async createRegularIndexes(indexesArg: Array<{field: string, options: IIndexOptions}> = []) {
|
||||
for (const indexDef of indexesArg) {
|
||||
// Check if we've already created this index
|
||||
const indexKey = indexDef.field;
|
||||
if (!this.regularIndexes.some(i => i.field === indexKey)) {
|
||||
this.mongoDbCollection.createIndex(
|
||||
await this.mongoDbCollection.createIndex(
|
||||
{ [indexDef.field]: 1 }, // Simple single-field index
|
||||
indexDef.options
|
||||
);
|
||||
@@ -222,53 +265,74 @@ export class SmartdataCollection<T> {
|
||||
/**
|
||||
* finds an object in the DbCollection
|
||||
*/
|
||||
public async findOne(filterObject: any): Promise<any> {
|
||||
public async findOne(
|
||||
filterObject: any,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<any> {
|
||||
await this.init();
|
||||
const cursor = this.mongoDbCollection.find(filterObject);
|
||||
const result = await cursor.next();
|
||||
cursor.close();
|
||||
return result;
|
||||
// Use MongoDB driver's findOne with optional session
|
||||
return this.mongoDbCollection.findOne(filterObject, { session: opts?.session });
|
||||
}
|
||||
|
||||
public async getCursor(
|
||||
filterObjectArg: any,
|
||||
dbDocArg: typeof SmartDataDbDoc,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<SmartdataDbCursor<any>> {
|
||||
await this.init();
|
||||
const cursor = this.mongoDbCollection.find(filterObjectArg);
|
||||
const cursor = this.mongoDbCollection.find(filterObjectArg, { session: opts?.session });
|
||||
return new SmartdataDbCursor(cursor, dbDocArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* finds an object in the DbCollection
|
||||
*/
|
||||
public async findAll(filterObject: any): Promise<any[]> {
|
||||
public async findAll(
|
||||
filterObject: any,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<any[]> {
|
||||
await this.init();
|
||||
const cursor = this.mongoDbCollection.find(filterObject);
|
||||
const cursor = this.mongoDbCollection.find(filterObject, { session: opts?.session });
|
||||
const result = await cursor.toArray();
|
||||
cursor.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* watches the collection while applying a filter
|
||||
* Watches the collection, returning a SmartdataDbWatcher with RxJS and EventEmitter support.
|
||||
* @param filterObject match filter for change stream
|
||||
* @param opts optional MongoDB ChangeStreamOptions & { bufferTimeMs } to buffer events
|
||||
* @param smartdataDbDocArg document class for instance creation
|
||||
*/
|
||||
public async watch(
|
||||
filterObject: any,
|
||||
smartdataDbDocArg: typeof SmartDataDbDoc,
|
||||
opts: (plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number }) = {},
|
||||
smartdataDbDocArg?: typeof SmartDataDbDoc,
|
||||
): Promise<SmartdataDbWatcher> {
|
||||
await this.init();
|
||||
// Extract bufferTimeMs from options
|
||||
const { bufferTimeMs, fullDocument, ...otherOptions } = opts || {};
|
||||
// Determine fullDocument behavior: default to 'updateLookup'
|
||||
const changeStreamOptions: plugins.mongodb.ChangeStreamOptions = {
|
||||
...otherOptions,
|
||||
fullDocument:
|
||||
fullDocument === undefined
|
||||
? 'updateLookup'
|
||||
: (fullDocument as any) === true
|
||||
? 'updateLookup'
|
||||
: fullDocument,
|
||||
} as any;
|
||||
// Build pipeline with match if provided
|
||||
const pipeline = filterObject ? [{ $match: filterObject }] : [];
|
||||
const changeStream = this.mongoDbCollection.watch(
|
||||
[
|
||||
{
|
||||
$match: filterObject,
|
||||
},
|
||||
],
|
||||
{
|
||||
fullDocument: 'updateLookup',
|
||||
},
|
||||
pipeline,
|
||||
changeStreamOptions,
|
||||
);
|
||||
const smartdataWatcher = new SmartdataDbWatcher(
|
||||
changeStream,
|
||||
smartdataDbDocArg,
|
||||
{ bufferTimeMs },
|
||||
);
|
||||
const smartdataWatcher = new SmartdataDbWatcher(changeStream, smartdataDbDocArg);
|
||||
await smartdataWatcher.readyDeferred.promise;
|
||||
return smartdataWatcher;
|
||||
}
|
||||
@@ -276,7 +340,10 @@ export class SmartdataCollection<T> {
|
||||
/**
|
||||
* create an object in the database
|
||||
*/
|
||||
public async insert(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
|
||||
public async insert(
|
||||
dbDocArg: T & SmartDataDbDoc<T, unknown>,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<any> {
|
||||
await this.init();
|
||||
await this.checkDoc(dbDocArg);
|
||||
this.markUniqueIndexes(dbDocArg.uniqueIndexes);
|
||||
@@ -287,14 +354,17 @@ export class SmartdataCollection<T> {
|
||||
}
|
||||
|
||||
const saveableObject = await dbDocArg.createSavableObject();
|
||||
const result = await this.mongoDbCollection.insertOne(saveableObject);
|
||||
const result = await this.mongoDbCollection.insertOne(saveableObject, { session: opts?.session });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* inserts object into the DbCollection
|
||||
*/
|
||||
public async update(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
|
||||
public async update(
|
||||
dbDocArg: T & SmartDataDbDoc<T, unknown>,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<any> {
|
||||
await this.init();
|
||||
await this.checkDoc(dbDocArg);
|
||||
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
||||
@@ -309,21 +379,27 @@ export class SmartdataCollection<T> {
|
||||
const result = await this.mongoDbCollection.updateOne(
|
||||
identifiableObject,
|
||||
{ $set: updateableObject },
|
||||
{ upsert: true },
|
||||
{ upsert: true, session: opts?.session },
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async delete(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
|
||||
public async delete(
|
||||
dbDocArg: T & SmartDataDbDoc<T, unknown>,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<any> {
|
||||
await this.init();
|
||||
await this.checkDoc(dbDocArg);
|
||||
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
||||
await this.mongoDbCollection.deleteOne(identifiableObject);
|
||||
await this.mongoDbCollection.deleteOne(identifiableObject, { session: opts?.session });
|
||||
}
|
||||
|
||||
public async getCount(filterObject: any) {
|
||||
public async getCount(
|
||||
filterObject: any,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
) {
|
||||
await this.init();
|
||||
return this.mongoDbCollection.countDocuments(filterObject);
|
||||
return this.mongoDbCollection.countDocuments(filterObject, { session: opts?.session });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,13 +6,8 @@ export class CollectionFactory {
|
||||
public collections: { [key: string]: SmartdataCollection<any> } = {};
|
||||
|
||||
public getCollection = (nameArg: string, dbArg: SmartdataDb): SmartdataCollection<any> => {
|
||||
if (!this.collections[nameArg]) {
|
||||
this.collections[nameArg] = (() => {
|
||||
if (dbArg instanceof SmartdataDb) {
|
||||
// tslint:disable-next-line: no-string-literal
|
||||
return new SmartdataCollection(nameArg, dbArg);
|
||||
}
|
||||
})();
|
||||
if (!this.collections[nameArg] && dbArg instanceof SmartdataDb) {
|
||||
this.collections[nameArg] = new SmartdataCollection(nameArg, dbArg);
|
||||
}
|
||||
return this.collections[nameArg];
|
||||
};
|
||||
|
||||
@@ -35,24 +35,44 @@ export class SmartdataDb {
|
||||
* connects to the database that was specified during instance creation
|
||||
*/
|
||||
public async init(): Promise<any> {
|
||||
const finalConnectionUrl = this.smartdataOptions.mongoDbUrl
|
||||
.replace('<USERNAME>', this.smartdataOptions.mongoDbUser)
|
||||
.replace('<username>', this.smartdataOptions.mongoDbUser)
|
||||
.replace('<USER>', this.smartdataOptions.mongoDbUser)
|
||||
.replace('<user>', this.smartdataOptions.mongoDbUser)
|
||||
.replace('<PASSWORD>', this.smartdataOptions.mongoDbPass)
|
||||
.replace('<password>', this.smartdataOptions.mongoDbPass)
|
||||
.replace('<DBNAME>', this.smartdataOptions.mongoDbName)
|
||||
.replace('<dbname>', this.smartdataOptions.mongoDbName);
|
||||
try {
|
||||
// Safely encode credentials to handle special characters
|
||||
const encodedUser = this.smartdataOptions.mongoDbUser
|
||||
? encodeURIComponent(this.smartdataOptions.mongoDbUser)
|
||||
: '';
|
||||
const encodedPass = this.smartdataOptions.mongoDbPass
|
||||
? encodeURIComponent(this.smartdataOptions.mongoDbPass)
|
||||
: '';
|
||||
|
||||
const finalConnectionUrl = this.smartdataOptions.mongoDbUrl
|
||||
.replace('<USERNAME>', encodedUser)
|
||||
.replace('<username>', encodedUser)
|
||||
.replace('<USER>', encodedUser)
|
||||
.replace('<user>', encodedUser)
|
||||
.replace('<PASSWORD>', encodedPass)
|
||||
.replace('<password>', encodedPass)
|
||||
.replace('<DBNAME>', this.smartdataOptions.mongoDbName)
|
||||
.replace('<dbname>', this.smartdataOptions.mongoDbName);
|
||||
|
||||
this.mongoDbClient = await plugins.mongodb.MongoClient.connect(finalConnectionUrl, {
|
||||
maxPoolSize: 100,
|
||||
maxIdleTimeMS: 10,
|
||||
});
|
||||
this.mongoDb = this.mongoDbClient.db(this.smartdataOptions.mongoDbName);
|
||||
this.status = 'connected';
|
||||
this.statusConnectedDeferred.resolve();
|
||||
console.log(`Connected to database ${this.smartdataOptions.mongoDbName}`);
|
||||
const clientOptions: plugins.mongodb.MongoClientOptions = {
|
||||
maxPoolSize: (this.smartdataOptions as any).maxPoolSize ?? 100,
|
||||
maxIdleTimeMS: (this.smartdataOptions as any).maxIdleTimeMS ?? 300000, // 5 minutes default
|
||||
serverSelectionTimeoutMS: (this.smartdataOptions as any).serverSelectionTimeoutMS ?? 30000,
|
||||
socketTimeoutMS: (this.smartdataOptions as any).socketTimeoutMS ?? 30000, // 30 seconds default — prevents hung operations from holding connections
|
||||
retryWrites: true,
|
||||
};
|
||||
|
||||
this.mongoDbClient = await plugins.mongodb.MongoClient.connect(finalConnectionUrl, clientOptions);
|
||||
this.mongoDb = this.mongoDbClient.db(this.smartdataOptions.mongoDbName);
|
||||
this.status = 'connected';
|
||||
this.statusConnectedDeferred.resolve();
|
||||
logger.log('info', `Connected to database ${this.smartdataOptions.mongoDbName}`);
|
||||
} catch (error) {
|
||||
this.status = 'disconnected';
|
||||
this.statusConnectedDeferred.reject(error);
|
||||
logger.log('error', `Failed to connect to database ${this.smartdataOptions.mongoDbName}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,6 +83,12 @@ export class SmartdataDb {
|
||||
this.status = 'disconnected';
|
||||
logger.log('info', `disconnected from database ${this.smartdataOptions.mongoDbName}`);
|
||||
}
|
||||
/**
|
||||
* Start a MongoDB client session for transactions
|
||||
*/
|
||||
public startSession(): plugins.mongodb.ClientSession {
|
||||
return this.mongoDbClient.startSession();
|
||||
}
|
||||
|
||||
// handle table to class distribution
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SmartdataDb } from './classes.db.js';
|
||||
import { managed, setDefaultManagerForDoc } from './classes.collection.js';
|
||||
import { SmartDataDbDoc, svDb, unI } from './classes.doc.js';
|
||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
@managed()
|
||||
export class DistributedClass extends SmartDataDbDoc<DistributedClass, DistributedClass> {
|
||||
@@ -63,11 +64,11 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
this.ownInstance.data.elected = false;
|
||||
}
|
||||
if (this.ownInstance?.data.status === 'stopped') {
|
||||
console.log(`stopping a distributed instance that has not been started yet.`);
|
||||
logger.log('warn', `stopping a distributed instance that has not been started yet.`);
|
||||
}
|
||||
this.ownInstance.data.status = 'stopped';
|
||||
await this.ownInstance.save();
|
||||
console.log(`stopped ${this.ownInstance.id}`);
|
||||
logger.log('info', `stopped ${this.ownInstance.id}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,17 +84,17 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
public async sendHeartbeat() {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
if (this.ownInstance.data.status === 'stopped') {
|
||||
console.log(`aborted sending heartbeat because status is stopped`);
|
||||
logger.log('debug', `aborted sending heartbeat because status is stopped`);
|
||||
return;
|
||||
}
|
||||
await this.ownInstance.updateFromDb();
|
||||
this.ownInstance.data.lastUpdated = Date.now();
|
||||
await this.ownInstance.save();
|
||||
console.log(`sent heartbeat for ${this.ownInstance.id}`);
|
||||
logger.log('debug', `sent heartbeat for ${this.ownInstance.id}`);
|
||||
const allInstances = DistributedClass.getInstances({});
|
||||
});
|
||||
if (this.ownInstance.data.status === 'stopped') {
|
||||
console.log(`aborted sending heartbeat because status is stopped`);
|
||||
logger.log('info', `aborted sending heartbeat because status is stopped`);
|
||||
return;
|
||||
}
|
||||
const eligibleLeader = await this.getEligibleLeader();
|
||||
@@ -120,7 +121,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
await this.ownInstance.save();
|
||||
});
|
||||
} else {
|
||||
console.warn(`distributed instance already initialized`);
|
||||
logger.log('warn', `distributed instance already initialized`);
|
||||
}
|
||||
|
||||
// lets enable the heartbeat
|
||||
@@ -149,24 +150,24 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
public async checkAndMaybeLead() {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
this.ownInstance.data.status = 'initializing';
|
||||
this.ownInstance.save();
|
||||
await this.ownInstance.save();
|
||||
});
|
||||
if (await this.getEligibleLeader()) {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
await this.ownInstance.updateFromDb();
|
||||
this.ownInstance.data.status = 'settled';
|
||||
await this.ownInstance.save();
|
||||
console.log(`${this.ownInstance.id} settled as follower`);
|
||||
logger.log('info', `${this.ownInstance.id} settled as follower`);
|
||||
});
|
||||
return;
|
||||
} else if (
|
||||
(await DistributedClass.getInstances({})).find((instanceArg) => {
|
||||
instanceArg.data.status === 'bidding' &&
|
||||
return instanceArg.data.status === 'bidding' &&
|
||||
instanceArg.data.biddingStartTime <= Date.now() - 4000 &&
|
||||
instanceArg.data.biddingStartTime >= Date.now() - 30000;
|
||||
})
|
||||
) {
|
||||
console.log('too late to the bidding party... waiting for next round.');
|
||||
logger.log('info', 'too late to the bidding party... waiting for next round.');
|
||||
return;
|
||||
} else {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
@@ -175,9 +176,9 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
this.ownInstance.data.biddingStartTime = Date.now();
|
||||
this.ownInstance.data.biddingShortcode = plugins.smartunique.shortId();
|
||||
await this.ownInstance.save();
|
||||
console.log('bidding code stored.');
|
||||
logger.log('info', 'bidding code stored.');
|
||||
});
|
||||
console.log(`bidding for leadership...`);
|
||||
logger.log('info', `bidding for leadership...`);
|
||||
await plugins.smartdelay.delayFor(plugins.smarttime.getMilliSecondsFromUnits({ seconds: 5 }));
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
let biddingInstances = await DistributedClass.getInstances({});
|
||||
@@ -187,7 +188,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
instanceArg.data.lastUpdated >=
|
||||
Date.now() - plugins.smarttime.getMilliSecondsFromUnits({ seconds: 10 }),
|
||||
);
|
||||
console.log(`found ${biddingInstances.length} bidding instances...`);
|
||||
logger.log('info', `found ${biddingInstances.length} bidding instances...`);
|
||||
this.ownInstance.data.elected = true;
|
||||
for (const biddingInstance of biddingInstances) {
|
||||
if (biddingInstance.data.biddingShortcode < this.ownInstance.data.biddingShortcode) {
|
||||
@@ -195,7 +196,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
}
|
||||
}
|
||||
await plugins.smartdelay.delayFor(5000);
|
||||
console.log(`settling with status elected = ${this.ownInstance.data.elected}`);
|
||||
logger.log('info', `settling with status elected = ${this.ownInstance.data.elected}`);
|
||||
this.ownInstance.data.status = 'settled';
|
||||
await this.ownInstance.save();
|
||||
});
|
||||
@@ -226,11 +227,11 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
this.distributedWatcher.changeSubject.subscribe({
|
||||
next: async (distributedDoc) => {
|
||||
if (!distributedDoc) {
|
||||
console.log(`registered deletion of instance...`);
|
||||
logger.log('info', `registered deletion of instance...`);
|
||||
return;
|
||||
}
|
||||
console.log(distributedDoc);
|
||||
console.log(`registered change for ${distributedDoc.id}`);
|
||||
logger.log('info', distributedDoc);
|
||||
logger.log('info', `registered change for ${distributedDoc.id}`);
|
||||
distributedDoc;
|
||||
},
|
||||
});
|
||||
@@ -252,7 +253,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
): Promise<plugins.taskbuffer.distributedCoordination.IDistributedTaskRequestResult> {
|
||||
await this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
if (!this.ownInstance) {
|
||||
console.error('instance need to be started first...');
|
||||
logger.log('error', 'instance need to be started first...');
|
||||
return;
|
||||
}
|
||||
await this.ownInstance.updateFromDb();
|
||||
@@ -268,7 +269,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
return taskRequestResult;
|
||||
});
|
||||
if (!result) {
|
||||
console.warn('no result found for task request...');
|
||||
logger.log('warn', 'no result found for task request...');
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
@@ -285,7 +286,7 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
);
|
||||
});
|
||||
if (!existingInfoBasis) {
|
||||
console.warn('trying to update a non existing task request... aborting!');
|
||||
logger.log('warn', 'trying to update a non existing task request... aborting!');
|
||||
return;
|
||||
}
|
||||
Object.assign(existingInfoBasis, infoBasisArg);
|
||||
@@ -293,8 +294,10 @@ export class SmartdataDistributedCoordinator extends plugins.taskbuffer.distribu
|
||||
plugins.smartdelay.delayFor(60000).then(() => {
|
||||
this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||
const indexToRemove = this.ownInstance.data.taskRequests.indexOf(existingInfoBasis);
|
||||
this.ownInstance.data.taskRequests.splice(indexToRemove, indexToRemove);
|
||||
await this.ownInstance.save();
|
||||
if (indexToRemove >= 0) {
|
||||
this.ownInstance.data.taskRequests.splice(indexToRemove, 1);
|
||||
await this.ownInstance.save();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
import { SmartdataDb } from './classes.db.js';
|
||||
import { logger } from './logging.js';
|
||||
import { SmartdataDbCursor } from './classes.cursor.js';
|
||||
import { type IManager, SmartdataCollection } from './classes.collection.js';
|
||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||
@@ -11,34 +12,110 @@ import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
|
||||
* - validate: post-fetch validator, return true to keep a doc
|
||||
*/
|
||||
export interface SearchOptions<T> {
|
||||
/**
|
||||
* Additional MongoDB filter to AND‐merge into the query
|
||||
*/
|
||||
filter?: Record<string, any>;
|
||||
/**
|
||||
* Post‐fetch validator; return true to keep each doc
|
||||
*/
|
||||
validate?: (doc: T) => Promise<boolean> | boolean;
|
||||
/**
|
||||
* Optional MongoDB session for transactional operations
|
||||
*/
|
||||
session?: plugins.mongodb.ClientSession;
|
||||
}
|
||||
|
||||
export type TDocCreation = 'db' | 'new' | 'mixed';
|
||||
|
||||
|
||||
// Type for decorator metadata - extends TypeScript's built-in DecoratorMetadataObject
|
||||
interface ISmartdataDecoratorMetadata extends DecoratorMetadataObject {
|
||||
globalSaveableProperties?: string[];
|
||||
saveableProperties?: string[];
|
||||
uniqueIndexes?: string[];
|
||||
regularIndexes?: Array<{field: string, options: IIndexOptions}>;
|
||||
searchableFields?: string[];
|
||||
_svDbOptions?: Record<string, SvDbOptions>;
|
||||
}
|
||||
|
||||
export function globalSvDb() {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
||||
if (!target.globalSaveableProperties) {
|
||||
target.globalSaveableProperties = [];
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('globalSvDb can only decorate fields');
|
||||
}
|
||||
target.globalSaveableProperties.push(key);
|
||||
|
||||
// Store metadata at class level using Symbol.metadata
|
||||
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||
if (!metadata.globalSaveableProperties) {
|
||||
metadata.globalSaveableProperties = [];
|
||||
}
|
||||
metadata.globalSaveableProperties.push(String(context.name));
|
||||
|
||||
// Use addInitializer to ensure prototype arrays are set up once
|
||||
context.addInitializer(function(this: any) {
|
||||
const proto = this.constructor.prototype;
|
||||
const metadata = this.constructor[Symbol.metadata];
|
||||
|
||||
if (metadata && metadata.globalSaveableProperties && !proto.globalSaveableProperties) {
|
||||
// Initialize prototype array from metadata (runs once per class)
|
||||
proto.globalSaveableProperties = [...metadata.globalSaveableProperties];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for custom serialization/deserialization of a field.
|
||||
*/
|
||||
export interface SvDbOptions {
|
||||
/** Function to serialize the field value before saving to DB */
|
||||
serialize?: (value: any) => any;
|
||||
/** Function to deserialize the field value after reading from DB */
|
||||
deserialize?: (value: any) => any;
|
||||
}
|
||||
|
||||
/**
|
||||
* saveable - saveable decorator to be used on class properties
|
||||
*/
|
||||
export function svDb() {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
||||
if (!target.saveableProperties) {
|
||||
target.saveableProperties = [];
|
||||
export function svDb(options?: SvDbOptions) {
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('svDb can only decorate fields');
|
||||
}
|
||||
target.saveableProperties.push(key);
|
||||
|
||||
const propName = String(context.name);
|
||||
|
||||
// Store metadata at class level using Symbol.metadata
|
||||
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||
if (!metadata.saveableProperties) {
|
||||
metadata.saveableProperties = [];
|
||||
}
|
||||
metadata.saveableProperties.push(propName);
|
||||
|
||||
// Store options in metadata
|
||||
if (options) {
|
||||
if (!metadata._svDbOptions) {
|
||||
metadata._svDbOptions = {};
|
||||
}
|
||||
metadata._svDbOptions[propName] = options;
|
||||
}
|
||||
|
||||
// Use addInitializer to ensure prototype arrays are set up once
|
||||
context.addInitializer(function(this: any) {
|
||||
const proto = this.constructor.prototype;
|
||||
const ctor = this.constructor;
|
||||
const metadata = ctor[Symbol.metadata];
|
||||
|
||||
if (metadata && metadata.saveableProperties && !proto.saveableProperties) {
|
||||
// Initialize prototype array from metadata (runs once per class)
|
||||
proto.saveableProperties = [...metadata.saveableProperties];
|
||||
}
|
||||
|
||||
// Initialize svDbOptions from metadata
|
||||
if (metadata && metadata._svDbOptions && !ctor._svDbOptions) {
|
||||
ctor._svDbOptions = { ...metadata._svDbOptions };
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,13 +123,30 @@ export function svDb() {
|
||||
* searchable - marks a property as searchable with Lucene query syntax
|
||||
*/
|
||||
export function searchable() {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
// Attach to class constructor for direct access
|
||||
const ctor = target.constructor as any;
|
||||
if (!Array.isArray(ctor.searchableFields)) {
|
||||
ctor.searchableFields = [];
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('searchable can only decorate fields');
|
||||
}
|
||||
ctor.searchableFields.push(key);
|
||||
|
||||
const propName = String(context.name);
|
||||
|
||||
// Store metadata at class level
|
||||
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||
if (!metadata.searchableFields) {
|
||||
metadata.searchableFields = [];
|
||||
}
|
||||
metadata.searchableFields.push(propName);
|
||||
|
||||
// Use addInitializer to set up constructor property once
|
||||
context.addInitializer(function(this: any) {
|
||||
const ctor = this.constructor as any;
|
||||
const metadata = ctor[Symbol.metadata];
|
||||
|
||||
if (metadata && metadata.searchableFields && !Array.isArray(ctor.searchableFields)) {
|
||||
// Initialize from metadata (runs once per class)
|
||||
ctor.searchableFields = [...metadata.searchableFields];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,20 +159,41 @@ function escapeForRegex(input: string): string {
|
||||
* unique index - decorator to mark a unique index
|
||||
*/
|
||||
export function unI() {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called unI on >>${target.constructor.name}.${key}<<`);
|
||||
|
||||
// mark the index as unique
|
||||
if (!target.uniqueIndexes) {
|
||||
target.uniqueIndexes = [];
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('unI can only decorate fields');
|
||||
}
|
||||
target.uniqueIndexes.push(key);
|
||||
|
||||
// and also save it
|
||||
if (!target.saveableProperties) {
|
||||
target.saveableProperties = [];
|
||||
const propName = String(context.name);
|
||||
|
||||
// Store metadata at class level
|
||||
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||
if (!metadata.uniqueIndexes) {
|
||||
metadata.uniqueIndexes = [];
|
||||
}
|
||||
target.saveableProperties.push(key);
|
||||
metadata.uniqueIndexes.push(propName);
|
||||
|
||||
// Also mark as saveable
|
||||
if (!metadata.saveableProperties) {
|
||||
metadata.saveableProperties = [];
|
||||
}
|
||||
if (!metadata.saveableProperties.includes(propName)) {
|
||||
metadata.saveableProperties.push(propName);
|
||||
}
|
||||
|
||||
// Use addInitializer to ensure prototype arrays are set up once
|
||||
context.addInitializer(function(this: any) {
|
||||
const proto = this.constructor.prototype;
|
||||
const metadata = this.constructor[Symbol.metadata];
|
||||
|
||||
if (metadata && metadata.uniqueIndexes && !proto.uniqueIndexes) {
|
||||
proto.uniqueIndexes = [...metadata.uniqueIndexes];
|
||||
}
|
||||
|
||||
if (metadata && metadata.saveableProperties && !proto.saveableProperties) {
|
||||
proto.saveableProperties = [...metadata.saveableProperties];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,67 +212,222 @@ export interface IIndexOptions {
|
||||
* index - decorator to mark a field for regular indexing
|
||||
*/
|
||||
export function index(options?: IIndexOptions) {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called index() on >${target.constructor.name}.${key}<`);
|
||||
|
||||
// Initialize regular indexes array if it doesn't exist
|
||||
if (!target.regularIndexes) {
|
||||
target.regularIndexes = [];
|
||||
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
||||
if (context.kind !== 'field') {
|
||||
throw new Error('index can only decorate fields');
|
||||
}
|
||||
|
||||
// Add this field to regularIndexes with its options
|
||||
target.regularIndexes.push({
|
||||
field: key,
|
||||
|
||||
const propName = String(context.name);
|
||||
|
||||
// Store metadata at class level
|
||||
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
||||
if (!metadata.regularIndexes) {
|
||||
metadata.regularIndexes = [];
|
||||
}
|
||||
metadata.regularIndexes.push({
|
||||
field: propName,
|
||||
options: options || {}
|
||||
});
|
||||
|
||||
// Also ensure it's marked as saveable
|
||||
if (!target.saveableProperties) {
|
||||
target.saveableProperties = [];
|
||||
|
||||
// Also mark as saveable
|
||||
if (!metadata.saveableProperties) {
|
||||
metadata.saveableProperties = [];
|
||||
}
|
||||
|
||||
if (!target.saveableProperties.includes(key)) {
|
||||
target.saveableProperties.push(key);
|
||||
if (!metadata.saveableProperties.includes(propName)) {
|
||||
metadata.saveableProperties.push(propName);
|
||||
}
|
||||
|
||||
// Use addInitializer to ensure prototype arrays are set up once
|
||||
context.addInitializer(function(this: any) {
|
||||
const proto = this.constructor.prototype;
|
||||
const metadata = this.constructor[Symbol.metadata];
|
||||
|
||||
if (metadata && metadata.regularIndexes && !proto.regularIndexes) {
|
||||
proto.regularIndexes = [...metadata.regularIndexes];
|
||||
}
|
||||
|
||||
if (metadata && metadata.saveableProperties && !proto.saveableProperties) {
|
||||
proto.saveableProperties = [...metadata.saveableProperties];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Helper type to extract element type from arrays or return T itself
|
||||
type ElementOf<T> = T extends ReadonlyArray<infer U> ? U : T;
|
||||
|
||||
// Type for $in/$nin values - arrays of the element type
|
||||
type InValues<T> = ReadonlyArray<ElementOf<T>>;
|
||||
|
||||
// Type that allows MongoDB operators on leaf values while maintaining nested type safety
|
||||
export type MongoFilterCondition<T> = T | {
|
||||
$eq?: T;
|
||||
$ne?: T;
|
||||
$gt?: T;
|
||||
$gte?: T;
|
||||
$lt?: T;
|
||||
$lte?: T;
|
||||
$in?: InValues<T>;
|
||||
$nin?: InValues<T>;
|
||||
$exists?: boolean;
|
||||
$type?: string | number;
|
||||
$regex?: string | RegExp;
|
||||
$options?: string;
|
||||
$all?: T extends ReadonlyArray<infer U> ? ReadonlyArray<U> : never;
|
||||
$elemMatch?: T extends ReadonlyArray<infer U> ? MongoFilter<U> : never;
|
||||
$size?: T extends ReadonlyArray<any> ? number : never;
|
||||
$not?: MongoFilterCondition<T>;
|
||||
};
|
||||
|
||||
export type MongoFilter<T> = {
|
||||
[K in keyof T]?: T[K] extends object
|
||||
? T[K] extends any[]
|
||||
? MongoFilterCondition<T[K]> // Arrays can have operators
|
||||
: MongoFilter<T[K]> | MongoFilterCondition<T[K]> // Objects can be nested or have operators
|
||||
: MongoFilterCondition<T[K]>; // Primitives get operators
|
||||
} & {
|
||||
// Logical operators
|
||||
$and?: MongoFilter<T>[];
|
||||
$or?: MongoFilter<T>[];
|
||||
$nor?: MongoFilter<T>[];
|
||||
$not?: MongoFilter<T>;
|
||||
// Allow any string key for dot notation (we lose type safety here but maintain flexibility)
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
||||
// Special case: detect MongoDB operators and pass them through directly
|
||||
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$where', '$regex'];
|
||||
// SECURITY: Block $where to prevent server-side JS execution
|
||||
if (filterArg.$where !== undefined) {
|
||||
throw new Error('$where operator is not allowed for security reasons');
|
||||
}
|
||||
|
||||
// Handle logical operators recursively
|
||||
const logicalOperators = ['$and', '$or', '$nor', '$not'];
|
||||
const processedFilter: { [key: string]: any } = {};
|
||||
|
||||
for (const key of Object.keys(filterArg)) {
|
||||
if (topLevelOperators.includes(key)) {
|
||||
return filterArg; // Return the filter as-is for MongoDB operators
|
||||
if (logicalOperators.includes(key)) {
|
||||
if (key === '$not') {
|
||||
processedFilter[key] = convertFilterForMongoDb(filterArg[key]);
|
||||
} else if (Array.isArray(filterArg[key])) {
|
||||
processedFilter[key] = filterArg[key].map((subFilter: any) => convertFilterForMongoDb(subFilter));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If only logical operators, return them
|
||||
const hasOnlyLogicalOperators = Object.keys(filterArg).every(key => logicalOperators.includes(key));
|
||||
if (hasOnlyLogicalOperators) {
|
||||
return processedFilter;
|
||||
}
|
||||
|
||||
// Original conversion logic for non-MongoDB query objects
|
||||
const convertedFilter: { [key: string]: any } = {};
|
||||
|
||||
// Helper to merge operator objects
|
||||
const mergeIntoConverted = (path: string, value: any) => {
|
||||
const existing = convertedFilter[path];
|
||||
if (!existing) {
|
||||
convertedFilter[path] = value;
|
||||
} else if (
|
||||
typeof existing === 'object' && !Array.isArray(existing) &&
|
||||
typeof value === 'object' && !Array.isArray(value) &&
|
||||
(Object.keys(existing).some(k => k.startsWith('$')) || Object.keys(value).some(k => k.startsWith('$')))
|
||||
) {
|
||||
// Both have operators, merge them
|
||||
convertedFilter[path] = { ...existing, ...value };
|
||||
} else {
|
||||
// Otherwise later wins
|
||||
convertedFilter[path] = value;
|
||||
}
|
||||
};
|
||||
|
||||
const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => {
|
||||
if (Array.isArray(filterArg2)) {
|
||||
// Directly assign arrays (they might be using operators like $in or $all)
|
||||
convertFilterArgument(keyPathArg2, filterArg2[0]);
|
||||
// Arrays are typically used as values for operators like $in or as direct equality matches
|
||||
mergeIntoConverted(keyPathArg2, filterArg2);
|
||||
return;
|
||||
} else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
|
||||
for (const key of Object.keys(filterArg2)) {
|
||||
if (key.startsWith('$')) {
|
||||
convertedFilter[keyPathArg2] = filterArg2;
|
||||
return;
|
||||
} else if (key.includes('.')) {
|
||||
// Check if this is an object with MongoDB operators
|
||||
const keys = Object.keys(filterArg2);
|
||||
const hasOperators = keys.some(key => key.startsWith('$'));
|
||||
|
||||
if (hasOperators) {
|
||||
// This object contains MongoDB operators
|
||||
// Validate and pass through allowed operators
|
||||
const allowedOperators = [
|
||||
// Comparison operators
|
||||
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte',
|
||||
// Array operators
|
||||
'$in', '$nin', '$all', '$elemMatch', '$size',
|
||||
// Element operators
|
||||
'$exists', '$type',
|
||||
// Evaluation operators (safe ones only)
|
||||
'$regex', '$options', '$text', '$mod',
|
||||
// Logical operators (nested)
|
||||
'$and', '$or', '$nor', '$not'
|
||||
];
|
||||
|
||||
// Check for dangerous operators
|
||||
if (keys.includes('$where')) {
|
||||
throw new Error('$where operator is not allowed for security reasons');
|
||||
}
|
||||
|
||||
// Validate all operators are in the allowed list
|
||||
const invalidOperators = keys.filter(key =>
|
||||
key.startsWith('$') && !allowedOperators.includes(key)
|
||||
);
|
||||
|
||||
if (invalidOperators.length > 0) {
|
||||
console.warn(`Warning: Unknown MongoDB operators detected: ${invalidOperators.join(', ')}`);
|
||||
}
|
||||
|
||||
// For array operators, ensure the values are appropriate
|
||||
if (filterArg2.$in && !Array.isArray(filterArg2.$in)) {
|
||||
throw new Error('$in operator requires an array value');
|
||||
}
|
||||
if (filterArg2.$nin && !Array.isArray(filterArg2.$nin)) {
|
||||
throw new Error('$nin operator requires an array value');
|
||||
}
|
||||
if (filterArg2.$all && !Array.isArray(filterArg2.$all)) {
|
||||
throw new Error('$all operator requires an array value');
|
||||
}
|
||||
if (filterArg2.$size && typeof filterArg2.$size !== 'number') {
|
||||
throw new Error('$size operator requires a numeric value');
|
||||
}
|
||||
|
||||
// Use merge helper to handle duplicate paths
|
||||
mergeIntoConverted(keyPathArg2, filterArg2);
|
||||
return;
|
||||
}
|
||||
|
||||
// No operators, check for dots in keys
|
||||
for (const key of keys) {
|
||||
if (key.includes('.')) {
|
||||
throw new Error('keys cannot contain dots');
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(filterArg2)) {
|
||||
|
||||
// Recursively process nested objects
|
||||
for (const key of keys) {
|
||||
convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]);
|
||||
}
|
||||
} else {
|
||||
convertedFilter[keyPathArg2] = filterArg2;
|
||||
// Primitive values
|
||||
mergeIntoConverted(keyPathArg2, filterArg2);
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of Object.keys(filterArg)) {
|
||||
convertFilterArgument(key, filterArg[key]);
|
||||
// Skip logical operators, they were already processed
|
||||
if (!logicalOperators.includes(key)) {
|
||||
convertFilterArgument(key, filterArg[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add back processed logical operators
|
||||
Object.assign(convertedFilter, processedFilter);
|
||||
|
||||
return convertedFilter;
|
||||
};
|
||||
|
||||
@@ -166,10 +436,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
* the collection object an Doc belongs to
|
||||
*/
|
||||
public static collection: SmartdataCollection<any>;
|
||||
public collection: SmartdataCollection<any>;
|
||||
declare public collection: SmartdataCollection<any>;
|
||||
public static defaultManager;
|
||||
public static manager;
|
||||
public manager: TManager;
|
||||
declare public manager: TManager;
|
||||
|
||||
/**
|
||||
* Helper to get collection with fallback to static for Deno compatibility
|
||||
*/
|
||||
private getCollectionSafe(): SmartdataCollection<any> {
|
||||
return this.collection || (this.constructor as any).collection;
|
||||
}
|
||||
|
||||
// STATIC
|
||||
public static createInstanceFromMongoDbNativeDoc<T>(
|
||||
@@ -179,7 +456,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
const newInstance = new this();
|
||||
(newInstance as any).creationStatus = 'db';
|
||||
for (const key of Object.keys(mongoDbNativeDocArg)) {
|
||||
newInstance[key] = mongoDbNativeDocArg[key];
|
||||
const rawValue = mongoDbNativeDocArg[key];
|
||||
const optionsMap = (this as any)._svDbOptions || {};
|
||||
const opts = optionsMap[key];
|
||||
newInstance[key] = opts && typeof opts.deserialize === 'function'
|
||||
? opts.deserialize(rawValue)
|
||||
: rawValue;
|
||||
}
|
||||
return newInstance;
|
||||
}
|
||||
@@ -187,14 +469,19 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
/**
|
||||
* gets all instances as array
|
||||
* @param this
|
||||
* @param filterArg
|
||||
* @param filterArg - Type-safe MongoDB filter with nested object support and operators
|
||||
* @returns
|
||||
*/
|
||||
public static async getInstances<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<T[]> {
|
||||
const foundDocs = await (this as any).collection.findAll(convertFilterForMongoDb(filterArg));
|
||||
// Pass session through to findAll for transactional queries
|
||||
const foundDocs = await (this as any).collection.findAll(
|
||||
convertFilterForMongoDb(filterArg),
|
||||
{ session: opts?.session },
|
||||
);
|
||||
const returnArray = [];
|
||||
for (const foundDoc of foundDocs) {
|
||||
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
||||
@@ -211,9 +498,14 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public static async getInstance<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: { session?: plugins.mongodb.ClientSession }
|
||||
): Promise<T> {
|
||||
const foundDoc = await (this as any).collection.findOne(convertFilterForMongoDb(filterArg));
|
||||
// Retrieve one document, with optional session for transactions
|
||||
const foundDoc = await (this as any).collection.findOne(
|
||||
convertFilterForMongoDb(filterArg),
|
||||
{ session: opts?.session },
|
||||
);
|
||||
if (foundDoc) {
|
||||
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
||||
return newInstance;
|
||||
@@ -233,33 +525,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
}
|
||||
|
||||
/**
|
||||
* get cursor
|
||||
* @returns
|
||||
* Get a cursor for streaming results, with optional session and native cursor modifiers.
|
||||
* @param filterArg Partial filter to apply
|
||||
* @param opts Optional session and modifier for the raw MongoDB cursor
|
||||
*/
|
||||
public static async getCursor<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
) {
|
||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||
const cursor: SmartdataDbCursor<T> = await collection.getCursor(
|
||||
convertFilterForMongoDb(filterArg),
|
||||
this as any as typeof SmartDataDbDoc,
|
||||
);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public static async getCursorExtended<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
modifierFunction = (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => cursorArg,
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: {
|
||||
session?: plugins.mongodb.ClientSession;
|
||||
modifier?: (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>;
|
||||
}
|
||||
): Promise<SmartdataDbCursor<T>> {
|
||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||
const { session, modifier } = opts || {};
|
||||
await collection.init();
|
||||
let cursor: plugins.mongodb.FindCursor<any> = collection.mongoDbCollection.find(
|
||||
convertFilterForMongoDb(filterArg),
|
||||
);
|
||||
cursor = modifierFunction(cursor);
|
||||
return new SmartdataDbCursor<T>(cursor, this as any as typeof SmartDataDbDoc);
|
||||
let rawCursor: plugins.mongodb.FindCursor<any> =
|
||||
collection.mongoDbCollection.find(convertFilterForMongoDb(filterArg), { session });
|
||||
if (modifier) {
|
||||
rawCursor = modifier(rawCursor);
|
||||
}
|
||||
return new SmartdataDbCursor<T>(rawCursor, this as any as typeof SmartDataDbDoc);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,13 +554,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
* @param filterArg
|
||||
* @param forEachFunction
|
||||
*/
|
||||
/**
|
||||
* Watch the collection for changes, with optional buffering and change stream options.
|
||||
* @param filterArg MongoDB filter to select which changes to observe
|
||||
* @param opts optional ChangeStreamOptions plus bufferTimeMs
|
||||
*/
|
||||
public static async watch<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
) {
|
||||
filterArg: MongoFilter<T>,
|
||||
opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number },
|
||||
): Promise<SmartdataDbWatcher<T>> {
|
||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||
const watcher: SmartdataDbWatcher<T> = await collection.watch(
|
||||
convertFilterForMongoDb(filterArg),
|
||||
opts || {},
|
||||
this as any,
|
||||
);
|
||||
return watcher;
|
||||
@@ -286,7 +579,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public static async forEach<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||
filterArg: MongoFilter<T>,
|
||||
forEachFunction: (itemArg: T) => Promise<any>,
|
||||
) {
|
||||
const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg);
|
||||
@@ -298,7 +591,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public static async getCount<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
filterArg: plugins.tsclass.typeFest.PartialDeep<T> = {} as any,
|
||||
filterArg: MongoFilter<T> = {} as any,
|
||||
) {
|
||||
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||
return await collection.getCount(filterArg);
|
||||
@@ -339,7 +632,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
if (opts?.filter) {
|
||||
mongoFilter = { $and: [mongoFilter, opts.filter] };
|
||||
}
|
||||
let docs: T[] = await (this as any).getInstances(mongoFilter);
|
||||
// Fetch with optional session for transactions
|
||||
// Fetch within optional session
|
||||
let docs: T[] = await (this as any).getInstances(mongoFilter, { session: opts?.session });
|
||||
if (opts?.validate) {
|
||||
const out: T[] = [];
|
||||
for (const d of docs) {
|
||||
@@ -512,23 +807,28 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
|
||||
/**
|
||||
* an array of saveable properties of ALL doc
|
||||
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
||||
* Declared with definite assignment assertion to satisfy TypeScript without creating instance property
|
||||
*/
|
||||
public globalSaveableProperties: string[];
|
||||
declare globalSaveableProperties: string[];
|
||||
|
||||
/**
|
||||
* unique indexes
|
||||
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
||||
*/
|
||||
public uniqueIndexes: string[];
|
||||
declare uniqueIndexes: string[];
|
||||
|
||||
/**
|
||||
* regular indexes with their options
|
||||
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
||||
*/
|
||||
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
|
||||
declare regularIndexes: Array<{field: string, options: IIndexOptions}>;
|
||||
|
||||
/**
|
||||
* an array of saveable properties of a specific doc
|
||||
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
||||
*/
|
||||
public saveableProperties: string[];
|
||||
declare saveableProperties: string[];
|
||||
|
||||
/**
|
||||
* name
|
||||
@@ -546,35 +846,52 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* saves this instance but not any connected items
|
||||
* may lead to data inconsistencies, but is faster
|
||||
* saves this instance (optionally within a transaction)
|
||||
*/
|
||||
public async save() {
|
||||
public async save(opts?: { session?: plugins.mongodb.ClientSession }) {
|
||||
// allow hook before saving
|
||||
if (typeof (this as any).beforeSave === 'function') {
|
||||
await (this as any).beforeSave();
|
||||
}
|
||||
// tslint:disable-next-line: no-this-assignment
|
||||
const self: any = this;
|
||||
let dbResult: any;
|
||||
|
||||
// update timestamp
|
||||
this._updatedAt = new Date().toISOString();
|
||||
|
||||
// perform insert or update
|
||||
switch (this.creationStatus) {
|
||||
case 'db':
|
||||
dbResult = await this.collection.update(self);
|
||||
dbResult = await this.getCollectionSafe().update(self, { session: opts?.session });
|
||||
break;
|
||||
case 'new':
|
||||
dbResult = await this.collection.insert(self);
|
||||
dbResult = await this.getCollectionSafe().insert(self, { session: opts?.session });
|
||||
this.creationStatus = 'db';
|
||||
break;
|
||||
default:
|
||||
console.error('neither new nor in db?');
|
||||
logger.log('error', 'neither new nor in db?');
|
||||
}
|
||||
// allow hook after saving
|
||||
if (typeof (this as any).afterSave === 'function') {
|
||||
await (this as any).afterSave();
|
||||
}
|
||||
return dbResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes a document from the database
|
||||
* deletes a document from the database (optionally within a transaction)
|
||||
*/
|
||||
public async delete() {
|
||||
await this.collection.delete(this);
|
||||
public async delete(opts?: { session?: plugins.mongodb.ClientSession }) {
|
||||
// allow hook before deleting
|
||||
if (typeof (this as any).beforeDelete === 'function') {
|
||||
await (this as any).beforeDelete();
|
||||
}
|
||||
// perform deletion
|
||||
const result = await this.getCollectionSafe().delete(this, { session: opts?.session });
|
||||
// allow hook after delete
|
||||
if (typeof (this as any).afterDelete === 'function') {
|
||||
await (this as any).afterDelete();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,11 +915,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
/**
|
||||
* updates an object from db
|
||||
*/
|
||||
public async updateFromDb() {
|
||||
const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject());
|
||||
for (const key of Object.keys(mongoDbNativeDoc)) {
|
||||
this[key] = mongoDbNativeDoc[key];
|
||||
public async updateFromDb(): Promise<boolean> {
|
||||
const mongoDbNativeDoc = await this.getCollectionSafe().findOne(await this.createIdentifiableObject());
|
||||
if (!mongoDbNativeDoc) {
|
||||
return false; // Document not found in database
|
||||
}
|
||||
for (const key of Object.keys(mongoDbNativeDoc)) {
|
||||
const rawValue = mongoDbNativeDoc[key];
|
||||
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
||||
const opts = optionsMap[key];
|
||||
this[key] = opts && typeof opts.deserialize === 'function'
|
||||
? opts.deserialize(rawValue)
|
||||
: rawValue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,9 +936,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
*/
|
||||
public async createSavableObject(): Promise<TImplements> {
|
||||
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
|
||||
const saveableProperties = [...this.globalSaveableProperties, ...this.saveableProperties];
|
||||
const globalProps = this.globalSaveableProperties || [];
|
||||
const specificProps = this.saveableProperties || [];
|
||||
const saveableProperties = [...globalProps, ...specificProps];
|
||||
// apply custom serialization if configured
|
||||
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
||||
for (const propertyNameString of saveableProperties) {
|
||||
saveableObject[propertyNameString] = this[propertyNameString];
|
||||
const rawValue = (this as any)[propertyNameString];
|
||||
const opts = optionsMap[propertyNameString];
|
||||
(saveableObject as any)[propertyNameString] = opts && typeof opts.serialize === 'function'
|
||||
? opts.serialize(rawValue)
|
||||
: rawValue;
|
||||
}
|
||||
return saveableObject as TImplements;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class EasyStore<T> {
|
||||
public nameId: string;
|
||||
|
||||
@svDb()
|
||||
public ephermal: {
|
||||
public ephemeral: {
|
||||
activated: boolean;
|
||||
timeout: number;
|
||||
};
|
||||
@@ -32,8 +32,8 @@ export class EasyStore<T> {
|
||||
return SmartdataEasyStore;
|
||||
})();
|
||||
|
||||
constructor(nameIdArg: string, smnartdataDbRefArg: SmartdataDb) {
|
||||
this.smartdataDbRef = smnartdataDbRefArg;
|
||||
constructor(nameIdArg: string, smartdataDbRefArg: SmartdataDb) {
|
||||
this.smartdataDbRef = smartdataDbRefArg;
|
||||
this.nameId = nameIdArg;
|
||||
}
|
||||
|
||||
@@ -110,10 +110,12 @@ export class EasyStore<T> {
|
||||
await easyStore.save();
|
||||
}
|
||||
|
||||
public async cleanUpEphermal() {
|
||||
while (
|
||||
(await this.smartdataDbRef.statusConnectedDeferred.promise) &&
|
||||
this.smartdataDbRef.status === 'connected'
|
||||
) {}
|
||||
public async cleanUpEphemeral() {
|
||||
// Clean up ephemeral data periodically while connected
|
||||
while (this.smartdataDbRef.status === 'connected') {
|
||||
await plugins.smartdelay.delayFor(60000); // Check every minute
|
||||
// TODO: Implement actual cleanup logic for ephemeral data
|
||||
// For now, this prevents the infinite CPU loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Lucene to MongoDB query adapter for SmartData
|
||||
*/
|
||||
import * as plugins from './plugins.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
// Types
|
||||
type NodeType =
|
||||
@@ -754,7 +755,7 @@ export class SmartdataLuceneAdapter {
|
||||
// Transform the AST to a MongoDB query
|
||||
return this.transformWithFields(ast, fieldsToSearch);
|
||||
} catch (error) {
|
||||
console.error(`Failed to convert Lucene query "${luceneQuery}":`, error);
|
||||
logger.log('error', `Failed to convert Lucene query "${luceneQuery}":`, error);
|
||||
throw new Error(`Failed to convert Lucene query: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,73 @@
|
||||
import { SmartDataDbDoc } from './classes.doc.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
/**
|
||||
* a wrapper for the native mongodb cursor. Exposes better
|
||||
*/
|
||||
export class SmartdataDbWatcher<T = any> {
|
||||
/**
|
||||
* Wraps a MongoDB ChangeStream with RxJS and EventEmitter support.
|
||||
*/
|
||||
export class SmartdataDbWatcher<T = any> extends EventEmitter {
|
||||
// STATIC
|
||||
public readyDeferred = plugins.smartpromise.defer();
|
||||
|
||||
// INSTANCE
|
||||
private changeStream: plugins.mongodb.ChangeStream<T>;
|
||||
|
||||
public changeSubject = new plugins.smartrx.rxjs.Subject<T>();
|
||||
private rawSubject: plugins.smartrx.rxjs.Subject<T>;
|
||||
/** Emits change documents (or arrays of documents if buffered) */
|
||||
public changeSubject: any;
|
||||
/**
|
||||
* @param changeStreamArg native MongoDB ChangeStream
|
||||
* @param smartdataDbDocArg document class for instance creation
|
||||
* @param opts.bufferTimeMs optional milliseconds to buffer events via RxJS
|
||||
*/
|
||||
constructor(
|
||||
changeStreamArg: plugins.mongodb.ChangeStream<T>,
|
||||
smartdataDbDocArg: typeof SmartDataDbDoc,
|
||||
opts?: { bufferTimeMs?: number },
|
||||
) {
|
||||
super();
|
||||
this.rawSubject = new plugins.smartrx.rxjs.Subject<T>();
|
||||
// Apply buffering if requested
|
||||
if (opts && opts.bufferTimeMs) {
|
||||
this.changeSubject = this.rawSubject.pipe(plugins.smartrx.rxjs.ops.bufferTime(opts.bufferTimeMs));
|
||||
} else {
|
||||
this.changeSubject = this.rawSubject;
|
||||
}
|
||||
this.changeStream = changeStreamArg;
|
||||
this.changeStream.on('change', async (item: any) => {
|
||||
if (!item.fullDocument) {
|
||||
this.changeSubject.next(null);
|
||||
return;
|
||||
let docInstance: T = null;
|
||||
if (item.fullDocument) {
|
||||
docInstance = smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(
|
||||
item.fullDocument
|
||||
) as any as T;
|
||||
}
|
||||
this.changeSubject.next(
|
||||
smartdataDbDocArg.createInstanceFromMongoDbNativeDoc(item.fullDocument) as any as T,
|
||||
);
|
||||
// Notify subscribers
|
||||
this.rawSubject.next(docInstance);
|
||||
this.emit('change', docInstance);
|
||||
});
|
||||
// Signal readiness after one tick
|
||||
plugins.smartdelay.delayFor(0).then(() => {
|
||||
this.readyDeferred.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
public async close() {
|
||||
/**
|
||||
* Close the change stream, complete the RxJS subject, and remove listeners.
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
// Close MongoDB ChangeStream
|
||||
await this.changeStream.close();
|
||||
// Complete the subject to teardown any buffering operators
|
||||
this.rawSubject.complete();
|
||||
// Remove all EventEmitter listeners
|
||||
this.removeAllListeners();
|
||||
}
|
||||
/**
|
||||
* Alias for close(), matching README usage
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
return this.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Polyfill must be imported first - ES modules hoist exports before code runs
|
||||
import './shim.js';
|
||||
|
||||
export * from './classes.db.js';
|
||||
export * from './classes.collection.js';
|
||||
export * from './classes.doc.js';
|
||||
|
||||
6
ts/shim.ts
Normal file
6
ts/shim.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Polyfill for Symbol.metadata required by TC39 Stage 3 decorators.
|
||||
* Must be imported before any decorator code loads.
|
||||
* @see https://github.com/tc39/proposal-decorator-metadata
|
||||
*/
|
||||
(Symbol as any).metadata ??= Symbol.for('Symbol.metadata');
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"target": "ES2024",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in New Issue
Block a user