@programisto/edrm-search 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "env": { "node": true, "es2021": true },
3
+ "extends": ["standard"],
4
+ "parser": "@typescript-eslint/parser",
5
+ "parserOptions": { "ecmaVersion": 12, "sourceType": "module" },
6
+ "plugins": ["@typescript-eslint"],
7
+ "rules": {
8
+ "semi": ["error", "always"],
9
+ "indent": "off",
10
+ "space-before-function-paren": "off"
11
+ },
12
+ "ignorePatterns": ["dist/", "node_modules/"]
13
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## [1.0.0] (unreleased)
4
+
5
+ ### Added
6
+
7
+ - Initial release: EDRM Search module for Endurance.
8
+ - Listener for `postSave` and `postDeleteOne` events to sync indexes with MongoDB collections.
9
+ - OpenSearch provider (index, remove, search, ensureIndex).
10
+ - Search service with configurable prefix and lazy index creation.
11
+ - Adapter implementing `EnduranceSearchProvider` and registration via `EnduranceSearchMiddleware.setInstance()`.
12
+ - Optional routes POST/GET `/search` (disabled by default via `EDRM_SEARCH_ROUTES_ENABLED`).
13
+ - Support for `q`, `filter`, `sort`, `from`, `size`, and raw `body` in search options.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # EDRM Search
2
+
3
+ ## Description
4
+
5
+ EDRM Search is an Endurance module that provides full-text search via OpenSearch (or Elasticsearch). It listens to Endurance schema events (`postSave`, `postDeleteOne`) to keep search indexes in sync with MongoDB collections. Search is exposed through the **Endurance search middleware**, so other EDRM modules can call search without depending on edrm-search.
6
+
7
+ ## Features
8
+
9
+ - Automatic index sync: documents are indexed on save and removed on delete.
10
+ - One index per MongoDB collection (with optional prefix).
11
+ - Search via **middleware**: use `EnduranceSearchMiddleware.getInstance().search(collection, options)` in any module.
12
+ - Optional unified route `POST /search` (disabled by default; enable with `EDRM_SEARCH_ROUTES_ENABLED=true`).
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @programisto/edrm-search
18
+ ```
19
+
20
+ ## Requirements
21
+
22
+ - **@programisto/endurance** ≥ 1.0.x (with `EnduranceSearchMiddleware` and `postSave` / `postDeleteOne` events)
23
+ - Node.js ≥ 18
24
+ - OpenSearch (or compatible) cluster
25
+
26
+ ## Environment Variables
27
+
28
+ | Variable | Required | Default | Description |
29
+ | -------- | -------- | ------- | ----------- |
30
+ | `SEARCH_ENGINE_URL` | Yes (if search enabled) | - | OpenSearch/Elasticsearch cluster URL (e.g. `https://localhost:9200`) |
31
+ | `SEARCH_ENGINE_ENABLED` | No | - | Set to `true` to enable indexing and the search provider |
32
+ | `SEARCH_ENGINE_INDEX_PREFIX` | No | `''` | Prefix for index names (e.g. `app_` → `app_users`) |
33
+ | `SEARCH_ENGINE_PROVIDER` | No | `opensearch` | `opensearch` or `elasticsearch` |
34
+ | `SEARCH_ENGINE_USERNAME` | No | - | Basic auth username |
35
+ | `SEARCH_ENGINE_PASSWORD` | No | - | Basic auth password |
36
+ | `EDRM_SEARCH_ROUTES_ENABLED` | No | `false` | Set to `true` to enable the `/search` HTTP routes (disabled by default for security) |
37
+
38
+ ## Using search from another module (middleware)
39
+
40
+ In any EDRM module, use the Endurance search middleware so you do not depend on edrm-search:
41
+
42
+ ```ts
43
+ import { EnduranceSearchMiddleware } from '@programisto/endurance';
44
+
45
+ // In a route handler:
46
+ const provider = EnduranceSearchMiddleware.getInstance();
47
+ if (provider.isSearchEnabled()) {
48
+ const result = await provider.search('users', { q: req.query.q, size: 20 });
49
+ res.json(result);
50
+ } else {
51
+ // Fallback to Model.find() or other logic
52
+ }
53
+ ```
54
+
55
+ ## Routes (when enabled)
56
+
57
+ When `EDRM_SEARCH_ROUTES_ENABLED=true`:
58
+
59
+ - **POST /search** – Body: `{ collection, q?, filter?, sort?, from?, size?, body? }`
60
+ - **GET /search** – Query: `collection` (required), `q`, `filter`, `sort`, `from`, `size`
61
+
62
+ By default these routes return **503** with `code: "SEARCH_ROUTES_DISABLED"` so that search is used only via the middleware from your modules.
63
+
64
+ ## Collection name convention
65
+
66
+ If the document does not expose a collection name via `constructor.getModel().collection.name`, the listener derives it from the event name (e.g. `User:postSave` → collection `users`).
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ /// <reference types="node" />
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+ const localAppPath = path.join(__dirname, '../lib/app.js');
8
+ const coreAppPath = path.join(__dirname, '../../node_modules/@programisto/endurance/dist/internal/app.js');
9
+ if (fs.existsSync(localAppPath)) {
10
+ await import(localAppPath);
11
+ }
12
+ else {
13
+ await import(coreAppPath);
14
+ }
@@ -0,0 +1,2 @@
1
+ import type { InternalSearchProvider } from './types.js';
2
+ export declare function createOpenSearchProvider(): InternalSearchProvider;
@@ -0,0 +1,70 @@
1
+ import { Client } from '@opensearch-project/opensearch';
2
+ let clientInstance = null;
3
+ function getClient() {
4
+ if (!clientInstance) {
5
+ const node = process.env.SEARCH_ENGINE_URL ?? '';
6
+ const clientConfig = { node };
7
+ const username = process.env.SEARCH_ENGINE_USERNAME;
8
+ const password = process.env.SEARCH_ENGINE_PASSWORD;
9
+ if (username && password) {
10
+ clientConfig.auth = { username, password };
11
+ }
12
+ clientInstance = new Client(clientConfig);
13
+ }
14
+ return clientInstance;
15
+ }
16
+ export function createOpenSearchProvider() {
17
+ const client = getClient();
18
+ return {
19
+ async index(indexName, id, body) {
20
+ await client.index({
21
+ index: indexName,
22
+ id: String(id),
23
+ body: body,
24
+ refresh: false
25
+ });
26
+ },
27
+ async remove(indexName, id) {
28
+ await client.delete({
29
+ index: indexName,
30
+ id: String(id),
31
+ refresh: false
32
+ });
33
+ },
34
+ async search(indexName, body) {
35
+ const response = await client.search({
36
+ index: indexName,
37
+ body: body
38
+ });
39
+ const hits = (response.body.hits?.hits ?? []).map((h) => ({
40
+ _id: h._id,
41
+ _source: h._source ?? {}
42
+ }));
43
+ return { hits };
44
+ },
45
+ async ensureIndex(indexName) {
46
+ try {
47
+ const exists = await client.indices.exists({ index: indexName });
48
+ const existsBody = exists.body;
49
+ if (!existsBody) {
50
+ await client.indices.create({
51
+ index: indexName,
52
+ body: { settings: { 'index.mapping.total_fields.limit': 2000 } }
53
+ });
54
+ }
55
+ }
56
+ catch (err) {
57
+ const status = err?.meta?.statusCode;
58
+ if (status === 404) {
59
+ await client.indices.create({
60
+ index: indexName,
61
+ body: { settings: { 'index.mapping.total_fields.limit': 2000 } }
62
+ });
63
+ }
64
+ else {
65
+ throw err;
66
+ }
67
+ }
68
+ }
69
+ };
70
+ }
@@ -0,0 +1,5 @@
1
+ import { EnduranceSearchProvider, type SearchOptions, type SearchResult } from '@programisto/endurance';
2
+ export declare class EdrmSearchProviderAdapter extends EnduranceSearchProvider {
3
+ isSearchEnabled(): boolean;
4
+ search(collection: string, options: SearchOptions): Promise<SearchResult>;
5
+ }
@@ -0,0 +1,10 @@
1
+ import { EnduranceSearchProvider } from '@programisto/endurance';
2
+ import { searchService } from './search-service.js';
3
+ export class EdrmSearchProviderAdapter extends EnduranceSearchProvider {
4
+ isSearchEnabled() {
5
+ return searchService.isEnabled();
6
+ }
7
+ async search(collection, options) {
8
+ return searchService.search(collection, { ...options, collection });
9
+ }
10
+ }
@@ -0,0 +1,13 @@
1
+ import type { SearchOptions, SearchResult } from '@programisto/endurance';
2
+ declare class SearchService {
3
+ private provider;
4
+ private prefix;
5
+ private getProvider;
6
+ isEnabled(): boolean;
7
+ getIndexName(collectionName: string): string;
8
+ index(collectionName: string, id: string, document: object): Promise<void>;
9
+ remove(collectionName: string, id: string): Promise<void>;
10
+ search(collectionName: string, options: SearchOptions): Promise<SearchResult>;
11
+ }
12
+ export declare const searchService: SearchService;
13
+ export {};
@@ -0,0 +1,100 @@
1
+ import { createOpenSearchProvider } from './opensearch-provider.js';
2
+ const DEFAULT_SIZE = 20;
3
+ const MAX_SIZE = 100;
4
+ function buildSearchBody(options) {
5
+ if (options.body && typeof options.body === 'object') {
6
+ return options.body;
7
+ }
8
+ const body = {
9
+ from: options.from ?? 0,
10
+ size: Math.min(options.size ?? DEFAULT_SIZE, MAX_SIZE)
11
+ };
12
+ const must = [];
13
+ if (options.q && String(options.q).trim()) {
14
+ must.push({
15
+ query_string: {
16
+ query: String(options.q).trim(),
17
+ default_field: '*'
18
+ }
19
+ });
20
+ }
21
+ if (options.filter && typeof options.filter === 'object' && Object.keys(options.filter).length > 0) {
22
+ const filterClauses = Object.entries(options.filter).map(([field, value]) => ({
23
+ term: { [field]: value }
24
+ }));
25
+ must.push({ bool: { filter: filterClauses } });
26
+ }
27
+ if (must.length > 0) {
28
+ body.query = { bool: { must } };
29
+ }
30
+ else if (!body.query) {
31
+ body.query = { match_all: {} };
32
+ }
33
+ if (options.sort && options.sort.length > 0) {
34
+ body.sort = options.sort;
35
+ }
36
+ return body;
37
+ }
38
+ class SearchService {
39
+ provider = null;
40
+ prefix = process.env.SEARCH_ENGINE_INDEX_PREFIX ?? '';
41
+ getProvider() {
42
+ if (!this.provider) {
43
+ this.provider = createOpenSearchProvider();
44
+ }
45
+ return this.provider;
46
+ }
47
+ isEnabled() {
48
+ const url = process.env.SEARCH_ENGINE_URL;
49
+ const enabled = process.env.SEARCH_ENGINE_ENABLED;
50
+ return Boolean(url && (enabled === 'true' || enabled === '1'));
51
+ }
52
+ getIndexName(collectionName) {
53
+ return this.prefix + collectionName;
54
+ }
55
+ async index(collectionName, id, document) {
56
+ if (!this.isEnabled())
57
+ return;
58
+ try {
59
+ const indexName = this.getIndexName(collectionName);
60
+ const provider = this.getProvider();
61
+ await provider.ensureIndex(indexName);
62
+ await provider.index(indexName, String(id), document);
63
+ }
64
+ catch (err) {
65
+ console.error('[edrm-search] Index error:', err);
66
+ }
67
+ }
68
+ async remove(collectionName, id) {
69
+ if (!this.isEnabled())
70
+ return;
71
+ try {
72
+ const indexName = this.getIndexName(collectionName);
73
+ const provider = this.getProvider();
74
+ await provider.remove(indexName, String(id));
75
+ }
76
+ catch (err) {
77
+ const status = err?.meta?.statusCode;
78
+ if (status !== 404) {
79
+ console.error('[edrm-search] Remove error:', err);
80
+ }
81
+ }
82
+ }
83
+ async search(collectionName, options) {
84
+ if (!this.isEnabled()) {
85
+ return { hits: [] };
86
+ }
87
+ try {
88
+ const indexName = this.getIndexName(collectionName);
89
+ const body = buildSearchBody({ ...options, collection: collectionName });
90
+ const provider = this.getProvider();
91
+ const result = await provider.search(indexName, body);
92
+ return { hits: result.hits };
93
+ }
94
+ catch (err) {
95
+ console.error('[edrm-search] Search error:', err);
96
+ return { hits: [] };
97
+ }
98
+ }
99
+ }
100
+ export const searchService = new SearchService();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Internal provider interface for the search engine (OpenSearch/ES).
3
+ * Public search contract is EnduranceSearchProvider from @programisto/endurance.
4
+ */
5
+ export interface InternalSearchProvider {
6
+ index(indexName: string, id: string, body: object): Promise<void>;
7
+ remove(indexName: string, id: string): Promise<void>;
8
+ search(indexName: string, body: object): Promise<InternalSearchResponse>;
9
+ ensureIndex(indexName: string): Promise<void>;
10
+ }
11
+ export interface InternalSearchResponse {
12
+ hits: Array<{
13
+ _id: string;
14
+ _source: object;
15
+ }>;
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { enduranceListener, EnduranceSearchMiddleware } from '@programisto/endurance';
2
+ import { searchService } from '../lib/search-service.js';
3
+ import { EdrmSearchProviderAdapter } from '../lib/search-provider-adapter.js';
4
+ function getCollectionNameFromDoc(doc, eventName) {
5
+ const ctor = doc
6
+ ?.constructor;
7
+ const name = ctor?.getModel?.()?.collection?.name;
8
+ if (name)
9
+ return name;
10
+ const className = eventName.split(':')[0] ?? '';
11
+ const lower = className.toLowerCase();
12
+ return lower.endsWith('s') ? lower : lower + 's';
13
+ }
14
+ function docToPlainObject(doc) {
15
+ if (doc === null || doc === undefined)
16
+ return {};
17
+ const d = doc;
18
+ if (typeof d.toJSON === 'function')
19
+ return d.toJSON();
20
+ if (typeof d.toObject === 'function')
21
+ return d.toObject();
22
+ if (typeof doc === 'object' && doc !== null)
23
+ return { ...doc };
24
+ return {};
25
+ }
26
+ if (searchService.isEnabled()) {
27
+ EnduranceSearchMiddleware.setInstance(new EdrmSearchProviderAdapter());
28
+ }
29
+ enduranceListener.createAnyListener(async (...args) => {
30
+ if (!searchService.isEnabled())
31
+ return;
32
+ if (!args?.length || typeof args[0] !== 'string')
33
+ return;
34
+ const event = args[0];
35
+ const data = args.length > 1 ? args[1] : undefined;
36
+ try {
37
+ if (event.endsWith(':postSave') && data) {
38
+ const doc = data;
39
+ const id = doc._id != null ? String(doc._id) : undefined;
40
+ if (id == null)
41
+ return;
42
+ const collectionName = getCollectionNameFromDoc(doc, event);
43
+ const payload = docToPlainObject(doc);
44
+ searchService.index(collectionName, id, payload).catch((err) => {
45
+ console.error('[edrm-search] postSave index error:', err);
46
+ });
47
+ }
48
+ else if (event.endsWith(':postDeleteOne') && data && typeof data === 'object') {
49
+ const payload = data;
50
+ const id = payload.id != null ? String(payload.id) : undefined;
51
+ const collectionName = payload.collectionName;
52
+ if (id != null && collectionName) {
53
+ searchService.remove(collectionName, id).catch((err) => {
54
+ console.error('[edrm-search] postDeleteOne remove error:', err);
55
+ });
56
+ }
57
+ }
58
+ }
59
+ catch (err) {
60
+ console.error('[edrm-search] Listener error:', err);
61
+ }
62
+ });
@@ -0,0 +1,8 @@
1
+ import { EnduranceRouter } from '@programisto/endurance';
2
+ declare class SearchRouter extends EnduranceRouter {
3
+ protected setupRoutes(): void;
4
+ private searchPost;
5
+ private searchGet;
6
+ }
7
+ declare const _default: SearchRouter;
8
+ export default _default;
@@ -0,0 +1,78 @@
1
+ import { EnduranceRouter, EnduranceSearchMiddleware } from '@programisto/endurance';
2
+ const ROUTES_DISABLED_MESSAGE = 'Search routes are disabled. Use the search middleware from your module.';
3
+ const ROUTES_DISABLED_CODE = 'SEARCH_ROUTES_DISABLED';
4
+ function routesEnabled() {
5
+ return process.env.EDRM_SEARCH_ROUTES_ENABLED === 'true';
6
+ }
7
+ class SearchRouter extends EnduranceRouter {
8
+ setupRoutes() {
9
+ this.router.use((req, res, next) => {
10
+ if (!routesEnabled()) {
11
+ res.status(503).json({ message: ROUTES_DISABLED_MESSAGE, code: ROUTES_DISABLED_CODE });
12
+ return;
13
+ }
14
+ next();
15
+ });
16
+ const securityOptions = { requireAuth: true, permissions: [] };
17
+ this.post('/', securityOptions, this.searchPost.bind(this));
18
+ this.get('/', securityOptions, this.searchGet.bind(this));
19
+ }
20
+ async searchPost(req, res) {
21
+ try {
22
+ const collection = req.body?.collection ?? req.query?.collection;
23
+ if (!collection || typeof collection !== 'string') {
24
+ res.status(400).json({ error: 'Missing or invalid "collection" (string)' });
25
+ return;
26
+ }
27
+ const provider = EnduranceSearchMiddleware.getInstance();
28
+ if (!provider.isSearchEnabled()) {
29
+ res.status(503).json({ error: 'Search is not enabled', code: 'SEARCH_NOT_ENABLED' });
30
+ return;
31
+ }
32
+ const options = {
33
+ collection,
34
+ q: req.body?.q ?? req.query?.q,
35
+ filter: req.body?.filter ?? req.query?.filter,
36
+ sort: req.body?.sort ?? req.query?.sort,
37
+ from: req.body?.from ?? req.query?.from,
38
+ size: req.body?.size ?? req.query?.size,
39
+ body: req.body?.body
40
+ };
41
+ const result = await provider.search(collection, options);
42
+ res.json(result);
43
+ }
44
+ catch (err) {
45
+ console.error('[edrm-search] Search error:', err);
46
+ res.status(500).json({ error: 'Search failed' });
47
+ }
48
+ }
49
+ async searchGet(req, res) {
50
+ try {
51
+ const collection = req.query?.collection;
52
+ if (!collection || typeof collection !== 'string') {
53
+ res.status(400).json({ error: 'Missing or invalid "collection" query (string)' });
54
+ return;
55
+ }
56
+ const provider = EnduranceSearchMiddleware.getInstance();
57
+ if (!provider.isSearchEnabled()) {
58
+ res.status(503).json({ error: 'Search is not enabled', code: 'SEARCH_NOT_ENABLED' });
59
+ return;
60
+ }
61
+ const options = {
62
+ collection,
63
+ q: req.query?.q,
64
+ filter: req.query?.filter,
65
+ sort: req.query?.sort,
66
+ from: req.query?.from != null ? Number(req.query.from) : undefined,
67
+ size: req.query?.size != null ? Number(req.query.size) : undefined
68
+ };
69
+ const result = await provider.search(collection, options);
70
+ res.json(result);
71
+ }
72
+ catch (err) {
73
+ console.error('[edrm-search] Search error:', err);
74
+ res.status(500).json({ error: 'Search failed' });
75
+ }
76
+ }
77
+ }
78
+ export default new SearchRouter();
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@programisto/edrm-search",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./dist/routes/search.router.js",
6
+ "types": "./dist/routes/search.router.d.ts",
7
+ "scripts": {
8
+ "start": "node ./dist/bin/www.js",
9
+ "dev": "tsc-watch --onSuccess \"node ./dist/bin/www.js\"",
10
+ "test": "echo \"Tests skipped\"",
11
+ "build": "tsc",
12
+ "lint": "eslint \"**/*.ts\"",
13
+ "prepare": "husky install 2>/dev/null || true"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/programisto-labs/edrm-search"
18
+ },
19
+ "author": "",
20
+ "license": "ISC",
21
+ "bugs": {
22
+ "url": "https://github.com/programisto-labs/edrm-search/issues"
23
+ },
24
+ "homepage": "https://github.com/programisto-labs/edrm-search#readme",
25
+ "description": "EDRM module for full-text search via OpenSearch/Elasticsearch, synced from Endurance schema events",
26
+ "dependencies": {
27
+ "@programisto/endurance": "^1.0.8",
28
+ "@opensearch-project/opensearch": "^2.10.0"
29
+ },
30
+ "devDependencies": {
31
+ "@commitlint/config-conventional": "^19.8.1",
32
+ "@types/express": "^4.17.21",
33
+ "@types/node": "^22.0.0",
34
+ "@typescript-eslint/eslint-plugin": "^8.35.0",
35
+ "@typescript-eslint/parser": "^8.35.0",
36
+ "eslint": "^8.57.1",
37
+ "eslint-config-standard": "^17.1.0",
38
+ "eslint-plugin-import": "^2.32.0",
39
+ "eslint-plugin-n": "^16.6.2",
40
+ "eslint-plugin-promise": "^6.6.0",
41
+ "commitlint": "^19.8.1",
42
+ "husky": "^9.0.0",
43
+ "reflect-metadata": "^0.2.2",
44
+ "tsc-watch": "^6.2.1",
45
+ "typescript": "^5.8.3"
46
+ },
47
+ "files": [
48
+ "dist",
49
+ "README.md",
50
+ "CHANGELOG.md",
51
+ ".eslintrc.json",
52
+ "package.json",
53
+ "tsconfig.json"
54
+ ],
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "rootDir": "./src/",
6
+ "outDir": "./dist",
7
+ "moduleResolution": "node",
8
+ "declaration": true,
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "esModuleInterop": true,
12
+ "allowSyntheticDefaultImports": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "resolveJsonModule": true,
15
+ "experimentalDecorators": true,
16
+ "emitDecoratorMetadata": true
17
+ },
18
+ "include": ["src/**/*.ts", "src/**/*.d.ts"],
19
+ "exclude": ["node_modules", "dist", "__tests__", "**/*.test.ts"]
20
+ }