ti2 1.0.122 → 1.0.124

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,30 @@ 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 createManualCatalogRefreshReasonError = () => {
197
+ const err = new Error(
198
+ 'A manual catalog refresh requires a non-blank admissionOverrideReason',
199
+ );
200
+ err.status = 400;
201
+ return err;
202
+ };
203
+
204
+ const createUnsupportedCatalogRefreshError = appKey => {
205
+ const err = new Error(
206
+ `${appKey} does not support complete product catalog refreshes`,
207
+ );
208
+ err.status = 400;
209
+ return err;
210
+ };
211
+
166
212
  const getAppAndToken = async ({ plugins, appKey, userId, hint }) => {
167
213
  const app = plugins.find(({ name }) => name === appKey);
168
214
  assert(app, 'could not find the app ' + appKey);
@@ -271,7 +317,8 @@ const confirmBooking = plugins => async (req, res, next) => {
271
317
  const $searchProductList = (products, searchInput = '', optionId = '') => {
272
318
  // NOTE: optionId could be a string or an array of strings
273
319
  // NOTE: searchInput should not appear at the same time as optionId
274
- if (!(searchInput && searchInput.trim()) && !(optionId && optionId.length)) {
320
+ const trimmedSearchInput = (searchInput || '').trim();
321
+ if ((!trimmedSearchInput || trimmedSearchInput === '*') && !(optionId && optionId.length)) {
275
322
  return products;
276
323
  }
277
324
  const getFullSearchStr = (product, option) => `${
@@ -279,7 +326,7 @@ const $searchProductList = (products, searchInput = '', optionId = '') => {
279
326
  } ${R.path(['optionName'], option) || ''
280
327
  } ${R.path(['optionId'], option) || ''
281
328
  } ${R.path(['supplierId'], product) || ''}`;
282
- const inputValueLower = (searchInput || '').trim().toLowerCase();
329
+ const inputValueLower = trimmedSearchInput.toLowerCase();
283
330
  const parts = inputValueLower.split(' ').filter(Boolean); // Filter out any empty strings just in case
284
331
  const pwFilteredOptions = products.map(product => {
285
332
  const filteredOptions = R.pathOr([], ['options'], product).filter(option => {
@@ -309,6 +356,13 @@ const hasCacheableProductResults = pluginResults => Boolean(
309
356
  && !pluginResults.partial
310
357
  );
311
358
 
359
+ const assertCanonicalProductSearchResult = (pluginResults, appKey) => {
360
+ assert(
361
+ pluginResults && Array.isArray(pluginResults.products),
362
+ `${appKey} product search must return an object with a products array`,
363
+ );
364
+ };
365
+
312
366
  const hasNonEmptyProductCache = cacheContent => Boolean(
313
367
  cacheContent
314
368
  && cacheContent.products
@@ -322,7 +376,7 @@ const $bookingsProductSearch = plugins => async ({
322
376
  hint,
323
377
  payload: originalRequestBody, // Renamed for clarity, this is req.body
324
378
  requestId,
325
- headers, // To pass original request headers for background job
379
+ headers,
326
380
  }) => {
327
381
  const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
328
382
  assert(userId, 'userId is required');
@@ -345,18 +399,64 @@ const $bookingsProductSearch = plugins => async ({
345
399
  searchInput = '', // Provide defaults
346
400
  optionId = '',
347
401
  forceRefresh = false,
402
+ cacheOnly = false,
403
+ fullSyncTrigger: requestedFullSyncTrigger,
404
+ admissionOverrideReason: requestedOverrideReason,
405
+ fullSyncStartedAt: requestedFullSyncStartedAt,
406
+ fullSyncAdmissionToken: requestedAdmissionToken,
348
407
  } = normalizedRequestBody;
349
408
 
409
+ const fullSyncTrigger = ['organic', 'scheduled', 'manual'].indexOf(
410
+ requestedFullSyncTrigger,
411
+ ) >= 0
412
+ ? requestedFullSyncTrigger
413
+ : (forceRefresh ? 'manual' : 'organic');
414
+ const admissionOverrideReason = typeof requestedOverrideReason === 'string'
415
+ ? requestedOverrideReason.trim() || undefined
416
+ : undefined;
417
+ const fullSyncStartedAt = requestedFullSyncStartedAt !== undefined
418
+ && requestedFullSyncStartedAt !== null
419
+ && requestedFullSyncStartedAt !== ''
420
+ && Number.isFinite(Number(requestedFullSyncStartedAt))
421
+ ? Number(requestedFullSyncStartedAt)
422
+ : undefined;
423
+ const fullSyncAdmissionToken = typeof requestedAdmissionToken === 'string'
424
+ ? requestedAdmissionToken.trim() || undefined
425
+ : undefined;
426
+
350
427
  // Keep forceRefresh in the plugin payload so plugins can trigger their own rebuilds.
351
428
  const payloadForPlugin = {
352
- ...R.omit(['forceRefresh'], normalizedRequestBody),
429
+ ...R.omit([
430
+ 'forceRefresh',
431
+ 'cacheOnly',
432
+ 'fullSyncTrigger',
433
+ 'admissionOverrideReason',
434
+ 'fullSyncStartedAt',
435
+ 'fullSyncAdmissionToken',
436
+ ], normalizedRequestBody),
353
437
  forceRefresh,
354
438
  };
355
439
  const isScopedSearch = hasProductSearchSelector(normalizedRequestBody);
356
- const payloadForBackgroundJob = R.omit(
357
- ['credentials', ...productSearchSelectorFields],
358
- payloadForPlugin,
359
- );
440
+ const isDeclaredFullCatalogRefresh = forceRefresh
441
+ && ['scheduled', 'manual'].indexOf(fullSyncTrigger) >= 0;
442
+ if (
443
+ forceRefresh
444
+ && !cacheOnly
445
+ && fullSyncTrigger === 'manual'
446
+ && !admissionOverrideReason
447
+ ) {
448
+ throw createManualCatalogRefreshReasonError();
449
+ }
450
+ if (isDeclaredFullCatalogRefresh && isScopedSearch) {
451
+ throw createScopedCatalogRefreshError();
452
+ }
453
+ if (
454
+ forceRefresh
455
+ && !cacheOnly
456
+ && R.path(['capabilities', 'weeklyProductCatalogSync'], app) !== true
457
+ ) {
458
+ throw createUnsupportedCatalogRefreshError(appKey);
459
+ }
360
460
 
361
461
  const cacheKey = productSearchCacheKey({ userId, hint });
362
462
  const telemetryContext = {
@@ -368,6 +468,10 @@ const $bookingsProductSearch = plugins => async ({
368
468
  searchInput,
369
469
  optionId,
370
470
  forceRefresh,
471
+ fullSyncTrigger,
472
+ admissionOverrideReason,
473
+ fullSyncStartedAt,
474
+ fullSyncAdmissionToken,
371
475
  startedAt: Date.now(),
372
476
  };
373
477
  const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(
@@ -376,12 +480,12 @@ const $bookingsProductSearch = plugins => async ({
376
480
  extra,
377
481
  );
378
482
  const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
379
- const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
483
+ const refreshOutcomeCacheKey = `${pluginExecutionLockKey}:outcome`;
380
484
 
381
485
  // Fetch actualCacheContent once at the beginning.
382
486
  const initialActualCacheContent = await app.cache.get({ key: cacheKey });
383
487
  const lastUpdated = await app.cache.get({ key: `${cacheKey}:lastUpdated` });
384
- const ttr = token.ttlForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'ttr'], app) || 60 * 60 * 24;
488
+ const ttr = resolveProductSearchTtrSeconds(token, app);
385
489
  const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
386
490
  const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
387
491
  const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
@@ -400,19 +504,30 @@ const $bookingsProductSearch = plugins => async ({
400
504
  return null;
401
505
  };
402
506
 
403
- const waitForProductSearchCache = async () => {
404
- const timeoutAt = Date.now() + productSearchLockWaitMs;
507
+ const waitForProductSearchRefresh = async previousLockOwnerToken => {
508
+ const timeoutAt = Date.now() + getProductSearchLockWaitMs();
405
509
  while (Date.now() < timeoutAt) {
406
- const cacheContent = await getCachedProductSearchResults();
407
- if (cacheContent) return cacheContent;
510
+ const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
511
+ if (refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken) {
512
+ return refreshOutcome;
513
+ }
408
514
 
409
515
  const lockStillActive = await app.cache.get({ key: pluginExecutionLockKey });
410
- if (!lockStillActive) return null;
516
+ if (!lockStillActive) {
517
+ const completedOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
518
+ return completedOutcome
519
+ && completedOutcome.lockOwnerToken !== previousLockOwnerToken
520
+ ? completedOutcome
521
+ : null;
522
+ }
411
523
 
412
524
  await sleep(productSearchLockPollMs);
413
525
  }
414
526
 
415
- return getCachedProductSearchResults();
527
+ const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
528
+ return refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken
529
+ ? refreshOutcome
530
+ : null;
416
531
  };
417
532
 
418
533
  const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
@@ -434,6 +549,7 @@ const $bookingsProductSearch = plugins => async ({
434
549
  userId,
435
550
  hint,
436
551
  });
552
+ assertCanonicalProductSearchResult(pluginResults, appKey);
437
553
  emitDecision('scoped_result_not_cached', {
438
554
  reason,
439
555
  pluginElapsedMs: Date.now() - pluginStartedAt,
@@ -444,13 +560,29 @@ const $bookingsProductSearch = plugins => async ({
444
560
  }
445
561
 
446
562
  const lockOwnerToken = crypto.randomBytes(16).toString('hex');
563
+ const previousRefreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
447
564
  const lockStartedAt = Date.now();
448
565
  const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
449
566
  if (!lockAcquired) {
450
567
  emitDecision('lock_wait', { reason, lockWaitMs: 0 });
451
- const cacheContent = await waitForProductSearchCache();
568
+ const refreshOutcome = await waitForProductSearchRefresh(
569
+ previousRefreshOutcome && previousRefreshOutcome.lockOwnerToken,
570
+ );
571
+ const cacheContent = await getCachedProductSearchResults();
452
572
  const lockWaitMs = Date.now() - lockStartedAt;
453
- if (cacheContent) {
573
+ if (forceRefresh && refreshOutcome) {
574
+ emitDecision('cache_hit', {
575
+ reason: 'waitedForLeader',
576
+ lockWaitMs,
577
+ cacheProductCount: productCount(cacheContent),
578
+ cacheOptionCount: optionCount(cacheContent),
579
+ });
580
+ return {
581
+ ...(cacheContent || { products: [] }),
582
+ ...R.omit(['lockOwnerToken'], refreshOutcome),
583
+ };
584
+ }
585
+ if (!forceRefresh && cacheContent) {
454
586
  emitDecision('cache_hit', {
455
587
  reason: 'waitedForLeader',
456
588
  lockWaitMs,
@@ -459,6 +591,21 @@ const $bookingsProductSearch = plugins => async ({
459
591
  });
460
592
  return cacheContent;
461
593
  }
594
+ if (forceRefresh && cacheContent) {
595
+ emitDecision('stale_served', {
596
+ reason: 'refreshInProgress',
597
+ lockWaitMs,
598
+ cacheProductCount: productCount(cacheContent),
599
+ cacheOptionCount: optionCount(cacheContent),
600
+ });
601
+ return {
602
+ ...cacheContent,
603
+ catalogRefreshOutcome: 'refresh_in_progress_cache_served',
604
+ cacheUpdated: false,
605
+ cachePreserved: true,
606
+ cachedProductCount: productCount(cacheContent),
607
+ };
608
+ }
462
609
  emitDecision('lock_timeout', { reason, lockWaitMs });
463
610
  throw createProductSearchUnavailableError();
464
611
  }
@@ -474,6 +621,11 @@ const $bookingsProductSearch = plugins => async ({
474
621
  });
475
622
 
476
623
  let pluginResults;
624
+ let catalogRefreshOutcome;
625
+ let cacheUpdated = false;
626
+ let cachePreserved = false;
627
+ let cachedProductCount;
628
+ let effectiveCatalogResults;
477
629
  try {
478
630
  const pluginStartedAt = Date.now();
479
631
  pluginResults = await func({
@@ -485,11 +637,10 @@ const $bookingsProductSearch = plugins => async ({
485
637
  userId,
486
638
  hint,
487
639
  });
640
+ assertCanonicalProductSearchResult(pluginResults, appKey);
488
641
  const pluginElapsedMs = Date.now() - pluginStartedAt;
489
642
 
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.
643
+ // Cache usable results from a direct plugin fetch (forceRefresh or cache miss).
493
644
  if (hasCacheableProductResults(pluginResults)) {
494
645
  await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
495
646
  await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
@@ -499,22 +650,85 @@ const $bookingsProductSearch = plugins => async ({
499
650
  cacheProductCount: productCount(pluginResults),
500
651
  cacheOptionCount: optionCount(pluginResults),
501
652
  });
653
+ catalogRefreshOutcome = 'cache_updated';
654
+ cacheUpdated = true;
655
+ cachedProductCount = productCount(pluginResults);
502
656
  } else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
657
+ const existingCacheContent = await app.cache.get({ key: cacheKey });
658
+ cachePreserved = hasNonEmptyProductCache(existingCacheContent);
659
+ effectiveCatalogResults = cachePreserved ? existingCacheContent : undefined;
660
+ catalogRefreshOutcome = cachePreserved
661
+ ? 'partial_result_preserved_cache'
662
+ : 'partial_result_not_cached';
663
+ cachedProductCount = cachePreserved
664
+ ? existingCacheContent.products.length
665
+ : null;
503
666
  emitDecision('partial_refresh_skipped', {
504
- reason: 'directPartialResultNotCached',
667
+ reason: cachePreserved
668
+ ? 'partialResultPreservedExistingCache'
669
+ : 'partialResultNotCached',
505
670
  pluginElapsedMs,
506
- cachePreserved: false,
671
+ cachePreserved,
672
+ existingProductCount: cachePreserved
673
+ ? existingCacheContent.products.length
674
+ : undefined,
507
675
  returnedProductCount: productCount(pluginResults),
508
676
  });
509
677
  } 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
- });
678
+ const existingCacheContent = await app.cache.get({ key: cacheKey });
679
+ if (hasNonEmptyProductCache(existingCacheContent)) {
680
+ catalogRefreshOutcome = 'empty_result_preserved_cache';
681
+ cachePreserved = true;
682
+ cachedProductCount = existingCacheContent.products.length;
683
+ effectiveCatalogResults = existingCacheContent;
684
+ emitDecision('empty_refresh_skipped', {
685
+ reason: 'emptyResultPreservedExistingCache',
686
+ pluginElapsedMs,
687
+ cachePreserved: true,
688
+ existingProductCount: existingCacheContent.products.length,
689
+ });
690
+ } else {
691
+ const latestCacheContent = await app.cache.get({ key: cacheKey });
692
+ if (hasNonEmptyProductCache(latestCacheContent)) {
693
+ catalogRefreshOutcome = 'empty_result_preserved_cache';
694
+ cachePreserved = true;
695
+ cachedProductCount = latestCacheContent.products.length;
696
+ effectiveCatalogResults = latestCacheContent;
697
+ emitDecision('empty_refresh_skipped', {
698
+ reason: 'emptyResultPreservedConcurrentCache',
699
+ pluginElapsedMs,
700
+ cachePreserved: true,
701
+ existingProductCount: latestCacheContent.products.length,
702
+ });
703
+ } else {
704
+ // Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
705
+ await app.cache.save({
706
+ key: cacheKey,
707
+ value: pluginResults,
708
+ ttl: emptyProductSearchCacheTtlSeconds,
709
+ });
710
+ catalogRefreshOutcome = 'empty_cache_saved';
711
+ cacheUpdated = true;
712
+ cachedProductCount = 0;
713
+ emitDecision('empty_cache_saved', {
714
+ reason,
715
+ pluginElapsedMs,
716
+ cacheProductCount: 0,
717
+ });
718
+ }
719
+ }
517
720
  }
721
+ await app.cache.save({
722
+ key: refreshOutcomeCacheKey,
723
+ value: {
724
+ lockOwnerToken,
725
+ catalogRefreshOutcome,
726
+ cacheUpdated,
727
+ cachePreserved,
728
+ cachedProductCount,
729
+ },
730
+ ttl: productSearchRefreshOutcomeTtlSeconds,
731
+ });
518
732
  } finally {
519
733
  await lockRenewal.stop();
520
734
  if (app.cache.dropIfValue) {
@@ -523,9 +737,39 @@ const $bookingsProductSearch = plugins => async ({
523
737
  await app.cache.drop({ key: pluginExecutionLockKey });
524
738
  }
525
739
  }
526
- return pluginResults || { products: [] }; // Ensure products array exists
740
+ const result = effectiveCatalogResults || pluginResults || { products: [] };
741
+ if (!forceRefresh) return result;
742
+ return {
743
+ ...result,
744
+ catalogRefreshOutcome,
745
+ cacheUpdated,
746
+ cachePreserved,
747
+ cachedProductCount,
748
+ };
527
749
  };
528
750
 
751
+ // 0. `cacheOnly`: return current Ti2 cache or empty. Never call the plugin.
752
+ if (cacheOnly) {
753
+ if (hasProductCache(initialActualCacheContent)) {
754
+ const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
755
+ emitDecision('cache_hit', {
756
+ reason: 'cacheOnly',
757
+ cacheProductCount: initialActualCacheContent.products.length,
758
+ cacheOptionCount: optionCount(initialActualCacheContent),
759
+ returnedProductCount: searchResults.length,
760
+ returnedOptionCount: optionCount({ products: searchResults }),
761
+ });
762
+ return {
763
+ ...initialActualCacheContent,
764
+ products: searchResults,
765
+ ...(token.configuration || {}),
766
+ cacheFound: true,
767
+ };
768
+ }
769
+ emitDecision('cache_miss', { reason: 'cacheOnly' });
770
+ return { products: [], ...(token.configuration || {}), cacheFound: false };
771
+ }
772
+
529
773
  // 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
530
774
  if (doNotCallPluginForProducts && !forceRefresh) {
531
775
  if (hasProductCache(initialActualCacheContent)) {
@@ -549,7 +793,15 @@ const $bookingsProductSearch = plugins => async ({
549
793
  emitDecision('force_refresh');
550
794
  const funcResults = await fetchFromPluginAndCache('force_refresh');
551
795
  const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
552
- return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
796
+ return {
797
+ ...funcResults,
798
+ products: searchResults,
799
+ ...(token.configuration || {}),
800
+ catalogRefreshOutcome: funcResults.catalogRefreshOutcome,
801
+ cacheUpdated: funcResults.cacheUpdated,
802
+ cachePreserved: funcResults.cachePreserved,
803
+ cachedProductCount: funcResults.cachedProductCount,
804
+ };
553
805
  }
554
806
 
555
807
  // 3. Cache exists (initialActualCacheContent) and not forceRefresh:
@@ -577,38 +829,13 @@ const $bookingsProductSearch = plugins => async ({
577
829
 
578
830
  const isEffectivelyStale = isStaleByTTR && !doNotCallPluginForProducts;
579
831
 
580
- // Cache is fresh or plugin execution in progress: serve from cache
832
+ // Cache is fresh, a plugin fetch is already running, or the cache is
833
+ // stale: organic search never starts a catalog refresh. The scheduled
834
+ // forceRefresh search is the only catalog owner.
581
835
  if (!isEffectivelyStale || hasPluginExecutionLock) {
582
836
  return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
583
837
  }
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
- });
838
+ return returnCachedResults('stale_served', { reason: 'awaitingScheduledRefresh' });
612
839
  }
613
840
  }
614
841
 
@@ -889,93 +1116,6 @@ const getCreateBookingFields = plugins => async (req, res, next) => {
889
1116
  }
890
1117
  };
891
1118
 
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
1119
  const controllerFactory = plugins => {
980
1120
  const controllerFunctions = {
981
1121
  bookingsSearch: bookingsSearch(plugins),
@@ -993,7 +1133,6 @@ const controllerFactory = plugins => {
993
1133
  getAffiliateDesks: getAffiliateDesks(plugins),
994
1134
  getPickupPoints: getPickupPoints(plugins),
995
1135
  getCreateBookingFields: getCreateBookingFields(plugins),
996
- $updateProductSearchCache: $updateProductSearchCache(plugins), // Add the new function here
997
1136
  };
998
1137
  // Ensure $bookingsProductSearch can be called internally by worker with plugins already bound
999
1138
  // This is more of a conceptual note as the factory pattern already handles this.
@@ -1002,5 +1141,8 @@ const controllerFactory = plugins => {
1002
1141
 
1003
1142
  // Add this line to attach typeDefsAndQueries to the factory:
1004
1143
  controllerFactory.typeDefsAndQueries = typeDefsAndQueries;
1144
+ controllerFactory.resolveProductSearchTtrSeconds = resolveProductSearchTtrSeconds;
1145
+ controllerFactory.defaultProductSearchTtrSeconds = defaultProductSearchTtrSeconds;
1146
+ controllerFactory.oneDayProductSearchTtrSeconds = oneDayProductSearchTtrSeconds;
1005
1147
 
1006
1148
  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) {