@memberjunction/core 5.21.0 → 5.23.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.
Files changed (40) hide show
  1. package/dist/generic/baseEngine.js +1 -1
  2. package/dist/generic/baseEngine.js.map +1 -1
  3. package/dist/generic/entityInfo.d.ts +17 -0
  4. package/dist/generic/entityInfo.d.ts.map +1 -1
  5. package/dist/generic/entityInfo.js +17 -0
  6. package/dist/generic/entityInfo.js.map +1 -1
  7. package/dist/generic/interfaces.d.ts +159 -23
  8. package/dist/generic/interfaces.d.ts.map +1 -1
  9. package/dist/generic/interfaces.js +1 -2
  10. package/dist/generic/interfaces.js.map +1 -1
  11. package/dist/generic/localCacheManager.d.ts +22 -3
  12. package/dist/generic/localCacheManager.d.ts.map +1 -1
  13. package/dist/generic/localCacheManager.js +96 -54
  14. package/dist/generic/localCacheManager.js.map +1 -1
  15. package/dist/generic/metadata.d.ts +32 -1
  16. package/dist/generic/metadata.d.ts.map +1 -1
  17. package/dist/generic/metadata.js +37 -0
  18. package/dist/generic/metadata.js.map +1 -1
  19. package/dist/generic/providerBase.d.ts +22 -1
  20. package/dist/generic/providerBase.d.ts.map +1 -1
  21. package/dist/generic/providerBase.js +131 -36
  22. package/dist/generic/providerBase.js.map +1 -1
  23. package/dist/generic/scoring/ReciprocalRankFusion.d.ts +45 -0
  24. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -0
  25. package/dist/generic/scoring/ReciprocalRankFusion.js +63 -0
  26. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -0
  27. package/dist/generic/telemetryManager.d.ts +29 -2
  28. package/dist/generic/telemetryManager.d.ts.map +1 -1
  29. package/dist/generic/telemetryManager.js +1 -0
  30. package/dist/generic/telemetryManager.js.map +1 -1
  31. package/dist/generic/util.d.ts +46 -0
  32. package/dist/generic/util.d.ts.map +1 -1
  33. package/dist/generic/util.js +60 -0
  34. package/dist/generic/util.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/package.json +2 -2
  40. package/readme.md +6 -0
@@ -15,6 +15,7 @@ import { Metadata } from "./metadata.js";
15
15
  import { RunView } from "../views/runView.js";
16
16
  import { IsPlatformSQL } from "./platformSQL.js";
17
17
  import { GetDataHooks } from "./dataHooks.js";
18
+ import { TransformSimpleObjectToEntityObject } from "./util.js";
18
19
  /**
19
20
  * Creates a new instance of AllMetadata from a simple object.
20
21
  * Handles deserialization and proper instantiation of all metadata classes.
@@ -529,10 +530,12 @@ export class ProviderBase {
529
530
  boundaries.push({ start: allParams.length, count: entry.params.length });
530
531
  allParams.push(...entry.params);
531
532
  }
532
- const entityNames = allParams.map(p => p.EntityName || p.ViewName || '?').join(', ');
533
- LogStatusEx({
534
- message: `⚡ [Coalesce] Merged ${queue.length} RunViews calls (${allParams.length} total entities) into 1 mega-batch: [${entityNames}]`,
535
- verboseOnly: false
533
+ const entityNames = allParams.map(p => p.EntityName || p.ViewName || '?');
534
+ const eventId = TelemetryManager.Instance.StartEvent('Coalesce', 'ProviderBase.flushCoalesceQueue', {
535
+ CallerCount: queue.length,
536
+ TotalEntityCount: allParams.length,
537
+ Entities: entityNames,
538
+ CallerBoundaries: [...boundaries]
536
539
  });
537
540
  try {
538
541
  // Execute the mega-batch as a single pipeline call
@@ -543,8 +546,10 @@ export class ProviderBase {
543
546
  const callerResults = allResults.slice(start, start + count);
544
547
  queue[i].resolve(callerResults);
545
548
  }
549
+ TelemetryManager.Instance.EndEvent(eventId);
546
550
  }
547
551
  catch (err) {
552
+ TelemetryManager.Instance.EndEvent(eventId, { success: false, error: String(err) });
548
553
  // If the mega-batch fails, reject all callers
549
554
  for (const entry of queue) {
550
555
  entry.reject(err);
@@ -555,6 +560,111 @@ export class ProviderBase {
555
560
  * Runs RunViews without coalescing (used internally after coalescing merge).
556
561
  * This goes through dedup + linger + the full pipeline.
557
562
  */
