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.
package/README.md CHANGED
@@ -65,6 +65,35 @@ Plugins are the connectores to other systems and/or features you intent to use;
65
65
  |searchItineraries|||||||||✓|
66
66
  |queryAllotment|||||||||✓|
67
67
 
68
+ ### Product catalog refresh contract
69
+
70
+ `POST /products/{appKey}/{userId}/{hint}/search` returns a canonical object with a
71
+ `products` array. Use `cacheOnly: true` to read Ti2's current catalog without
72
+ calling the integration; `cacheFound` distinguishes a cache miss from a cached
73
+ empty catalog.
74
+
75
+ A forced request is a full-catalog refresh by default. `fullSyncTrigger` may be
76
+ `scheduled`, `manual`, or `organic`; an omitted trigger on a forced request is
77
+ treated as manual. Manual refreshes require a non-blank `admissionOverrideReason`.
78
+ Scheduled and manual refreshes reject `searchInput`, `optionId`, `productId`,
79
+ `productName`, or `lastUpdatedFrom` selectors. An empty `searchInput` or
80
+ `searchInput: "*"` represents the full catalog. Scheduler-owned requests may
81
+ also carry `fullSyncStartedAt` and `fullSyncAdmissionToken`.
82
+
83
+ Unscoped forced responses include `catalogRefreshOutcome`, `cacheUpdated`,
84
+ `cachePreserved`, and `cachedProductCount`. Consumers that update a downstream
85
+ catalog should use these fields instead of assuming that HTTP 200 or the returned
86
+ `products.length` means Ti2 replaced its cache. Empty or partial plugin results
87
+ can preserve an existing catalog. If a concurrent refresh remains in progress
88
+ past the lock wait, Ti2 serves the retained catalog with
89
+ `catalogRefreshOutcome: "refresh_in_progress_cache_served"`. This outcome is
90
+ non-terminal: background-job `success` means the HTTP call completed, not that
91
+ the catalog was written.
92
+
93
+ The product cache refresh interval defaults to seven days when no TTR is set.
94
+ Any explicit `ttlForProducts` or plugin `cacheSettings.bookingsProductSearch.ttr`
95
+ value is honored, including `86400` for a one-day interval.
96
+
68
97
  ## Contributing
69
98
 
70
99
  Contributions are welcome and ecouraged.
@@ -94,4 +123,3 @@ TL;DR Here's what the license entails:
94
123
  7. Any modifications of this code base MUST be distributed with the same license, GPLv3.
95
124
  8. This software is provided without warranty.
96
125
  9. The software author or license can not be held liable for any damages inflicted by the software.
97
-
@@ -0,0 +1,42 @@
1
+ /* globals describe expect it */
2
+
3
+ const createApp = require('../index');
4
+
5
+ class SelectivePlugin {
6
+ constructor({ cache, events, name }) {
7
+ this.cache = cache;
8
+ this.events = events;
9
+ this.name = name;
10
+ }
11
+ }
12
+
13
+ describe('host-owned plugin capabilities', () => {
14
+ it('assigns configured capabilities when a plugin ignores unknown constructor fields', async () => {
15
+ const app = await createApp({
16
+ pluginCapabilities: {
17
+ selective: { weeklyProductCatalogSync: true },
18
+ },
19
+ plugins: { selective: SelectivePlugin },
20
+ startServer: false,
21
+ });
22
+
23
+ expect(app.plugins[0].capabilities).toEqual({
24
+ weeklyProductCatalogSync: true,
25
+ });
26
+ });
27
+
28
+ it('assigns configured capabilities to pre-instantiated plugins', async () => {
29
+ const plugin = { name: 'instantiated' };
30
+ const app = await createApp({
31
+ pluginCapabilities: {
32
+ instantiated: { weeklyProductCatalogSync: true },
33
+ },
34
+ pluginsInstantiated: [plugin],
35
+ startServer: false,
36
+ });
37
+
38
+ expect(app.plugins[0].capabilities).toEqual({
39
+ weeklyProductCatalogSync: true,
40
+ });
41
+ });
42
+ });
package/api.yml CHANGED
@@ -275,6 +275,11 @@ components:
275
275
  appMethods:
276
276
  type: object
277
277
  properties:
278
+ capabilities:
279
+ type: object
280
+ properties:
281
+ weeklyProductCatalogSync:
282
+ type: boolean
278
283
  methods:
279
284
  type: array
280
285
  items:
@@ -426,15 +431,56 @@ components:
426
431
  bookingsProductQuery:
427
432
  type: object
428
433
  additionalProperties: true
429
- description: Additional properties may be included
434
+ description: Product search or full-catalog refresh request. Scheduled and manual force refreshes must not include selectors.
430
435
  properties:
