ti2 1.0.122 → 1.0.123

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.
@@ -3,7 +3,6 @@ const crypto = require('crypto');
3
3
  const hash = require('object-hash');
4
4
  const R = require('ramda');
5
5
  const { UserAppKey } = require('../models/index');
6
- const { addJob } = require('../worker/queue');
7
6
  const { typeDefs: productTypeDefs, query: productQuery } = require('./graphql-schemas/product');
8
7
  const { typeDefs: availTypeDefs, query: availQuery } = require('./graphql-schemas/availability');
9
8
  const { typeDefs: bookingTypeDefs, query: bookingQuery } = require('./graphql-schemas/booking');
@@ -36,10 +35,19 @@ const getPositiveIntegerEnv = (name, defaultValue) => {
36
35
  };
37
36
 
38
37
  const getProductSearchLockTtlSeconds = () => getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_TTL_SECONDS', 120);
39
- const productSearchLockWaitMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
38
+ const getProductSearchLockWaitMs = () => getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
40
39
  const productSearchLockPollMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_POLL_MS', 250);
40
+ const productSearchRefreshOutcomeTtlSeconds = 60;
41
41
  const emptyProductSearchCacheTtlSeconds = 60;
42
42
  const productSearchCacheTtlSeconds = 30 * 24 * 60 * 60;
43
+ const oneDayProductSearchTtrSeconds = 60 * 60 * 24;
44
+ const defaultProductSearchTtrSeconds = oneDayProductSearchTtrSeconds * 7;
45
+
46
+ const resolveProductSearchTtrSeconds = (token = {}, app = {}) => {
47
+ const configured = token.ttlForProducts
48
+ || R.path(['cacheSettings', 'bookingsProductSearch', 'ttr'], app);
49
+ return configured == null ? defaultProductSearchTtrSeconds : configured;
50
+ };
43
51
  const productSearchOperationId = 'bookingsProductSearch';
44
52
  const productSearchCacheDecisionEvent = 'bookingsProductSearch:cache:decision';