563
+ /**
564
+ * Performs a full-text search across all entities that have FullTextSearchEnabled=true.
565
+ * Uses the existing RunView + UserSearchString infrastructure which routes through
566
+ * the database-native FTS capabilities (SQL Server FREETEXT functions, PostgreSQL tsvector).
567
+ *
568
+ * This is the default implementation that works across all database providers. Each provider's
569
+ * createViewUserSearchSQL() method handles the platform-specific SQL generation.
570
+ *
571
+ * @see /packages/MJCore/docs/FULL_TEXT_SEARCH_GUIDE.md for comprehensive documentation
572
+ */
573
+ async FullTextSearch(params, contextUser) {
574
+ const startTime = Date.now();
575
+ try {
576
+ const md = new Metadata();
577
+ const maxRows = params.MaxRowsPerEntity ?? 10;
578
+ // Get all FTS-enabled entities
579
+ const ftsEntities = this.resolveFTSEntities(md, params.EntityNames);
580
+ if (ftsEntities.length === 0) {
581
+ return {
582
+ Success: true,
583
+ Results: [],
584
+ TotalCount: 0,
585
+ EntitiesSearched: 0,
586
+ ElapsedMs: Date.now() - startTime
587
+ };
588
+ }
589
+ // Build RunView params for each FTS entity
590
+ const viewParams = ftsEntities.map(entity => ({
591
+ EntityName: entity.Name,
592
+ UserSearchString: params.SearchText,
593
+ MaxRows: maxRows,
594
+ ResultType: 'simple'
595
+ }));
596
+ // Execute all searches in parallel via RunViews
597
+ const viewResults = await this.RunViews(viewParams, contextUser);
598
+ // Convert results to FullTextSearchResultItems
599
+ const allResults = [];
600
+ for (let i = 0; i < viewResults.length; i++) {
601
+ const result = viewResults[i];
602
+ const entity = ftsEntities[i];
603
+ if (!result.Success)
604
+ continue;
605
+ const titleField = this.findBestField(entity, ['Name', 'Title', 'Subject', 'Label']);
606
+ const snippetField = this.findBestField(entity, ['Description', 'Summary', 'Body', 'Content', 'Text', 'Notes']);
607
+ for (let j = 0; j < result.Results.length; j++) {
608
+ const record = result.Results[j];
609
+ allResults.push({
610
+ EntityName: entity.Name,
611
+ RecordID: String(record[entity.FirstPrimaryKey?.Name ?? 'ID'] ?? ''),
612
+ Title: String(record[titleField] ?? 'Untitled'),
613
+ Snippet: String(record[snippetField] ?? '').substring(0, 200),
614
+ Score: 1.0 / (j + 1) // Rank-based scoring for RRF compatibility
615
+ });
616
+ }
617
+ }
618
+ // Sort by score descending
619
+ allResults.sort((a, b) => b.Score - a.Score);
620
+ return {
621
+ Success: true,
622
+ Results: allResults,
623
+ TotalCount: allResults.length,
624
+ EntitiesSearched: ftsEntities.length,
625
+ ElapsedMs: Date.now() - startTime
626
+ };
627
+ }
628
+ catch (error) {
629
+ const msg = error instanceof Error ? error.message : String(error);
630
+ LogError(`FullTextSearch failed: ${msg}`);
631
+ return {
632
+ Success: false,
633
+ ErrorMessage: msg,
634
+ Results: [],
635
+ TotalCount: 0,
636
+ EntitiesSearched: 0,
637
+ ElapsedMs: Date.now() - startTime
638
+ };
639
+ }
640
+ }
641
+ /**
642
+ * Resolve which entities to search. Filters to only FTS-enabled entities.
643
+ * If entityNames provided, intersects with the FTS-enabled set.
644
+ */
645
+ resolveFTSEntities(md, entityNames) {
646
+ const allEntities = md.Entities.filter(e => e.FullTextSearchEnabled);
647
+ if (!entityNames || entityNames.length === 0) {
648
+ return allEntities;
649
+ }
650
+ // Intersect requested names with FTS-enabled entities only
651
+ const nameSet = new Set(entityNames.map(n => n.toLowerCase()));
652
+ return allEntities.filter(e => nameSet.has(e.Name.toLowerCase()));
653
+ }
654
+ /**
655
+ * Find the best display field from an entity by checking preferred field names.
656
+ * Returns the first match or falls back to the first text field.
657
+ */
658
+ findBestField(entity, preferredNames) {
659
+ for (const name of preferredNames) {
660
+ const field = entity.Fields.find(f => f.Name === name);
661
+ if (field)
662
+ return field.Name;
663
+ }
664
+ // Fallback to first text field
665
+ const textField = entity.Fields.find(f => f.Type.toLowerCase().includes('varchar') || f.Type.toLowerCase().includes('text'));
666
+ return textField?.Name ?? entity.FirstPrimaryKey?.Name ?? 'ID';
667
+ }
558
668
  async RunViewsUncoalesced(params, contextUser) {
559
669
  const key = this.GenerateDedupKey(params, contextUser);
560
670
  const existing = this._inflightViews.get(key);
@@ -828,7 +938,7 @@ export class ProviderBase {
828
938
  Success: true,
829
939
  Results: cached.results,
830
940
  RowCount: cached.results.length,
831
- TotalRowCount: cached.results.length,
941
+ TotalRowCount: cached.totalRowCount ?? cached.results.length,
832
942
  ExecutionTime: 0, // Cached, no execution time
833
943
  ErrorMessage: '',
834
944
  UserViewRunID: '',
@@ -956,7 +1066,7 @@ export class ProviderBase {
956
1066
  Success: true,
957
1067
  Results: cached.results,
958
1068
  RowCount: cached.results.length,
959
- TotalRowCount: cached.results.length,
1069
+ TotalRowCount: cached.totalRowCount ?? cached.results.length,
960
1070
  ExecutionTime: 0,
961
1071
  ErrorMessage: '',
962
1072
  UserViewRunID: '',
@@ -1119,7 +1229,7 @@ export class ProviderBase {
1119
1229
  Success: true,
1120
1230
  Results: cached.results,
1121
1231
  RowCount: cached.rowCount,
1122
- TotalRowCount: cached.rowCount,
1232
+ TotalRowCount: cached.totalRowCount ?? cached.rowCount,
1123
1233
  ExecutionTime: 0,
1124
1234
  ErrorMessage: '',
1125
1235
  UserViewRunID: '',
@@ -1161,7 +1271,7 @@ export class ProviderBase {
1161
1271
  Success: true,
1162
1272
  Results: merged.results,
1163
1273
  RowCount: merged.rowCount,
1164
- TotalRowCount: merged.rowCount,
1274
+ TotalRowCount: merged.totalRowCount ?? merged.rowCount,
1165
1275
  ExecutionTime: 0,
1166
1276
  ErrorMessage: '',
1167
1277
  UserViewRunID: '',
@@ -1182,11 +1292,12 @@ export class ProviderBase {
1182
1292
  }
1183
1293
  else if (checkResult.status === 'stale') {
1184
1294
  // Cache is stale - use fresh data and update cache (entity doesn't support differential)
1295
+ const staleResults = checkResult.results || [];
1185
1296
  const freshResult = {
1186
1297
  Success: true,
1187
- Results: checkResult.results || [],
1188
- RowCount: checkResult.rowCount || 0,
1189
- TotalRowCount: checkResult.rowCount || 0,
1298
+ Results: staleResults,
1299
+ RowCount: staleResults.length,
1300
+ TotalRowCount: checkResult.rowCount || staleResults.length,
1190
1301
  ExecutionTime: 0,
1191
1302
  ErrorMessage: '',
1192
1303
  UserViewRunID: '',
@@ -1197,8 +1308,8 @@ export class ProviderBase {
1197
1308
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1198
1309
  // Note: We don't await here to avoid blocking the response
1199
1310
  // Cache update happens in background
1200
- LocalCacheManager.Instance.SetRunViewResult(fingerprint, param, checkResult.results || [], checkResult.maxUpdatedAt, checkResult.aggregateResults // Include aggregate results in cache
1201
- ).catch(e => LogError(`Failed to update cache: ${e}`));
1311
+ LocalCacheManager.Instance.SetRunViewResult(fingerprint, param, checkResult.results || [], checkResult.maxUpdatedAt, checkResult.aggregateResults, // Include aggregate results in cache
1312
+ checkResult.rowCount).catch(e => LogError(`Failed to update cache: ${e}`));
1202
1313
  }
1203
1314
  // Transform to entity objects if needed
1204
1315
  await this.TransformSimpleObjectToEntityObject(param, freshResult, contextUser);
@@ -1287,9 +1398,9 @@ export class ProviderBase {
1287
1398
  // with circular subscriber references that break JSON.stringify.
1288
1399
  // On cache read, TransformSimpleObjectToEntityObject is called to restore
1289
1400
  // entity objects when ResultType === 'entity_object'.
1290
- if (params.CacheLocal && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
1401
+ if ((params.CacheLocal || this.TrustLocalCacheCompletely) && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
1291
1402
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1292
- await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults);
1403
+ await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount);
1293
1404
  }
1294
1405
  else if (this.shouldAutoCache(params, result)) {
1295
1406
  // Server-side auto-cache: small, unfiltered, unsorted results are
@@ -1297,7 +1408,7 @@ export class ProviderBase {
1297
1408
  // safe for in-place upsert on entity changes (no filter to evaluate).
1298
1409
  const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
1299
1410
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1300
- await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults);
1411
+ await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount);
1301
1412
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
1302
1413
  }
1303
1414
  // Transform the result set into BaseEntity-derived objects, if needed
@@ -1339,13 +1450,13 @@ export class ProviderBase {
1339
1450
  continue;
1340
1451
  }
1341
1452
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString);
1342
- if (params[i].CacheLocal && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
1453
+ if ((params[i].CacheLocal || this.TrustLocalCacheCompletely) && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
1343
1454
  const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
1344
- cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults));
1455
+ cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount));
1345
1456
  }
1346
1457
  else if (this.shouldAutoCache(params[i], results[i])) {
1347
1458
  const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
1348
- cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults));
1459
+ cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount));
1349
1460
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
1350
1461
  }