436
+ searchInput:
437
+ type: string
438
+ description: Free-text product selector. An empty value or "*" means the full catalog.
439
+ optionId:
440
+ description: Option ID selector
441
+ oneOf:
442
+ - type: string
443
+ - type: array
444
+ items:
445
+ type: string
446
+ productId:
447
+ type: string
448
+ description: Product ID selector
431
449
  productName:
432
450
  type: string
433
- required: false
434
451
  description: Product Name wildcard match
435
452
  example: '*bus*'
453
+ lastUpdatedFrom:
454
+ description: Return products updated since this value
455
+ forceRefresh:
456
+ type: boolean
457
+ default: false
458
+ description: Call the plugin directly. Full-catalog refreshes require the app's weeklyProductCatalogSync capability and default to a manual trigger when fullSyncTrigger is omitted. Manual refreshes require admissionOverrideReason.
459
+ cacheOnly:
460
+ type: boolean
461
+ default: false
462
+ description: Return the current Ti2 cache without calling the plugin. cacheFound distinguishes a miss from a cached empty catalog.
463
+ backgroundJob:
464
+ type: boolean
465
+ default: false
466
+ description: Execute the request through the Ti2 worker.
467
+ fullSyncTrigger:
468
+ type: string
469
+ description: Catalog refresh owner. Forced requests default to manual when omitted. Scheduled and manual triggers reject selector fields.
470
+ enum:
471
+ - organic
472
+ - scheduled
473
+ - manual
474
+ admissionOverrideReason:
475
+ type: string
476
+ description: Required non-blank audit reason for a manual refresh override.
477
+ fullSyncStartedAt:
478
+ type: number
479
+ description: Start time supplied by the full-sync admission owner.
480
+ fullSyncAdmissionToken:
481
+ type: string
482
+ description: Opaque token supplied by the full-sync admission owner.
436
483
  omitServiceCodes:
437
- required: false
438
484
  description: Service codes to omit from the product catalog. When the integration has productSearchOmitServiceCodes, that list is used instead.
439
485
  example: ['AC', 'SM']
440
486
  oneOf:
@@ -443,25 +489,42 @@ components:
443
489
  items:
444
490
  type: string
445
491
  credentials:
446
- required: false
447
492
  $ref: '#/components/schemas/integrationCredentials'
448
493
  bookingsProductSearchReturn:
449
494
  type: object
450
495
  additionalProperties: true
451
- description: Additional properties may be included
496
+ required:
497
+ - products
498
+ description: Canonical product search response. Additional properties may be included.
452
499
  properties:
453
500
  products:
454
501
  type: array
455
- required: false
456
- description: Product Search Results
502
+ description: Effective Ti2 catalog; preserved refreshes return the retained cached products.
457
503
  items:
458
504
  type: object
459
- accommodation:
460
- type: array
461
- required: false,
462
- decription: Accommodation search results
463
- items:
464
- type: object
505
+ cacheFound:
506
+ type: boolean
507
+ description: For cacheOnly requests, whether Ti2 had a cached catalog (including an intentionally cached empty catalog).
508
+ catalogRefreshOutcome:
509
+ type: string
510
+ description: Outcome of an unscoped forceRefresh catalog operation
511
+ enum:
512
+ - cache_updated
513
+ - empty_cache_saved
514
+ - empty_result_preserved_cache
515
+ - partial_result_preserved_cache
516
+ - partial_result_not_cached
517
+ - refresh_in_progress_cache_served
518
+ cacheUpdated:
519
+ type: boolean
520
+ description: Whether this forceRefresh wrote the returned catalog to Ti2 cache
521
+ cachePreserved:
522
+ type: boolean
523
+ description: Whether this forceRefresh retained a prior catalog instead of replacing it
524
+ cachedProductCount:
525
+ type: integer
526
+ nullable: true
527
+ description: Product count in the Ti2 cache after the forceRefresh decision
465
528
  pricingItem:
466
529
  type: object
467
530
  additionalProperties: true
@@ -1,6 +1,6 @@
1
1
  # Bookings Controller Caching and Locking Strategy
2
2
 
3
- The `$bookingsProductSearch` function in `controllers/bookings.js` implements a sophisticated caching and locking strategy to optimize product searches, manage stale data, and prevent redundant operations. This document outlines its core flow and the locking mechanisms involved.
3
+ The `$bookingsProductSearch` function in `controllers/bookings.js` implements a caching and locking strategy to optimize product searches, manage stale data, and prevent redundant plugin calls. This document outlines its core flow.
4
4
 
5
5
  ## Core Flow of `$bookingsProductSearch`
6
6
 
@@ -9,63 +9,53 @@ The `$bookingsProductSearch` function in `controllers/bookings.js` implements a
9
9
  * Determines the specific plugin function to call for product search (e.g., `searchProducts` or `searchProductsForItinerary`).
