/** * Company info from MM (`/accounts/current`) is loaded during SDK startup * (`ApplicationInitializer.initialize` → {@link ensureCompanyInfo}), written to shared * SQLite runtime state, and read by synchronous {@link getCachedCompanyInfo} and related getters. * * Persisted data lives in `sdk_runtime_state` (same SQLite file as `SQLITE_DB_PATH`). Company fields are treated * as stable for the deployment; refreshing them if MM changes is the SDK’s responsibility when we * add that behavior—not connector boilerplate. * * Async helpers {@link getCompanyInfo}, {@link ensureCompanyInfo}, and {@link refreshCompanyInfoFromApi} * are for the SDK and advanced call sites. Connector code that runs after successful initialization * should use the `getCached*` APIs; they are deliberately not re-exported from the package root so * callers are not nudged toward re-fetching what startup already guaranteed. * * Startup fetch uses the MM API bearer token; a successful load confirms that token works for * `/accounts/current` (among other requirements). Initialization fails if company info cannot be loaded. */ import { CoreConfiguration } from "../data-sync-service/configuration-manager.js"; import { HTTPClientFactory } from "../../utils/http-client.js"; import { getIANATimezoneOffsetHours } from "../../utils/iana-timezone-offset.js"; import { readPersistedCompanyInfo, writePersistedCompanyInfo, } from "../../utils/local-data-store/jobs-shared-data.js"; import { logger } from "../reporting-service/index.js"; // Cache for company info to avoid repeated API calls let companyInfoCache: CompanyInfo | null = null; export interface CompanyInfo { timezone: string; locationRef: string; companyId: string; } export interface EnsureCompanyInfoParams { maxRetries: number; retryIntervalMs: number; } function hydrateCacheFromDiskIfPresent(): CompanyInfo | null { const persisted = readPersistedCompanyInfo(); if (persisted) { companyInfoCache = persisted; return persisted; } return null; } /** * Resolves company info for synchronous readers: in-memory, then persisted SQLite runtime state. * @throws Error if neither is available (initialization / API fetch has not populated data). */ function resolveCompanyInfoOrThrow(): CompanyInfo { if (companyInfoCache) { return companyInfoCache; } const fromDisk = hydrateCacheFromDiskIfPresent(); if (fromDisk) { return fromDisk; } throw new Error( "Company info is not available: run ApplicationInitializer.initialize() or ensureCompanyInfo() in a process with API access, or ensure sdk_runtime_state contains companyInfo from a prior fetch" ); } /** * Fetches company info from MM, updates in-memory cache and persisted job-state. * Used by {@link ensureCompanyInfo} during startup so workers can read the same data from disk. * If MM-side company data must be refreshed after deploy, that should be handled inside the SDK—not * by connector code calling this routinely. */ export const refreshCompanyInfoFromApi = async (): Promise => { try { const config = CoreConfiguration.inst(); const apiUrl = config.mmApiBaseUrl; const authToken = config.mmApiAuthToken; if (!apiUrl || !authToken) { throw new Error("Missing required configuration for company info fetch"); } const client = HTTPClientFactory.getInstance({ baseUrl: apiUrl, retryAttempts: config.mmApiRetryAttempts, }); const response = await client.request<{ company: { timezone: string; id: string; }; locationRef: number; }>({ url: "/accounts/current?includeLocation=true", method: "GET", headers: { Authorization: `Bearer ${authToken}`, }, }); const userInfo = response.data; if (!userInfo?.company) { throw new Error("Unable to retrieve company information from API"); } logger.info("Fetched company info from /accounts/current", { locationRef: userInfo.locationRef, companyId: userInfo.company.id, timezone: userInfo.company.timezone, }); companyInfoCache = { timezone: userInfo.company.timezone, locationRef: String(userInfo.locationRef), companyId: userInfo.company.id, }; writePersistedCompanyInfo(companyInfoCache); return companyInfoCache; } catch (error) { throw new Error( `Failed to get company info: ${error instanceof Error ? error.message : "Unknown error"}` ); } }; /** * Async resolution: memory, then persisted job-state, then a single API fetch if both are empty. * Prefer {@link ensureCompanyInfo} at startup (with retries). Connector runtime code should normally * use {@link getCachedCompanyInfo} after initialization succeeds—this exists for SDK-internal and * edge paths (e.g. first caller before workers have disk). */ export const getCompanyInfo = async (): Promise => { if (companyInfoCache) { return companyInfoCache; } const fromDisk = hydrateCacheFromDiskIfPresent(); if (fromDisk) { return fromDisk; } return refreshCompanyInfoFromApi(); }; /** * Invoked from SDK startup (`ApplicationInitializer.initialize`). Retries until {@link refreshCompanyInfoFromApi} * succeeds or `maxRetries` is exceeded. On success, memory and job-state are updated; a successful * round-trip also validates the MM bearer token for `/accounts/current`. Initialization aborts if this fails. */ export const ensureCompanyInfo = async ( params: EnsureCompanyInfoParams = { maxRetries: 36000, retryIntervalMs: 10_000, } ): Promise => { let success = false; let retries = 0; logger.info("Fetching company info from MachineMetrics (with retries if needed)"); while (!success && retries < params.maxRetries) { try { await refreshCompanyInfoFromApi(); success = true; } catch (error) { logger.error("Error fetching company info:", error); logger.info(`Retrying in ${params.retryIntervalMs / 1000} seconds...`); await new Promise((resolve) => setTimeout(resolve, params.retryIntervalMs)); retries++; } } if (!success) { throw new Error(`Failed to fetch company info after ${params.maxRetries} retries`); } }; /** * Full company info after successful SDK initialization (in-memory or same job-state file on disk). * Intended for connector and SDK code paths that run once startup has populated data—not a substitute * for calling `ApplicationInitializer.initialize()`. * @throws Error if company info is not available in memory or job-state */ export function getCachedCompanyInfo(): CompanyInfo { return resolveCompanyInfoOrThrow(); } /** * `locationRef` — same contract as {@link getCachedCompanyInfo}. * @throws Error if company info is not available in memory or job-state */ export function getCachedCompanyLocationRef(): string { return resolveCompanyInfoOrThrow().locationRef; } /** * MM company id — same contract as {@link getCachedCompanyInfo}. * @throws Error if company info is not available in memory or job-state */ export function getCachedCompanyId(): string { return resolveCompanyInfoOrThrow().companyId; } /** * Timezone offset in hours (IANA → offset at "now") — same contract as {@link getCachedCompanyInfo}. * @throws Error if company info is not available in memory or job-state */ export function getCachedCompanyTimezoneOffsetHours(): number { const info = resolveCompanyInfoOrThrow(); return getIANATimezoneOffsetHours(info.timezone); } /** * IANA timezone name — same contract as {@link getCachedCompanyInfo}. * @throws Error if company info is not available in memory or job-state */ export function getCachedCompanyTimezoneName(): string { return resolveCompanyInfoOrThrow().timezone; } /** @see getCachedCompanyTimezoneOffsetHours — legacy name, same strict startup contract. */ export function getCachedTimezoneOffset(): number { return getCachedCompanyTimezoneOffsetHours(); } /** @see getCachedCompanyTimezoneName — legacy name, same strict startup contract. */ export function getCachedTimezoneName(): string { return getCachedCompanyTimezoneName(); }