1351
1462
  // Register OnDataChanged callback if provided
@@ -1677,24 +1788,8 @@ export class ProviderBase {
1677
1788
  * @param contextUser - The user context for permissions
1678
1789
  */
1679
1790
  async TransformSimpleObjectToEntityObject(param, result, contextUser) {
1680
- // only if needed (e.g. ResultType==='entity_object'), transform the result set into BaseEntity-derived objects
1681
1791
  if (param.ResultType === 'entity_object' && result && result.Success && result.Results?.length > 0) {
1682
- // we need to transform each of the items in the result set into a BaseEntity-derived object
1683
- // Create entities and load data in parallel for better performance
1684
- const entityPromises = result.Results.map(async (item) => {
1685
- if (item instanceof BaseEntity || (typeof item.Save === 'function')) {
1686
- // the second check is a "duck-typing" check in case we have different runtime
1687
- // loading sources where the instanceof will fail
1688
- return item;
1689
- }
1690
- else {
1691
- // not a base entity sub-class already so convert
1692
- const entity = await this.GetEntityObject(param.EntityName, contextUser);
1693
- await entity.LoadFromData(item);
1694
- return entity;
1695
- }
1696
- });
1697
- result.Results = await Promise.all(entityPromises);
1792
+ result.Results = await TransformSimpleObjectToEntityObject(this, param.EntityName, result.Results, contextUser);
1698
1793
  }
1699
1794
  }
1700
1795
  /**