diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a1e8091..4eb6961f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,16 +85,25 @@ jobs: echo "ERROR: dist/index.js not found" exit 1 fi - if [ ! -f dist/wasm/rulesengine_bg.wasm ]; then - echo "ERROR: dist/wasm/rulesengine_bg.wasm not found" - echo "The build may have failed to copy WASM artifacts" - exit 1 - fi if [ ! -f dist/wasm/rulesengine.js ]; then echo "ERROR: dist/wasm/rulesengine.js not found" exit 1 fi - echo "Verified: WASM artifacts present in dist/wasm/" + # The .wasm binary is base64-inlined into rulesengine.js at build + # time (see build.js inlineWasmBinary), so the standalone .wasm + # is intentionally absent from dist/. Verify the inlining sentinel + # is present instead — guards against a future build change that + # silently reverts to the disk-loaded path. + if ! grep -q "WASM binary inlined at build time" dist/wasm/rulesengine.js; then + echo "ERROR: dist/wasm/rulesengine.js does not contain the inlined WASM sentinel" + echo "The build may have failed to run inlineWasmBinary()" + exit 1 + fi + if [ -f dist/wasm/rulesengine_bg.wasm ]; then + echo "ERROR: dist/wasm/rulesengine_bg.wasm should have been removed by inlineWasmBinary()" + exit 1 + fi + echo "Verified: WASM inlined into dist/wasm/rulesengine.js" publish: needs: [ compile, test, verify-package ] diff --git a/build.js b/build.js index 7ff25664..d839279b 100644 --- a/build.js +++ b/build.js @@ -1,7 +1,7 @@ // build.js const esbuild = require('esbuild'); const { execSync } = require('child_process'); -const { cpSync, mkdirSync, existsSync } = require('fs'); +const { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, rmSync } = require('fs'); const sharedConfig = { entryPoints: ['src/index.ts'], @@ -52,6 +52,8 @@ async function build() { } console.log('✅ WASM artifacts copied to dist/wasm/'); + inlineWasmBinary(); + // Generate TypeScript declarations with tsc console.log('🔧 Generating TypeScript declarations...'); execSync('tsc --emitDeclarationOnly --outDir dist', { stdio: 'inherit' }); @@ -65,4 +67,55 @@ async function build() { } } +// Inline the WASM binary into dist/wasm/rulesengine.js so the runtime no +// longer reads it off disk via `fs.readFileSync(${__dirname}/...)`. +// +// The wasm-bindgen-generated loader resolves the .wasm file relative to +// its own `__dirname`, which breaks the moment a downstream bundler +// (webpack, Next.js, Vite, etc.) follows the require chain and rewrites +// `__dirname` to point inside the bundle output — there's no `.wasm` +// sibling there, so initialization fails with a misleading ENOENT. +// See the linked Next.js failure mode at +// .next/dev/server/vendor-chunks/rulesengine_bg.wasm → ENOENT. +// +// Inlining the bytes as a base64 string sidesteps the whole class of +// `__dirname`-aware loaders. Costs ~+138 KB on the tarball (550 KB +// base64 string in JS replaces a 414 KB .wasm + the loader stub) but +// makes the SDK bundler-agnostic for free. We delete the standalone +// `.wasm` after rewriting since nothing reads it at runtime anymore. +function inlineWasmBinary() { + const loaderPath = 'dist/wasm/rulesengine.js'; + const binaryPath = 'dist/wasm/rulesengine_bg.wasm'; + if (!existsSync(loaderPath) || !existsSync(binaryPath)) { + console.warn('⚠️ Skipping WASM inlining — files not found:', { loaderPath, binaryPath }); + return; + } + + const wasmBase64 = readFileSync(binaryPath).toString('base64'); + const loaderSource = readFileSync(loaderPath, 'utf8'); + + // Match the wasm-bindgen-emitted block that reads the binary off disk. + // Captures any whitespace/comments wasm-bindgen emits between the two + // statements so future loader updates don't silently bypass this step. + const loaderPattern = /const wasmPath = `\$\{__dirname\}\/rulesengine_bg\.wasm`;\s*\nconst wasmBytes = require\('fs'\)\.readFileSync\(wasmPath\);/; + if (!loaderPattern.test(loaderSource)) { + throw new Error( + 'WASM inlining failed: expected `const wasmPath = `${__dirname}/...`; const wasmBytes = require(\'fs\').readFileSync(wasmPath);` in ' + + loaderPath + + '. wasm-bindgen output shape changed — update inlineWasmBinary() to match.' + ); + } + + const inlined = loaderSource.replace( + loaderPattern, + `// WASM binary inlined at build time (see build.js inlineWasmBinary).\nconst wasmBytes = Buffer.from('${wasmBase64}', 'base64');` + ); + writeFileSync(loaderPath, inlined); + + // Standalone .wasm is dead weight once the bytes are embedded. Drop + // it so we don't ship two copies of the binary. + rmSync(binaryPath); + console.log(`✅ WASM inlined into ${loaderPath} (${wasmBase64.length} base64 chars, .wasm removed)`); +} + build(); diff --git a/src/cache/redis.ts b/src/cache/redis.ts index 474623af..f4a548a4 100644 --- a/src/cache/redis.ts +++ b/src/cache/redis.ts @@ -1,8 +1,11 @@ import { CacheProvider, CacheOptions } from "./types"; /** - * Minimal interface describing the redis client methods used by RedisCacheProvider. - * Compatible with the 'redis' package's RedisClientType. + * Minimal interface describing the redis client methods used by the SDK. + * Compatible with the 'redis' package's RedisClientType (node-redis v4). + * + * Includes hash + sorted-set + eval surface used by the credit-lease and + * reservation stores to coordinate state across multiple SDK pods. */ export interface RedisClient { get(key: string): Promise; @@ -10,6 +13,33 @@ export interface RedisClient { setEx(key: string, seconds: number, value: string): Promise; del(key: string | string[]): Promise; scanIterator(options: { MATCH: string; COUNT: number }): AsyncIterable; + // Hash ops — used to store lease + reservation state as a single field-set + // so partial updates (e.g. atomic decrement on localRemainingCredits) don't + // step on neighboring fields. + hSet(key: string, field: string | Record, value?: string | number): Promise; + hGet(key: string, field: string): Promise; + hGetAll(key: string): Promise>; + hDel(key: string, field: string | string[]): Promise; + // Sorted-set ops — used to index reservations by expiry timestamp so the + // sweeper can pop expired entries in O(log n). + zAdd(key: string, members: { score: number; value: string } | { score: number; value: string }[]): Promise; + zRangeByScore(key: string, min: number | string, max: number | string): Promise; + zRem(key: string, member: string | string[]): Promise; + zCard(key: string): Promise; + // Set ops — used by `RedisLeaseStore` as an index of outstanding lease slots + // so `snapshot()` (driving `releaseAll` on close) can enumerate them without + // scanning Redis. + sMembers(key: string): Promise; + sRem(key: string, member: string): Promise; + // Lua scripts — required for atomic check-and-decrement on lease balance + // and atomic consume-and-refund on reservations. + eval( + script: string, + options: { keys: string[]; arguments: string[] }, + ): Promise; + // Expiry on a millisecond-precision absolute timestamp — used to auto-clean + // lease + reservation rows shortly after their declared expiry. + pExpireAt(key: string, timestamp: number): Promise; } export interface RedisOptions extends CacheOptions { diff --git a/src/credits/check.ts b/src/credits/check.ts new file mode 100644 index 00000000..347b9874 --- /dev/null +++ b/src/credits/check.ts @@ -0,0 +1,387 @@ +import { randomUUID } from "crypto"; + +import type * as api from "../api"; +import type { DataStreamClient } from "../datastream"; +import type { Logger } from "../logger"; +import type { CheckFlagOptions, WasmFeatureEntitlement } from "../rules-engine"; + +import { CreditLeaseManager } from "./lease-manager"; +import type { ILeaseStore } from "./lease-store"; +import type { IReservationStore } from "./reservation-store"; +import type { + CheckOptions, + CheckResult, + Reservation, + ResolvedLeaseConfig, +} from "./types"; + +/** Internal helper bundling everything needed to satisfy a lease-bearing check. */ +export interface CreditCheckDeps { + leaseStore: ILeaseStore; + reservations: IReservationStore; + manager: CreditLeaseManager; + datastream: DataStreamClient | undefined; + logger: Logger; +} + +/** + * Drives a single `client.check` with `usage` / `eventUsage` set. + * + * Steps: + * 1. Resolve the matching credit-balance condition on the flag to get + * `creditId` + `consumptionRate`. + * 2. Acquire (or reuse) a lease for `(company, creditId)`. + * 3. Try to reserve `quantity × consumptionRate` from the lease. + * 4. Run the WASM rules engine against a substituted company snapshot + * (`credit_balances[creditId] = lease.localRemaining` *before* the + * reservation we just made was debited), plus `event_usage` options so + * the engine evaluates the post-call balance. The reservation we made + * in step 3 only sticks if the engine says allowed. + */ +export async function checkWithLease( + deps: CreditCheckDeps, + key: string, + evalCtx: api.CheckFlagRequestBody, + options: CheckOptions, + fallback: () => Promise, +): Promise { + const { datastream, leaseStore, reservations, manager, logger } = deps; + const onFailure = options.onAcquireFailure ?? "fail-closed"; + + if (!datastream) { + logger.debug( + "Credit-lease check requested without datastream — falling back to plain check", + ); + return fallback(); + } + + let flag: api.RulesengineFlag | null = null; + try { + flag = await datastream.getFlag(key); + } catch (err) { + logger.warn(`Lease check: failed to load flag ${key}: ${err}`); + } + if (!flag) { + logger.debug(`Lease check: no cached flag for ${key}, falling back`); + return fallback(); + } + + const { eventSubtype, quantity } = extractPreflightQuantity(options); + + const company = evalCtx.company ? await datastream.getCachedCompany(evalCtx.company) : null; + const user = evalCtx.user ? await datastream.getCachedUser(evalCtx.user) : null; + if (evalCtx.company && !company) { + logger.debug(`Lease check: company not in cache for keys ${JSON.stringify(evalCtx.company)}, falling back`); + return fallback(); + } + if (!company) { + logger.debug("Lease check: no company on evalCtx, falling back"); + return fallback(); + } + + // A flag can carry credit conditions from several plans, each metering a + // different credit type. Lease the credit the company's *matched* plan + // entitlement actually uses — not whichever credit condition is declared + // first on the flag. We can't read that off the flag structurally (a + // condition doesn't know which rule the company matched), so we probe the + // engine: its entitlement reports the plan-correct creditId regardless of + // balance. Falls back to first-match for single-credit flags or when the + // probe can't resolve a credit. + const match = await resolveCreditCondition(datastream, flag, company, user, eventSubtype, logger); + if (!match) { + logger.debug( + `Lease check: flag ${key} has no matching credit condition (subtype=${eventSubtype ?? ""}), falling back`, + ); + return fallback(); + } + + const creditId = match.condition.creditId; + const consumptionRate = match.condition.consumptionRate ?? 0; + if (consumptionRate <= 0) { + logger.debug(`Lease check: condition has no consumption_rate, falling back`); + return fallback(); + } + const creditCost = quantity * consumptionRate; + + const lease = await manager.acquireIfNeeded(company.id, creditId); + if (!lease) { + return handleAcquireFailure(onFailure, key, "lease_acquire_failed", flag); + } + + const reservedLocally = await leaseStore.tryReserve(company.id, creditId, creditCost); + if (!reservedLocally) { + // Lease has less than `creditCost` left locally. Pass `creditCost` so + // `maybeExtendInBackground` extends even when the ratio is still above + // the low-watermark (e.g. a single large request). + await manager.maybeExtendInBackground(company.id, creditId, creditCost); + const retry = await leaseStore.tryReserve(company.id, creditId, creditCost); + if (!retry) { + return handleAcquireFailure(onFailure, key, "insufficient_lease_balance", flag); + } + } + + // Substitute the lease balance into the company snapshot so the engine + // gates against the lease's local view, not the server's authoritative + // balance. We use the *pre-reservation* localRemaining + event_usage so + // the engine computes `pre - qty*rate >= 0`, which matches the plan. + // Pre-reservation = current store balance (post-tryReserve) + creditCost + // we just debited. + const postReservationEntry = await leaseStore.get(company.id, creditId); + const preReservation = (postReservationEntry?.localRemainingCredits ?? 0) + creditCost; + const substituted = substituteCreditBalance(company, creditId, preReservation); + + const wasmOptions = buildWasmOptions(options); + let result; + try { + result = await datastream + .getRulesEngine() + .checkFlagWithOptions(flag, substituted, user ?? null, wasmOptions); + } catch (err) { + logger.error(`Lease check: WASM eval failed: ${err}`); + await leaseStore.refund(company.id, creditId, creditCost); + return handleAcquireFailure(onFailure, key, `wasm_error: ${err}`, flag); + } + + if (!result.value) { + await leaseStore.refund(company.id, creditId, creditCost); + return { + allowed: false, + value: false, + reason: result.reason ?? "denied_by_engine", + entitlement: normalizeEntitlement(result.entitlement), + flagKey: result.flagKey ?? key, + flagId: result.flagId, + }; + } + + const reservation = registerReservation({ + leaseId: lease.leaseId, + companyId: company.id, + creditTypeId: creditId, + eventSubtype: eventSubtype ?? match.condition.eventSubtype ?? "", + quantityReserved: quantity, + creditsReserved: creditCost, + consumptionRate, + reservationTTL: resolveReservationTTL(deps, creditId), + evalCtx, + }); + await reservations.add(reservation); + + // Fire-and-forget low-water-mark refresh now that we've debited. + void manager.maybeExtendInBackground(company.id, creditId); + + return { + allowed: true, + value: true, + reservation, + reason: result.reason ?? "lease_reserved", + entitlement: normalizeEntitlement(result.entitlement), + flagKey: result.flagKey ?? key, + flagId: result.flagId, + }; +} + +function resolveReservationTTL(deps: CreditCheckDeps, creditId: string): ResolvedLeaseConfig { + return deps.manager.resolveConfig(creditId); +} + +function extractPreflightQuantity(options: CheckOptions): { + eventSubtype: string | undefined; + quantity: number; +} { + if (options.eventUsage) { + const entries = Object.entries(options.eventUsage); + if (entries.length > 0) { + // Only one subtype per check is supported for the demo path. If + // multiple are passed, take the first; the engine will still + // gate on whichever conditions match. + const [subtype, qty] = entries[0]; + return { eventSubtype: subtype, quantity: qty }; + } + } + if (options.usage !== undefined) { + return { eventSubtype: undefined, quantity: options.usage }; + } + return { eventSubtype: undefined, quantity: 0 }; +} + +function buildWasmOptions(options: CheckOptions): CheckFlagOptions | undefined { + const out: CheckFlagOptions = {}; + if (options.eventUsage && Object.keys(options.eventUsage).length > 0) { + out.eventUsage = options.eventUsage; + } + if (options.usage !== undefined) { + out.usage = options.usage; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +interface CreditConditionMatch { + condition: api.RulesengineCondition & { creditId: string }; +} + +/** + * Resolve the credit condition to lease against. A flag can declare credit + * conditions for several credit types — one per plan that entitles the feature + * — and the right one for this company is the credit its *matched* plan + * entitlement uses, which only the rules engine knows. + * + * When the flag meters this event subtype in a single credit type (the common + * case) the first matching condition is unambiguous and we return it directly, + * with no extra engine call. Only when conditions disagree on credit type do + * we probe: the engine's `entitlement.creditId` names the company's metered + * credit regardless of balance, and we select that credit's condition to read + * its `consumption_rate`. A probe that can't resolve a credit (non-credit + * entitlement or an error) falls back to first-match, preserving prior + * behavior. + */ +async function resolveCreditCondition( + datastream: DataStreamClient, + flag: api.RulesengineFlag, + company: api.RulesengineCompany, + user: object | null, + eventSubtype: string | undefined, + logger: Logger, +): Promise { + const first = findCreditCondition(flag, eventSubtype); + if (!first) return null; + if (collectCreditIds(flag, eventSubtype).size <= 1) return first; + + try { + const probe = await datastream.getRulesEngine().checkFlagWithOptions(flag, company, user, null); + const creditId = probe.entitlement?.creditId; + if (creditId) { + const byCredit = findCreditCondition(flag, eventSubtype, creditId); + if (byCredit) return byCredit; + logger.debug( + `Lease check: entitlement credit ${creditId} has no matching condition on flag, falling back to first credit condition`, + ); + } + } catch (err) { + logger.warn(`Lease check: credit-resolution probe failed (${err}), falling back to first credit condition`); + } + return first; +} + +/** Distinct credit-type ids across the flag's credit conditions for `eventSubtype`. */ +function collectCreditIds(flag: api.RulesengineFlag, eventSubtype: string | undefined): Set { + const ids = new Set(); + const scan = (conditions: api.RulesengineCondition[]) => { + for (const c of conditions) { + if (c.conditionType !== "credit" || !c.creditId) continue; + if (eventSubtype !== undefined && c.eventSubtype !== eventSubtype) continue; + ids.add(c.creditId); + } + }; + for (const rule of flag.rules ?? []) { + scan(rule.conditions ?? []); + for (const group of rule.conditionGroups ?? []) scan(group.conditions ?? []); + } + return ids; +} + +function findCreditCondition( + flag: api.RulesengineFlag, + eventSubtype: string | undefined, + creditId?: string, +): CreditConditionMatch | null { + for (const rule of flag.rules ?? []) { + const inRule = matchInConditions(rule.conditions ?? [], eventSubtype, creditId); + if (inRule) return inRule; + for (const group of rule.conditionGroups ?? []) { + const inGroup = matchInConditions(group.conditions ?? [], eventSubtype, creditId); + if (inGroup) return inGroup; + } + } + return null; +} + +function matchInConditions( + conditions: api.RulesengineCondition[], + eventSubtype: string | undefined, + creditId?: string, +): CreditConditionMatch | null { + for (const c of conditions) { + if (c.conditionType !== "credit") continue; + if (!c.creditId) continue; + if (eventSubtype !== undefined && c.eventSubtype !== eventSubtype) continue; + if (creditId !== undefined && c.creditId !== creditId) continue; + return { condition: c as api.RulesengineCondition & { creditId: string } }; + } + return null; +} + +function substituteCreditBalance( + company: api.RulesengineCompany, + creditId: string, + balance: number, +): api.RulesengineCompany { + return { + ...company, + creditBalances: { + ...(company.creditBalances ?? {}), + [creditId]: balance, + }, + }; +} + +function registerReservation(args: { + leaseId: string; + companyId: string; + creditTypeId: string; + eventSubtype: string; + quantityReserved: number; + creditsReserved: number; + consumptionRate: number; + reservationTTL: ResolvedLeaseConfig; + evalCtx: api.CheckFlagRequestBody; +}): Reservation { + return { + id: randomUUID(), + leaseId: args.leaseId, + companyId: args.companyId, + creditTypeId: args.creditTypeId, + eventSubtype: args.eventSubtype, + quantityReserved: args.quantityReserved, + creditsReserved: args.creditsReserved, + consumptionRate: args.consumptionRate, + expiresAt: new Date(Date.now() + args.reservationTTL.reservationTTL), + evalCtx: args.evalCtx, + }; +} + +function normalizeEntitlement( + raw: WasmFeatureEntitlement | undefined, +): api.RulesengineFeatureEntitlement | undefined { + if (!raw) return undefined; + return { + ...raw, + metricResetAt: raw.metricResetAt ? new Date(raw.metricResetAt) : undefined, + }; +} + +function handleAcquireFailure( + mode: "fail-open" | "fail-closed", + flagKey: string, + reason: string, + flag: api.RulesengineFlag | null, +): CheckResult { + if (mode === "fail-closed") { + return { + allowed: false, + value: false, + reason, + flagKey, + flagId: flag?.id, + err: reason, + }; + } + return { + allowed: true, + value: true, + reason: `${reason}_fail_open`, + flagKey, + flagId: flag?.id, + err: reason, + }; +} diff --git a/src/credits/index.ts b/src/credits/index.ts new file mode 100644 index 00000000..f49db17e --- /dev/null +++ b/src/credits/index.ts @@ -0,0 +1,24 @@ +export { LeaseStore, leaseKey, type LeaseEntry, type ILeaseStore } from "./lease-store"; +export { ReservationStore, type IReservationStore } from "./reservation-store"; +export { CreditLeaseManager } from "./lease-manager"; +export { RedisLeaseStore } from "./redis-lease-store"; +export { RedisReservationStore } from "./redis-reservation-store"; +export { + DEFAULT_LEASE_DURATION_MS, + DEFAULT_LEASE_SIZE, + DEFAULT_LOW_WATER_MARK, + DEFAULT_PREWARM_POLL_INTERVAL_MS, + DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS, + DEFAULT_RESERVATION_TTL_MS, + DEFAULT_SWEEP_INTERVAL_MS, +} from "./types"; +export type { + CreditLeaseConfig, + ResolvedLeaseConfig, + Reservation, + CheckOptions, + CheckResult, + IdentifyOptions, + OnAcquireFailure, + TrackWithReservationOptions, +} from "./types"; diff --git a/src/credits/lease-manager.ts b/src/credits/lease-manager.ts new file mode 100644 index 00000000..3738acaa --- /dev/null +++ b/src/credits/lease-manager.ts @@ -0,0 +1,193 @@ +import type * as api from "../api"; +import type { CreditsClient } from "../api/resources/credits/client/Client"; +import type { Logger } from "../logger"; + +import { type ILeaseStore, type LeaseEntry, leaseKey } from "./lease-store"; +import { + DEFAULT_LEASE_DURATION_MS, + DEFAULT_LEASE_SIZE, + DEFAULT_LOW_WATER_MARK, + DEFAULT_RESERVATION_TTL_MS, + type CreditLeaseConfig, + type ResolvedLeaseConfig, +} from "./types"; + +/** + * Owns the lifecycle of `credit_lease` rows for a single client: acquire on + * first use or after expiry, extend when the local view dips below the low + * water mark, release on `client.close()`. + * + * Concurrency: each operation (acquire, extend) has its own single-flight + * map keyed by `(company, creditType)`. Concurrent callers of the *same* + * operation share one wire request; a concurrent acquire + extend on the + * same slot are two independent wire requests (they're semantically + * different and must not share state). + */ +export class CreditLeaseManager { + private readonly creditsClient: CreditsClient; + private readonly leaseStore: ILeaseStore; + private readonly logger: Logger; + private readonly config: CreditLeaseConfig; + // Per-operation single-flight maps keyed by `(company, creditType)`. Kept + // separate so a concurrent `maybeExtendInBackground` doesn't accidentally + // return the in-flight acquire promise (or vice-versa) — the shapes + // match, but the semantics don't. + private readonly inflightAcquire = new Map>(); + private readonly inflightExtend = new Map>(); + + constructor(opts: { + creditsClient: CreditsClient; + leaseStore: ILeaseStore; + logger: Logger; + config: CreditLeaseConfig; + }) { + this.creditsClient = opts.creditsClient; + this.leaseStore = opts.leaseStore; + this.logger = opts.logger; + this.config = opts.config; + } + + resolveConfig(creditTypeId: string): ResolvedLeaseConfig { + const override = this.config.overrides?.[creditTypeId]; + return { + leaseDuration: override?.defaultLeaseDuration ?? this.config.defaultLeaseDuration ?? DEFAULT_LEASE_DURATION_MS, + reservationTTL: override?.defaultReservationTTL ?? this.config.defaultReservationTTL ?? DEFAULT_RESERVATION_TTL_MS, + leaseSize: override?.defaultLeaseSize ?? this.config.defaultLeaseSize ?? DEFAULT_LEASE_SIZE, + lowWaterMark: override?.lowWaterMark ?? this.config.lowWaterMark ?? DEFAULT_LOW_WATER_MARK, + }; + } + + /** + * Return the current lease entry, acquiring one (or replacing an expired + * one) if none is live. Single-flight so racing callers share one request. + */ + async acquireIfNeeded(companyId: string, creditTypeId: string): Promise { + const existing = await this.leaseStore.get(companyId, creditTypeId); + if (existing && existing.expiresAt.getTime() > Date.now()) { + return existing; + } + // Lazy expiry: drop the stale entry so the upcoming acquire installs + // a fresh one. Per Model B the server treats the expired lease as + // released and refunds the full grant back to the company balance. + if (existing) { + await this.leaseStore.drop(companyId, creditTypeId); + } + + const key = leaseKey(companyId, creditTypeId); + const inflight = this.inflightAcquire.get(key); + if (inflight) return inflight; + + const promise = this.acquire(companyId, creditTypeId).finally(() => { + this.inflightAcquire.delete(key); + }); + this.inflightAcquire.set(key, promise); + return promise; + } + + private async acquire(companyId: string, creditTypeId: string): Promise { + const resolved = this.resolveConfig(creditTypeId); + const body: api.AcquireCreditLeaseRequestBody = { + companyId, + creditTypeId, + requestedAmount: resolved.leaseSize, + expiresAt: new Date(Date.now() + resolved.leaseDuration), + }; + + try { + const response = await this.creditsClient.acquireCreditLease(body); + const data = response.data; + await this.leaseStore.replace({ + leaseId: data.id, + companyId: data.companyId, + creditTypeId: data.creditTypeId, + grantedAmount: data.grantedAmount, + expiresAt: data.expiresAt, + }); + this.logger.debug( + `Acquired credit lease ${data.id} for ${companyId}/${creditTypeId} (granted=${data.grantedAmount}, expires=${data.expiresAt.toISOString()})`, + ); + return await this.leaseStore.get(companyId, creditTypeId); + } catch (err) { + this.logger.error(`Failed to acquire credit lease for ${companyId}/${creditTypeId}: ${err}`); + return undefined; + } + } + + /** + * Single-flight: kick off a background extend when one is warranted. + * An extend is triggered if EITHER: + * - the local remaining is below the low-water-mark ratio (steady-state + * refresh), or + * - the caller passes `requiredCredits` and the local remaining is below + * that figure (a single check just failed a reserve for that many + * credits — extend opportunistically instead of waiting for the next + * sub-watermark check). + * Returns the in-flight promise so callers can await it or fire-and-forget. + */ + async maybeExtendInBackground( + companyId: string, + creditTypeId: string, + requiredCredits?: number, + ): Promise { + const entry = await this.leaseStore.get(companyId, creditTypeId); + if (!entry) return undefined; + const resolved = this.resolveConfig(creditTypeId); + const ratio = entry.localRemainingCredits / Math.max(entry.grantedAmount, 1); + const belowWatermark = ratio <= resolved.lowWaterMark; + const belowRequired = requiredCredits !== undefined && entry.localRemainingCredits < requiredCredits; + if (!belowWatermark && !belowRequired) return entry; + + const key = leaseKey(companyId, creditTypeId); + const inflight = this.inflightExtend.get(key); + if (inflight) return inflight; + + const promise = this.extend(entry, resolved).finally(() => { + this.inflightExtend.delete(key); + }); + this.inflightExtend.set(key, promise); + return promise; + } + + private async extend(entry: LeaseEntry, resolved: ResolvedLeaseConfig): Promise { + const body: api.ExtendCreditLeaseRequestBody = { + additionalAmount: resolved.leaseSize, + expiresAt: new Date(Date.now() + resolved.leaseDuration), + }; + try { + const response = await this.creditsClient.extendCreditLease(entry.leaseId, body); + const data = response.data; + // The server returns the new totals; mirror them locally. + const delta = data.grantedAmount - entry.grantedAmount; + if (delta > 0) { + await this.leaseStore.extend(entry.companyId, entry.creditTypeId, delta, data.expiresAt); + } + this.logger.debug( + `Extended credit lease ${entry.leaseId} by ${delta} (now ${data.grantedAmount}, expires ${data.expiresAt.toISOString()})`, + ); + return await this.leaseStore.get(entry.companyId, entry.creditTypeId); + } catch (err) { + this.logger.warn(`Failed to extend credit lease ${entry.leaseId}: ${err}`); + return undefined; + } + } + + /** Release all outstanding leases. Called from `client.close()`. */ + async releaseAll(): Promise { + const entries = await this.leaseStore.snapshot(); + await Promise.all( + entries.map(async (entry) => { + try { + await this.creditsClient.releaseCreditLease(entry.leaseId, {}); + } catch (err) { + this.logger.warn(`Failed to release credit lease ${entry.leaseId}: ${err}`); + } finally { + await this.leaseStore.dropIfLeaseIdMatches( + entry.companyId, + entry.creditTypeId, + entry.leaseId, + ); + } + }), + ); + } +} diff --git a/src/credits/lease-store.ts b/src/credits/lease-store.ts new file mode 100644 index 00000000..84ea1b76 --- /dev/null +++ b/src/credits/lease-store.ts @@ -0,0 +1,203 @@ +/** + * In-memory lease store, keyed by `${companyId}:${creditTypeId}`. + * + * Holds the lease ID, original granted amount, expiry, and the SDK's local + * view of `localRemainingCredits` — the portion of the lease not yet reserved + * by an outstanding reservation. Per-key serialization (Promise chain) keeps + * reserve / refund / replace atomic without external locking. + * + * The store doesn't talk to the API directly; `CreditLeaseManager` drives + * acquire/extend/release through the wire client and uses these methods to + * mirror remote state locally. + * + * For cross-pod deployments, swap this for `RedisLeaseStore` — both + * implement `ILeaseStore`. + */ + +export interface LeaseEntry { + leaseId: string; + companyId: string; + creditTypeId: string; + grantedAmount: number; + localRemainingCredits: number; + expiresAt: Date; +} + +/** Backing-store contract shared by `LeaseStore` (in-memory) and `RedisLeaseStore` (shared). */ +export interface ILeaseStore { + get(companyId: string, creditTypeId: string): Promise | LeaseEntry | undefined; + snapshot(): Promise | LeaseEntry[]; + /** + * Install a fresh lease for the slot. Implementations must be safe against + * concurrent writers from other pods — if a live lease already exists and + * its `leaseId` matches `entry.leaseId`, the implementation should leave + * the existing `localRemainingCredits` alone (already-debited state wins). + * Returns `true` if it wrote a fresh row, `false` if it kept an existing one. + */ + replace(entry: Omit): Promise; + extend( + companyId: string, + creditTypeId: string, + additionalGranted: number, + newExpiresAt?: Date, + ): Promise; + drop(companyId: string, creditTypeId: string): Promise; + tryReserve(companyId: string, creditTypeId: string, credits: number): Promise; + refund(companyId: string, creditTypeId: string, credits: number): Promise; + dropIfLeaseIdMatches(companyId: string, creditTypeId: string, leaseId: string): Promise; +} + +export function leaseKey(companyId: string, creditTypeId: string): string { + return `${companyId}:${creditTypeId}`; +} + +export class LeaseStore implements ILeaseStore { + private leases = new Map(); + /** + * Per-key serializer chain. Each `withLock` appends to the tail so + * reserve / refund / replace see consistent intermediate state. + */ + private locks = new Map>(); + + /** Snapshot of the current entry, or undefined if no active lease. */ + get(companyId: string, creditTypeId: string): LeaseEntry | undefined { + const entry = this.leases.get(leaseKey(companyId, creditTypeId)); + return entry ? { ...entry } : undefined; + } + + /** All current entries (snapshot). Used by close() to release leases. */ + snapshot(): LeaseEntry[] { + return Array.from(this.leases.values()).map((e) => ({ ...e })); + } + + /** + * Replace (or insert) the lease for the given (company, creditType). + * Resets `localRemainingCredits` to `grantedAmount` for a new lease. + * If an existing live lease with the same `leaseId` already has a debited + * `localRemainingCredits`, it's left alone — the existing state wins. + * Returns `true` if a fresh row was written, `false` if the existing + * lease was kept. + */ + async replace(entry: Omit): Promise { + const key = leaseKey(entry.companyId, entry.creditTypeId); + return this.withLock(key, () => { + const existing = this.leases.get(key); + if (existing && existing.leaseId === entry.leaseId && existing.expiresAt.getTime() > Date.now()) { + // Same live lease — preserve any already-debited localRemaining. + return false; + } + this.leases.set(key, { + ...entry, + localRemainingCredits: entry.grantedAmount, + }); + return true; + }); + } + + /** + * Add granted credits to an existing lease (after a remote extend). If + * no lease exists for the key, this is a no-op (caller should have + * replaced). + */ + async extend( + companyId: string, + creditTypeId: string, + additionalGranted: number, + newExpiresAt?: Date, + ): Promise { + const key = leaseKey(companyId, creditTypeId); + return this.withLock(key, () => { + const entry = this.leases.get(key); + if (!entry) return; + entry.grantedAmount += additionalGranted; + entry.localRemainingCredits += additionalGranted; + if (newExpiresAt) entry.expiresAt = newExpiresAt; + }); + } + + /** Drop the lease entry (after a remote release or lazy expiry). */ + async drop(companyId: string, creditTypeId: string): Promise { + const key = leaseKey(companyId, creditTypeId); + return this.withLock(key, () => { + this.leases.delete(key); + }); + } + + /** + * Attempt to reserve `credits` from the local remaining balance. + * Returns true on success (debits the local balance), false if there + * isn't enough remaining. + */ + async tryReserve( + companyId: string, + creditTypeId: string, + credits: number, + ): Promise { + if (credits <= 0) return true; + const key = leaseKey(companyId, creditTypeId); + return this.withLock(key, () => { + const entry = this.leases.get(key); + if (!entry) return false; + if (entry.localRemainingCredits < credits) return false; + entry.localRemainingCredits -= credits; + return true; + }); + } + + /** Refund credits back to the local balance (capped at grantedAmount). */ + async refund( + companyId: string, + creditTypeId: string, + credits: number, + ): Promise { + if (credits <= 0) return; + const key = leaseKey(companyId, creditTypeId); + return this.withLock(key, () => { + const entry = this.leases.get(key); + if (!entry) return; + entry.localRemainingCredits = Math.min( + entry.localRemainingCredits + credits, + entry.grantedAmount, + ); + }); + } + + /** + * Drop the lease entry only if its `leaseId` matches the given value — + * guards against racing with a fresh re-acquire that happened in + * parallel. + */ + async dropIfLeaseIdMatches( + companyId: string, + creditTypeId: string, + leaseId: string, + ): Promise { + const key = leaseKey(companyId, creditTypeId); + return this.withLock(key, () => { + const entry = this.leases.get(key); + if (!entry || entry.leaseId !== leaseId) return false; + this.leases.delete(key); + return true; + }); + } + + private async withLock(key: string, fn: () => T | Promise): Promise { + const prev = this.locks.get(key) ?? Promise.resolve(); + let resolve!: () => void; + const next = new Promise((r) => (resolve = r)); + const chain = prev.then(() => next); + this.locks.set(key, chain); + await prev; + try { + return await fn(); + } finally { + resolve(); + // Best-effort cleanup so the map doesn't grow without bound. + // Only delete if we're still the tail — otherwise a later caller + // has appended and needs the chain to stay live. + if (this.locks.get(key) === chain) { + this.locks.delete(key); + } + } + } +} diff --git a/src/credits/redis-lease-store.ts b/src/credits/redis-lease-store.ts new file mode 100644 index 00000000..ac0d4ab6 --- /dev/null +++ b/src/credits/redis-lease-store.ts @@ -0,0 +1,229 @@ +import type { RedisClient } from "../cache/redis"; + +import { type ILeaseStore, type LeaseEntry, leaseKey } from "./lease-store"; +import { DEFAULT_LEASE_DURATION_MS } from "./types"; + +const DEFAULT_KEY_PREFIX = "schematic:"; +const LEASE_KEY_NAMESPACE = "credit-lease:"; +const LEASES_INDEX_KEY = "credit-lease:index"; +// How long after the declared expiry to keep the Redis row around before +// auto-eviction. Gives the sweeper a window to refund expired reservations +// before the underlying lease state disappears. +const LEASE_TTL_GRACE_MS = 60_000; + +/** + * Atomic `replace`. Writes the lease hash only when the slot is empty, the + * existing lease is expired, or the existing `leaseId` differs from the + * incoming one. Returns "1" on write, "0" if it kept an existing live lease + * with the same id (preserves any already-debited localRemainingCredits). + */ +const REPLACE_SCRIPT = ` +local existing_id = redis.call('HGET', KEYS[1], 'leaseId') +local existing_expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0') +local new_id = ARGV[1] +local new_granted = ARGV[2] +local new_expiry = tonumber(ARGV[3]) +local now = tonumber(ARGV[4]) +local grace = tonumber(ARGV[5]) +local index_key = KEYS[2] +local index_member = ARGV[6] + +if existing_id and existing_id == new_id and existing_expiry > now then + return 0 +end + +redis.call('DEL', KEYS[1]) +redis.call('HSET', KEYS[1], + 'leaseId', new_id, + 'companyId', ARGV[7], + 'creditTypeId', ARGV[8], + 'grantedAmount', new_granted, + 'localRemainingCredits', new_granted, + 'expiresAt', ARGV[3]) +redis.call('PEXPIREAT', KEYS[1], new_expiry + grace) +redis.call('SADD', index_key, index_member) +return 1 +`; + +/** + * Atomic check-and-decrement on `localRemainingCredits`. Returns "1" on + * success, "0" if there's no lease or insufficient remaining. + */ +const TRY_RESERVE_SCRIPT = ` +local raw = redis.call('HGET', KEYS[1], 'localRemainingCredits') +if not raw then return 0 end +local remaining = tonumber(raw) +local requested = tonumber(ARGV[1]) +if remaining < requested then return 0 end +redis.call('HSET', KEYS[1], 'localRemainingCredits', tostring(remaining - requested)) +return 1 +`; + +/** Refund credits, clamped at `grantedAmount`. */ +const REFUND_SCRIPT = ` +local raw_remaining = redis.call('HGET', KEYS[1], 'localRemainingCredits') +if not raw_remaining then return 0 end +local remaining = tonumber(raw_remaining) +local granted = tonumber(redis.call('HGET', KEYS[1], 'grantedAmount') or '0') +local refund = tonumber(ARGV[1]) +local new_balance = remaining + refund +if new_balance > granted then new_balance = granted end +redis.call('HSET', KEYS[1], 'localRemainingCredits', tostring(new_balance)) +return 1 +`; + +/** Bump grantedAmount + localRemainingCredits, update expiry. */ +const EXTEND_SCRIPT = ` +local raw_granted = redis.call('HGET', KEYS[1], 'grantedAmount') +if not raw_granted then return 0 end +local granted = tonumber(raw_granted) +local remaining = tonumber(redis.call('HGET', KEYS[1], 'localRemainingCredits') or '0') +local add = tonumber(ARGV[1]) +local new_expiry = tonumber(ARGV[2]) +local grace = tonumber(ARGV[3]) +redis.call('HSET', KEYS[1], + 'grantedAmount', tostring(granted + add), + 'localRemainingCredits', tostring(remaining + add), + 'expiresAt', ARGV[2]) +redis.call('PEXPIREAT', KEYS[1], new_expiry + grace) +return 1 +`; + +/** Drop the lease only if its `leaseId` matches the supplied one. */ +const DROP_IF_MATCH_SCRIPT = ` +local existing_id = redis.call('HGET', KEYS[1], 'leaseId') +if not existing_id or existing_id ~= ARGV[1] then return 0 end +redis.call('DEL', KEYS[1]) +redis.call('SREM', KEYS[2], ARGV[2]) +return 1 +`; + +/** + * Redis-backed lease store. One hash per `(companyId, creditTypeId)` slot, + * atomic mutations via Lua scripts, and a SET index for `snapshot()` / + * `releaseAll()`. + */ +export class RedisLeaseStore implements ILeaseStore { + private readonly client: RedisClient; + private readonly keyPrefix: string; + private readonly defaultLeaseDurationMs: number; + + constructor(opts: { + client: RedisClient; + keyPrefix?: string; + /** + * Defensive fallback used by `extend()` when the caller doesn't supply + * `newExpiresAt`. The lease manager always passes one today, so this + * only matters for direct callers. Default `DEFAULT_LEASE_DURATION_MS`. + */ + defaultLeaseDurationMs?: number; + }) { + this.client = opts.client; + this.keyPrefix = opts.keyPrefix ?? DEFAULT_KEY_PREFIX; + this.defaultLeaseDurationMs = opts.defaultLeaseDurationMs ?? DEFAULT_LEASE_DURATION_MS; + } + + private hashKey(companyId: string, creditTypeId: string): string { + return `${this.keyPrefix}${LEASE_KEY_NAMESPACE}${leaseKey(companyId, creditTypeId)}`; + } + + private indexKey(): string { + return `${this.keyPrefix}${LEASES_INDEX_KEY}`; + } + + async get(companyId: string, creditTypeId: string): Promise { + const raw = await this.client.hGetAll(this.hashKey(companyId, creditTypeId)); + if (!raw || !raw.leaseId) return undefined; + return decodeEntry(raw); + } + + async snapshot(): Promise { + // We track outstanding lease slots in a SET so we can release them on + // close without scanning Redis. + const members: string[] = await this.client.sMembers(this.indexKey()).catch(() => []); + const entries: LeaseEntry[] = []; + for (const member of members) { + const [companyId, creditTypeId] = member.split("\x00"); + if (!companyId || !creditTypeId) continue; + const entry = await this.get(companyId, creditTypeId); + if (entry) entries.push(entry); + } + return entries; + } + + async replace(entry: Omit): Promise { + const member = `${entry.companyId}\x00${entry.creditTypeId}`; + const result = await this.client.eval(REPLACE_SCRIPT, { + keys: [this.hashKey(entry.companyId, entry.creditTypeId), this.indexKey()], + arguments: [ + entry.leaseId, + String(entry.grantedAmount), + String(entry.expiresAt.getTime()), + String(Date.now()), + String(LEASE_TTL_GRACE_MS), + member, + entry.companyId, + entry.creditTypeId, + ], + }); + return Number(result) === 1; + } + + async extend( + companyId: string, + creditTypeId: string, + additionalGranted: number, + newExpiresAt?: Date, + ): Promise { + const expiry = (newExpiresAt ?? new Date(Date.now() + this.defaultLeaseDurationMs)).getTime(); + await this.client.eval(EXTEND_SCRIPT, { + keys: [this.hashKey(companyId, creditTypeId)], + arguments: [String(additionalGranted), String(expiry), String(LEASE_TTL_GRACE_MS)], + }); + } + + async drop(companyId: string, creditTypeId: string): Promise { + await this.client.del(this.hashKey(companyId, creditTypeId)); + await this.client.sRem(this.indexKey(), `${companyId}\x00${creditTypeId}`).catch(() => {}); + } + + async tryReserve(companyId: string, creditTypeId: string, credits: number): Promise { + if (credits <= 0) return true; + const result = await this.client.eval(TRY_RESERVE_SCRIPT, { + keys: [this.hashKey(companyId, creditTypeId)], + arguments: [String(credits)], + }); + return Number(result) === 1; + } + + async refund(companyId: string, creditTypeId: string, credits: number): Promise { + if (credits <= 0) return; + await this.client.eval(REFUND_SCRIPT, { + keys: [this.hashKey(companyId, creditTypeId)], + arguments: [String(credits)], + }); + } + + async dropIfLeaseIdMatches( + companyId: string, + creditTypeId: string, + leaseId: string, + ): Promise { + const result = await this.client.eval(DROP_IF_MATCH_SCRIPT, { + keys: [this.hashKey(companyId, creditTypeId), this.indexKey()], + arguments: [leaseId, `${companyId}\x00${creditTypeId}`], + }); + return Number(result) === 1; + } +} + +function decodeEntry(raw: Record): LeaseEntry { + return { + leaseId: raw.leaseId, + companyId: raw.companyId, + creditTypeId: raw.creditTypeId, + grantedAmount: Number(raw.grantedAmount ?? "0"), + localRemainingCredits: Number(raw.localRemainingCredits ?? "0"), + expiresAt: new Date(Number(raw.expiresAt ?? "0")), + }; +} diff --git a/src/credits/redis-reservation-store.ts b/src/credits/redis-reservation-store.ts new file mode 100644 index 00000000..9af4fa5a --- /dev/null +++ b/src/credits/redis-reservation-store.ts @@ -0,0 +1,189 @@ +import type { RedisClient } from "../cache/redis"; + +import type { ILeaseStore } from "./lease-store"; +import type { IReservationStore } from "./reservation-store"; +import type { Reservation } from "./types"; + +const DEFAULT_KEY_PREFIX = "schematic:"; +const RES_KEY_NAMESPACE = "credit-reservation:"; +const RES_INDEX_KEY = "credit-reservations:byExpiry"; +// Buffer past `expiresAt` before Redis auto-evicts the row, so the sweeper +// has a window to refund. +const RES_TTL_GRACE_MS = 30_000; + +/** + * Atomic consume-and-refund. + * + * KEYS[1] = reservation hash key + * KEYS[2] = sorted-set index key + * KEYS[3] = lease hash key + * ARGV[1] = reservationId (for ZREM) + * ARGV[2] = creditsConsumed (caller-side) + * + * Returns either the credits actually consumed (clamped) as a string, or + * `nil` if the reservation was missing/already-consumed. + */ +const CONSUME_SCRIPT = ` +local raw = redis.call('HGETALL', KEYS[1]) +if #raw == 0 then return nil end + +local reserved = 0 +for i = 1, #raw, 2 do + if raw[i] == 'creditsReserved' then + reserved = tonumber(raw[i+1]) + end +end + +local consumed = tonumber(ARGV[2]) +if consumed < 0 then consumed = 0 end +if consumed > reserved then consumed = reserved end +local refund = reserved - consumed + +redis.call('DEL', KEYS[1]) +redis.call('ZREM', KEYS[2], ARGV[1]) + +if refund > 0 then + local raw_remaining = redis.call('HGET', KEYS[3], 'localRemainingCredits') + if raw_remaining then + local remaining = tonumber(raw_remaining) + local granted = tonumber(redis.call('HGET', KEYS[3], 'grantedAmount') or '0') + local new_balance = remaining + refund + if new_balance > granted then new_balance = granted end + redis.call('HSET', KEYS[3], 'localRemainingCredits', tostring(new_balance)) + end +end + +return tostring(consumed) +`; + +/** + * Redis-backed reservation table. Each reservation is stored as a hash, + * indexed by `expiresAt` in a sorted set so the sweeper can pop expired + * entries in O(log n). + */ +export class RedisReservationStore implements IReservationStore { + private readonly client: RedisClient; + private readonly leaseStore: ILeaseStore; + private readonly sweepIntervalMs: number; + private readonly keyPrefix: string; + private readonly leaseKeyPrefix: string; + private sweepInterval: NodeJS.Timeout | null = null; + private stopped = false; + + constructor(opts: { + client: RedisClient; + leaseStore: ILeaseStore; + sweepIntervalMs?: number; + keyPrefix?: string; + }) { + this.client = opts.client; + this.leaseStore = opts.leaseStore; + this.sweepIntervalMs = opts.sweepIntervalMs ?? 1000; + this.keyPrefix = opts.keyPrefix ?? DEFAULT_KEY_PREFIX; + this.leaseKeyPrefix = `${this.keyPrefix}credit-lease:`; + } + + private hashKey(id: string): string { + return `${this.keyPrefix}${RES_KEY_NAMESPACE}${id}`; + } + + private indexKey(): string { + return `${this.keyPrefix}${RES_INDEX_KEY}`; + } + + private leaseHashKey(companyId: string, creditTypeId: string): string { + return `${this.leaseKeyPrefix}${companyId}:${creditTypeId}`; + } + + async add(reservation: Reservation): Promise { + const key = this.hashKey(reservation.id); + await this.client.hSet(key, { + id: reservation.id, + leaseId: reservation.leaseId, + companyId: reservation.companyId, + creditTypeId: reservation.creditTypeId, + eventSubtype: reservation.eventSubtype, + quantityReserved: String(reservation.quantityReserved), + creditsReserved: String(reservation.creditsReserved), + consumptionRate: String(reservation.consumptionRate), + expiresAt: String(reservation.expiresAt.getTime()), + evalCtx: JSON.stringify(reservation.evalCtx), + }); + await this.client.pExpireAt(key, reservation.expiresAt.getTime() + RES_TTL_GRACE_MS); + await this.client.zAdd(this.indexKey(), { + score: reservation.expiresAt.getTime(), + value: reservation.id, + }); + } + + async get(id: string): Promise { + const raw = await this.client.hGetAll(this.hashKey(id)); + if (!raw || !raw.id) return undefined; + return decodeReservation(raw); + } + + async consume(id: string, creditsConsumed: number): Promise { + const reservation = await this.get(id); + if (!reservation) return null; + + const result = await this.client.eval(CONSUME_SCRIPT, { + keys: [ + this.hashKey(id), + this.indexKey(), + this.leaseHashKey(reservation.companyId, reservation.creditTypeId), + ], + arguments: [id, String(creditsConsumed)], + }); + if (result === null || result === undefined) return null; + return Number(result); + } + + startSweep(): void { + if (this.sweepInterval || this.stopped) return; + this.sweepInterval = setInterval(() => { + this.sweepExpired().catch(() => { + // Swallow so the timer keeps running. + }); + }, this.sweepIntervalMs); + if (this.sweepInterval.unref) this.sweepInterval.unref(); + } + + async sweepExpired(now: Date = new Date()): Promise { + const cutoff = now.getTime(); + // ZRANGEBYSCORE returns ids whose score (expiresAt) is <= cutoff. + const expired = await this.client.zRangeByScore(this.indexKey(), 0, cutoff); + let swept = 0; + for (const id of expired) { + const refunded = await this.consume(id, 0); + if (refunded !== null) swept++; + } + return swept; + } + + stop(): void { + this.stopped = true; + if (this.sweepInterval) { + clearInterval(this.sweepInterval); + this.sweepInterval = null; + } + } + + async size(): Promise { + return this.client.zCard(this.indexKey()).catch(() => 0); + } +} + +function decodeReservation(raw: Record): Reservation { + return { + id: raw.id, + leaseId: raw.leaseId, + companyId: raw.companyId, + creditTypeId: raw.creditTypeId, + eventSubtype: raw.eventSubtype, + quantityReserved: Number(raw.quantityReserved), + creditsReserved: Number(raw.creditsReserved), + consumptionRate: Number(raw.consumptionRate), + expiresAt: new Date(Number(raw.expiresAt)), + evalCtx: raw.evalCtx ? JSON.parse(raw.evalCtx) : {}, + }; +} diff --git a/src/credits/reservation-store.ts b/src/credits/reservation-store.ts new file mode 100644 index 00000000..a1cc0ec1 --- /dev/null +++ b/src/credits/reservation-store.ts @@ -0,0 +1,110 @@ +import type { Reservation } from "./types"; +import type { ILeaseStore } from "./lease-store"; + +/** Backing-store contract for the reservation table. */ +export interface IReservationStore { + add(reservation: Reservation): Promise | void; + get(id: string): Promise | Reservation | undefined; + consume(id: string, creditsConsumed: number): Promise; + startSweep(): void; + sweepExpired(now?: Date): Promise; + stop(): void; + size(): Promise | number; +} + +/** + * In-memory reservation table, paired with a sweep loop that returns expired + * reservations to their underlying lease. + * + * For cross-pod deployments, swap this for `RedisReservationStore` — both + * implement `IReservationStore`. + */ +export class ReservationStore implements IReservationStore { + private reservations = new Map(); + private sweepInterval: NodeJS.Timeout | null = null; + private stopped = false; + + constructor( + private readonly leaseStore: ILeaseStore, + private readonly sweepIntervalMs: number = 1000, + ) {} + + /** Register a new reservation. Idempotent on `id`. */ + add(reservation: Reservation): void { + this.reservations.set(reservation.id, reservation); + } + + /** Look up a reservation by ID. */ + get(id: string): Reservation | undefined { + return this.reservations.get(id); + } + + /** + * Consume a reservation — removes it from the table and refunds + * `creditsReserved - creditsConsumed` back to the underlying lease. + * Returns the credits actually consumed (clamped to `creditsReserved`) + * or `null` if the reservation is missing/already consumed. + */ + async consume(id: string, creditsConsumed: number): Promise { + const reservation = this.reservations.get(id); + if (!reservation) return null; + this.reservations.delete(id); + + const actual = Math.max(0, Math.min(creditsConsumed, reservation.creditsReserved)); + const refund = reservation.creditsReserved - actual; + if (refund > 0) { + await this.leaseStore.refund( + reservation.companyId, + reservation.creditTypeId, + refund, + ); + } + return actual; + } + + /** Start the background sweep loop. Safe to call repeatedly. */ + startSweep(): void { + if (this.sweepInterval || this.stopped) return; + this.sweepInterval = setInterval(() => { + this.sweepExpired().catch(() => { + // sweepExpired only throws on programmer error; swallow so the + // timer keeps running. + }); + }, this.sweepIntervalMs); + if (this.sweepInterval.unref) this.sweepInterval.unref(); + } + + /** + * Scan for expired reservations, remove them, and refund their credits to + * the lease. + */ + async sweepExpired(now: Date = new Date()): Promise { + let swept = 0; + for (const [id, reservation] of this.reservations) { + if (reservation.expiresAt.getTime() <= now.getTime()) { + this.reservations.delete(id); + await this.leaseStore.refund( + reservation.companyId, + reservation.creditTypeId, + reservation.creditsReserved, + ); + swept++; + } + } + return swept; + } + + /** Stop the sweep loop. */ + stop(): void { + this.stopped = true; + if (this.sweepInterval) { + clearInterval(this.sweepInterval); + this.sweepInterval = null; + } + } + + /** Test/debug: current reservation count. */ + size(): number { + return this.reservations.size; + } +} diff --git a/src/credits/track.ts b/src/credits/track.ts new file mode 100644 index 00000000..4d37f532 --- /dev/null +++ b/src/credits/track.ts @@ -0,0 +1,44 @@ +import * as api from "../api"; + +import type { IReservationStore } from "./reservation-store"; +import type { Reservation, TrackWithReservationOptions } from "./types"; + +/** + * Build the `EventBodyTrack` payload for a Track event that consumes a + * reservation, and consume the reservation against the lease (refunding the + * unused slice locally). + */ +export async function consumeReservationAndBuildEvent( + reservations: IReservationStore, + reservation: Reservation, + actualQuantity: number, + options?: TrackWithReservationOptions, +): Promise { + const credits = actualQuantity * reservation.consumptionRate; + const consumed = await reservations.consume(reservation.id, credits); + if (consumed === null) { + // Reservation was already swept/consumed — emit no track to avoid + // double-counting on the server. + return null; + } + + const body: api.EventBodyTrack = { + event: reservation.eventSubtype, + quantity: actualQuantity, + // Routes the server-side credit consumption through the lease's + // sub-ledger instead of decrementing the grant again (which was + // already pre-debited at acquire/extend). Without this the grant + // double-debits and eventually starves redemptions mid-session. + leaseId: reservation.leaseId, + }; + if (reservation.evalCtx.company) { + body.company = reservation.evalCtx.company; + } + if (reservation.evalCtx.user) { + body.user = reservation.evalCtx.user; + } + if (options?.traits) { + body.traits = options.traits; + } + return body; +} diff --git a/src/credits/types.ts b/src/credits/types.ts new file mode 100644 index 00000000..ccaac15f --- /dev/null +++ b/src/credits/types.ts @@ -0,0 +1,165 @@ +import type * as api from "../api"; +import type { RedisClient } from "../cache/redis"; + +/** + * Behavior when a lease cannot be acquired (e.g. API error, insufficient balance). + * - `fail-open`: `check()` returns `{ allowed: true }` so the caller proceeds without preflight gating + * - `fail-closed`: `check()` returns `{ allowed: false }` so the caller blocks the action + */ +export type OnAcquireFailure = "fail-open" | "fail-closed"; + +// Single source of truth for defaults consumed by `CreditLeaseManager` and +// `SchematicClient`. Re-exported so the values referenced in the doc strings +// below stay accurate even if a consumer overrides only a subset of fields. +export const DEFAULT_LEASE_DURATION_MS: number = 5 * 60 * 1000; +export const DEFAULT_RESERVATION_TTL_MS: number = 60 * 1000; +export const DEFAULT_LEASE_SIZE: number = 10_000; +export const DEFAULT_LOW_WATER_MARK: number = 0.25; +export const DEFAULT_SWEEP_INTERVAL_MS: number = 1000; +// How long `prewarm()` is willing to wait for a freshly-identified company to +// surface in the datastream cache before giving up. Long enough to cover the +// buffer-flush → server-ingest → datastream-push round-trip for a new +// company; short enough that a misconfigured caller doesn't hang. +export const DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS: number = 5000; +export const DEFAULT_PREWARM_POLL_INTERVAL_MS: number = 100; + +/** + * Configuration block enabling client-side lease + reservation behavior on + * `client.check` / `client.trackWithReservation`. Omit to keep the SDK + * lease-unaware (`check` falls back to a plain flag check). + */ +export interface CreditLeaseConfig { + /** Default lease duration in milliseconds. Default `DEFAULT_LEASE_DURATION_MS` (5 minutes). */ + defaultLeaseDuration?: number; + /** Default reservation TTL in milliseconds. Default `DEFAULT_RESERVATION_TTL_MS` (60 seconds). */ + defaultReservationTTL?: number; + /** Default lease size (credit amount requested). Default `DEFAULT_LEASE_SIZE` (10000). */ + defaultLeaseSize?: number; + /** + * Fraction of remaining lease balance below which the SDK kicks off a + * background extend. Default `DEFAULT_LOW_WATER_MARK` (0.25). + */ + lowWaterMark?: number; + /** Sweep interval (ms) for expired reservations. Default `DEFAULT_SWEEP_INTERVAL_MS` (1000). */ + sweepIntervalMs?: number; + /** + * Max time `prewarm()` will wait for a freshly-identified company to + * surface in the datastream cache when only secondary keys are passed. + * Set to 0 to skip waiting entirely (prewarm bails immediately if the + * company isn't already cached). Default `DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS` + * (5000ms). + */ + prewarmResolveTimeoutMs?: number; + /** + * Pre-connected Redis client. When supplied, lease balance and reservation + * state live in Redis (shared across SDK pods) instead of in-process; the + * Lua-driven `tryReserve` / `consume` paths give atomic cross-pod gating. + * Without it, the SDK falls back to single-pod in-memory stores. + */ + redisClient?: RedisClient; + /** Optional Redis key prefix. Default `schematic:`. */ + redisKeyPrefix?: string; + /** Per-credit-type overrides (keyed by credit type ID). */ + overrides?: Record>>; +} + +/** Resolved config for a single credit type after applying defaults + overrides. */ +export interface ResolvedLeaseConfig { + leaseDuration: number; + reservationTTL: number; + leaseSize: number; + lowWaterMark: number; +} + +/** Handle returned by `client.check` when a reservation is issued. Pass to `client.trackWithReservation`. */ +export interface Reservation { + /** Opaque reservation ID. */ + id: string; + /** Underlying lease ID this reservation draws from. */ + leaseId: string; + /** Company that owns the lease. */ + companyId: string; + /** Credit type the reservation reserves against. */ + creditTypeId: string; + /** Event subtype to record on the Track event when consumed. */ + eventSubtype: string; + /** Quantity (in event units) the caller declared upfront. */ + quantityReserved: number; + /** Credits reserved = `quantityReserved * consumptionRate`. */ + creditsReserved: number; + /** Consumption rate at the time the reservation was issued. */ + consumptionRate: number; + /** When the reservation expires and gets swept back to the lease. */ + expiresAt: Date; + /** + * Evaluation context used to issue this reservation. Threaded into the + * Track event in `trackWithReservation` so the server can attribute usage + * to the same company/user. + */ + evalCtx: api.CheckFlagRequestBody; +} + +/** Options accepted by `client.check`. */ +export interface CheckOptions { + /** + * Per-event-subtype simulated quantity. Mode A demo path uses this. + * Pass `{ "inference_tokens": maxTokens }` to gate the check on a + * `maxTokens × consumption_rate` slice of the lease balance. + */ + eventUsage?: Record; + /** + * Single integer quantity applied to whatever numeric condition is being + * evaluated. Less specific than `eventUsage`; use when the event subtype + * is unambiguous from the flag. + */ + usage?: number; + /** + * What to do when a lease cannot be acquired (API error, insufficient + * remote balance). Default: `fail-closed` (`allowed = false`, no + * reservation) — matches the gist's "deny when the gate can't gate" + * semantics. Override to `fail-open` for trusted/known customers where + * letting traffic through is preferable to a denial. + */ + onAcquireFailure?: OnAcquireFailure; + /** Default value to return on error. */ + defaultValue?: boolean | (() => boolean); + /** Custom timeout for API calls within this check (ms). */ + timeoutMs?: number; +} + +/** Result of `client.check`. */ +export interface CheckResult { + /** Whether the caller is permitted to proceed. */ + allowed: boolean; + /** Boolean flag value (`allowed` mirrors this in non-lease paths). */ + value: boolean; + /** Reservation handle when a lease-bearing check passed. */ + reservation?: Reservation; + /** Human-readable reason (from the rules engine or the SDK). */ + reason: string; + /** Entitlement payload from the check. */ + entitlement?: api.RulesengineFeatureEntitlement; + /** Flag key checked. */ + flagKey: string; + /** Flag ID if known. */ + flagId?: string; + /** Optional error string (populated when the SDK fell back to a default). */ + err?: string; +} + +/** Extras accepted by `trackWithReservation`. */ +export interface TrackWithReservationOptions { + /** Optional traits to attach to the emitted Track event. */ + traits?: Record; +} + +/** Extras accepted by `identify`. Mirrors the gist's bundled Prewarm field. */ +export interface IdentifyOptions { + /** + * Credit type IDs to acquire leases for in the background after the + * identify event is enqueued. Fire-and-forget — failures are logged but + * never surface to the caller. Equivalent to calling `client.prewarm()` + * directly with the same evalCtx and creditTypeIds. + */ + prewarm?: string[]; +} diff --git a/src/datastream/datastream-client.ts b/src/datastream/datastream-client.ts index 5d161793..15dd3e33 100644 --- a/src/datastream/datastream-client.ts +++ b/src/datastream/datastream-client.ts @@ -1,10 +1,11 @@ import * as Schematic from '../api/types'; import type { DatastreamWSClient } from './websocket-client'; import { DataStreamResp, DataStreamReq, DataStreamError, EntityType, MessageType } from './types'; -import { RulesEngineClient } from '../rules-engine'; +import { RulesEngineClient, type CheckFlagOptions } from '../rules-engine'; import { Logger } from '../logger'; import { LazyEmitter } from './emitter'; import { partialCompany, partialUser, deepCopyCompany as deepCopyCompanyFn } from './merge'; +import * as serializers from '../serialization'; // Import cache providers from the cache module import type { CacheProvider } from '../cache/types'; @@ -474,7 +475,8 @@ export class DataStreamClient extends LazyEmitter { */ public async checkFlag( evalCtx: { company?: Record; user?: Record }, - flagKey: string + flagKey: string, + options?: CheckFlagOptions ): Promise { // Get flag first - return error if not found const flag = await this.getFlag(flagKey); @@ -502,13 +504,13 @@ export class DataStreamClient extends LazyEmitter { if (this.replicatorMode) { // In replicator mode, if we don't have all cached data, evaluate with null values instead of fetching // The external replicator should have populated the cache with all necessary data - return this.evaluateFlag(flag, cachedCompany, cachedUser); + return this.evaluateFlag(flag, cachedCompany, cachedUser, options); } // Non-replicator mode: if we have all cached data we need, use it if ((!needsCompany || cachedCompany) && (!needsUser || cachedUser)) { this.logger.debug(`All required resources found in cache for flag ${flagKey} evaluation`); - return this.evaluateFlag(flag, cachedCompany, cachedUser); + return this.evaluateFlag(flag, cachedCompany, cachedUser, options); } // Check if we're connected to datastream for live fetching @@ -528,7 +530,30 @@ export class DataStreamClient extends LazyEmitter { const [company, user] = await Promise.all([companyPromise, userPromise]); // Evaluate against the rules engine - return this.evaluateFlag(flag, company, user); + return this.evaluateFlag(flag, company, user, options); + } + + /** + * Public accessor for the cached company snapshot. Used by credit-lease + * callers that need the company's `credit_balances` so they can substitute + * a lease-bound view before calling the rules engine. + */ + public async getCachedCompany( + keys: Record, + ): Promise { + return this.getCompanyFromCache(keys); + } + + /** Public accessor for the cached user snapshot. */ + public async getCachedUser( + keys: Record, + ): Promise { + return this.getUserFromCache(keys); + } + + /** Public accessor for the rules engine. Used by credit-lease eval. */ + public getRulesEngine(): RulesEngineClient { + return this.rulesEngine; } /** @@ -705,7 +730,12 @@ export class DataStreamClient extends LazyEmitter { return; } } else { - company = message.data as Schematic.RulesengineCompany; + try { + company = serializers.RulesengineCompany.parseOrThrow(message.data); + } catch (error) { + this.logger.warn(`Failed to deserialize company payload: ${error}`); + return; + } } if (!company) { @@ -768,7 +798,12 @@ export class DataStreamClient extends LazyEmitter { return; } } else { - user = message.data as Schematic.RulesengineUser; + try { + user = serializers.RulesengineUser.parseOrThrow(message.data); + } catch (error) { + this.logger.warn(`Failed to deserialize user payload: ${error}`); + return; + } } if (!user) { @@ -808,13 +843,28 @@ export class DataStreamClient extends LazyEmitter { * handleFlagsMessage processes bulk flags messages */ private async handleFlagsMessage(message: DataStreamResp): Promise { - const flags = message.data as Schematic.RulesengineFlag[]; - - if (!Array.isArray(flags)) { + const rawFlags = message.data as unknown[]; + + if (!Array.isArray(rawFlags)) { this.logger.warn('Expected flags array in bulk flags message'); return; } + const flags: Schematic.RulesengineFlag[] = []; + let parseFailureCount = 0; + let firstFailure: unknown = undefined; + for (const raw of rawFlags) { + try { + flags.push(serializers.RulesengineFlag.parseOrThrow(raw)); + } catch (error) { + parseFailureCount++; + if (firstFailure === undefined) firstFailure = error; + } + } + if (parseFailureCount > 0) { + this.logger.warn(`Failed to deserialize ${parseFailureCount} flag(s) in bulk message: ${String(firstFailure)}`); + } + const results = await Promise.allSettled( flags .filter((flag) => flag?.key) @@ -854,7 +904,13 @@ export class DataStreamClient extends LazyEmitter { * handleFlagMessage processes single flag messages */ private async handleFlagMessage(message: DataStreamResp): Promise { - const flag = message.data as Schematic.RulesengineFlag; + let flag: Schematic.RulesengineFlag; + try { + flag = serializers.RulesengineFlag.parseOrThrow(message.data); + } catch (error) { + this.logger.warn(`Failed to deserialize flag payload: ${error}`); + return; + } if (!flag?.key) { return; @@ -1312,7 +1368,8 @@ export class DataStreamClient extends LazyEmitter { private async evaluateFlag( flag: Schematic.RulesengineFlag, company: Schematic.RulesengineCompany | null, - user: Schematic.RulesengineUser | null + user: Schematic.RulesengineUser | null, + options?: CheckFlagOptions ): Promise { const defaultValue = flag.defaultValue ?? false; @@ -1321,7 +1378,7 @@ export class DataStreamClient extends LazyEmitter { if (this.rulesEngine.isInitialized()) { this.logger.debug(`Evaluating flag with rules engine: ${JSON.stringify({ flagId: flag.id, flagRules: flag.rules?.length || 0, companyId: company?.id, userId: user?.id })}`); - const result = await this.rulesEngine.checkFlag(flag, company, user); + const result = await this.rulesEngine.checkFlagWithOptions(flag, company, user, options); this.logger.debug(`Rules engine evaluation result: ${JSON.stringify(result)}`); return { diff --git a/src/datastream/merge.ts b/src/datastream/merge.ts index 90599b88..1cd78afc 100644 --- a/src/datastream/merge.ts +++ b/src/datastream/merge.ts @@ -1,36 +1,16 @@ import type * as Schematic from "../api/types"; -/** - * Helper to read a property that may be in camelCase or snake_case form. - * Wire data from WebSocket uses snake_case; Fern-generated types use camelCase. - */ -function getProp(obj: Record, camel: string, snake: string): unknown { - return obj[camel] ?? obj[snake]; -} - -/** - * Creates a complete deep copy of a Company object. - */ export function deepCopyCompany(c: Schematic.RulesengineCompany): Schematic.RulesengineCompany { return JSON.parse(JSON.stringify(c)); } -/** - * Creates a complete deep copy of a User object. - */ export function deepCopyUser(u: Schematic.RulesengineUser): Schematic.RulesengineUser { return JSON.parse(JSON.stringify(u)); } -/** - * Merges a partial update into an existing Company. - * Deep-copies the existing company, then applies only the fields - * present in the partial object. - * - * Wire format uses snake_case keys. The existing company from cache - * may have either camelCase or snake_case keys depending on how it - * was stored. - */ +// Partial updates arrive as raw wire payloads (snake_case keys) and are merged +// into an existing camelCase-canonicalized entity. Each case writes the +// corresponding camelCase field so the cached entity stays in a single shape. export function partialCompany( existing: Schematic.RulesengineCompany, partial: Record, @@ -40,42 +20,54 @@ export function partialCompany( for (const key of Object.keys(partial)) { switch (key) { case "id": + merged.id = partial[key]; + break; case "account_id": + merged.accountId = partial[key]; + break; case "environment_id": - merged[key] = partial[key]; + merged.environmentId = partial[key]; break; case "base_plan_id": - merged[key] = partial[key] ?? null; + merged.basePlanId = partial[key] ?? null; break; case "billing_product_ids": + merged.billingProductIds = partial[key]; + break; case "plan_ids": + merged.planIds = partial[key]; + break; case "plan_version_ids": + merged.planVersionIds = partial[key]; + break; case "entitlements": + merged.entitlements = partial[key]; + break; case "rules": + merged.rules = partial[key]; + break; case "traits": + merged.traits = partial[key]; + break; case "subscription": - merged[key] = partial[key]; + merged.subscription = partial[key]; break; case "keys": { - const existingKeys = (getProp(merged, "keys", "keys") ?? {}) as Record; + const existingKeys = (merged.keys ?? {}) as Record; const incomingKeys = partial[key] as Record; - merged[key] = { ...existingKeys, ...incomingKeys }; + merged.keys = { ...existingKeys, ...incomingKeys }; break; } case "credit_balances": { - const existingCB = (getProp(merged, "creditBalances", "credit_balances") ?? {}) as Record< - string, - number - >; + const existingCB = (merged.creditBalances ?? {}) as Record; const incomingCB = partial[key] as Record; - merged[key] = { ...existingCB, ...incomingCB }; + merged.creditBalances = { ...existingCB, ...incomingCB }; break; } case "metrics": { - const existingMetrics = ((getProp(merged, "metrics", "metrics") as unknown[]) ?? - []) as Schematic.RulesengineCompanyMetric[]; + const existingMetrics = (merged.metrics ?? []) as Schematic.RulesengineCompanyMetric[]; const incomingMetrics = partial[key] as Schematic.RulesengineCompanyMetric[]; - merged[key] = upsertMetrics(existingMetrics, incomingMetrics); + merged.metrics = upsertMetrics(existingMetrics, incomingMetrics); break; } // Ignore unknown keys silently @@ -85,11 +77,6 @@ export function partialCompany( return merged as unknown as Schematic.RulesengineCompany; } -/** - * Merges a partial update into an existing User. - * Deep-copies the existing user, then applies only the fields - * present in the partial object. - */ export function partialUser( existing: Schematic.RulesengineUser, partial: Record, @@ -99,19 +86,25 @@ export function partialUser( for (const key of Object.keys(partial)) { switch (key) { case "id": + merged.id = partial[key]; + break; case "account_id": + merged.accountId = partial[key]; + break; case "environment_id": - merged[key] = partial[key]; + merged.environmentId = partial[key]; break; case "keys": { - const existingKeys = (getProp(merged, "keys", "keys") ?? {}) as Record; + const existingKeys = (merged.keys ?? {}) as Record; const incomingKeys = partial[key] as Record; - merged[key] = { ...existingKeys, ...incomingKeys }; + merged.keys = { ...existingKeys, ...incomingKeys }; break; } case "traits": + merged.traits = partial[key]; + break; case "rules": - merged[key] = partial[key]; + merged.rules = partial[key]; break; // Ignore unknown keys silently } diff --git a/src/index.ts b/src/index.ts index a68aff6e..7dca059f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,16 @@ export { RedisCacheProvider, type RedisClient } from "./cache/redis"; export { SchematicClient, type CheckFlagWithEntitlementResponse } from "./wrapper"; export { SchematicEnvironment } from "./environments"; export { SchematicError, SchematicTimeoutError } from "./errors"; -export { RulesEngineClient } from "./rules-engine"; +export { RulesEngineClient, type CheckFlagOptions } from "./rules-engine"; +export type { + CheckOptions, + CheckResult, + CreditLeaseConfig, + IdentifyOptions, + OnAcquireFailure, + Reservation, + TrackWithReservationOptions, +} from "./credits"; export { verifyWebhookSignature, verifySignature, diff --git a/src/rules-engine.ts b/src/rules-engine.ts index ebb7a112..70b15d8a 100644 --- a/src/rules-engine.ts +++ b/src/rules-engine.ts @@ -18,6 +18,21 @@ export interface WasmFeatureEntitlement { creditRemaining?: number; } +/** + * Preflight options forwarded to the WASM rules engine's `checkFlagWithOptions`. + * + * The engine picks the most specific knob for each condition it evaluates. + * Demo path uses `eventUsage`; `creditCost` ships for v1.x consumers. + */ +export interface CheckFlagOptions { + /** Pre-computed per-credit-id cost. Highest precedence for credit-balance gates. */ + creditCost?: Record; + /** Single integer quantity applied to whatever numeric condition is being evaluated. */ + usage?: number; + /** Per-event-subtype simulated quantity. Preferred when the subtype is known. */ + eventUsage?: Record; +} + /** Result returned by the WASM rules engine */ export interface WasmCheckFlagResult { value: boolean; @@ -67,6 +82,15 @@ export class RulesEngineClient { flag: object, company?: object | null, user?: object | null + ): Promise { + return this.checkFlagWithOptions(flag, company, user); + } + + async checkFlagWithOptions( + flag: object, + company?: object | null, + user?: object | null, + options?: CheckFlagOptions | null ): Promise { this.ensureInitialized(); @@ -78,11 +102,13 @@ export class RulesEngineClient { const flagJson = JSON.stringify(flag, stripNulls); const companyJson = company ? JSON.stringify(company, stripNulls) : undefined; const userJson = user ? JSON.stringify(user, stripNulls) : undefined; + const optionsJson = options ? JSON.stringify(serializeCheckFlagOptions(options), stripNulls) : undefined; - const resultJson = this.wasmInstance!.checkFlag( + const resultJson = this.wasmInstance!.checkFlagWithOptions( flagJson, companyJson, - userJson + userJson, + optionsJson ); return JSON.parse(resultJson); @@ -114,5 +140,20 @@ export class RulesEngineClient { } } +// Serialize CheckFlagOptions to the snake_case envelope the WASM expects. +function serializeCheckFlagOptions(options: CheckFlagOptions): Record { + const envelope: Record = {}; + if (options.creditCost && Object.keys(options.creditCost).length > 0) { + envelope.credit_cost = options.creditCost; + } + if (options.usage !== undefined) { + envelope.usage = options.usage; + } + if (options.eventUsage && Object.keys(options.eventUsage).length > 0) { + envelope.event_usage = options.eventUsage; + } + return envelope; +} + // Export for backward compatibility export { RulesEngineClient as default }; \ No newline at end of file diff --git a/src/wrapper.ts b/src/wrapper.ts index 17e9022f..9b49eca5 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -9,6 +9,26 @@ import { offlineFetcher, provideFetcher } from "./core/fetcher/custom"; import { RUNTIME } from "./core/runtime"; import { DataStreamClient, type DataStreamClientOptions } from "./datastream"; import type { RedisClient } from "./cache/redis"; +import { + CreditLeaseManager, + DEFAULT_PREWARM_POLL_INTERVAL_MS, + DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS, + DEFAULT_SWEEP_INTERVAL_MS, + LeaseStore, + RedisLeaseStore, + RedisReservationStore, + ReservationStore, + type ILeaseStore, + type IReservationStore, + type CheckOptions, + type CheckResult, + type CreditLeaseConfig, + type IdentifyOptions, + type Reservation, + type TrackWithReservationOptions, +} from "./credits"; +import { checkWithLease } from "./credits/check"; +import { consumeReservationAndBuildEvent } from "./credits/track"; /** * Configuration options for the SchematicClient @@ -56,6 +76,15 @@ export interface SchematicOptions { offline?: boolean; /** The default maximum time to wait for a response in milliseconds */ timeoutMs?: number; + /** + * Enable client-side credit lease + reservation behavior on `check` / + * `trackWithReservation`. Omit to keep the SDK lease-unaware. + * + * Lease-bearing checks require DataStream (or replicator mode) so the + * SDK has access to the cached flag + company state needed for local + * gating against the lease balance. + */ + creditLeases?: CreditLeaseConfig; } export interface CheckFlagOptions { @@ -85,6 +114,10 @@ export class SchematicClient extends BaseClient { private flagDefaults: { [key: string]: boolean }; private logger: Logger; private offline: boolean; + private creditLeaseManager?: CreditLeaseManager; + private leaseStore?: ILeaseStore; + private reservations?: IReservationStore; + private prewarmResolveTimeoutMs: number = DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS; /** * Creates a new instance of the SchematicClient @@ -199,6 +232,42 @@ export class SchematicClient extends BaseClient { } } + + // Set up credit lease + reservation plumbing if the caller opted in. + if (opts?.creditLeases && !offline) { + const sweepMs = opts.creditLeases.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS; + if (opts.creditLeases.redisClient) { + // Shared-state backend: lease balance + reservation table live + // in Redis. Lua-script-driven atomicity gives cross-pod gating + // without a separate lock service. + const redisClient = opts.creditLeases.redisClient; + const keyPrefix = opts.creditLeases.redisKeyPrefix; + this.leaseStore = new RedisLeaseStore({ + client: redisClient, + keyPrefix, + defaultLeaseDurationMs: opts.creditLeases.defaultLeaseDuration, + }); + this.reservations = new RedisReservationStore({ + client: redisClient, + leaseStore: this.leaseStore, + sweepIntervalMs: sweepMs, + keyPrefix, + }); + } else { + // Single-pod fallback: in-memory stores. + this.leaseStore = new LeaseStore(); + this.reservations = new ReservationStore(this.leaseStore, sweepMs); + } + this.reservations.startSweep(); + this.creditLeaseManager = new CreditLeaseManager({ + creditsClient: this.credits, + leaseStore: this.leaseStore, + logger, + config: opts.creditLeases, + }); + this.prewarmResolveTimeoutMs = + opts.creditLeases.prewarmResolveTimeoutMs ?? DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS; + } } /** @@ -535,10 +604,21 @@ export class SchematicClient extends BaseClient { } /** - * Gracefully shuts down the client by stopping the event buffer and DataStream client + * Gracefully shuts down the client by stopping the event buffer, DataStream client, + * and releasing any outstanding credit leases. * @returns Promise that resolves when everything has been stopped */ async close(): Promise { + if (this.reservations) { + this.reservations.stop(); + } + if (this.creditLeaseManager) { + try { + await this.creditLeaseManager.releaseAll(); + } catch (err) { + this.logger.warn(`Error releasing credit leases on close: ${err}`); + } + } if (this.datastreamClient) { this.datastreamClient.close(); } @@ -546,12 +626,253 @@ export class SchematicClient extends BaseClient { } /** - * Send a non-blocking event to create or update companies and users - * @param body - The identify event payload containing user properties - * @returns Promise that resolves when the event has been enqueued - * @throws Will log error if event enqueueing fails + * Pre-warm a credit lease for each given credit type ID, so the first + * `check()` against it doesn't pay the acquire round-trip. Fire-and-forget + * — failures are logged but don't throw. + * + * When `evalCtx.company` carries only secondary keys (no `id`), `prewarm` + * actively fetches the company over the datastream (waiting up to + * `creditLeases.prewarmResolveTimeoutMs` for the WS to connect), which both + * resolves the id and warms the cache so the first `check()` hits the lease + * path. Covers a brand-new company too: the fetch retries until the server + * has ingested the preceding `identify` and can stream it back. + */ + async prewarm(evalCtx: api.CheckFlagRequestBody, creditTypeIds: string[]): Promise { + if (!this.creditLeaseManager || !this.leaseStore) { + this.logger.debug("prewarm called but creditLeases is not configured"); + return; + } + if (!evalCtx.company || Object.keys(evalCtx.company).length === 0) { + this.logger.debug("prewarm requires a company on evalCtx"); + return; + } + const companyId = await this.resolveCompanyIdWithWait(evalCtx); + if (!companyId) { + this.logger.debug( + `prewarm: company not resolved within ${this.prewarmResolveTimeoutMs}ms for keys ${JSON.stringify(evalCtx.company)} (first check() will acquire)`, + ); + return; + } + await Promise.all( + creditTypeIds.map((id) => + this.creditLeaseManager! + .acquireIfNeeded(companyId, id) + .catch((err) => + this.logger.warn(`prewarm: failed to acquire lease for ${id}: ${err}`), + ), + ), + ); + } + + /** + * Like `resolveCompanyId` but actively fetches the company over the + * datastream when only secondary keys are supplied, warming the cache as a + * side effect. Returns the resolved id, or undefined if the company never + * surfaced within `prewarmResolveTimeoutMs`. + * + * `identify` does not push a company into the datastream cache — companies + * are only streamed in response to a request. So we call `getCompany` + * (cache-first, then sends the request and awaits the push) rather than + * passively polling `getCachedCompany`, which would watch an empty cache + * until it times out. Fetching also primes the cache so the first real + * `check()` hits the lease path instead of falling back. + */ + private async resolveCompanyIdWithWait( + evalCtx: api.CheckFlagRequestBody, + ): Promise { + if (!evalCtx.company) return undefined; + if (evalCtx.company.id) return evalCtx.company.id; + const datastream = this.datastreamClient; + if (!datastream || this.prewarmResolveTimeoutMs <= 0) { + return this.resolveCompanyId(evalCtx); + } + const company = evalCtx.company; + // Fast path — already cached by an earlier check or prewarm. + const cached = await datastream.getCachedCompany(company); + if (cached?.id) return cached.id; + // Actively fetch. Retry across the brief WS-connecting window at boot + // (getCompany throws "not connected" until the socket is ready), + // bounded by prewarmResolveTimeoutMs. + const deadline = Date.now() + this.prewarmResolveTimeoutMs; + for (;;) { + try { + const resolved = await datastream.getCompany(company); + if (resolved?.id) return resolved.id; + } catch (err) { + this.logger.debug(`prewarm: datastream company fetch failed (${err})`); + } + if (Date.now() >= deadline) return undefined; + await new Promise((r) => setTimeout(r, DEFAULT_PREWARM_POLL_INTERVAL_MS)); + } + } + + /** + * Lease-aware feature check. When `creditLeases` is configured and the + * caller passes `usage` / `eventUsage`, this acquires a lease (if needed), + * gates the check against the lease's local balance via WASM, and returns + * a reservation handle on success — pass that handle to + * `trackWithReservation` when the work completes. + * + * When `creditLeases` is not configured (or `usage`/`eventUsage` is omitted) + * this falls through to a plain flag check and returns `{allowed: value}` + * with no reservation, byte-compatible with `checkFlag` semantics. + */ + async check( + key: string, + evalCtx: api.CheckFlagRequestBody, + options?: CheckOptions, + ): Promise { + const fallback = async (): Promise => { + const resp = await this.checkFlagWithEntitlement(evalCtx, key, { + defaultValue: options?.defaultValue, + timeoutMs: options?.timeoutMs, + }); + return { + allowed: resp.value, + value: resp.value, + reason: resp.reason, + entitlement: resp.entitlement, + flagKey: resp.flagKey, + flagId: resp.flagId, + err: resp.err, + }; + }; + + const hasPreflight = + options?.usage !== undefined || + (options?.eventUsage && Object.keys(options.eventUsage).length > 0); + if ( + !hasPreflight || + !this.creditLeaseManager || + !this.leaseStore || + !this.reservations + ) { + return fallback(); + } + + return checkWithLease( + { + leaseStore: this.leaseStore, + reservations: this.reservations, + manager: this.creditLeaseManager, + datastream: this.datastreamClient, + logger: this.logger, + }, + key, + evalCtx, + options, + fallback, + ); + } + + /** + * Consume a reservation issued by `check()`. Refunds the unused slice + * (`creditsReserved - actualQuantity * consumptionRate`) back to the + * lease's local balance and enqueues a Track event with + * `quantity = actualQuantity`. The server-side event processor consumes + * `actualQuantity × consumption_rate` from the company's real credit + * balance. + */ + async trackWithReservation( + reservation: Reservation, + actualQuantity: number, + options?: TrackWithReservationOptions, + ): Promise { + if (this.offline) return; + if (!this.reservations) { + this.logger.warn( + "trackWithReservation called but creditLeases is not configured — emitting unreserved track", + ); + await this.track({ + event: reservation.eventSubtype, + quantity: actualQuantity, + company: reservation.evalCtx.company, + user: reservation.evalCtx.user, + traits: options?.traits, + }); + return; + } + const trackBody = await consumeReservationAndBuildEvent( + this.reservations, + reservation, + actualQuantity, + options, + ); + if (!trackBody) { + this.logger.warn( + `trackWithReservation: reservation ${reservation.id} already consumed or expired`, + ); + return; + } + await this.track(trackBody); + } + + /** + * Lease-aware credit balance for display. The server balance + * (`company.creditBalances[creditId]`) is already debited by any open + * lease's hold, so on its own it reads low — or 0 — while the lease still + * has unspent local balance, which is why a browser-side counter reading + * only the server balance shows "0 remaining" next to a still-working + * action. Adding the lease's `localRemainingCredits` back recovers the true + * spendable total: server `(B − L)` + lease `(L − spent)` = `B − spent`. + * + * `lease` is 0 when no lease is held (then `server` is already the true + * balance). Returns zeros when credit leases / datastream aren't configured + * or the company can't be resolved. The lease portion comes from this + * process's in-memory store, so `total` is per-process unless a Redis lease + * store is configured. + */ + async getCreditBalance( + company: Record, + creditId: string, + ): Promise<{ server: number; lease: number; total: number }> { + const empty = { server: 0, lease: 0, total: 0 }; + const datastream = this.datastreamClient; + if (!datastream || !company || Object.keys(company).length === 0) { + return empty; + } + + let snapshot = await datastream.getCachedCompany(company); + if (!snapshot) { + // Not cached yet (e.g. first paint before any check/prewarm). + // Actively fetch so the counter populates; tolerate a cold/closed + // datastream by returning zeros rather than throwing. + try { + snapshot = await datastream.getCompany(company); + } catch (err) { + this.logger.debug(`getCreditBalance: company fetch failed (${err})`); + return empty; + } + } + if (!snapshot?.id) return empty; + + const server = snapshot.creditBalances?.[creditId] ?? 0; + const leaseEntry = await this.leaseStore?.get(snapshot.id, creditId); + const lease = leaseEntry?.localRemainingCredits ?? 0; + + return { server, lease, total: server + lease }; + } + + private async resolveCompanyId(evalCtx: api.CheckFlagRequestBody): Promise { + if (!evalCtx.company) return undefined; + // If the caller passed `id`, use that directly. + if (evalCtx.company.id) return evalCtx.company.id; + // Otherwise, the datastream cache can resolve secondary keys → id. + if (this.datastreamClient) { + const cached = await this.datastreamClient.getCachedCompany(evalCtx.company); + return cached?.id; + } + return undefined; + } + + /** + * Send a non-blocking event to create or update companies and users. + * Mirrors the gist's `Identify(ctx, userID, {Company, Prewarm: [...]})` + * surface: pass `options.prewarm` with credit type IDs to kick off lease + * acquires in the background after the identify event is enqueued (no-op + * unless `creditLeases` is configured on the client). */ - async identify(body: api.EventBodyIdentify): Promise { + async identify(body: api.EventBodyIdentify, options?: IdentifyOptions): Promise { if (this.offline) return; try { @@ -559,6 +880,23 @@ export class SchematicClient extends BaseClient { } catch (err) { this.logger.error(`Error sending identify event: ${err}`); } + + if (options?.prewarm && options.prewarm.length > 0) { + const evalCtx: api.CheckFlagRequestBody = { + company: body.company?.keys, + user: body.keys, + }; + // Force-flush so the server processes the identify ASAP — without + // it, the company may sit in the local buffer for up to the + // buffer's flush interval before the server even sees it, and + // prewarm's bounded poll would just be waiting on us. Fire-and-forget. + void this.eventBuffer.flush().catch((err) => { + this.logger.debug(`identify flush before prewarm failed: ${err}`); + }); + void this.prewarm(evalCtx, options.prewarm).catch((err) => { + this.logger.warn(`identify prewarm failed: ${err}`); + }); + } } /** diff --git a/tests/unit/credits/check-and-track.test.ts b/tests/unit/credits/check-and-track.test.ts new file mode 100644 index 00000000..5bbac73a --- /dev/null +++ b/tests/unit/credits/check-and-track.test.ts @@ -0,0 +1,503 @@ +import { SchematicClient } from "../../../src/wrapper"; + +const mockCheckFlag = jest.fn(); +const mockAcquireCreditLease = jest.fn(); +const mockExtendCreditLease = jest.fn(); +const mockReleaseCreditLease = jest.fn(); + +jest.mock("../../../src/Client", () => { + class MockBaseClient { + features = { + checkFlag: mockCheckFlag, + checkFlags: jest.fn().mockResolvedValue({ data: { flags: [] } }), + }; + credits = { + acquireCreditLease: mockAcquireCreditLease, + extendCreditLease: mockExtendCreditLease, + releaseCreditLease: mockReleaseCreditLease, + }; + events = {}; + } + return { SchematicClient: MockBaseClient }; +}); + +const mockEventBufferPush = jest.fn(); +jest.mock("../../../src/events", () => ({ + EventBuffer: jest.fn().mockImplementation(() => ({ + push: mockEventBufferPush, + flush: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + })), +})); + +// Stub DataStreamClient to a fixed shape — we manually wire its methods so +// the lease path has everything it needs without spinning up a websocket. +const mockDataStream = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + checkFlag: jest.fn(), + updateCompanyMetrics: jest.fn().mockResolvedValue(undefined), + getFlag: jest.fn(), + getCachedCompany: jest.fn(), + getCompany: jest.fn(), + getCachedUser: jest.fn().mockResolvedValue(null), + getRulesEngine: jest.fn(), +}; + +jest.mock("../../../src/datastream", () => ({ + DataStreamClient: jest.fn().mockImplementation(() => mockDataStream), +})); + +// Stub rules engine +const mockRulesEngine = { + initialize: jest.fn().mockResolvedValue(undefined), + isInitialized: jest.fn().mockReturnValue(true), + checkFlag: jest.fn(), + checkFlagWithOptions: jest.fn(), + getVersionKey: jest.fn().mockReturnValue("1"), +}; +jest.mock("../../../src/rules-engine", () => ({ + RulesEngineClient: jest.fn().mockImplementation(() => mockRulesEngine), +})); + +mockDataStream.getRulesEngine.mockReturnValue(mockRulesEngine); + +const flag = { + accountId: "acct", + defaultValue: false, + environmentId: "env", + id: "flag_1", + key: "inference", + rules: [ + { + accountId: "acct", + conditionGroups: [], + conditions: [ + { + accountId: "acct", + conditionType: "credit", + consumptionRate: 10, + creditId: "bilcr_inference", + environmentId: "env", + eventSubtype: "inference_tokens", + id: "cond_1", + operator: "gte", + resourceIds: [], + traitValue: "0", + }, + ], + environmentId: "env", + id: "rule_1", + name: "credit gate", + priority: 1, + ruleType: "standard", + value: true, + }, + ], +}; + +// Build a single-condition credit rule for a given credit type. Used to +// assemble flags that meter one feature across multiple credit types. +function makeCreditRule(id: string, creditId: string, consumptionRate: number) { + return { + accountId: "acct", + conditionGroups: [], + conditions: [ + { + accountId: "acct", + conditionType: "credit", + consumptionRate, + creditId, + environmentId: "env", + eventSubtype: "inference_tokens", + id: `cond_${creditId}`, + operator: "gte", + resourceIds: [], + traitValue: "0", + }, + ], + environmentId: "env", + id, + name: id, + priority: 1, + ruleType: "standard", + value: true, + }; +} + +const company = { + id: "co_1", + accountId: "acct", + environmentId: "env", + keys: { id: "co_1" }, + metrics: [], + creditBalances: { bilcr_inference: 5000 }, + planIds: [], + planVersionIds: [], + billingProductIds: [], + subscription: null, + traits: [], + rules: [], +}; + +function configureSuccessfulAcquire() { + mockAcquireCreditLease.mockResolvedValue({ + data: { + id: "lse_1", + companyId: "co_1", + creditTypeId: "bilcr_inference", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }); +} + +function configureFailingAcquire() { + mockAcquireCreditLease.mockRejectedValue(new Error("lease 503")); +} + +function configureDataStream() { + mockDataStream.getFlag.mockResolvedValue(flag); + mockDataStream.getCachedCompany.mockResolvedValue(company); +} + +function makeClient() { + return new SchematicClient({ + apiKey: "test-key", + useDataStream: true, + creditLeases: { + defaultLeaseDuration: 5 * 60_000, + defaultReservationTTL: 60_000, + defaultLeaseSize: 1000, + lowWaterMark: 0.25, + sweepIntervalMs: 60_000, + }, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockDataStream.getRulesEngine.mockReturnValue(mockRulesEngine); + mockDataStream.isConnected.mockReturnValue(true); +}); + +describe("client.check (lease path)", () => { + it("issues a reservation when the engine says allowed", async () => { + configureSuccessfulAcquire(); + configureDataStream(); + mockRulesEngine.checkFlagWithOptions.mockResolvedValue({ + value: true, + reason: "matched", + flagKey: "inference", + flagId: "flag_1", + entitlement: { + featureId: "feat", + featureKey: "inference", + valueType: "credit_burndown", + creditId: "bilcr_inference", + creditTotal: 1000, + creditUsed: 0, + creditRemaining: 1000, + }, + }); + + const client = makeClient(); + const result = await client.check( + "inference", + { company: { id: "co_1" } }, + { eventUsage: { inference_tokens: 50 } }, + ); + + expect(result.allowed).toBe(true); + expect(result.reservation).toBeDefined(); + expect(result.reservation?.creditsReserved).toBe(500); + expect(result.reservation?.consumptionRate).toBe(10); + expect(result.reservation?.quantityReserved).toBe(50); + expect(mockAcquireCreditLease).toHaveBeenCalledTimes(1); + // Substituted balance flows into WASM: pre-reservation localRemaining = 1000. + const callArgs = mockRulesEngine.checkFlagWithOptions.mock.calls[0]; + expect(callArgs[1].creditBalances.bilcr_inference).toBe(1000); + expect(callArgs[3]).toEqual({ eventUsage: { inference_tokens: 50 } }); + await client.close(); + }); + + it("leases the credit the company's matched plan uses when a flag mixes credit types", async () => { + // `inference` is entitled via two plans: a legacy USD-cents credit + // (declared first on the flag) and an AI-credits credit. The company is + // on the AI-credits plan, so the lease must target AI credits even + // though the USD condition appears first. The engine probe reports the + // matched plan's credit; we lease that, not the first-declared one. + const mixedCreditFlag = { + ...flag, + rules: [ + makeCreditRule("rule_usd", "bilcr_usd", 10), + makeCreditRule("rule_ai", "bilcr_ai", 5), + ], + }; + mockDataStream.getFlag.mockResolvedValue(mixedCreditFlag); + mockDataStream.getCachedCompany.mockResolvedValue({ + ...company, + creditBalances: { bilcr_usd: 0, bilcr_ai: 5000 }, + }); + mockAcquireCreditLease.mockResolvedValue({ + data: { + id: "lse_ai", + companyId: "co_1", + creditTypeId: "bilcr_ai", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }); + // Probe + final eval both report the matched plan's credit (AI credits). + mockRulesEngine.checkFlagWithOptions.mockResolvedValue({ + value: true, + reason: "matched ai plan", + flagKey: "inference", + flagId: "flag_1", + entitlement: { + featureId: "feat", + featureKey: "inference", + valueType: "credit_burndown", + creditId: "bilcr_ai", + creditRemaining: 5000, + }, + }); + + const client = makeClient(); + const result = await client.check( + "inference", + { company: { id: "co_1" } }, + { eventUsage: { inference_tokens: 50 } }, + ); + + expect(result.allowed).toBe(true); + // Leased AI credits (the matched plan), not the first-declared USD credit. + expect(result.reservation?.creditTypeId).toBe("bilcr_ai"); + expect(result.reservation?.consumptionRate).toBe(5); + expect(result.reservation?.creditsReserved).toBe(250); + expect(mockAcquireCreditLease).toHaveBeenCalledTimes(1); + expect(mockAcquireCreditLease.mock.calls[0][0].creditTypeId).toBe("bilcr_ai"); + // One probe eval (raw balance) + one gating eval (substituted balance). + expect(mockRulesEngine.checkFlagWithOptions).toHaveBeenCalledTimes(2); + await client.close(); + }); + + it("returns allowed=false and refunds reservation when engine denies", async () => { + configureSuccessfulAcquire(); + configureDataStream(); + mockRulesEngine.checkFlagWithOptions.mockResolvedValue({ + value: false, + reason: "denied", + flagKey: "inference", + }); + + const client = makeClient(); + const result = await client.check( + "inference", + { company: { id: "co_1" } }, + { eventUsage: { inference_tokens: 50 } }, + ); + + expect(result.allowed).toBe(false); + expect(result.reservation).toBeUndefined(); + await client.close(); + }); + + it("fails closed when lease acquire errors and onAcquireFailure='fail-closed'", async () => { + configureFailingAcquire(); + configureDataStream(); + + const client = makeClient(); + const result = await client.check( + "inference", + { company: { id: "co_1" } }, + { eventUsage: { inference_tokens: 50 }, onAcquireFailure: "fail-closed" }, + ); + + expect(result.allowed).toBe(false); + expect(result.reservation).toBeUndefined(); + expect(mockRulesEngine.checkFlagWithOptions).not.toHaveBeenCalled(); + await client.close(); + }); + + it("defaults to fail-closed when onAcquireFailure is not specified", async () => { + configureFailingAcquire(); + configureDataStream(); + + const client = makeClient(); + const result = await client.check( + "inference", + { company: { id: "co_1" } }, + { eventUsage: { inference_tokens: 50 } }, + ); + + expect(result.allowed).toBe(false); + expect(result.reservation).toBeUndefined(); + expect(mockRulesEngine.checkFlagWithOptions).not.toHaveBeenCalled(); + await client.close(); + }); + + it("respects explicit fail-open override on acquire failure", async () => { + configureFailingAcquire(); + configureDataStream(); + + const client = makeClient(); + const result = await client.check( + "inference", + { company: { id: "co_1" } }, + { eventUsage: { inference_tokens: 50 }, onAcquireFailure: "fail-open" }, + ); + + expect(result.allowed).toBe(true); + expect(result.reservation).toBeUndefined(); + expect(mockRulesEngine.checkFlagWithOptions).not.toHaveBeenCalled(); + await client.close(); + }); + + it("falls back to plain check when no preflight options are provided", async () => { + configureDataStream(); + mockCheckFlag.mockResolvedValue({ + data: { value: true, flag: "inference", reason: "match" }, + }); + + const client = makeClient(); + const result = await client.check("inference", { company: { id: "co_1" } }); + + expect(result.allowed).toBe(true); + expect(mockAcquireCreditLease).not.toHaveBeenCalled(); + await client.close(); + }); +}); + +describe("client.prewarm (datastream resolution)", () => { + it("actively fetches a not-yet-cached company over the datastream before acquiring", async () => { + configureSuccessfulAcquire(); + mockDataStream.getFlag.mockResolvedValue(flag); + // Not cached yet. prewarm actively fetches via getCompany (identify + // doesn't push the company into the cache on its own). Reject once to + // exercise the WS-connecting retry, then surface the company. + mockDataStream.getCachedCompany.mockResolvedValue(null); + mockDataStream.getCompany + .mockRejectedValueOnce(new Error("DataStream client is not connected")) + .mockResolvedValue(company); + + const client = makeClient(); + await client.prewarm( + { company: { external_id: "ext-co-1" } }, + ["bilcr_inference"], + ); + + expect(mockDataStream.getCompany).toHaveBeenCalledWith({ external_id: "ext-co-1" }); + expect(mockAcquireCreditLease).toHaveBeenCalledTimes(1); + expect(mockAcquireCreditLease).toHaveBeenCalledWith( + expect.objectContaining({ companyId: "co_1", creditTypeId: "bilcr_inference" }), + ); + await client.close(); + }); + + it("gives up after prewarmResolveTimeoutMs without acquiring", async () => { + configureSuccessfulAcquire(); + mockDataStream.getFlag.mockResolvedValue(flag); + mockDataStream.getCachedCompany.mockResolvedValue(null); + // Company never surfaces over the datastream. + mockDataStream.getCompany.mockRejectedValue( + new Error("DataStream client is not connected"), + ); + + const client = new SchematicClient({ + apiKey: "test-key", + useDataStream: true, + creditLeases: { + defaultLeaseSize: 1000, + sweepIntervalMs: 60_000, + prewarmResolveTimeoutMs: 250, + }, + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + }); + await client.prewarm( + { company: { external_id: "ext-co-missing" } }, + ["bilcr_inference"], + ); + + expect(mockAcquireCreditLease).not.toHaveBeenCalled(); + await client.close(); + }); +}); + +describe("client.trackWithReservation", () => { + it("refunds unused credits and emits a Track event with actual quantity", async () => { + configureSuccessfulAcquire(); + configureDataStream(); + mockRulesEngine.checkFlagWithOptions.mockResolvedValue({ + value: true, + reason: "matched", + flagKey: "inference", + flagId: "flag_1", + }); + + const client = makeClient(); + const res = await client.check( + "inference", + { company: { id: "co_1" } }, + { eventUsage: { inference_tokens: 50 } }, + ); + if (!res.reservation) throw new Error("expected reservation"); + + // Local balance after reservation: 1000 - 500 = 500 + await client.trackWithReservation(res.reservation, 20); + + // Track event was enqueued with actualQuantity + const pushed = mockEventBufferPush.mock.calls.find( + (call) => call[0]?.eventType === "track", + ); + expect(pushed).toBeDefined(); + expect(pushed?.[0].body.event).toBe("inference_tokens"); + expect(pushed?.[0].body.quantity).toBe(20); + expect(pushed?.[0].body.company).toEqual({ id: "co_1" }); + await client.close(); + }); + + it("warns and skips when the reservation is unknown", async () => { + configureSuccessfulAcquire(); + configureDataStream(); + const warn = jest.fn(); + const client = new SchematicClient({ + apiKey: "test-key", + useDataStream: true, + creditLeases: { defaultLeaseSize: 1000, sweepIntervalMs: 60_000 }, + logger: { debug: jest.fn(), info: jest.fn(), warn, error: jest.fn() }, + }); + await client.trackWithReservation( + { + id: "missing", + leaseId: "lse_x", + companyId: "co_1", + creditTypeId: "bilcr_inference", + eventSubtype: "inference_tokens", + quantityReserved: 10, + creditsReserved: 100, + consumptionRate: 10, + expiresAt: new Date(Date.now() + 60_000), + evalCtx: { company: { id: "co_1" } }, + }, + 5, + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("already consumed or expired"), + ); + await client.close(); + }); +}); diff --git a/tests/unit/credits/fake-redis.ts b/tests/unit/credits/fake-redis.ts new file mode 100644 index 00000000..240f1a02 --- /dev/null +++ b/tests/unit/credits/fake-redis.ts @@ -0,0 +1,267 @@ +import type { RedisClient } from "../../../src/cache/redis"; + +/** + * In-memory implementation of the subset of the `RedisClient` interface used + * by `RedisLeaseStore` + `RedisReservationStore`. The `eval` method + * interprets the specific Lua scripts those stores send — it's not a generic + * Lua engine, just enough to exercise the atomic semantics in tests. + * + * Atomicity: the JS event loop is single-threaded and our Lua paths are all + * synchronous against the in-memory maps, so calling them from concurrent + * promises emulates real Redis Lua atomicity faithfully enough for these + * tests. + */ +export function makeFakeRedis(): RedisClient { + const strings = new Map(); + const hashes = new Map>(); + const sets = new Map>(); + const zsets = new Map>(); // member -> score + const expirations = new Map(); // key -> epoch ms + + const checkExpiry = (key: string): void => { + const exp = expirations.get(key); + if (exp !== undefined && exp <= Date.now()) { + strings.delete(key); + hashes.delete(key); + sets.delete(key); + zsets.delete(key); + expirations.delete(key); + } + }; + + const hgetAll = (key: string): Record => { + checkExpiry(key); + const h = hashes.get(key); + if (!h) return {}; + return Object.fromEntries(h.entries()); + }; + + const hset = (key: string, field: string, value: string): void => { + if (!hashes.has(key)) hashes.set(key, new Map()); + hashes.get(key)!.set(field, value); + }; + + const hget = (key: string, field: string): string | null => { + checkExpiry(key); + return hashes.get(key)?.get(field) ?? null; + }; + + const del = (key: string): void => { + strings.delete(key); + hashes.delete(key); + sets.delete(key); + zsets.delete(key); + expirations.delete(key); + }; + + const sadd = (key: string, member: string): void => { + if (!sets.has(key)) sets.set(key, new Set()); + sets.get(key)!.add(member); + }; + + const srem = (key: string, member: string): void => { + sets.get(key)?.delete(member); + }; + + const zadd = (key: string, score: number, member: string): void => { + if (!zsets.has(key)) zsets.set(key, new Map()); + zsets.get(key)!.set(member, score); + }; + + const zrem = (key: string, member: string): void => { + zsets.get(key)?.delete(member); + }; + + const interpretScript = (script: string, keys: string[], args: string[]): unknown => { + const trimmed = script.trim(); + // Match by a stable substring per script. + if (trimmed.includes("local existing_id = redis.call('HGET', KEYS[1], 'leaseId')") && + trimmed.includes("redis.call('SADD', index_key, index_member)")) { + // REPLACE_SCRIPT + const [leaseHashKey, indexKey] = keys; + const [newId, newGranted, newExpiryStr, nowStr, _grace, indexMember, companyId, creditTypeId] = args; + const newExpiry = Number(newExpiryStr); + const now = Number(nowStr); + const existingId = hget(leaseHashKey, "leaseId"); + const existingExpiry = Number(hget(leaseHashKey, "expiresAt") ?? "0"); + if (existingId && existingId === newId && existingExpiry > now) { + return 0; + } + del(leaseHashKey); + hset(leaseHashKey, "leaseId", newId); + hset(leaseHashKey, "companyId", companyId); + hset(leaseHashKey, "creditTypeId", creditTypeId); + hset(leaseHashKey, "grantedAmount", newGranted); + hset(leaseHashKey, "localRemainingCredits", newGranted); + hset(leaseHashKey, "expiresAt", newExpiryStr); + expirations.set(leaseHashKey, newExpiry + Number(_grace)); + sadd(indexKey, indexMember); + return 1; + } + if (trimmed.includes("local raw = redis.call('HGET', KEYS[1], 'localRemainingCredits')") && + trimmed.includes("if remaining < requested then return 0 end")) { + // TRY_RESERVE_SCRIPT + const [leaseHashKey] = keys; + const remainingRaw = hget(leaseHashKey, "localRemainingCredits"); + if (remainingRaw === null) return 0; + const remaining = Number(remainingRaw); + const requested = Number(args[0]); + if (remaining < requested) return 0; + hset(leaseHashKey, "localRemainingCredits", String(remaining - requested)); + return 1; + } + if (trimmed.includes("local new_balance = remaining + refund") && + trimmed.includes("if new_balance > granted then new_balance = granted end") && + !trimmed.includes("HGETALL")) { + // REFUND_SCRIPT + const [leaseHashKey] = keys; + const rawRemaining = hget(leaseHashKey, "localRemainingCredits"); + if (rawRemaining === null) return 0; + const remaining = Number(rawRemaining); + const granted = Number(hget(leaseHashKey, "grantedAmount") ?? "0"); + const refund = Number(args[0]); + const newBalance = Math.min(remaining + refund, granted); + hset(leaseHashKey, "localRemainingCredits", String(newBalance)); + return 1; + } + if (trimmed.includes("local raw_granted = redis.call('HGET', KEYS[1], 'grantedAmount')") && + trimmed.includes("'localRemainingCredits', tostring(remaining + add)")) { + // EXTEND_SCRIPT + const [leaseHashKey] = keys; + const rawGranted = hget(leaseHashKey, "grantedAmount"); + if (rawGranted === null) return 0; + const granted = Number(rawGranted); + const remaining = Number(hget(leaseHashKey, "localRemainingCredits") ?? "0"); + const add = Number(args[0]); + const newExpiry = Number(args[1]); + const grace = Number(args[2]); + hset(leaseHashKey, "grantedAmount", String(granted + add)); + hset(leaseHashKey, "localRemainingCredits", String(remaining + add)); + hset(leaseHashKey, "expiresAt", args[1]); + expirations.set(leaseHashKey, newExpiry + grace); + return 1; + } + if (trimmed.includes("if not existing_id or existing_id ~= ARGV[1] then return 0 end")) { + // DROP_IF_MATCH_SCRIPT + const [leaseHashKey, indexKey] = keys; + const existingId = hget(leaseHashKey, "leaseId"); + if (!existingId || existingId !== args[0]) return 0; + del(leaseHashKey); + srem(indexKey, args[1]); + return 1; + } + if (trimmed.includes("local raw = redis.call('HGETALL', KEYS[1])") && + trimmed.includes("redis.call('ZREM', KEYS[2], ARGV[1])")) { + // CONSUME_SCRIPT + const [resHashKey, indexKey, leaseHashKey] = keys; + const raw = hgetAll(resHashKey); + if (Object.keys(raw).length === 0) return null; + const reserved = Number(raw.creditsReserved ?? "0"); + let consumed = Number(args[1]); + if (consumed < 0) consumed = 0; + if (consumed > reserved) consumed = reserved; + const refund = reserved - consumed; + del(resHashKey); + zrem(indexKey, args[0]); + if (refund > 0) { + const rawRemaining = hget(leaseHashKey, "localRemainingCredits"); + if (rawRemaining !== null) { + const remaining = Number(rawRemaining); + const granted = Number(hget(leaseHashKey, "grantedAmount") ?? "0"); + const newBalance = Math.min(remaining + refund, granted); + hset(leaseHashKey, "localRemainingCredits", String(newBalance)); + } + } + return String(consumed); + } + throw new Error(`fake-redis: unrecognized Lua script:\n${script}`); + }; + + return { + // Basic string ops (unused by lease stores but required by interface) + async get(key) { + checkExpiry(key); + return strings.get(key) ?? null; + }, + async set(key, value) { + strings.set(key, String(value)); + }, + async setEx(key, seconds, value) { + strings.set(key, String(value)); + expirations.set(key, Date.now() + seconds * 1000); + }, + async del(keyOrKeys) { + const arr = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys]; + for (const k of arr) del(k); + }, + async *scanIterator(_options) { + for (const k of [ + ...strings.keys(), + ...hashes.keys(), + ...sets.keys(), + ...zsets.keys(), + ]) { + yield k; + } + }, + // Hash ops + async hSet(key, field, value) { + if (typeof field === "object" && field !== null) { + for (const [f, v] of Object.entries(field)) { + hset(key, f, String(v)); + } + return; + } + hset(key, field, String(value)); + }, + async hGet(key, field) { + return hget(key, field) ?? undefined; + }, + async hGetAll(key) { + return hgetAll(key); + }, + async hDel(key, field) { + const fields = Array.isArray(field) ? field : [field]; + const h = hashes.get(key); + if (!h) return; + for (const f of fields) h.delete(f); + }, + // Sorted-set ops + async zAdd(key, members) { + const arr = Array.isArray(members) ? members : [members]; + for (const m of arr) zadd(key, m.score, m.value); + }, + async zRangeByScore(key, min, max) { + checkExpiry(key); + const z = zsets.get(key); + if (!z) return []; + const minN = min === "-inf" ? Number.NEGATIVE_INFINITY : Number(min); + const maxN = max === "+inf" ? Number.POSITIVE_INFINITY : Number(max); + return Array.from(z.entries()) + .filter(([, score]) => score >= minN && score <= maxN) + .sort(([, a], [, b]) => a - b) + .map(([m]) => m); + }, + async zRem(key, member) { + const members = Array.isArray(member) ? member : [member]; + for (const m of members) zrem(key, m); + }, + async eval(script, options) { + return interpretScript(script, options.keys, options.arguments); + }, + async pExpireAt(key, timestamp) { + expirations.set(key, timestamp); + }, + // Extras used by store snapshot / size paths + async sMembers(key) { + return Array.from(sets.get(key) ?? []); + }, + async sRem(key, member) { + srem(key, member); + }, + async zCard(key) { + checkExpiry(key); + return zsets.get(key)?.size ?? 0; + }, + }; +} diff --git a/tests/unit/credits/lease-manager.test.ts b/tests/unit/credits/lease-manager.test.ts new file mode 100644 index 00000000..c491aede --- /dev/null +++ b/tests/unit/credits/lease-manager.test.ts @@ -0,0 +1,278 @@ +import { CreditLeaseManager } from "../../../src/credits/lease-manager"; +import { LeaseStore } from "../../../src/credits/lease-store"; +import type { Logger } from "../../../src/logger"; + +function makeLogger(): Logger { + return { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +} + +function makeManager(creditsClient: { [k: string]: jest.Mock }) { + const store = new LeaseStore(); + const manager = new CreditLeaseManager({ + // biome-ignore lint/suspicious/noExplicitAny: stubbed client + creditsClient: creditsClient as any, + leaseStore: store, + logger: makeLogger(), + config: { + defaultLeaseDuration: 5 * 60_000, + defaultReservationTTL: 60_000, + defaultLeaseSize: 1000, + lowWaterMark: 0.25, + }, + }); + return { manager, store }; +} + +describe("CreditLeaseManager", () => { + it("acquireIfNeeded calls acquireCreditLease and installs the lease", async () => { + const expiresAt = new Date(Date.now() + 5 * 60_000); + const creditsClient = { + acquireCreditLease: jest.fn().mockResolvedValue({ + data: { + id: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt, + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }), + extendCreditLease: jest.fn(), + releaseCreditLease: jest.fn(), + }; + const { manager, store } = makeManager(creditsClient); + + const entry = await manager.acquireIfNeeded("co_1", "ct_1"); + expect(creditsClient.acquireCreditLease).toHaveBeenCalledTimes(1); + expect(entry?.leaseId).toBe("lse_1"); + expect(entry?.grantedAmount).toBe(1000); + expect(store.get("co_1", "ct_1")?.localRemainingCredits).toBe(1000); + }); + + it("acquireIfNeeded is single-flight for concurrent callers", async () => { + let resolve!: (v: unknown) => void; + const pending = new Promise((r) => (resolve = r)); + const creditsClient = { + acquireCreditLease: jest.fn().mockReturnValue(pending), + extendCreditLease: jest.fn(), + releaseCreditLease: jest.fn(), + }; + const { manager } = makeManager(creditsClient); + + const p1 = manager.acquireIfNeeded("co_1", "ct_1"); + const p2 = manager.acquireIfNeeded("co_1", "ct_1"); + const p3 = manager.acquireIfNeeded("co_1", "ct_1"); + + resolve({ + data: { + id: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }); + + await Promise.all([p1, p2, p3]); + expect(creditsClient.acquireCreditLease).toHaveBeenCalledTimes(1); + }); + + it("acquireIfNeeded reuses a live lease (no second wire call)", async () => { + const creditsClient = { + acquireCreditLease: jest.fn().mockResolvedValue({ + data: { + id: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }), + extendCreditLease: jest.fn(), + releaseCreditLease: jest.fn(), + }; + const { manager } = makeManager(creditsClient); + await manager.acquireIfNeeded("co_1", "ct_1"); + await manager.acquireIfNeeded("co_1", "ct_1"); + expect(creditsClient.acquireCreditLease).toHaveBeenCalledTimes(1); + }); + + it("maybeExtendInBackground triggers extend when below low water mark", async () => { + const creditsClient = { + acquireCreditLease: jest.fn().mockResolvedValue({ + data: { + id: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }), + extendCreditLease: jest.fn().mockResolvedValue({ + data: { + id: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 2000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }), + releaseCreditLease: jest.fn(), + }; + const { manager, store } = makeManager(creditsClient); + await manager.acquireIfNeeded("co_1", "ct_1"); + // Spend down to below 25% + await store.tryReserve("co_1", "ct_1", 800); + await manager.maybeExtendInBackground("co_1", "ct_1"); + expect(creditsClient.extendCreditLease).toHaveBeenCalledTimes(1); + expect(store.get("co_1", "ct_1")?.grantedAmount).toBe(2000); + }); + + it("maybeExtendInBackground extends when requiredCredits exceeds local remaining even above watermark", async () => { + const creditsClient = { + acquireCreditLease: jest.fn().mockResolvedValue({ + data: { + id: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }), + extendCreditLease: jest.fn().mockResolvedValue({ + data: { + id: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 2000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }), + releaseCreditLease: jest.fn(), + }; + const { manager, store } = makeManager(creditsClient); + await manager.acquireIfNeeded("co_1", "ct_1"); + // Spend a little — still well above the 25% watermark (900/1000 = 90%). + await store.tryReserve("co_1", "ct_1", 100); + // Without the hint, this would no-op (ratio > watermark). + await manager.maybeExtendInBackground("co_1", "ct_1"); + expect(creditsClient.extendCreditLease).not.toHaveBeenCalled(); + // Caller asks for 1500 credits worth — we only have 900 local, so extend. + await manager.maybeExtendInBackground("co_1", "ct_1", 1500); + expect(creditsClient.extendCreditLease).toHaveBeenCalledTimes(1); + expect(store.get("co_1", "ct_1")?.grantedAmount).toBe(2000); + }); + + it("does not conflate concurrent acquire and extend on the same key", async () => { + // Pre-install a live lease with a sub-watermark balance so an extend + // is warranted. We then hold the extend mid-flight and fire an + // acquireIfNeeded against an *expired* slot for a different lease id — + // the two operations must not share inflight state. + let releaseExtend!: (v: unknown) => void; + const extendPending = new Promise((r) => (releaseExtend = r)); + const creditsClient = { + acquireCreditLease: jest.fn().mockResolvedValue({ + data: { + id: "lse_fresh", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }), + extendCreditLease: jest.fn().mockReturnValue(extendPending), + releaseCreditLease: jest.fn(), + }; + const { manager, store } = makeManager(creditsClient); + // Seed a live, debited lease so `maybeExtendInBackground` triggers an extend. + await store.replace({ + leaseId: "lse_live", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 5 * 60_000), + }); + await store.tryReserve("co_1", "ct_1", 800); // 200 left → below 25% watermark + + // Kick off the extend (will hang on extendPending). + const extendP = manager.maybeExtendInBackground("co_1", "ct_1"); + + // Drop the lease so acquire is needed, then call acquireIfNeeded — + // this must NOT receive the in-flight extend promise. + await store.drop("co_1", "ct_1"); + const acquired = await manager.acquireIfNeeded("co_1", "ct_1"); + expect(acquired?.leaseId).toBe("lse_fresh"); + expect(creditsClient.acquireCreditLease).toHaveBeenCalledTimes(1); + + // Let the extend resolve so we don't leak the pending promise. + releaseExtend({ + data: { + id: "lse_live", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 2000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }); + await extendP; + }); + + it("releaseAll calls release for every outstanding lease", async () => { + const creditsClient = { + acquireCreditLease: jest + .fn() + .mockImplementation((body: { creditTypeId: string; companyId: string }) => + Promise.resolve({ + data: { + id: `lse_${body.creditTypeId}`, + companyId: body.companyId, + creditTypeId: body.creditTypeId, + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 5 * 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + params: {}, + }), + ), + extendCreditLease: jest.fn(), + releaseCreditLease: jest.fn().mockResolvedValue({ data: {}, params: {} }), + }; + const { manager } = makeManager(creditsClient); + await manager.acquireIfNeeded("co_1", "ct_1"); + await manager.acquireIfNeeded("co_1", "ct_2"); + await manager.releaseAll(); + expect(creditsClient.releaseCreditLease).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/credits/lease-store.test.ts b/tests/unit/credits/lease-store.test.ts new file mode 100644 index 00000000..a61613c7 --- /dev/null +++ b/tests/unit/credits/lease-store.test.ts @@ -0,0 +1,116 @@ +import { LeaseStore } from "../../../src/credits/lease-store"; + +describe("LeaseStore", () => { + let store: LeaseStore; + + beforeEach(() => { + store = new LeaseStore(); + }); + + it("replaces installs a lease with localRemaining=grantedAmount", async () => { + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + const entry = store.get("co_1", "ct_1"); + expect(entry).toBeDefined(); + expect(entry?.localRemainingCredits).toBe(100); + expect(entry?.grantedAmount).toBe(100); + }); + + it("tryReserve debits localRemaining and returns true on success", async () => { + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + const ok = await store.tryReserve("co_1", "ct_1", 30); + expect(ok).toBe(true); + expect(store.get("co_1", "ct_1")?.localRemainingCredits).toBe(70); + }); + + it("tryReserve returns false when insufficient and does not debit", async () => { + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + const ok = await store.tryReserve("co_1", "ct_1", 150); + expect(ok).toBe(false); + expect(store.get("co_1", "ct_1")?.localRemainingCredits).toBe(100); + }); + + it("refund adds credits back, capped at grantedAmount", async () => { + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + await store.tryReserve("co_1", "ct_1", 30); + await store.refund("co_1", "ct_1", 20); + expect(store.get("co_1", "ct_1")?.localRemainingCredits).toBe(90); + // Refunding past grantedAmount caps at grantedAmount + await store.refund("co_1", "ct_1", 9999); + expect(store.get("co_1", "ct_1")?.localRemainingCredits).toBe(100); + }); + + it("concurrent tryReserves serialize and only succeed up to grantedAmount", async () => { + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + const results = await Promise.all([ + store.tryReserve("co_1", "ct_1", 40), + store.tryReserve("co_1", "ct_1", 40), + store.tryReserve("co_1", "ct_1", 40), + ]); + const successes = results.filter((r) => r === true).length; + expect(successes).toBe(2); + expect(store.get("co_1", "ct_1")?.localRemainingCredits).toBe(20); + }); + + it("extend adds to grantedAmount and localRemaining and updates expiry", async () => { + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 10_000), + }); + await store.tryReserve("co_1", "ct_1", 30); + const newExpiry = new Date(Date.now() + 60_000); + await store.extend("co_1", "ct_1", 50, newExpiry); + const e = store.get("co_1", "ct_1"); + expect(e?.grantedAmount).toBe(150); + expect(e?.localRemainingCredits).toBe(120); + expect(e?.expiresAt.getTime()).toBe(newExpiry.getTime()); + }); + + it("dropIfLeaseIdMatches drops only when leaseId matches", async () => { + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + const dropped = await store.dropIfLeaseIdMatches("co_1", "ct_1", "lse_other"); + expect(dropped).toBe(false); + expect(store.get("co_1", "ct_1")).toBeDefined(); + const dropped2 = await store.dropIfLeaseIdMatches("co_1", "ct_1", "lse_1"); + expect(dropped2).toBe(true); + expect(store.get("co_1", "ct_1")).toBeUndefined(); + }); +}); diff --git a/tests/unit/credits/redis-lease-store.test.ts b/tests/unit/credits/redis-lease-store.test.ts new file mode 100644 index 00000000..8823e3c8 --- /dev/null +++ b/tests/unit/credits/redis-lease-store.test.ts @@ -0,0 +1,141 @@ +import { RedisLeaseStore } from "../../../src/credits/redis-lease-store"; +import { makeFakeRedis } from "./fake-redis"; + +describe("RedisLeaseStore", () => { + it("replace installs a fresh lease, returns true", async () => { + const client = makeFakeRedis(); + const store = new RedisLeaseStore({ client }); + const wrote = await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + expect(wrote).toBe(true); + const entry = await store.get("co_1", "ct_1"); + expect(entry?.leaseId).toBe("lse_1"); + expect(entry?.localRemainingCredits).toBe(100); + expect(entry?.grantedAmount).toBe(100); + }); + + it("replace preserves debited state when the same live leaseId is rewritten", async () => { + const client = makeFakeRedis(); + const store = new RedisLeaseStore({ client }); + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 60_000), + }); + await store.tryReserve("co_1", "ct_1", 400); + // Simulate a second pod calling acquire and getting the same lease back. + const wrote = await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 60_000), + }); + expect(wrote).toBe(false); + const entry = await store.get("co_1", "ct_1"); + expect(entry?.localRemainingCredits).toBe(600); + }); + + it("tryReserve atomically gates against the shared balance", async () => { + const client = makeFakeRedis(); + const store = new RedisLeaseStore({ client }); + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + const results = await Promise.all([ + store.tryReserve("co_1", "ct_1", 40), + store.tryReserve("co_1", "ct_1", 40), + store.tryReserve("co_1", "ct_1", 40), + ]); + // Two should succeed, one should fail (40 left after two debits) + expect(results.filter((r) => r).length).toBe(2); + const entry = await store.get("co_1", "ct_1"); + expect(entry?.localRemainingCredits).toBe(20); + }); + + it("refund caps at grantedAmount", async () => { + const client = makeFakeRedis(); + const store = new RedisLeaseStore({ client }); + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + await store.tryReserve("co_1", "ct_1", 30); + await store.refund("co_1", "ct_1", 9999); + const entry = await store.get("co_1", "ct_1"); + expect(entry?.localRemainingCredits).toBe(100); + }); + + it("snapshot returns all outstanding leases", async () => { + const client = makeFakeRedis(); + const store = new RedisLeaseStore({ client }); + await store.replace({ + leaseId: "lse_a", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + await store.replace({ + leaseId: "lse_b", + companyId: "co_1", + creditTypeId: "ct_2", + grantedAmount: 200, + expiresAt: new Date(Date.now() + 60_000), + }); + const snap = await store.snapshot(); + expect(snap).toHaveLength(2); + }); + + it("extend honors defaultLeaseDurationMs option when newExpiresAt is omitted", async () => { + const client = makeFakeRedis(); + const store = new RedisLeaseStore({ client, defaultLeaseDurationMs: 250 }); + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + const before = Date.now(); + await store.extend("co_1", "ct_1", 50); + const entry = await store.get("co_1", "ct_1"); + // Configured fallback is 250ms — expiry should land near `before + 250`. + const expiry = entry?.expiresAt.getTime() ?? 0; + expect(expiry).toBeGreaterThanOrEqual(before + 200); + expect(expiry).toBeLessThanOrEqual(before + 500); + expect(entry?.grantedAmount).toBe(150); + }); + + it("dropIfLeaseIdMatches only drops when leaseId matches", async () => { + const client = makeFakeRedis(); + const store = new RedisLeaseStore({ client }); + await store.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 100, + expiresAt: new Date(Date.now() + 60_000), + }); + const ok = await store.dropIfLeaseIdMatches("co_1", "ct_1", "lse_wrong"); + expect(ok).toBe(false); + expect(await store.get("co_1", "ct_1")).toBeDefined(); + const ok2 = await store.dropIfLeaseIdMatches("co_1", "ct_1", "lse_1"); + expect(ok2).toBe(true); + expect(await store.get("co_1", "ct_1")).toBeUndefined(); + }); +}); diff --git a/tests/unit/credits/redis-reservation-store.test.ts b/tests/unit/credits/redis-reservation-store.test.ts new file mode 100644 index 00000000..1facb5dd --- /dev/null +++ b/tests/unit/credits/redis-reservation-store.test.ts @@ -0,0 +1,105 @@ +import { RedisLeaseStore } from "../../../src/credits/redis-lease-store"; +import { RedisReservationStore } from "../../../src/credits/redis-reservation-store"; +import type { Reservation } from "../../../src/credits/types"; +import { makeFakeRedis } from "./fake-redis"; + +function makeReservation(overrides: Partial = {}): Reservation { + return { + id: "res_1", + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + eventSubtype: "inference_tokens", + quantityReserved: 10, + creditsReserved: 100, + consumptionRate: 10, + expiresAt: new Date(Date.now() + 60_000), + evalCtx: { company: { id: "co_1" } }, + ...overrides, + }; +} + +describe("RedisReservationStore", () => { + it("add round-trips through get and shows up in the expiry index", async () => { + const client = makeFakeRedis(); + const leaseStore = new RedisLeaseStore({ client }); + const reservations = new RedisReservationStore({ client, leaseStore, sweepIntervalMs: 60_000 }); + await leaseStore.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 60_000), + }); + + const reservation = makeReservation(); + await reservations.add(reservation); + const fetched = await reservations.get(reservation.id); + expect(fetched?.creditsReserved).toBe(100); + expect(await reservations.size()).toBe(1); + reservations.stop(); + }); + + it("consume refunds unused credits atomically", async () => { + const client = makeFakeRedis(); + const leaseStore = new RedisLeaseStore({ client }); + const reservations = new RedisReservationStore({ client, leaseStore, sweepIntervalMs: 60_000 }); + await leaseStore.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 60_000), + }); + await leaseStore.tryReserve("co_1", "ct_1", 100); + await reservations.add(makeReservation()); + expect((await leaseStore.get("co_1", "ct_1"))?.localRemainingCredits).toBe(900); + + const consumed = await reservations.consume("res_1", 30); + expect(consumed).toBe(30); + // Refunded 100-30=70 + expect((await leaseStore.get("co_1", "ct_1"))?.localRemainingCredits).toBe(970); + expect(await reservations.get("res_1")).toBeUndefined(); + reservations.stop(); + }); + + it("sweepExpired returns expired reservations to the lease", async () => { + const client = makeFakeRedis(); + const leaseStore = new RedisLeaseStore({ client }); + const reservations = new RedisReservationStore({ client, leaseStore, sweepIntervalMs: 60_000 }); + await leaseStore.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 60_000), + }); + await leaseStore.tryReserve("co_1", "ct_1", 100); + await reservations.add(makeReservation({ expiresAt: new Date(Date.now() - 1) })); + + const swept = await reservations.sweepExpired(); + expect(swept).toBe(1); + expect((await leaseStore.get("co_1", "ct_1"))?.localRemainingCredits).toBe(1000); + expect(await reservations.get("res_1")).toBeUndefined(); + reservations.stop(); + }); + + it("double-consume returns null on the second call", async () => { + const client = makeFakeRedis(); + const leaseStore = new RedisLeaseStore({ client }); + const reservations = new RedisReservationStore({ client, leaseStore, sweepIntervalMs: 60_000 }); + await leaseStore.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 60_000), + }); + await leaseStore.tryReserve("co_1", "ct_1", 100); + await reservations.add(makeReservation()); + await reservations.consume("res_1", 50); + const second = await reservations.consume("res_1", 10); + expect(second).toBeNull(); + reservations.stop(); + }); +}); diff --git a/tests/unit/credits/reservation-store.test.ts b/tests/unit/credits/reservation-store.test.ts new file mode 100644 index 00000000..ddb90bf7 --- /dev/null +++ b/tests/unit/credits/reservation-store.test.ts @@ -0,0 +1,86 @@ +import { LeaseStore } from "../../../src/credits/lease-store"; +import { ReservationStore } from "../../../src/credits/reservation-store"; +import type { Reservation } from "../../../src/credits/types"; + +function makeReservation(overrides: Partial = {}): Reservation { + return { + id: "res_1", + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + eventSubtype: "inference_tokens", + quantityReserved: 10, + creditsReserved: 100, + consumptionRate: 10, + expiresAt: new Date(Date.now() + 60_000), + evalCtx: { company: { id: "co_1" } }, + ...overrides, + }; +} + +describe("ReservationStore", () => { + let leases: LeaseStore; + let reservations: ReservationStore; + + beforeEach(async () => { + leases = new LeaseStore(); + reservations = new ReservationStore(leases, 50); + await leases.replace({ + leaseId: "lse_1", + companyId: "co_1", + creditTypeId: "ct_1", + grantedAmount: 1000, + expiresAt: new Date(Date.now() + 60_000), + }); + }); + + afterEach(() => { + reservations.stop(); + }); + + it("consume refunds unused credits to the lease", async () => { + await leases.tryReserve("co_1", "ct_1", 100); + const reservation = makeReservation(); + reservations.add(reservation); + expect(leases.get("co_1", "ct_1")?.localRemainingCredits).toBe(900); + + const consumed = await reservations.consume(reservation.id, 30); + expect(consumed).toBe(30); + // Refunded 100 - 30 = 70 + expect(leases.get("co_1", "ct_1")?.localRemainingCredits).toBe(970); + expect(reservations.get(reservation.id)).toBeUndefined(); + }); + + it("consume clamps credits consumed to creditsReserved", async () => { + await leases.tryReserve("co_1", "ct_1", 100); + const reservation = makeReservation(); + reservations.add(reservation); + const consumed = await reservations.consume(reservation.id, 999); + expect(consumed).toBe(100); + expect(leases.get("co_1", "ct_1")?.localRemainingCredits).toBe(900); + }); + + it("consume returns null on missing reservation", async () => { + const result = await reservations.consume("nope", 10); + expect(result).toBeNull(); + }); + + it("sweepExpired refunds expired reservations to the lease", async () => { + await leases.tryReserve("co_1", "ct_1", 100); + const reservation = makeReservation({ expiresAt: new Date(Date.now() - 1) }); + reservations.add(reservation); + + const swept = await reservations.sweepExpired(); + expect(swept).toBe(1); + expect(reservations.get(reservation.id)).toBeUndefined(); + expect(leases.get("co_1", "ct_1")?.localRemainingCredits).toBe(1000); + }); + + it("sweepExpired ignores non-expired reservations", async () => { + await leases.tryReserve("co_1", "ct_1", 100); + reservations.add(makeReservation()); + const swept = await reservations.sweepExpired(); + expect(swept).toBe(0); + expect(reservations.size()).toBe(1); + }); +}); diff --git a/tests/unit/datastream/datastream-client.test.ts b/tests/unit/datastream/datastream-client.test.ts index c0f180fb..e9671d00 100644 --- a/tests/unit/datastream/datastream-client.test.ts +++ b/tests/unit/datastream/datastream-client.test.ts @@ -7,6 +7,23 @@ import { DatastreamWSClient } from '../../../src/datastream/websocket-client'; import { DataStreamResp, EntityType, MessageType } from '../../../src/datastream/types'; import { Logger } from '../../../src/logger'; import * as Schematic from '../../../src/api/types'; +import * as serializers from '../../../src/serialization'; + +const PARSE_OPTS = { + allowUnrecognizedEnumValues: true, + allowUnrecognizedUnionMembers: true, + unrecognizedObjectKeys: 'passthrough' as const, +}; +// The SUT runs incoming snake_case wire payloads through Fern's parseOrThrow +// to canonicalize them to camelCase before caching. Mock fixtures are written +// in wire format (snake_case), so we route them through the same serializer +// to compute the expected camelCase shape returned by getCompany/getUser/getFlag. +const asCompany = (c: unknown): Schematic.RulesengineCompany => + serializers.RulesengineCompany.parseOrThrow(c, PARSE_OPTS); +const asUser = (u: unknown): Schematic.RulesengineUser => + serializers.RulesengineUser.parseOrThrow(u, PARSE_OPTS); +const asFlag = (f: unknown): Schematic.RulesengineFlag => + serializers.RulesengineFlag.parseOrThrow(f, PARSE_OPTS); // Mock DatastreamWSClient const mockDatastreamWSClientInstance = { on: jest.fn(), @@ -23,11 +40,23 @@ jest.mock('../../../src/datastream/websocket-client', () => { }; }); -// Mock RulesEngineClient so we can control what checkFlag returns -const mockRulesEngineInstance = { +// Mock RulesEngineClient so we can control what checkFlag returns. +// The real client routes evaluateFlag through `checkFlagWithOptions`; mock +// it to delegate to `checkFlag` so existing tests that stub `checkFlag` keep +// working without per-test churn. +const mockRulesEngineInstance: { + initialize: jest.Mock; + isInitialized: jest.Mock; + checkFlag: jest.Mock; + checkFlagWithOptions: jest.Mock; + getVersionKey: jest.Mock; +} = { initialize: jest.fn().mockResolvedValue(undefined), isInitialized: jest.fn().mockReturnValue(false), checkFlag: jest.fn(), + checkFlagWithOptions: jest.fn((flag, company, user, _options) => + mockRulesEngineInstance.checkFlag(flag, company, user), + ), getVersionKey: jest.fn().mockReturnValue('1'), }; @@ -52,8 +81,8 @@ describe('DataStreamClient', () => { rules: [], metrics: [], plan_ids: [], + plan_version_ids: [], billing_product_ids: [], - crm_product_ids: [], credit_balances: {}, } as unknown as Schematic.RulesengineCompany; @@ -251,7 +280,7 @@ describe('DataStreamClient', () => { // Verify company is cached and can be retrieved using the correct keys const retrievedCompany = await client.getCompany(mockCompany.keys!); - expect(retrievedCompany).toEqual(mockCompany); + expect(retrievedCompany).toEqual(asCompany(mockCompany)); }, 10000); test('should handle user messages and update cache', async () => { @@ -273,7 +302,7 @@ describe('DataStreamClient', () => { // Verify user is cached and can be retrieved using the correct keys const retrievedUser = await client.getUser(mockUser.keys!); - expect(retrievedUser).toEqual(mockUser); + expect(retrievedUser).toEqual(asUser(mockUser)); }, 10000); test('should handle flag messages and update cache', async () => { @@ -295,7 +324,7 @@ describe('DataStreamClient', () => { // Verify flag is cached and can be retrieved const retrievedFlag = await client.getFlag(mockFlag.key); - expect(retrievedFlag).toEqual(mockFlag); + expect(retrievedFlag).toEqual(asFlag(mockFlag)); }); test('should handle partial entity message merging', async () => { @@ -311,12 +340,12 @@ describe('DataStreamClient', () => { account_id: 'account-123', environment_id: 'env-123', keys: { name: 'Partial Corp' }, - traits: [{ key: 'tier', value: 'free' }], + traits: [{ value: 'free' }], rules: [], metrics: [], plan_ids: ['plan-1'], + plan_version_ids: [], billing_product_ids: [], - crm_product_ids: [], credit_balances: {}, } as unknown as Schematic.RulesengineCompany; @@ -328,7 +357,7 @@ describe('DataStreamClient', () => { // Verify the full company is cached const cachedFull = await client.getCompany({ name: 'Partial Corp' }); - expect(cachedFull).toEqual(fullCompany); + expect(cachedFull).toEqual(asCompany(fullCompany)); // Send a PARTIAL company message. Wire shape: data is the partial fields, // entity_id at the top level identifies the cached company to merge into. @@ -338,22 +367,24 @@ describe('DataStreamClient', () => { message_type: MessageType.PARTIAL, data: { keys: { name: 'Partial Corp' }, - traits: [{ key: 'tier', value: 'enterprise' }], + traits: [{ value: 'enterprise' }], plan_ids: ['plan-2'], }, }); // Partial messages are now properly merged: fields in the partial update // the cached entity, while fields not present in the partial are preserved. + // Cached values are camelCase (canonicalized by parseOrThrow on the FULL + // message), and partialCompany writes camelCase keys, so assertions read + // camelCase regardless of whether the field was full-loaded or merged. const cachedAfterPartial = await client.getCompany({ name: 'Partial Corp' }); expect(cachedAfterPartial.id).toBe('company-partial'); - expect((cachedAfterPartial as any).traits).toEqual([{ key: 'tier', value: 'enterprise' }]); - expect((cachedAfterPartial as any).plan_ids).toEqual(['plan-2']); - // Original fields not present in the partial message are preserved + expect((cachedAfterPartial as any).traits).toEqual([{ value: 'enterprise' }]); + expect((cachedAfterPartial as any).planIds).toEqual(['plan-2']); expect((cachedAfterPartial as any).metrics).toEqual([]); expect((cachedAfterPartial as any).rules).toEqual([]); - expect((cachedAfterPartial as any).account_id).toBe('account-123'); - expect((cachedAfterPartial as any).billing_product_ids).toEqual([]); + expect((cachedAfterPartial as any).accountId).toBe('account-123'); + expect((cachedAfterPartial as any).billingProductIds).toEqual([]); }, 10000); test('should skip partial company message when entity is not in cache', async () => { @@ -561,9 +592,9 @@ describe('DataStreamClient', () => { const cachedUser = await client.getUser(mockUser.keys!); const cachedFlag = await client.getFlag(mockFlag.key); - expect(cachedCompany).toEqual(mockCompany); - expect(cachedUser).toEqual(mockUser); - expect(cachedFlag).toEqual(mockFlag); + expect(cachedCompany).toEqual(asCompany(mockCompany)); + expect(cachedUser).toEqual(asUser(mockUser)); + expect(cachedFlag).toEqual(asFlag(mockFlag)); }); test('should handle error type messages from WebSocket', async () => { @@ -793,8 +824,8 @@ describe('DataStreamClient', () => { rules: [], metrics: [], plan_ids: [], + plan_version_ids: [], billing_product_ids: [], - crm_product_ids: [], credit_balances: {}, } as unknown as Schematic.RulesengineCompany; @@ -827,9 +858,9 @@ describe('DataStreamClient', () => { const bySlug = await client.getCompany({ slug: 'acme-corp' }); const byExtId = await client.getCompany({ external_id: 'ext-1' }); - expect(byName).toEqual(multiKeyCompany); - expect(bySlug).toEqual(multiKeyCompany); - expect(byExtId).toEqual(multiKeyCompany); + expect(byName).toEqual(asCompany(multiKeyCompany)); + expect(bySlug).toEqual(asCompany(multiKeyCompany)); + expect(byExtId).toEqual(asCompany(multiKeyCompany)); }); test('should retrieve user by any of its keys after caching', async () => { @@ -842,8 +873,8 @@ describe('DataStreamClient', () => { const byEmail = await client.getUser({ email: 'alice@example.com' }); const byUserId = await client.getUser({ user_id: 'u-1' }); - expect(byEmail).toEqual(multiKeyUser); - expect(byUserId).toEqual(multiKeyUser); + expect(byEmail).toEqual(asUser(multiKeyUser)); + expect(byUserId).toEqual(asUser(multiKeyUser)); }); test('should remove company from cache on DELETE for all keys', async () => { @@ -859,7 +890,7 @@ describe('DataStreamClient', () => { // Verify it's cached — returns from cache without sending a WS request mockDatastreamWSClientInstance.sendMessage.mockClear(); const cached = await client.getCompany({ name: 'acme' }); - expect(cached).toEqual(multiKeyCompany); + expect(cached).toEqual(asCompany(multiKeyCompany)); expect(mockDatastreamWSClientInstance.sendMessage).not.toHaveBeenCalled(); // Send DELETE @@ -904,7 +935,7 @@ describe('DataStreamClient', () => { // Verify it's cached — returns from cache without sending a WS request mockDatastreamWSClientInstance.sendMessage.mockClear(); const cached = await client.getUser({ email: 'alice@example.com' }); - expect(cached).toEqual(multiKeyUser); + expect(cached).toEqual(asUser(multiKeyUser)); expect(mockDatastreamWSClientInstance.sendMessage).not.toHaveBeenCalled(); // Send DELETE @@ -945,7 +976,7 @@ describe('DataStreamClient', () => { // Send updated company with same keys but different data const updatedCompany = { ...multiKeyCompany, - traits: [{ key: 'tier', value: 'enterprise' }], + traits: [{ value: 'enterprise' }], } as unknown as Schematic.RulesengineCompany; await messageHandler({ @@ -958,8 +989,8 @@ describe('DataStreamClient', () => { const byName = await client.getCompany({ name: 'acme' }); const bySlug = await client.getCompany({ slug: 'acme-corp' }); - expect(byName).toEqual(updatedCompany); - expect(bySlug).toEqual(updatedCompany); + expect(byName).toEqual(asCompany(updatedCompany)); + expect(bySlug).toEqual(asCompany(updatedCompany)); }); test('should handle deep copy to prevent mutation of cached entities', async () => { @@ -972,7 +1003,7 @@ describe('DataStreamClient', () => { // Retrieve the company from cache const firstRetrieval = await client.getCompany({ name: 'acme' }); - expect(firstRetrieval).toEqual(multiKeyCompany); + expect(firstRetrieval).toEqual(asCompany(multiKeyCompany)); // Mutate a field on the returned object (firstRetrieval as any).traits = [{ key: 'mutated', value: 'yes' }]; @@ -999,9 +1030,9 @@ describe('DataStreamClient', () => { }); // Verify all three keys resolve from cache - expect(await client.getCompany({ name: 'acme' })).toEqual(multiKeyCompany); - expect(await client.getCompany({ slug: 'acme-corp' })).toEqual(multiKeyCompany); - expect(await client.getCompany({ external_id: 'ext-1' })).toEqual(multiKeyCompany); + expect(await client.getCompany({ name: 'acme' })).toEqual(asCompany(multiKeyCompany)); + expect(await client.getCompany({ slug: 'acme-corp' })).toEqual(asCompany(multiKeyCompany)); + expect(await client.getCompany({ external_id: 'ext-1' })).toEqual(asCompany(multiKeyCompany)); // Update with only two keys — external_id has been removed const updatedCompany = { @@ -1016,8 +1047,8 @@ describe('DataStreamClient', () => { }); // Remaining keys should still resolve from cache - expect(await client.getCompany({ name: 'acme' })).toEqual(updatedCompany); - expect(await client.getCompany({ slug: 'acme-corp' })).toEqual(updatedCompany); + expect(await client.getCompany({ name: 'acme' })).toEqual(asCompany(updatedCompany)); + expect(await client.getCompany({ slug: 'acme-corp' })).toEqual(asCompany(updatedCompany)); // Removed key should miss cache and trigger a WS request mockDatastreamWSClientInstance.sendMessage.mockClear(); @@ -1045,8 +1076,8 @@ describe('DataStreamClient', () => { }); // Verify both keys resolve from cache - expect(await client.getUser({ email: 'alice@example.com' })).toEqual(multiKeyUser); - expect(await client.getUser({ user_id: 'u-1' })).toEqual(multiKeyUser); + expect(await client.getUser({ email: 'alice@example.com' })).toEqual(asUser(multiKeyUser)); + expect(await client.getUser({ user_id: 'u-1' })).toEqual(asUser(multiKeyUser)); // Update with only email — user_id has been removed const updatedUser = { @@ -1061,7 +1092,7 @@ describe('DataStreamClient', () => { }); // Remaining key should still resolve from cache - expect(await client.getUser({ email: 'alice@example.com' })).toEqual(updatedUser); + expect(await client.getUser({ email: 'alice@example.com' })).toEqual(asUser(updatedUser)); // Removed key should miss cache and trigger a WS request mockDatastreamWSClientInstance.sendMessage.mockClear(); @@ -1088,7 +1119,7 @@ describe('DataStreamClient', () => { data: multiKeyCompany, }); - expect(await client.getCompany({ slug: 'acme-corp' })).toEqual(multiKeyCompany); + expect(await client.getCompany({ slug: 'acme-corp' })).toEqual(asCompany(multiKeyCompany)); // Update: slug value changed from 'acme-corp' to 'acme-inc' const updatedCompany = { @@ -1103,7 +1134,7 @@ describe('DataStreamClient', () => { }); // New slug should resolve from cache - expect(await client.getCompany({ slug: 'acme-inc' })).toEqual(updatedCompany); + expect(await client.getCompany({ slug: 'acme-inc' })).toEqual(asCompany(updatedCompany)); // Old slug value should miss cache and trigger a WS request mockDatastreamWSClientInstance.sendMessage.mockClear(); @@ -1124,7 +1155,16 @@ describe('DataStreamClient', () => { const companyWithMetrics = { ...multiKeyCompany, metrics: [ - { eventSubtype: 'api-call', value: 10 }, + { + account_id: 'account-123', + company_id: 'company-multi', + created_at: '2026-01-01T00:00:00Z', + environment_id: 'env-123', + event_subtype: 'api-call', + month_reset: 'first_of_month', + period: 'all_time', + value: 10, + }, ], } as unknown as Schematic.RulesengineCompany; @@ -1156,8 +1196,8 @@ describe('DataStreamClient', () => { rules: [], metrics: [], plan_ids: [], + plan_version_ids: [], billing_product_ids: [], - crm_product_ids: [], credit_balances: {}, } as unknown as Schematic.RulesengineCompany; diff --git a/tests/unit/datastream/merge.test.ts b/tests/unit/datastream/merge.test.ts index 8b9216b4..8208e255 100644 --- a/tests/unit/datastream/merge.test.ts +++ b/tests/unit/datastream/merge.test.ts @@ -6,23 +6,25 @@ import { deepCopyUser, } from '../../../src/datastream/merge'; -// Helper: base company in snake_case wire format (matches WebSocket data) +// Helper: base company in camelCase (matches the cached, parseOrThrow-normalized +// shape that partialCompany sees in production). Partial payloads arrive in +// snake_case from the wire; the merge function canonicalizes to camelCase. function baseCompany(): Schematic.RulesengineCompany { return { id: 'co-1', - account_id: 'acc-1', - environment_id: 'env-1', - base_plan_id: 'plan-1', - billing_product_ids: ['bp-1'], - credit_balances: { 'credit-1': 100.0 }, + accountId: 'acc-1', + environmentId: 'env-1', + basePlanId: 'plan-1', + billingProductIds: ['bp-1'], + creditBalances: { 'credit-1': 100.0 }, keys: { domain: 'example.com' }, - plan_ids: ['plan-1'], - plan_version_ids: ['pv-1'], + planIds: ['plan-1'], + planVersionIds: ['pv-1'], traits: [ - { value: 'Enterprise', trait_definition: { id: 'plan', comparable_type: 'string', entity_type: 'company' } }, + { value: 'Enterprise', traitDefinition: { id: 'plan', comparableType: 'string', entityType: 'company' } }, ], entitlements: [ - { feature_id: 'feat-1', feature_key: 'feature-one', value_type: 'boolean' }, + { featureId: 'feat-1', featureKey: 'feature-one', valueType: 'boolean' }, ], metrics: [], rules: [], @@ -32,11 +34,11 @@ function baseCompany(): Schematic.RulesengineCompany { function baseUser(): Schematic.RulesengineUser { return { id: 'user-1', - account_id: 'acc-1', - environment_id: 'env-1', + accountId: 'acc-1', + environmentId: 'env-1', keys: { email: 'user@example.com' }, traits: [ - { value: 'Premium', trait_definition: { id: 'tier', comparable_type: 'string', entity_type: 'user' } }, + { value: 'Premium', traitDefinition: { id: 'tier', comparableType: 'string', entityType: 'user' } }, ], rules: [], } as unknown as Schematic.RulesengineUser; @@ -73,11 +75,11 @@ describe('partialCompany', () => { expect((m.traits as Record[])[0].value).toBe('Startup'); // Other fields preserved - expect(m.account_id).toBe('acc-1'); - expect(m.environment_id).toBe('env-1'); + expect(m.accountId).toBe('acc-1'); + expect(m.environmentId).toBe('env-1'); expect(m.keys).toEqual({ domain: 'example.com' }); - expect(m.billing_product_ids).toEqual(['bp-1']); - expect(m.base_plan_id).toBe('plan-1'); + expect(m.billingProductIds).toEqual(['bp-1']); + expect(m.basePlanId).toBe('plan-1'); }); test('merges keys - new key added, existing preserved', () => { @@ -98,7 +100,7 @@ describe('partialCompany', () => { const merged = partialCompany(existing, partial); const m = merged as unknown as Record; - expect(m.credit_balances).toEqual({ 'credit-1': 100.0, 'credit-2': 200.0 }); + expect(m.creditBalances).toEqual({ 'credit-1': 100.0, 'credit-2': 200.0 }); }); test('overwrites credit balance', () => { @@ -108,7 +110,7 @@ describe('partialCompany', () => { const merged = partialCompany(existing, partial); const m = merged as unknown as Record; - expect(m.credit_balances).toEqual({ 'credit-1': 50.0 }); + expect(m.creditBalances).toEqual({ 'credit-1': 50.0 }); }); test('upserts metrics - updates existing, appends new', () => { @@ -169,7 +171,7 @@ describe('partialCompany', () => { const m = merged as unknown as Record; expect(m.entitlements).toEqual([]); - expect(m.account_id).toBe('acc-1'); + expect(m.accountId).toBe('acc-1'); }); test('null base_plan_id sets to null', () => { @@ -179,8 +181,8 @@ describe('partialCompany', () => { const merged = partialCompany(existing, partial); const m = merged as unknown as Record; - expect(m.base_plan_id).toBeNull(); - expect(m.billing_product_ids).toEqual(['bp-1']); + expect(m.basePlanId).toBeNull(); + expect(m.billingProductIds).toEqual(['bp-1']); }); test('tolerates missing id - cache lookup uses envelope entity_id', () => { @@ -193,7 +195,7 @@ describe('partialCompany', () => { const merged = partialCompany(existing, partial); const m = merged as unknown as Record; - expect(m.credit_balances).toEqual({ 'credit-1': 100.0, 'credit-2': 200.0 }); + expect(m.creditBalances).toEqual({ 'credit-1': 100.0, 'credit-2': 200.0 }); expect(m.id).toBe('co-1'); }); @@ -277,13 +279,13 @@ describe('partialCompany', () => { const m = merged as unknown as Record; expect(m.id).toBe('co-1'); - expect(m.account_id).toBe('acc-2'); - expect(m.environment_id).toBe('env-2'); - expect(m.base_plan_id).toBe('plan-99'); - expect(m.billing_product_ids).toEqual(['bp-10', 'bp-20']); + expect(m.accountId).toBe('acc-2'); + expect(m.environmentId).toBe('env-2'); + expect(m.basePlanId).toBe('plan-99'); + expect(m.billingProductIds).toEqual(['bp-10', 'bp-20']); // Credit balances merge: credit-1 overwritten, credit-new added - expect(m.credit_balances).toEqual({ 'credit-1': 999.0, 'credit-new': 50.0 }); + expect(m.creditBalances).toEqual({ 'credit-1': 999.0, 'credit-new': 50.0 }); const entitlements = m.entitlements as Record[]; expect(entitlements.length).toBe(2); @@ -301,8 +303,8 @@ describe('partialCompany', () => { expect(metrics[1].event_subtype).toBe('event-new'); expect(metrics[1].value).toBe(7); - expect(m.plan_ids).toEqual(['plan-99', 'plan-100']); - expect(m.plan_version_ids).toEqual(['pv-99']); + expect(m.planIds).toEqual(['plan-99', 'plan-100']); + expect(m.planVersionIds).toEqual(['pv-99']); const rules = m.rules as Record[]; expect(rules.length).toBe(2); @@ -318,8 +320,8 @@ describe('partialCompany', () => { // Original not mutated const orig = existing as unknown as Record; - expect(orig.account_id).toBe('acc-1'); - expect(orig.base_plan_id).toBe('plan-1'); + expect(orig.accountId).toBe('acc-1'); + expect(orig.basePlanId).toBe('plan-1'); expect(orig.keys).toEqual({ domain: 'example.com' }); expect((orig.metrics as Record[])[0].value).toBe(10); }); @@ -422,12 +424,12 @@ describe('deepCopyCompany', () => { const origRaw = orig as unknown as Record; origRaw.metrics = [ { - account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', - event_subtype: 'event-1', period: 'all_time', month_reset: 'first_of_month', - value: 42, created_at: '2026-01-01T00:00:00Z', + accountId: 'acc-1', environmentId: 'env-1', companyId: 'co-1', + eventSubtype: 'event-1', period: 'all_time', monthReset: 'first_of_month', + value: 42, createdAt: '2026-01-01T00:00:00Z', }, ]; - origRaw.subscription = { id: 'sub-1', period_start: '2026-01-01T00:00:00Z', period_end: '2027-01-01T00:00:00Z' }; + origRaw.subscription = { id: 'sub-1', periodStart: '2026-01-01T00:00:00Z', periodEnd: '2027-01-01T00:00:00Z' }; const cp = deepCopyCompany(orig); const cpRaw = cp as unknown as Record; @@ -437,8 +439,8 @@ describe('deepCopyCompany', () => { expect((origRaw.keys as Record).domain).toBe('example.com'); // Credit balances are independent - (cpRaw.credit_balances as Record)['credit-1'] = 999; - expect((origRaw.credit_balances as Record)['credit-1']).toBe(100.0); + (cpRaw.creditBalances as Record)['credit-1'] = 999; + expect((origRaw.creditBalances as Record)['credit-1']).toBe(100.0); // Metrics are independent ((cpRaw.metrics as Record[])[0]).value = 999; @@ -460,8 +462,8 @@ describe('deepCopyUser', () => { test('empty fields - user with only required fields', () => { const cp = deepCopyUser({ id: 'u1', - account_id: 'acc-1', - environment_id: 'env-1', + accountId: 'acc-1', + environmentId: 'env-1', keys: {}, traits: [], rules: [], diff --git a/tests/unit/wrapper.test.ts b/tests/unit/wrapper.test.ts index 3cbc0bee..b28a537c 100644 --- a/tests/unit/wrapper.test.ts +++ b/tests/unit/wrapper.test.ts @@ -23,6 +23,7 @@ jest.mock("../../src/events", () => { return { EventBuffer: jest.fn().mockImplementation(() => ({ push: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined), stop: jest.fn().mockResolvedValue(undefined), })), }; @@ -188,4 +189,64 @@ describe("SchematicClient wrapper - flag checking behavior", () => { await client.close(); }); }); + + describe("identify with prewarm", () => { + it("forwards prewarm credit type ids to client.prewarm and flushes the buffer", async () => { + const client = new SchematicClient({ + apiKey: "test-api-key", + cacheProviders: { flagChecks: [] }, + logger: mockLogger, + }); + const prewarmSpy = jest + .spyOn(client, "prewarm") + .mockResolvedValue(undefined); + // Reach into the buffer mock to verify flush is triggered so the + // server picks up the identify event before prewarm starts polling. + // biome-ignore lint/suspicious/noExplicitAny: introspect mock + const flushMock = (client as any).eventBuffer.flush as jest.Mock; + + await client.identify( + { + keys: { id: "user-1" }, + company: { keys: { id: "comp-1" } }, + }, + { prewarm: ["credit-type-1", "credit-type-2"] }, + ); + + // Yield once so the fire-and-forget prewarm resolves. + await new Promise((r) => setImmediate(r)); + + expect(flushMock).toHaveBeenCalledTimes(1); + expect(prewarmSpy).toHaveBeenCalledWith( + { company: { id: "comp-1" }, user: { id: "user-1" } }, + ["credit-type-1", "credit-type-2"], + ); + + await client.close(); + }); + + it("does not call prewarm or flush when options.prewarm is omitted", async () => { + const client = new SchematicClient({ + apiKey: "test-api-key", + cacheProviders: { flagChecks: [] }, + logger: mockLogger, + }); + const prewarmSpy = jest + .spyOn(client, "prewarm") + .mockResolvedValue(undefined); + // biome-ignore lint/suspicious/noExplicitAny: introspect mock + const flushMock = (client as any).eventBuffer.flush as jest.Mock; + + await client.identify({ + keys: { id: "user-1" }, + company: { keys: { id: "comp-1" } }, + }); + + await new Promise((r) => setImmediate(r)); + expect(prewarmSpy).not.toHaveBeenCalled(); + expect(flushMock).not.toHaveBeenCalled(); + + await client.close(); + }); + }); }); \ No newline at end of file