10
10
  * Injects the integration's configured `productSearchOmitServiceCodes` into the plugin payload so every caller for that token omits the same service codes. When the setting is empty, the request body is unchanged.
11
11
  * Calculates a `cacheKey` based on `userId`, `hint`, and a static `operationId` (`bookingsProductSearch`). Changing the omit setting does not change the key; clear that integration's product-search cache and resync.
12
- * Defines two distinct lock keys derived from this `cacheKey`:
12
+ * Defines a lock key derived from this `cacheKey`:
13
13
  * `pluginExecutionLockKey` (resolves to `${cacheKey}:lock`): Used to serialize direct calls to the plugin.
14
- * `jobQueueLockKey` (resolves to `${cacheKey}:jobLock`): Used to prevent multiple submissions of background refresh jobs.
14
+ * `${pluginExecutionLockKey}:outcome`: Short-lived refresh metadata used by concurrent waiters.
15
15
  * Fetches the current cache content (`initialActualCacheContent`) and its `lastUpdated` timestamp.
16
16
  * Determines if the cache is stale (`isStaleByTTR`) based on the Time-To-Refresh (`ttr`) value from the token or plugin settings.
17
17
  * Checks for a `doNotCallPluginForProducts` flag (from token or plugin settings) and whether a `pluginExecutionLockKey` is currently active.
18
18
 
19
19
  2. **Request Handling Logic (Simplified Order):**
20
20
 
21
- * **Condition 1: `doNotCallPluginForProducts` is true AND NOT `forceRefresh`**:
21
+ * **Condition 1: `cacheOnly` is true**:
22
+ * Returns the current cache without calling the plugin. `cacheFound` distinguishes a cache miss from an intentionally cached empty catalog.
23
+
24
+ * **Condition 2: `doNotCallPluginForProducts` is true AND NOT `forceRefresh`**:
22
25
  * If this flag is set and the request is not a forced refresh, the system serves data directly from the cache if available.
23
26
  * If the cache is empty, it returns an empty product list.
24
- * No plugin calls are made, and no background jobs are queued in this path.
27
+ * No plugin calls are made in this path.
25
28
 
26
- * **Condition 2: `forceRefresh` is true**:
27
- * The system attempts to fetch fresh data directly from the plugin.
29
+ * **Condition 3: `forceRefresh` is true**:
30
+ * This is the catalog refresh owner. The system fetches fresh data from the plugin.
31
+ * Ti2 rejects the request unless the host declared `weeklyProductCatalogSync: true` for the plugin. Manual audit reasons may override timing admission, but not catalog-completeness eligibility.
32
+ * An omitted `fullSyncTrigger` is treated as `manual`, and manual refreshes require a non-blank `admissionOverrideReason`. Weekly scheduler requests must explicitly send `fullSyncTrigger: scheduled`.
33
+ * Unless `fullSyncTrigger: organic` is explicit, a force refresh is a scheduled or manual full-catalog request and rejects `searchInput`, `optionId`, `productId`, `productName`, and `lastUpdatedFrom` selectors. `searchInput: "*"` is unscoped.
28
34
  * The `fetchFromPluginAndCache` helper function is invoked. This function:
29
35
  * Sets the `pluginExecutionLockKey` before calling the plugin to prevent other concurrent direct calls.
30
36
  * Calls the plugin's product search method.
31
37
  * If the plugin returns valid products, these are saved to the cache (both `cacheKey` for data and `${cacheKey}:lastUpdated` for timestamp).
38
+ * If the plugin returns empty or partial products and a non-empty cache already exists, the existing cache is kept and returned.
39
+ * Concurrent waiters return the same effective catalog and refresh outcome. If the wait expires while the leader is still active, the waiter returns the retained cache with `refresh_in_progress_cache_served`.
32
40
  * Drops the `pluginExecutionLockKey` after completion.
33
- * The (potentially filtered) results from the plugin are returned to the client.
41
+ * The response includes `catalogRefreshOutcome`, `cacheUpdated`, `cachePreserved`, and `cachedProductCount`. `catalogRefreshOutcome` is authoritative; background-job `success` only means the HTTP request completed and does not prove a terminal catalog write.
34
42
 
