feat: Redis opportunity cache, CW API retry/logging, adaptive TTLs

- Add Redis-backed opportunity cache with background refresh (30s interval)
- Fix concurrency bug: use lazy thunks instead of eager promises for batching
- Add withCwRetry utility with exponential backoff for transient CW errors
- Add adaptive TTL algorithms (primary, sub-resource, products) based on opportunity activity
- Add include query param on GET /sales/opportunities/:id (notes,contacts,products)
- Add opt-in CW API logger (LOG_CW_API env var) with timestamped files in cw-api-logs/
- Add debug-scripts/analyze-cw-calls.py for API call analysis
- Add computeSubResourceCacheTTL and computeProductsCacheTTL algorithms with tests
- Increase CW API timeout from 15s to 30s
- Unblock cache refresh from startup chain (remove await)
- Prioritize recently updated opportunities in refresh cycle
- Add CACHING.md documentation
- Update API_ROUTES.md with caching details and include param
- Update copilot instructions to require CACHING.md sync
- Add dev:log script for CW API call logging during development
This commit is contained in:
2026-03-02 23:23:24 -06:00
parent fe71248e88
commit 6d935e7180
33 changed files with 2634 additions and 176 deletions
@@ -0,0 +1,126 @@
import { describe, test, expect } from "bun:test";
import {
computeSubResourceCacheTTL,
SUB_TTL_HIGH_ACTIVITY,
SUB_TTL_MODERATE_ACTIVITY,
SUB_TTL_LOW_ACTIVITY,
} from "../../src/modules/algorithms/computeSubResourceCacheTTL";
const NOW = new Date("2026-03-02T12:00:00Z");
const DAY_MS = 24 * 60 * 60 * 1000;
describe("computeSubResourceCacheTTL", () => {
// -- Rule 1a: closed > 30 days → null -----------------------------------
test("returns null for records closed > 30 days ago", () => {
const result = computeSubResourceCacheTTL({
closedFlag: true,
closedDate: new Date(NOW.getTime() - 31 * DAY_MS),
expectedCloseDate: null,
lastUpdated: null,
now: NOW,
});
expect(result).toBeNull();
});
// -- Rule 1b: closed within 30 days → SUB_TTL_LOW_ACTIVITY ---------------
test("returns SUB_TTL_LOW_ACTIVITY for recently-closed records", () => {
const result = computeSubResourceCacheTTL({
closedFlag: true,
closedDate: new Date(NOW.getTime() - 10 * DAY_MS),
expectedCloseDate: null,
lastUpdated: null,
now: NOW,
});
expect(result).toBe(SUB_TTL_LOW_ACTIVITY);
});
// -- Rule 2: within 5 days → SUB_TTL_HIGH_ACTIVITY ----------------------
test("returns SUB_TTL_HIGH_ACTIVITY when expectedCloseDate is within 5 days", () => {
const result = computeSubResourceCacheTTL({
closedFlag: false,
closedDate: null,
expectedCloseDate: new Date(NOW.getTime() + 2 * DAY_MS),
lastUpdated: null,
now: NOW,
});
expect(result).toBe(SUB_TTL_HIGH_ACTIVITY);
});
test("returns SUB_TTL_HIGH_ACTIVITY when lastUpdated is within 5 days", () => {
const result = computeSubResourceCacheTTL({
closedFlag: false,
closedDate: null,
expectedCloseDate: null,
lastUpdated: new Date(NOW.getTime() - 3 * DAY_MS),
now: NOW,
});
expect(result).toBe(SUB_TTL_HIGH_ACTIVITY);
});
// -- Rule 3: within 14 days → SUB_TTL_MODERATE_ACTIVITY -----------------
test("returns SUB_TTL_MODERATE_ACTIVITY when expectedCloseDate is within 14 days", () => {
const result = computeSubResourceCacheTTL({
closedFlag: false,
closedDate: null,
expectedCloseDate: new Date(NOW.getTime() + 10 * DAY_MS),
lastUpdated: null,
now: NOW,
});
expect(result).toBe(SUB_TTL_MODERATE_ACTIVITY);
});
test("returns SUB_TTL_MODERATE_ACTIVITY when lastUpdated is within 14 days", () => {
const result = computeSubResourceCacheTTL({
closedFlag: false,
closedDate: null,
expectedCloseDate: null,
lastUpdated: new Date(NOW.getTime() - 8 * DAY_MS),
now: NOW,
});
expect(result).toBe(SUB_TTL_MODERATE_ACTIVITY);
});
// -- Rule 4: everything else → SUB_TTL_LOW_ACTIVITY ---------------------
test("returns SUB_TTL_LOW_ACTIVITY for stale records", () => {
const result = computeSubResourceCacheTTL({
closedFlag: false,
closedDate: null,
expectedCloseDate: new Date(NOW.getTime() - 30 * DAY_MS),
lastUpdated: new Date(NOW.getTime() - 30 * DAY_MS),
now: NOW,
});
expect(result).toBe(SUB_TTL_LOW_ACTIVITY);
});
test("returns SUB_TTL_LOW_ACTIVITY when no dates are set", () => {
const result = computeSubResourceCacheTTL({
closedFlag: false,
closedDate: null,
expectedCloseDate: null,
lastUpdated: null,
now: NOW,
});
expect(result).toBe(SUB_TTL_LOW_ACTIVITY);
});
// -- TTL ordering -------------------------------------------------------
test("SUB_TTL values are ordered correctly", () => {
expect(SUB_TTL_HIGH_ACTIVITY).toBe(60_000);
expect(SUB_TTL_MODERATE_ACTIVITY).toBe(120_000);
expect(SUB_TTL_LOW_ACTIVITY).toBe(300_000);
expect(SUB_TTL_HIGH_ACTIVITY).toBeLessThan(SUB_TTL_MODERATE_ACTIVITY);
expect(SUB_TTL_MODERATE_ACTIVITY).toBeLessThan(SUB_TTL_LOW_ACTIVITY);
});
// -- Closed flag takes priority ------------------------------------------
test("closed flag takes priority over recent activity dates", () => {
const result = computeSubResourceCacheTTL({
closedFlag: true,
closedDate: new Date(NOW.getTime() - 60 * DAY_MS),
expectedCloseDate: new Date(NOW.getTime() - 1 * DAY_MS),
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
now: NOW,
});
expect(result).toBeNull();
});
});