45
53
  const legacyProductSearchCacheEvents = {
@@ -75,7 +83,10 @@ const productSearchSelectorFields = [
75
83
  const hasProductSearchSelector = payload => productSearchSelectorFields.some(field => {
76
84
  const value = R.path([field], payload);
77
85
  if (Array.isArray(value)) return value.length > 0;
78
- if (typeof value === 'string') return Boolean(value.trim());
86
+ if (typeof value === 'string') {
87
+ const trimmedValue = value.trim();
88
+ return Boolean(trimmedValue) && !(field === 'searchInput' && trimmedValue === '*');
89
+ }
79
90
  return value !== undefined && value !== null && value !== false;
80
91
  });
81
92
 
@@ -99,6 +110,10 @@ const emitProductSearchCacheDecision = ({
99
110
  searchInput,
100
111
  optionId,
101
112
  forceRefresh,
113
+ fullSyncTrigger,
114
+ admissionOverrideReason,
115
+ fullSyncStartedAt,
116
+ fullSyncAdmissionToken,
102
117
  startedAt,
103
118
  }, action, extra = {}) => {
104
119
  if (!app.events || !app.events.emit) return;
@@ -111,6 +126,10 @@ const emitProductSearchCacheDecision = ({
111
126
  hintHash: safeHash(hint),
112
127
  cacheKeyHash: safeHash(cacheKey),
113
128
  forceRefresh: Boolean(forceRefresh),
129
+ fullSyncTrigger,
130
+ admissionOverrideReason,
131
+ fullSyncStartedAt,
132
+ fullSyncAdmissionTokenHash: safeHash(fullSyncAdmissionToken),
114
133
  hasSearchInput: Boolean((searchInput || '').trim()),
115
134
  hasOptionId: Boolean(optionId && optionId.length),
116
135
  elapsedMs: Date.now() - startedAt,
@@ -122,6 +141,9 @@ const emitProductSearchCacheDecision = ({
122
141
  ...payload,
123
142
  userId,
124
143
  hint,
144
+ ...(action === 'cache_saved' && fullSyncAdmissionToken
145
+ ? { fullSyncAdmissionToken }
146
+ : {}),
125
147
  });
126
148
  }
127
149
  };
@@ -163,6 +185,22 @@ const createProductSearchUnavailableError = () => {
163
185
  return err;
164
186
  };
165
187
 
188
+ const createScopedCatalogRefreshError = () => {
189
+ const err = new Error(
190
+ 'Scheduled and manual catalog refreshes must not include product selectors',
191
+ );
192
+ err.status = 400;
193
+ return err;
194
+ };
195
+
196
+ const createUnsupportedCatalogRefreshError = appKey => {
197
+ const err = new Error(
198
+ `${appKey} does not support complete product catalog refreshes`,
199
+ );
200
+ err.status = 400;
201
+ return err;
202
+ };
203
+
166
204
  const getAppAndToken = async ({ plugins, appKey, userId, hint }) => {
167
205
  const app = plugins.find(({ name }) => name === appKey);
168
206
  assert(app, 'could not find the app ' + appKey);
@@ -271,7 +309,8 @@ const confirmBooking = plugins => async (req, res, next) => {
271
309
  const $searchProductList = (products, searchInput = '', optionId = '') => {
272
310
  // NOTE: optionId could be a string or an array of strings
273
311
  // NOTE: searchInput should not appear at the same time as optionId
274
- if (!(searchInput && searchInput.trim()) && !(optionId && optionId.length)) {
312
+ const trimmedSearchInput = (searchInput || '').trim();
313
+ if ((!trimmedSearchInput || trimmedSearchInput === '*') && !(optionId && optionId.length)) {
275
314
  return products;
276
315
  }
277
316
  const getFullSearchStr = (product, option) => `${
@@ -279,7 +318,7 @@ const $searchProductList = (products, searchInput = '', optionId = '') => {
279
318
  } ${R.path(['optionName'], option) || ''
280
319
  } ${R.path(['optionId'], option) || ''
281
320
  } ${R.path(['supplierId'], product) || ''}`;
282
- const inputValueLower = (searchInput || '').trim().toLowerCase();
321
+ const inputValueLower = trimmedSearchInput.toLowerCase();
283
322
  const parts = inputValueLower.split(' ').filter(Boolean); // Filter out any empty strings just in case
284
323
  const pwFilteredOptions = products.map(product => {
285
324
  const filteredOptions = R.pathOr([], ['options'], product).filter(option => {
@@ -309,6 +348,13 @@ const hasCacheableProductResults = pluginResults => Boolean(
309
348
  && !pluginResults.partial
310
349
  );
311
350
 
351
+ const assertCanonicalProductSearchResult = (pluginResults, appKey) => {
352
+ assert(
353
+ pluginResults && Array.isArray(pluginResults.products),
354
+ `${appKey} product search must return an object with a products array`,
355
+ );
356
+ };
357
+
312
358
  const hasNonEmptyProductCache = cacheContent => Boolean(
313
359
  cacheContent
314
360
  && cacheContent.products
@@ -322,7 +368,7 @@ const $bookingsProductSearch = plugins => async ({
322
368
  hint,
323
369
  payload: originalRequestBody, // Renamed for clarity, this is req.body
324
370
  requestId,
325
- headers, // To pass original request headers for background job
371
+ headers,
326
372
  }) => {
327
373
  const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
328
374
  assert(userId, 'userId is required');
@@ -345,18 +391,56 @@ const $bookingsProductSearch = plugins => async ({
345
391
  searchInput = '', // Provide defaults
346
392
  optionId = '',
347
393
  forceRefresh = false,
394
+ cacheOnly = false,
395
+ fullSyncTrigger: requestedFullSyncTrigger,
396
+ admissionOverrideReason: requestedOverrideReason,
397
+ fullSyncStartedAt: requestedFullSyncStartedAt,
398
+ fullSyncAdmissionToken: requestedAdmissionToken,
348
399
  } = normalizedRequestBody;
349
400
 
401
+ const fullSyncTrigger = ['organic', 'scheduled', 'manual'].indexOf(
402
+ requestedFullSyncTrigger,
403
+ ) >= 0
404
+ ? requestedFullSyncTrigger
405
+ : (forceRefresh ? 'scheduled' : 'organic');
406
+ const admissionOverrideReason = typeof requestedOverrideReason === 'string'
407
+ ? requestedOverrideReason.trim() || undefined
408
+ : undefined;
409
+ const fullSyncStartedAt = requestedFullSyncStartedAt !== undefined
410
+ && requestedFullSyncStartedAt !== null
411
+ && requestedFullSyncStartedAt !== ''
412
+ && Number.isFinite(Number(requestedFullSyncStartedAt))
413
+ ? Number(requestedFullSyncStartedAt)
414
+ : undefined;
415
+ const fullSyncAdmissionToken = typeof requestedAdmissionToken === 'string'
416
+ ? requestedAdmissionToken.trim() || undefined
417
+ : undefined;
418
+
350
419
  // Keep forceRefresh in the plugin payload so plugins can trigger their own rebuilds.
351
420
  const payloadForPlugin = {
352
- ...R.omit(['forceRefresh'], normalizedRequestBody),
421
+ ...R.omit([
422
+ 'forceRefresh',
423
+ 'cacheOnly',
424
+ 'fullSyncTrigger',
425
+ 'admissionOverrideReason',
426
+ 'fullSyncStartedAt',
427
+ 'fullSyncAdmissionToken',
428
+ ], normalizedRequestBody),
353
429
  forceRefresh,
354
430
  };
355
431
  const isScopedSearch = hasProductSearchSelector(normalizedRequestBody);
356
- const payloadForBackgroundJob = R.omit(
357
- ['credentials', ...productSearchSelectorFields],
358
- payloadForPlugin,
359
- );
432
+ const isDeclaredFullCatalogRefresh = forceRefresh
433
+ && ['scheduled', 'manual'].indexOf(fullSyncTrigger) >= 0;
434
+ if (isDeclaredFullCatalogRefresh && isScopedSearch) {
435
+ throw createScopedCatalogRefreshError();
436
+ }
437
+ if (
438
+ forceRefresh
439
+ && !cacheOnly
440
+ && R.path(['capabilities', 'weeklyProductCatalogSync'], app) !== true
441
+ ) {
442
+ throw createUnsupportedCatalogRefreshError(appKey);
443
+ }
360
444
 
361
445
  const cacheKey = productSearchCacheKey({ userId, hint });
362
446
  const telemetryContext = {
@@ -368,6 +452,10 @@ const $bookingsProductSearch = plugins => async ({
368
452
  searchInput,
369
453
  optionId,
370
454
  forceRefresh,
455
+ fullSyncTrigger,
456
+ admissionOverrideReason,
457
+ fullSyncStartedAt,
458
+ fullSyncAdmissionToken,
371
459
  startedAt: Date.now(),
372
460
  };
373
461
  const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(
@@ -376,12 +464,12 @@ const $bookingsProductSearch = plugins => async ({
376
464
  extra,
377
465
  );
378
466
  const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
379
- const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
467
+ const refreshOutcomeCacheKey = `${pluginExecutionLockKey}:outcome`;
380
468
 
381
469
  // Fetch actualCacheContent once at the beginning.
382
470
  const initialActualCacheContent = await app.cache.get({ key: cacheKey });
383
471
  const lastUpdated = await app.cache.get({ key: `${cacheKey}:lastUpdated` });
384
- const ttr = token.ttlForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'ttr'], app) || 60 * 60 * 24;
472
+ const ttr = resolveProductSearchTtrSeconds(token, app);
385
473
  const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
386
474
  const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
387
475
  const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
@@ -400,19 +488,30 @@ const $bookingsProductSearch = plugins => async ({
400
488
  return null;
401
489
  };
402
490
 
403
- const waitForProductSearchCache = async () => {
404
- const timeoutAt = Date.now() + productSearchLockWaitMs;
491
+ const waitForProductSearchRefresh = async previousLockOwnerToken => {
492
+ const timeoutAt = Date.now() + getProductSearchLockWaitMs();
405
493
  while (Date.now() < timeoutAt) {
406
- const cacheContent = await getCachedProductSearchResults();
407
- if (cacheContent) return cacheContent;
494
+ const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
495
+ if (refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken) {
496
+ return refreshOutcome;
497
+ }
408
498
 
409
499
  const lockStillActive = await app.cache.get({ key: pluginExecutionLockKey });
410
- if (!lockStillActive) return null;
500
+ if (!lockStillActive) {
501
+ const completedOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
502
+ return completedOutcome
503
+ && completedOutcome.lockOwnerToken !== previousLockOwnerToken
504
+ ? completedOutcome
505
+ : null;
506
+ }
411
507
 
412
508
  await sleep(productSearchLockPollMs);
413
509
  }
414
510
 
415
- return getCachedProductSearchResults();
511
+ const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
512
+ return refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken
513
+ ? refreshOutcome
514
+ : null;
416
515
  };
417
516
 
418
517
  const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
@@ -434,6 +533,7 @@ const $bookingsProductSearch = plugins => async ({
434
533
  userId,
435
534
  hint,
436
535
  });
536
+ assertCanonicalProductSearchResult(pluginResults, appKey);
437
537
  emitDecision('scoped_result_not_cached', {
438
538
  reason,
439
539
  pluginElapsedMs: Date.now() - pluginStartedAt,
@@ -444,13 +544,29 @@ const $bookingsProductSearch = plugins => async ({
444
544
  }
445
545
 
446
546
  const lockOwnerToken = crypto.randomBytes(16).toString('hex');
547
+ const previousRefreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
447
548
  const lockStartedAt = Date.now();
448
549
  const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
449
550
  if (!lockAcquired) {
450
551
  emitDecision('lock_wait', { reason, lockWaitMs: 0 });
451
- const cacheContent = await waitForProductSearchCache();
552
+ const refreshOutcome = await waitForProductSearchRefresh(
553
+ previousRefreshOutcome && previousRefreshOutcome.lockOwnerToken,
554
+ );
555
+ const cacheContent = await getCachedProductSearchResults();
452
556
  const lockWaitMs = Date.now() - lockStartedAt;
453
- if (cacheContent) {
557
+ if (forceRefresh && refreshOutcome) {
558
+ emitDecision('cache_hit', {
559
+ reason: 'waitedForLeader',
560
+ lockWaitMs,
561
+ cacheProductCount: productCount(cacheContent),
562
+ cacheOptionCount: optionCount(cacheContent),
563
+ });
564
+ return {
565
+ ...(cacheContent || { products: [] }),
566
+ ...R.omit(['lockOwnerToken'], refreshOutcome),
567
+ };
568
+ }
569
+ if (!forceRefresh && cacheContent) {
454
570
  emitDecision('cache_hit', {
455
571
  reason: 'waitedForLeader',
456
572
  lockWaitMs,
@@ -459,6 +575,21 @@ const $bookingsProductSearch = plugins => async ({
459
575
  });
460
576
  return cacheContent;
461
577
  }
578
+ if (forceRefresh && cacheContent) {
579
+ emitDecision('stale_served', {
580
+ reason: 'refreshInProgress',
581
+ lockWaitMs,
582
+ cacheProductCount: productCount(cacheContent),
583
+ cacheOptionCount: optionCount(cacheContent),
584
+ });
585
+ return {
586
+ ...cacheContent,
587
+ catalogRefreshOutcome: 'refresh_in_progress_cache_served',
588
+ cacheUpdated: false,
589
+ cachePreserved: true,
590
+ cachedProductCount: productCount(cacheContent),
591
+ };
592
+ }
462
593
  emitDecision('lock_timeout', { reason, lockWaitMs });
463
594
  throw createProductSearchUnavailableError();
464
595
  }
@@ -474,6 +605,11 @@ const $bookingsProductSearch = plugins => async ({
474
605
  });
475
606
 
476
607
  let pluginResults;
608
+ let catalogRefreshOutcome;
609
+ let cacheUpdated = false;
610
+ let cachePreserved = false;
611
+ let cachedProductCount;
612
+ let effectiveCatalogResults;
477
613
  try {
478
614
  const pluginStartedAt = Date.now();
479
615
  pluginResults = await func({
@@ -485,11 +621,10 @@ const $bookingsProductSearch = plugins => async ({
485
621
  userId,
486
622
  hint,
487
623
  });
624
+ assertCanonicalProductSearchResult(pluginResults, appKey);
488
625
  const pluginElapsedMs = Date.now() - pluginStartedAt;
489
626
 
490
- // This function is responsible for caching if it fetched usable results.
491
- // This applies to forceRefresh, initial load, or direct calls that result in a fetch.
492
- // The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
627
+ // Cache usable results from a direct plugin fetch (forceRefresh or cache miss).
493
628
  if (hasCacheableProductResults(pluginResults)) {
494
629
  await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
495
630
  await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
@@ -499,22 +634,85 @@ const $bookingsProductSearch = plugins => async ({
499
634
  cacheProductCount: productCount(pluginResults),
500
635
  cacheOptionCount: optionCount(pluginResults),
501
636
  });
637
+ catalogRefreshOutcome = 'cache_updated';
638
+ cacheUpdated = true;
639
+ cachedProductCount = productCount(pluginResults);
502
640
  } else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
641
+ const existingCacheContent = await app.cache.get({ key: cacheKey });
642
+ cachePreserved = hasNonEmptyProductCache(existingCacheContent);
643
+ effectiveCatalogResults = cachePreserved ? existingCacheContent : undefined;
644
+ catalogRefreshOutcome = cachePreserved
645
+ ? 'partial_result_preserved_cache'
646
+ : 'partial_result_not_cached';
647
+ cachedProductCount = cachePreserved
648
+ ? existingCacheContent.products.length
649
+ : null;
503
650
  emitDecision('partial_refresh_skipped', {
504
- reason: 'directPartialResultNotCached',
651
+ reason: cachePreserved
652
+ ? 'partialResultPreservedExistingCache'
653
+ : 'partialResultNotCached',
505
654
  pluginElapsedMs,
506
- cachePreserved: false,
655
+ cachePreserved,
656
+ existingProductCount: cachePreserved
657
+ ? existingCacheContent.products.length
658
+ : undefined,
507
659
  returnedProductCount: productCount(pluginResults),
508
660
  });
509
661
  } else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
510
- // Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
511
- await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
512
- emitDecision('empty_cache_saved', {
513
- reason,
514
- pluginElapsedMs,
515
- cacheProductCount: 0,
516
- });
662
+ const existingCacheContent = await app.cache.get({ key: cacheKey });
663
+ if (hasNonEmptyProductCache(existingCacheContent)) {
664
+ catalogRefreshOutcome = 'empty_result_preserved_cache';
665
+ cachePreserved = true;
666
+ cachedProductCount = existingCacheContent.products.length;
667
+ effectiveCatalogResults = existingCacheContent;
668
+ emitDecision('empty_refresh_skipped', {
669
+ reason: 'emptyResultPreservedExistingCache',
670
+ pluginElapsedMs,
671
+ cachePreserved: true,
672
+ existingProductCount: existingCacheContent.products.length,
673
+ });
674
+ } else {
675
+ const latestCacheContent = await app.cache.get({ key: cacheKey });
676
+ if (hasNonEmptyProductCache(latestCacheContent)) {
677
+ catalogRefreshOutcome = 'empty_result_preserved_cache';
678
+ cachePreserved = true;
679
+ cachedProductCount = latestCacheContent.products.length;
680
+ effectiveCatalogResults = latestCacheContent;
681
+ emitDecision('empty_refresh_skipped', {
682
+ reason: 'emptyResultPreservedConcurrentCache',
683
+ pluginElapsedMs,
684
+ cachePreserved: true,
685
+ existingProductCount: latestCacheContent.products.length,
686
+ });
687
+ } else {
688
+ // Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
689
+ await app.cache.save({
690
+ key: cacheKey,
691
+ value: pluginResults,
692
+ ttl: emptyProductSearchCacheTtlSeconds,
693
+ });
694
+ catalogRefreshOutcome = 'empty_cache_saved';
695
+ cacheUpdated = true;
696
+ cachedProductCount = 0;
697
+ emitDecision('empty_cache_saved', {
698
+ reason,
699
+ pluginElapsedMs,
700
+ cacheProductCount: 0,
701
+ });
702
+ }
703
+ }
517
704
  }
705
+ await app.cache.save({
706
+ key: refreshOutcomeCacheKey,
707
+ value: {
708
+ lockOwnerToken,
709
+ catalogRefreshOutcome,
710
+ cacheUpdated,
711
+ cachePreserved,
712
+ cachedProductCount,
713
+ },
714
+ ttl: productSearchRefreshOutcomeTtlSeconds,
715
+ });
518
716
  } finally {
519
717
  await lockRenewal.stop();
520
718
  if (app.cache.dropIfValue) {
@@ -523,9 +721,39 @@ const $bookingsProductSearch = plugins => async ({
523
721
  await app.cache.drop({ key: pluginExecutionLockKey });
524
722
  }
525
723
  }
526
- return pluginResults || { products: [] }; // Ensure products array exists
724
+ const result = effectiveCatalogResults || pluginResults || { products: [] };
725
+ if (!forceRefresh) return result;
726
+ return {
727
+ ...result,
728
+ catalogRefreshOutcome,
729
+ cacheUpdated,
730
+ cachePreserved,
731
+ cachedProductCount,
732
+ };
527
733
  };
528
734
 
735
+ // 0. `cacheOnly`: return current Ti2 cache or empty. Never call the plugin.
736
+ if (cacheOnly) {
737
+ if (hasProductCache(initialActualCacheContent)) {
738
+ const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
739
+ emitDecision('cache_hit', {
740
+ reason: 'cacheOnly',
741
+ cacheProductCount: initialActualCacheContent.products.length,
742
+ cacheOptionCount: optionCount(initialActualCacheContent),
743
+ returnedProductCount: searchResults.length,
744
+ returnedOptionCount: optionCount({ products: searchResults }),
745
+ });
746
+ return {
747
+ ...initialActualCacheContent,
748
+ products: searchResults,
749
+ ...(token.configuration || {}),
750
+ cacheFound: true,
751
+ };
752
+ }
753
+ emitDecision('cache_miss', { reason: 'cacheOnly' });
754
+ return { products: [], ...(token.configuration || {}), cacheFound: false };
755
+ }
756
+
529
757
  // 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
530
758
  if (doNotCallPluginForProducts && !forceRefresh) {
531
759
  if (hasProductCache(initialActualCacheContent)) {
@@ -549,7 +777,15 @@ const $bookingsProductSearch = plugins => async ({
549
777
  emitDecision('force_refresh');
550
778
  const funcResults = await fetchFromPluginAndCache('force_refresh');
551
779
  const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
552
- return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
780
+ return {
781
+ ...funcResults,
782
+ products: searchResults,
783
+ ...(token.configuration || {}),
784
+ catalogRefreshOutcome: funcResults.catalogRefreshOutcome,
785
+ cacheUpdated: funcResults.cacheUpdated,
786
+ cachePreserved: funcResults.cachePreserved,
787
+ cachedProductCount: funcResults.cachedProductCount,
788
+ };
553
789
  }
554
790
 
555
791
  // 3. Cache exists (initialActualCacheContent) and not forceRefresh:
@@ -577,38 +813,13 @@ const $bookingsProductSearch = plugins => async ({
577
813
 
578
814
  const isEffectivelyStale = isStaleByTTR && !doNotCallPluginForProducts;
579
815
 
580
- // Cache is fresh or plugin execution in progress: serve from cache
816
+ // Cache is fresh, a plugin fetch is already running, or the cache is
817
+ // stale: organic search never starts a catalog refresh. The scheduled
818
+ // forceRefresh search is the only catalog owner.
581
819
  if (!isEffectivelyStale || hasPluginExecutionLock) {
582
820
  return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
583
821
  }
584
-
585
- // Cache is stale - queue at most one background refresh job and serve stale data.
586
- const jobQueueLockAcquired = await app.cache.saveIfNotExists({
587
- key: jobQueueLockKey,
588
- value: true,
589
- ttl: 60,
590
- });
591
- if (!jobQueueLockAcquired) {
592
- return returnCachedResults('stale_served', { reason: 'backgroundRefreshDeduped' });
593
- }
594
-
595
- const jobId = await addJob({
596
- type: 'plugin',
597
- pluginName: appKey,
598
- method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
599
- token,
600
- payload: { payload: payloadForBackgroundJob, userId, hint },
601
- postProcess: {
602
- controller: 'bookings',
603
- action: '$updateProductSearchCache',
604
- args: { appKey, userId, hint },
605
- },
606
- }, { removeOnComplete: true });
607
-
608
- return returnCachedResults('stale_served', {
609
- reason: 'backgroundRefreshQueued',
610
- jobId,
611
- });
822
+ return returnCachedResults('stale_served', { reason: 'awaitingScheduledRefresh' });
612
823
  }
613
824
  }
614
825
 
@@ -889,93 +1100,6 @@ const getCreateBookingFields = plugins => async (req, res, next) => {
889
1100
  }
890
1101
  };
891
1102
 
892
- const $updateProductSearchCache = plugins => async ({
893
- appKey,
894
- userId,
895
- hint,
896
- pluginResult, // Result from the plugin method call
897
- requestId,
898
- // 'plugins' is available via the factory closure
899
- }) => {
900
- const app = plugins.find(({ name }) => name === appKey);
901
- if (!app) {
902
- console.error(`[$updateProductSearchCache][requestId: ${requestId}] Plugin ${appKey} not found.`);
903
- return; // Or throw error
904
- }
905
-
906
- const cacheKey = productSearchCacheKey({ userId, hint });
907
- const markRefreshAttempted = () => app.cache.save({
908
- key: `${cacheKey}:lastUpdated`,
909
- value: Date.now(),
910
- ttl: productSearchCacheTtlSeconds,
911
- });
912
- const emitCacheEvent = (action, extra = {}) => emitProductSearchCacheDecision({
913
- app,
914
- cacheKey,
915
- userId,
916
- hint,
917
- requestId,
918
- searchInput: '',
919
- optionId: '',
920
- forceRefresh: false,
921
- startedAt: Date.now(),
922
- }, action, extra);
923
-
924
- if (hasCacheableProductResults(pluginResult)) {
925
- await markRefreshAttempted();
926
- await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
927
- emitCacheEvent('cache_saved', {
928
- cacheProductCount: productCount(pluginResult),
929
- cacheOptionCount: optionCount(pluginResult),
930
- });
931
- } else {
932
- const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
933
- const existingCacheContent = await app.cache.get({ key: cacheKey });
934
- if (hasNonEmptyProductCache(existingCacheContent)) {
935
- await markRefreshAttempted();
936
- const eventExtra = {
937
- reason: isPartialRefresh ? 'partialResultPreservedExistingCache' : 'emptyResultPreservedExistingCache',
938
- cachePreserved: true,
939
- existingProductCount: existingCacheContent.products.length,
940
- };
941
- if (isPartialRefresh) eventExtra.returnedProductCount = productCount(pluginResult);
942
- emitCacheEvent(
943
- isPartialRefresh ? 'partial_refresh_skipped' : 'empty_refresh_skipped',
944
- eventExtra,
945
- );
946
- return;
947
- }
948
-
949
- if (isPartialRefresh) {
950
- await markRefreshAttempted();
951
- emitCacheEvent('partial_refresh_skipped', {
952
- reason: 'partialResultNotCached',
953
- cachePreserved: false,
954
- returnedProductCount: productCount(pluginResult),
955
- });
956
- return;
957
- }
958
-
959
- // Best-effort race guard: avoid overwriting a non-empty cache written after the first read.
960
- const latestCacheContent = await app.cache.get({ key: cacheKey });
961
- if (hasNonEmptyProductCache(latestCacheContent)) {
962
- await markRefreshAttempted();
963
- emitCacheEvent('empty_refresh_skipped', {
964
- reason: 'emptyResultPreservedConcurrentCache',
965
- cachePreserved: true,
966
- existingProductCount: latestCacheContent.products.length,
967
- });
968
- return;
969
- }
970
-
971
- // Empty complete refreshes are authoritative only when there is no existing non-empty cache.
972
- await markRefreshAttempted();
973
- await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: productSearchCacheTtlSeconds });
974
- emitCacheEvent('empty_cache_saved', { cacheProductCount: 0 });
975
- }
976
- };
977
-
978
- // Update the module.exports to correctly assign the plugin-wrapped function
979
1103
  const controllerFactory = plugins => {
980
1104
  const controllerFunctions = {
981
1105
  bookingsSearch: bookingsSearch(plugins),
@@ -993,7 +1117,6 @@ const controllerFactory = plugins => {
993
1117
  getAffiliateDesks: getAffiliateDesks(plugins),
994
1118
  getPickupPoints: getPickupPoints(plugins),
995
1119
  getCreateBookingFields: getCreateBookingFields(plugins),
996
- $updateProductSearchCache: $updateProductSearchCache(plugins), // Add the new function here
997
1120
  };
998
1121
  // Ensure $bookingsProductSearch can be called internally by worker with plugins already bound
999
1122
  // This is more of a conceptual note as the factory pattern already handles this.
@@ -1002,5 +1125,8 @@ const controllerFactory = plugins => {
1002
1125
 
1003
1126
  // Add this line to attach typeDefsAndQueries to the factory:
1004
1127
  controllerFactory.typeDefsAndQueries = typeDefsAndQueries;
1128
+ controllerFactory.resolveProductSearchTtrSeconds = resolveProductSearchTtrSeconds;
1129
+ controllerFactory.defaultProductSearchTtrSeconds = defaultProductSearchTtrSeconds;
1130
+ controllerFactory.oneDayProductSearchTtrSeconds = oneDayProductSearchTtrSeconds;
1005
1131
 
1006
1132
  module.exports = controllerFactory;
@@ -42,6 +42,7 @@ const getAppMethods = plugins => async (req, res, next) => {
42
42
  const app = plugins.filter(({ name }) => name === appKey)[0];
43
43
  const methods = getAllFuncs(app);
44
44
  return res.json({
45
+ capabilities: app.capabilities || {},
45
46
  methods,
46
47
  });
47
48
  } catch (err) {