35
- * **Condition 3: Cache Exists AND NOT `forceRefresh`**:
36
- * **Sub-condition 3a: Cache is fresh OR `pluginExecutionLockKey` is active**:
37
- * If the cache is not stale according to its TTR, or if a direct plugin execution (like a `forceRefresh` or initial population) is already in progress (indicated by an active `pluginExecutionLockKey`), the system serves data from the existing cache.
38
- * **Sub-condition 3b: Cache is stale AND no `pluginExecutionLockKey` is active**:
39
- * The system checks for an active `jobQueueLockKey`.
40
- * If `jobQueueLockKey` IS active: It implies that another request very recently detected the stale cache and has already queued a background refresh job. The current request serves the stale data from the cache without queueing another job.
41
- * If `jobQueueLockKey` is NOT active: This request is the first (or among the first) to find the stale cache without an ongoing direct refresh or a recently queued job. It will:
42
- 1. Set the `jobQueueLockKey` with a short TTL (e.g., 60 seconds).
43
- 2. Queue a background job using `addJob`. This job will eventually call the plugin to refresh the data and then use `$updateProductSearchCache` to update the cache.
44
- 3. Serve the stale data from the cache to the current client.
43
+ * **Condition 4: Cache Exists AND NOT `forceRefresh`**:
44
+ * Organic search never starts a catalog refresh.
45
+ * If the cache is fresh, or a plugin fetch is already running, serve the cache.
46
+ * If the cache is stale, serve it while the scheduler separately owns the next `forceRefresh` catalog sync.
45
47
 
46
- * **Condition 4: No Cache Content AND NOT `forceRefresh` AND NOT `doNotCallPluginForProducts`**:
47
- * If there's no existing cache content and none of the preceding conditions (like `forceRefresh` or `doNotCallPluginForProducts`) were met, the system needs to populate the cache.
48
+ * **Condition 5: No Cache Content AND NOT `forceRefresh` AND NOT `doNotCallPluginForProducts`**:
49
+ * Cache miss still fetches from the plugin so search can return products.
48
50
  * It calls `fetchFromPluginAndCache` (which sets `pluginExecutionLockKey`, calls the plugin, caches results, and drops the lock) to get initial data.
49
51
  * The (potentially filtered) results are returned to the client.
50
52
 
51
53
  ## Locking Mechanisms Explained
52
54
 
53
- The system uses two types of locks, both based on the primary `cacheKey`, to manage concurrency and prevent redundant operations:
54
-
55
55
  1. **`pluginExecutionLockKey` (derived from `${cacheKey}:lock`)**:
56
- * **Purpose**: To prevent multiple simultaneous *direct calls* to the external plugin for the same product search parameters. This is crucial during `forceRefresh` scenarios or when the cache is being populated for the first time by concurrent requests.
56
+ * **Purpose**: To prevent multiple simultaneous *direct calls* to the external plugin for the same product search parameters. This is used during `forceRefresh` and first-time cache population.
57
57
  * **Behavior**:
58
58
  * This lock is set by the `fetchFromPluginAndCache` helper function immediately before it makes an actual call to the plugin's `searchProducts` (or equivalent) method.
59
59
  * It is configured with a TTL (e.g., 120 seconds) to ensure it automatically expires if the process holding the lock crashes or fails to release it.
60
60
  * The lock is explicitly dropped by `fetchFromPluginAndCache` after the plugin call completes (whether successfully or with an error).
61
- * Other parts of the main logic (e.g., in Condition 3a) check for the presence of this lock (`hasPluginExecutionLock`). If active, it signals that a direct plugin data fetch is already in progress, prompting the current request to, for example, serve stale data or wait, rather than initiating another direct plugin call.
62
-
63
- 2. **`jobQueueLockKey` (derived from `${cacheKey}:jobLock`)**:
64
- * **Purpose**: To prevent the submission of multiple identical *background refresh jobs* by nearly simultaneous requests when the cache is found to be stale and no direct plugin execution (covered by `pluginExecutionLockKey`) is active.
65
- * **Behavior**:
66
- * This lock is checked specifically when the cache is determined to be effectively stale (`isEffectivelyStale`) and no `pluginExecutionLockKey` is currently active (Condition 3b).
67
- * If the `jobQueueLockKey` is NOT found in the cache, the current request assumes responsibility for queuing the refresh job. It sets this lock with a short TTL (e.g., 60 seconds) *before* calling `addJob`.
68
- * If the `jobQueueLockKey` IS found, it indicates that another request has very recently detected the stale state and has already taken action to queue the refresh job. The current request will then proceed to serve stale data without attempting to queue another job.
69
- * This mechanism ensures that even if numerous requests detect stale data at virtually the same moment, only one of them will succeed in setting the `jobQueueLockKey` and thereby be responsible for queueing the single background refresh task.
70
-
71
- These two locks work in tandem: `pluginExecutionLockKey` manages contention for direct, immediate plugin interactions, while `jobQueueLockKey` manages contention for initiating background refresh tasks when stale data is being served. This dual-lock strategy helps maintain system performance and avoids overwhelming external plugin services or the background job queue.
61
+ * Other parts of the main logic (e.g., in Condition 3) check for the presence of this lock (`hasPluginExecutionLock`). If active, it signals that a direct plugin data fetch is already in progress, prompting the current request to serve cache rather than initiating another direct plugin call.