diff --git a/.github/workflows/typescript.yaml b/.github/workflows/typescript.yaml new file mode 100644 index 0000000..8792c1c --- /dev/null +++ b/.github/workflows/typescript.yaml @@ -0,0 +1,34 @@ +name: typescript + +on: + push: + pull_request: + +jobs: + gcache-ts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm ts:gcache:typecheck + - run: pnpm ts:gcache:test + # Dependabot-triggered runs cannot access repository secrets, so this + # upload would fail with an empty Codecov token. + - name: Upload TypeScript Coverage Reports + if: ${{ github.actor != 'dependabot[bot]' }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/gcache-ts/coverage/lcov.info + flags: gcache-ts + name: gcache-ts + fail_ci_if_error: true + verbose: true + - run: pnpm ts:gcache:build diff --git a/.gitignore b/.gitignore index 4b069eb..d4f542e 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,8 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Node / TypeScript +node_modules/ +packages/*/dist/ +packages/*/coverage/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..074fd43 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "gcache-monorepo", + "private": true, + "packageManager": "pnpm@10.33.0", + "workspaces": [ + "packages/*" + ], + "scripts": { + "ts:gcache:build": "pnpm --filter @rungalileo/gcache build", + "ts:gcache:test": "pnpm --filter @rungalileo/gcache test", + "ts:gcache:typecheck": "pnpm --filter @rungalileo/gcache typecheck" + } +} diff --git a/packages/gcache-ts/README.md b/packages/gcache-ts/README.md new file mode 100644 index 0000000..8800a64 --- /dev/null +++ b/packages/gcache-ts/README.md @@ -0,0 +1,247 @@ +# @rungalileo/gcache + +TypeScript port of GCache. Milestone 5 ships explicit enabled contexts, stable key construction, local/Redis TTL caching, runtime config providers, gradual rollout ramp controls, Prometheus-ready observability, and Redis watermark-based targeted invalidation with fail-open behavior. + +## Install + +```bash +pnpm add @rungalileo/gcache +``` + +## Quick start + +```ts +import { GCache, GCacheKeyConfig } from "@rungalileo/gcache"; + +const gcache = new GCache(); + +const getUser = gcache.cached({ + keyType: "user_id", + useCase: "GetUser", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), +})(async (userId: string) => { + return db.fetchUser(userId); +}); + +// Caching is disabled by default. +await getUser("123"); + +// Enable caching for one async scope. +const user = await gcache.enable(async () => { + return await getUser("123"); +}); +``` + +## Redis-backed TTL cache + +Pass a small Redis command-surface client, or a lazy factory, to enable the read-through chain: + +```ts +import { GCache, GCacheKeyConfig } from "@rungalileo/gcache"; + +const gcache = new GCache({ + redis: { + client: redisClient, // implements get, del, flushAll/flushall, and setEx/setex/set({ EX }) + keyPrefix: "gcache:", + }, +}); +``` + +When caching is enabled, reads flow through: + +```text +local cache -> Redis cache -> fallback function +``` + +- Local hits return immediately. +- Local misses try Redis and populate local on a Redis hit. +- Redis misses call the fallback and write both Redis and local. +- Redis read/write/delete/flush failures are logged, counted in metrics, and fail open; fallback results still return when fallback succeeds. +- Missing per-layer config disables that layer, records a disabled reason, and falls through to the next layer/fallback. + +You can also provide `createClient` for lazy client construction: + +```ts +const gcache = new GCache({ + redis: { + createClient: async () => createRedisClient({ url: process.env.REDIS_URL }), + }, +}); +``` + +Redis payloads use a TypeScript-specific JSON envelope, not the Python pickle format: + +```ts +type RedisValueEnvelope = { + version: 1; + createdAtMs: number; + expiresAtMs: number; + encoding: "utf8" | "base64"; + payload: string; +}; +``` + +`payload` is produced by the cached function's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; Buffer payloads are base64 encoded in the envelope. + +## Targeted invalidation and watermarks + +Mutable Redis-backed use cases can opt into targeted invalidation by setting `trackForInvalidation: true` on the cached function and calling `invalidate(keyType, id)` after writes: + +```ts +import { CacheLayer, GCache, GCacheKeyConfig } from "@rungalileo/gcache"; + +const gcache = new GCache({ redis: { client: redisClient } }); + +const getUser = gcache.cached({ + keyType: "user_id", + useCase: "GetMutableUser", + id: ([userId]: [string]) => userId, + trackForInvalidation: true, + // Strongly invalidated mutable data should usually disable local cache. + defaultConfig: new GCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 300 }, + ramp: { [CacheLayer.REMOTE]: 100 }, + }), +})(async (userId: string) => db.fetchUser(userId)); + +await updateUser("123", patch); +await gcache.invalidate("user_id", "123"); +``` + +Invalidation writes a Redis watermark at `{encodedUrnPrefix:encodedKeyType:encodedId}#watermark`. Tracked Redis cache entries use the same Redis Cluster hash tag, for example `{urn:user_id:123}?locale=en#GetMutableUser`, so the value key and watermark key live in the same slot. Key components are percent-encoded before joining so delimiters inside IDs or args cannot collide with delimiters in the key format. Components may not contain `{` or `}` because those characters would corrupt the hash tag. + +A cached Redis value whose `createdAtMs` is older than or equal to the watermark is treated as stale and refreshed through fallback. `invalidate(keyType, id, { futureBufferMs })` can extend the watermark into the future during write races; while the watermark is still in the future, fallback results are returned but not written to Redis or local cache. + +Watermarks use `DEFAULT_WATERMARK_TTL_SEC` (4 hours) by default. You can override it with `redis.watermarkTtlSec`, but it must exceed the maximum Redis cache TTL for invalidation-tracked data; otherwise a watermark can expire before old cached values do. + +Local cache limitation: targeted invalidation is enforced by Redis watermarks. Existing local cache hits are not synchronously invalidated across processes, so strongly invalidated mutable data should disable the local layer (or use very short local TTLs only when stale reads are acceptable). + +## Runtime config and ramp controls + +Every cached function can provide a decorator-local `defaultConfig`; a `cacheConfigProvider` can override it at runtime. If the provider returns `null`, GCache falls back to the cached function's `defaultConfig`. If neither exists, or a layer's TTL/ramp is missing or disabled, only that layer is skipped. + +```ts +import { CacheLayer, GCache, GCacheKeyConfig } from "@rungalileo/gcache"; + +const gcache = new GCache({ + cacheConfigProvider: async (key) => { + if (key.useCase === "GetUser") { + return new GCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 30, [CacheLayer.REMOTE]: 300 }, + ramp: { [CacheLayer.LOCAL]: 100, [CacheLayer.REMOTE]: 25 }, + }); + } + return null; // use the cached function's defaultConfig + }, + rampSampler: ({ key, layer }) => deterministicPercentFor(`${key.urn}:${layer}`), +}); +``` + +`ramp` values are percentages from 0 to 100. `0` disables the layer, `100` enables it, and intermediate values use `rampSampler`; the default sampler is random. Provider errors fail open and execute the fallback function. + +## Enabled context + +The TypeScript port uses Node `AsyncLocalStorage` to mirror Python's `with gcache.enable():` safety model. + +```ts +await gcache.enable(async () => { + await getUser("123"); // cached + + await gcache.disable(async () => { + await updateUser("123", patch); // uncached reads here + }); + + await getUser("123"); // cached again +}); +``` + +- Default is disabled. +- Enabled state is async-scope-local, not process-global. +- Nested `enable` / `disable` scopes restore the previous behavior when the callback completes. + +## Explicit key builders + +TypeScript does not have safe Python-style function argument introspection after transpilation/bundling. Use explicit key builders instead: + +```ts +const searchPosts = gcache.cached({ + keyType: "user_id", + useCase: "SearchPosts", + id: ([userId]: [string, number, string]) => userId, + args: ([, page, filter]) => ({ page, filter }), + defaultConfig: GCacheKeyConfig.enabled(60), +})(async (userId: string, page: number, filter: string) => { + return db.searchPosts(userId, page, filter); +}); +``` + +## Metrics + +GCache registers Prometheus metrics by default via `prom-client`. Metric names intentionally follow the Python package where practical: + +| Metric | Type | Labels | Description | +| --- | --- | --- | --- | +| `gcache_request_counter` | Counter | `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | +| `gcache_miss_counter` | Counter | `use_case`, `key_type`, `layer` | Cache misses | +| `gcache_disabled_counter` | Counter | `use_case`, `key_type`, `layer`, `reason` | Cache skips (`context`, `missing_config`, `invalid_ttl`, `ramped_down`, `config_error`) | +| `gcache_error_counter` | Counter | `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors, with `in_fallback` separating cache plumbing failures from application fallback failures | +| `gcache_invalidation_counter` | Counter | `key_type`, `layer` | Delete/invalidation calls for the layers touched today | +| `gcache_get_timer` | Histogram | `use_case`, `key_type`, `layer` | Cache get latency in seconds | +| `gcache_fallback_timer` | Histogram | `use_case`, `key_type`, `layer` | Time spent in the underlying function | +| `gcache_serialization_timer` | Histogram | `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency | +| `gcache_size_histogram` | Histogram | `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes | + +Use a custom registry or prefix when embedding GCache in an app with its own metrics endpoint: + +```ts +import { Registry } from "prom-client"; +import { GCache } from "@rungalileo/gcache"; + +const registry = new Registry(); +const gcache = new GCache({ + metricsRegistry: registry, + metricsPrefix: "myapp_", // myapp_gcache_request_counter, etc. +}); + +app.get("/metrics", async (_req, res) => { + res.type(registry.contentType).send(await registry.metrics()); +}); +``` + +For non-Prometheus telemetry, inject a `GCacheMetricsAdapter` through `new GCache({ metrics })`. Pass `metrics: false` to disable metrics entirely. GCache reuses existing collectors in a registry so repeated instances with the same prefix do not throw duplicate-registration errors. + +## Milestone 5 scope + +Included: + +- Local TTL cache +- Redis TTL cache +- Local → Redis → fallback read-through chain +- Lazy Redis client factory support +- Timestamped, versioned Redis envelope +- JSON and custom serializer support for Redis values +- Duplicate and reserved use-case validation +- `delete` and `flushAll` across configured layers +- Fail-open behavior for key/config/cache errors +- Runtime config provider with fallback to cached-function `defaultConfig` +- Per-layer TTL and ramp controls +- Injectable ramp sampler for deterministic rollout tests +- Missing config disables only the relevant layer and falls through +- Prometheus metrics with duplicate-registration safety +- Custom metrics adapter/registry/prefix hooks +- Cache-vs-fallback error classification through the `in_fallback` label +- Serialization latency and cached payload size metrics for Redis values +- Logger injection for cache operational failures +- `trackForInvalidation` on cached functions +- `invalidate(keyType, id, { futureBufferMs })` Redis watermark API +- Redis Cluster hash-tagged value/watermark keys for invalidation-tracked entries +- Configurable Redis watermark TTL via `redis.watermarkTtlSec` with `DEFAULT_WATERMARK_TTL_SEC` +- Future-buffer behavior that avoids cache writes during active invalidation windows + +Not included yet: + +- Framework middleware helpers/integrations +- `cachedObject` +- Expanded examples +- Release hardening diff --git a/packages/gcache-ts/package.json b/packages/gcache-ts/package.json new file mode 100644 index 0000000..f5b03be --- /dev/null +++ b/packages/gcache-ts/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rungalileo/gcache", + "version": "0.1.0", + "description": "TypeScript port of GCache with explicit-context local and Redis TTL caching.", + "license": "MIT", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --clean", + "typecheck": "tsc --noEmit", + "test": "vitest run --coverage", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@vitest/coverage-v8": "^4.0.14", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.14" + }, + "engines": { + "node": ">=18.17" + }, + "dependencies": { + "prom-client": "^15.1.3" + } +} diff --git a/packages/gcache-ts/src/config.ts b/packages/gcache-ts/src/config.ts new file mode 100644 index 0000000..487b5fe --- /dev/null +++ b/packages/gcache-ts/src/config.ts @@ -0,0 +1,74 @@ +import type { Registry } from "prom-client"; + +import type { GCacheKey } from "./key.js"; +import type { GCacheMetricsAdapter } from "./metrics.js"; +import type { RedisConfig } from "./internal/redis-cache.js"; + +export enum CacheLayer { + NOOP = "noop", + LOCAL = "local", + REMOTE = "remote", +} + +export type Awaitable = T | Promise; +export type LayerConfig = Partial>; + +export interface CacheRampSample { + readonly key: GCacheKey; + readonly layer: CacheLayer; + readonly ramp: number; +} + +export type CacheRampSampler = (sample: CacheRampSample) => Awaitable; + +export const randomRampSampler: CacheRampSampler = () => Math.random() * 100; + +// Watermark TTL must be longer than the longest Redis TTL used by any +// invalidation-tracked cached function. Otherwise the watermark can expire +// before older cached values do, allowing stale values to become readable again. +export const DEFAULT_WATERMARK_TTL_SEC = 3600 * 4; + +export class GCacheKeyConfig { + readonly ttlSec: LayerConfig; + readonly ramp: LayerConfig; + + constructor(config: { ttlSec: LayerConfig; ramp: LayerConfig }) { + this.ttlSec = { ...config.ttlSec }; + this.ramp = { ...config.ramp }; + } + + static enabled(ttlSec: number): GCacheKeyConfig { + return new GCacheKeyConfig({ + ttlSec: { + [CacheLayer.LOCAL]: ttlSec, + [CacheLayer.REMOTE]: ttlSec, + [CacheLayer.NOOP]: ttlSec, + }, + ramp: { + [CacheLayer.LOCAL]: 100, + [CacheLayer.REMOTE]: 100, + [CacheLayer.NOOP]: 100, + }, + }); + } +} + +export type CacheConfigProvider = (key: GCacheKey) => Promise; + +export type Logger = Pick; + +export interface InvalidateOptions { + readonly futureBufferMs?: number; +} + +export interface GCacheConfig { + readonly cacheConfigProvider?: CacheConfigProvider; + readonly urnPrefix?: string; + readonly logger?: Logger; + readonly localMaxSize?: number; + readonly redis?: RedisConfig; + readonly rampSampler?: CacheRampSampler; + readonly metrics?: GCacheMetricsAdapter | false; + readonly metricsPrefix?: string; + readonly metricsRegistry?: Registry; +} diff --git a/packages/gcache-ts/src/context.ts b/packages/gcache-ts/src/context.ts new file mode 100644 index 0000000..27b36aa --- /dev/null +++ b/packages/gcache-ts/src/context.ts @@ -0,0 +1,21 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export class GCacheContext { + private readonly storage = new AsyncLocalStorage(); + + isEnabled(): boolean { + return this.storage.getStore() ?? false; + } + + enable(fn: () => T | Promise): Promise { + return this.run(true, fn); + } + + disable(fn: () => T | Promise): Promise { + return this.run(false, fn); + } + + private async run(enabled: boolean, fn: () => T | Promise): Promise { + return await this.storage.run(enabled, async () => await fn()); + } +} diff --git a/packages/gcache-ts/src/errors.ts b/packages/gcache-ts/src/errors.ts new file mode 100644 index 0000000..42e37ea --- /dev/null +++ b/packages/gcache-ts/src/errors.ts @@ -0,0 +1,24 @@ +export class GCacheError extends Error { + constructor(message: string) { + super(message); + this.name = new.target.name; + } +} + +export class UseCaseIsAlreadyRegisteredError extends GCacheError { + constructor(useCase: string) { + super(`Use case already registered: ${useCase}`); + } +} + +export class UseCaseNameIsReservedError extends GCacheError { + constructor(useCase: string) { + super(`Use case name is reserved: ${useCase}`); + } +} + +export class MissingKeyConfigError extends GCacheError { + constructor(useCase: string) { + super(`Missing key config for use case: ${useCase}`); + } +} diff --git a/packages/gcache-ts/src/gcache.ts b/packages/gcache-ts/src/gcache.ts new file mode 100644 index 0000000..d3c8321 --- /dev/null +++ b/packages/gcache-ts/src/gcache.ts @@ -0,0 +1,356 @@ +import { performance } from "node:perf_hooks"; + +import { CacheLayer, GCacheConfig, randomRampSampler, type CacheConfigProvider, type CacheRampSampler, type InvalidateOptions, type Logger } from "./config.js"; +import { GCacheContext } from "./context.js"; +import { UseCaseIsAlreadyRegisteredError, UseCaseNameIsReservedError } from "./errors.js"; +import { GCacheKey, normalizeArgs } from "./key.js"; +import { createPrometheusGCacheMetrics, errorName, labelsFor, type CacheMetricLabels, type GCacheMetricsAdapter } from "./metrics.js"; +import type { Serializer } from "./serializer.js"; +import { LocalCache } from "./internal/local-cache.js"; +import { RedisCache } from "./internal/redis-cache.js"; + +type Awaitable = T | Promise; +type CacheableArgs = readonly unknown[]; +type CacheArgs = Record; + +export interface CachedOptions { + readonly keyType: string; + readonly useCase: string; + readonly id: (args: Args) => string | number | bigint; + readonly args?: (args: Args) => CacheArgs; + readonly defaultConfig?: import("./config.js").GCacheKeyConfig | null; + readonly serializer?: Serializer | null; + readonly trackForInvalidation?: boolean; +} + +const DEFAULT_LOCAL_MAX_SIZE = 10_000; +const defaultConfigProvider: CacheConfigProvider = async () => null; +const defaultLogger: Logger = console; + +export class GCache { + private readonly context = new GCacheContext(); + private readonly localCache: LocalCache; + private readonly useCases = new Set(); + private readonly configProvider: CacheConfigProvider; + private readonly urnPrefix: string; + private readonly logger: Logger; + private readonly rampSampler: CacheRampSampler; + private readonly redisCache: RedisCache | null; + private readonly metrics: GCacheMetricsAdapter | null; + + constructor(config: GCacheConfig = {}) { + this.configProvider = config.cacheConfigProvider ?? defaultConfigProvider; + this.urnPrefix = config.urnPrefix ?? "urn"; + this.logger = config.logger ?? defaultLogger; + this.rampSampler = config.rampSampler ?? randomRampSampler; + const metrics = + config.metrics === false + ? null + : config.metrics ?? + createPrometheusGCacheMetrics({ + prefix: config.metricsPrefix ?? "", + ...(config.metricsRegistry === undefined ? {} : { registry: config.metricsRegistry }), + }); + this.metrics = safeMetrics(metrics); + this.localCache = new LocalCache(this.configProvider, this.rampSampler, config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE); + this.redisCache = + config.redis === undefined + ? null + : new RedisCache({ + configProvider: this.configProvider, + rampSampler: this.rampSampler, + redis: config.redis, + metrics: this.metrics, + }); + } + + enable(fn: () => Awaitable): Promise { + return this.context.enable(fn); + } + + disable(fn: () => Awaitable): Promise { + return this.context.disable(fn); + } + + withEnabled(fn: () => Awaitable): Promise { + return this.enable(fn); + } + + withDisabled(fn: () => Awaitable): Promise { + return this.disable(fn); + } + + isEnabled(): boolean { + return this.context.isEnabled(); + } + + cached( + options: CachedOptions, + ): (fn: (...args: Args) => Awaitable) => (...args: Args) => Promise { + this.registerUseCase(options.useCase); + + return (fn: (...args: Args) => Awaitable) => { + return async (...args: Args): Promise => { + if (!this.isEnabled()) { + this.metrics?.disabled({ + useCase: options.useCase, + keyType: options.keyType, + layer: "noop", + reason: "context", + }); + return await fn(...args); + } + + let key: GCacheKey; + try { + key = this.createKey(options, args); + } catch (error) { + this.logger.error("Could not construct GCache key", error); + this.metrics?.error({ + useCase: options.useCase, + keyType: options.keyType, + layer: "noop", + error: errorName(error), + inFallback: false, + }); + return await this.callFallback({ useCase: options.useCase, keyType: options.keyType, layer: "noop" }, async () => await fn(...args)); + } + + if (this.redisCache === null) { + return await this.getThroughLocalOnly(key, async () => await fn(...args)); + } + + return await this.getThroughRedisChain(key, async () => await fn(...args)); + }; + }; + } + + async delete(key: GCacheKey): Promise { + this.metrics?.invalidation({ keyType: key.keyType, layer: CacheLayer.LOCAL }); + const localDeleted = await this.localCache.delete(key); + if (this.redisCache === null) { + return localDeleted; + } + + this.metrics?.invalidation({ keyType: key.keyType, layer: CacheLayer.REMOTE }); + try { + return (await this.redisCache.delete(key)) || localDeleted; + } catch (error) { + this.logger.warn("Error deleting value from Redis cache", error); + this.recordError(key, CacheLayer.REMOTE, error, false); + return localDeleted; + } + } + + async invalidate(keyType: string, id: string | number | bigint, options: InvalidateOptions = {}): Promise { + if (this.redisCache === null) { + return; + } + + this.metrics?.invalidation({ keyType, layer: CacheLayer.REMOTE }); + try { + await this.redisCache.invalidate(keyType, String(id), options.futureBufferMs ?? 0, this.urnPrefix); + } catch (error) { + this.logger.warn("Error writing GCache invalidation watermark", error); + this.metrics?.error({ + useCase: "watermark", + keyType, + layer: CacheLayer.REMOTE, + error: errorName(error), + inFallback: false, + }); + } + } + + async flushAll(): Promise { + await this.localCache.flushAll(); + if (this.redisCache === null) { + return; + } + + try { + await this.redisCache.flushAll(); + } catch (error) { + this.logger.warn("Error flushing Redis cache", error); + this.metrics?.error({ + useCase: "flushAll", + keyType: "all", + layer: CacheLayer.REMOTE, + error: errorName(error), + inFallback: false, + }); + } + } + + private async getThroughLocalOnly(key: GCacheKey, fallback: () => Promise): Promise { + const local = await this.readLocal(key); + if (local.status === "hit") { + return local.value; + } + + const value = await this.callFallback(labelsFor(key, CacheLayer.LOCAL), fallback); + if (local.status === "miss") { + await this.putLocalFailOpen(key, value, local.config); + } + return value; + } + + private async getThroughRedisChain(key: GCacheKey, fallback: () => Promise): Promise { + const local = await this.readLocal(key); + if (local.status === "hit") { + return local.value; + } + + const remote = await this.readRemote(key); + if (remote.status === "hit") { + await this.putLocalFailOpen(key, remote.value, local.status === "miss" ? local.config : undefined); + return remote.value; + } + + const remoteErrored = remote.status === "disabled" && remote.reason === "config_error"; + const fallbackLayer = remote.status === "miss" || remoteErrored ? CacheLayer.REMOTE : CacheLayer.LOCAL; + const value = await this.callFallback(labelsFor(key, fallbackLayer), fallback); + const skipCacheWrite = (remote.status === "miss" || remote.status === "disabled") && remote.skipCacheWrite === true; + let suppressCacheWrite = skipCacheWrite; + if (!suppressCacheWrite && (remote.status === "miss" || remoteErrored)) { + try { + const wroteRemote = await this.redisCache?.put(key, value, remote.status === "miss" ? remote.config : undefined); + suppressCacheWrite = wroteRemote === false; + } catch (error) { + this.logger.warn("Error putting value in Redis cache", error); + this.recordError(key, CacheLayer.REMOTE, error, false); + suppressCacheWrite = key.trackForInvalidation; + } + } + if (!suppressCacheWrite) { + await this.putLocalFailOpen(key, value, local.status === "miss" ? local.config : undefined); + } + return value; + } + + private async readLocal(key: GCacheKey) { + const start = performance.now(); + try { + const result = await this.localCache.getIfPresentResult(key); + if (result.status === "disabled") { + this.metrics?.disabled({ ...labelsFor(key, CacheLayer.LOCAL), reason: result.reason }); + return result; + } + + this.metrics?.request(labelsFor(key, CacheLayer.LOCAL)); + this.metrics?.observeGet(labelsFor(key, CacheLayer.LOCAL), elapsedSeconds(start)); + if (result.status === "miss") { + this.metrics?.miss(labelsFor(key, CacheLayer.LOCAL)); + } + return result; + } catch (error) { + this.logger.error("Error getting value from local cache", error); + this.recordError(key, CacheLayer.LOCAL, error, false); + this.metrics?.disabled({ ...labelsFor(key, CacheLayer.LOCAL), reason: "config_error" }); + return { status: "disabled", reason: "config_error" } as const; + } + } + + private async readRemote(key: GCacheKey) { + const start = performance.now(); + try { + const result = await this.redisCache?.getResult(key); + if (result === undefined) { + return { status: "disabled", reason: "missing_config" } as const; + } + if (result.status === "disabled") { + this.metrics?.disabled({ ...labelsFor(key, CacheLayer.REMOTE), reason: result.reason }); + return result; + } + + this.metrics?.request(labelsFor(key, CacheLayer.REMOTE)); + this.metrics?.observeGet(labelsFor(key, CacheLayer.REMOTE), elapsedSeconds(start)); + if (result.status === "miss") { + this.metrics?.miss(labelsFor(key, CacheLayer.REMOTE)); + } + return result; + } catch (error) { + this.logger.warn("Error getting value from Redis cache", error); + this.recordError(key, CacheLayer.REMOTE, error, false); + return { status: "disabled", reason: "config_error", ...(key.trackForInvalidation ? { skipCacheWrite: true } : {}) } as const; + } + } + + private async putLocalFailOpen(key: GCacheKey, value: T, config?: { readonly ttlSec: number }): Promise { + try { + await this.localCache.put(key, value, config); + } catch (error) { + this.logger.warn("Error putting value in local cache", error); + this.recordError(key, CacheLayer.LOCAL, error, false); + } + } + + private async callFallback(labels: CacheMetricLabels, fallback: () => Promise): Promise { + const start = performance.now(); + try { + return await fallback(); + } catch (error) { + this.metrics?.error({ ...labels, error: errorName(error), inFallback: true }); + throw error; + } finally { + this.metrics?.observeFallback(labels, elapsedSeconds(start)); + } + } + + private recordError(key: GCacheKey, layer: CacheLayer, error: unknown, inFallback: boolean): void { + this.metrics?.error({ ...labelsFor(key, layer), error: errorName(error), inFallback }); + } + + private registerUseCase(useCase: string): void { + if (useCase === "watermark") { + throw new UseCaseNameIsReservedError(useCase); + } + if (this.useCases.has(useCase)) { + throw new UseCaseIsAlreadyRegisteredError(useCase); + } + this.useCases.add(useCase); + } + + private createKey(options: CachedOptions, args: Args): GCacheKey { + return new GCacheKey({ + keyType: options.keyType, + id: String(options.id(args)), + useCase: options.useCase, + args: normalizeArgs(options.args?.(args) ?? {}), + urnPrefix: this.urnPrefix, + defaultConfig: options.defaultConfig ?? null, + serializer: (options.serializer as Serializer | null | undefined) ?? null, + trackForInvalidation: options.trackForInvalidation ?? false, + }); + } +} + +function elapsedSeconds(startMs: number): number { + return Math.max((performance.now() - startMs) / 1000, 0); +} + +function safeMetrics(metrics: GCacheMetricsAdapter | null): GCacheMetricsAdapter | null { + if (metrics === null) { + return null; + } + + return { + request: (labels) => callMetric(() => metrics.request(labels)), + miss: (labels) => callMetric(() => metrics.miss(labels)), + disabled: (labels) => callMetric(() => metrics.disabled(labels)), + error: (labels) => callMetric(() => metrics.error(labels)), + invalidation: (labels) => callMetric(() => metrics.invalidation(labels)), + observeGet: (labels, seconds) => callMetric(() => metrics.observeGet(labels, seconds)), + observeFallback: (labels, seconds) => callMetric(() => metrics.observeFallback(labels, seconds)), + observeSerialization: (labels, seconds) => callMetric(() => metrics.observeSerialization(labels, seconds)), + observeSize: (labels, bytes) => callMetric(() => metrics.observeSize(labels, bytes)), + }; +} + +function callMetric(record: () => void): void { + try { + record(); + } catch { + // Metrics adapters must not affect cache correctness or application fallbacks. + } +} diff --git a/packages/gcache-ts/src/index.ts b/packages/gcache-ts/src/index.ts new file mode 100644 index 0000000..4c02884 --- /dev/null +++ b/packages/gcache-ts/src/index.ts @@ -0,0 +1,34 @@ +export { CacheLayer, DEFAULT_WATERMARK_TTL_SEC, GCacheKeyConfig, randomRampSampler } from "./config.js"; +export type { CacheConfigProvider, CacheRampSample, CacheRampSampler, GCacheConfig, InvalidateOptions, LayerConfig, Logger } from "./config.js"; +export { GCacheContext } from "./context.js"; +export { PrometheusGCacheMetrics, createPrometheusGCacheMetrics } from "./metrics.js"; +export type { + CacheMetricLabels, + DisabledMetricLabels, + DisabledReason, + ErrorMetricLabels, + GCacheMetricsAdapter, + InvalidationMetricLabels, + MetricLayer, + PrometheusMetricsOptions, + SerializationMetricLabels, +} from "./metrics.js"; +export { + GCacheError, + MissingKeyConfigError, + UseCaseIsAlreadyRegisteredError, + UseCaseNameIsReservedError, +} from "./errors.js"; +export { GCache } from "./gcache.js"; +export type { CachedOptions } from "./gcache.js"; +export { GCacheKey, invalidationPrefix, normalizeArgs, redisClusterHashTag } from "./key.js"; +export type { GCacheKeyInit } from "./key.js"; +export type { + RedisCommandClient, + RedisConfig, + RedisClientFactory, + RedisStoredValue, + RedisValueEnvelope, +} from "./internal/redis-cache.js"; +export { JsonSerializer } from "./serializer.js"; +export type { Serializer } from "./serializer.js"; diff --git a/packages/gcache-ts/src/internal/cache-result.ts b/packages/gcache-ts/src/internal/cache-result.ts new file mode 100644 index 0000000..f158ab6 --- /dev/null +++ b/packages/gcache-ts/src/internal/cache-result.ts @@ -0,0 +1,7 @@ +import type { ResolvedLayerConfig } from "./runtime-config.js"; +import type { DisabledReason } from "../metrics.js"; + +export type CacheGetResult = + | { readonly status: "hit"; readonly value: T } + | { readonly status: "miss"; readonly config: ResolvedLayerConfig; readonly skipCacheWrite?: boolean } + | { readonly status: "disabled"; readonly reason: DisabledReason; readonly skipCacheWrite?: boolean }; diff --git a/packages/gcache-ts/src/internal/local-cache.ts b/packages/gcache-ts/src/internal/local-cache.ts new file mode 100644 index 0000000..783c666 --- /dev/null +++ b/packages/gcache-ts/src/internal/local-cache.ts @@ -0,0 +1,113 @@ +import { CacheLayer, type CacheConfigProvider, type CacheRampSampler } from "../config.js"; +import type { GCacheKey } from "../key.js"; +import type { CacheGetResult } from "./cache-result.js"; +import { resolveLayerConfigResult } from "./runtime-config.js"; + +export type Fallback = () => Promise; + +interface LocalEntry { + readonly expiresAtMs: number; + readonly value: T; +} + +export class LocalCache { + private readonly caches = new Map>>(); + + constructor( + private readonly configProvider: CacheConfigProvider, + private readonly rampSampler: CacheRampSampler, + private readonly maxSize: number, + ) {} + + async get(key: GCacheKey, fallback: Fallback): Promise { + const result = await this.getIfPresentResult(key); + if (result.status === "hit") { + return result.value; + } + + const value = await fallback(); + if (result.status === "miss") { + await this.put(key, value, result.config); + } + return value; + } + + async getIfPresent(key: GCacheKey): Promise { + const result = await this.getIfPresentResult(key); + return result.status === "hit" ? result.value : undefined; + } + + async getIfPresentResult(key: GCacheKey): Promise> { + const layerConfig = await this.resolveLocalLayerConfig(key); + if (layerConfig.status === "disabled") { + return layerConfig; + } + + const cache = this.caches.get(key.useCase); + const now = Date.now(); + const hit = cache?.get(key.urn) as LocalEntry | undefined; + + if (hit !== undefined && hit.expiresAtMs > now) { + return { status: "hit", value: hit.value }; + } + + if (hit !== undefined) { + cache?.delete(key.urn); + } + + return { status: "miss", config: layerConfig.config }; + } + + async put(key: GCacheKey, value: T, config?: { readonly ttlSec: number }): Promise { + const ttlSec = config?.ttlSec ?? await this.resolveLocalTtlSec(key); + if (ttlSec === null) { + return; + } + + const cache = this.getOrCreateUseCaseCache(key); + cache.set(key.urn, { expiresAtMs: Date.now() + ttlSec * 1000, value }); + this.evictOldestIfNeeded(cache); + } + + async delete(key: GCacheKey): Promise { + const cache = this.caches.get(key.useCase); + return cache?.delete(key.urn) ?? false; + } + + async flushAll(): Promise { + this.caches.clear(); + } + + private getOrCreateUseCaseCache(key: GCacheKey): Map> { + let cache = this.caches.get(key.useCase); + if (cache === undefined) { + cache = new Map>(); + this.caches.set(key.useCase, cache); + } + return cache; + } + + private async resolveLocalLayerConfig(key: GCacheKey) { + return await resolveLayerConfigResult({ + configProvider: this.configProvider, + key, + layer: CacheLayer.LOCAL, + rampSampler: this.rampSampler, + }); + } + + private async resolveLocalTtlSec(key: GCacheKey): Promise { + const layerConfig = await this.resolveLocalLayerConfig(key); + return layerConfig.status === "enabled" ? layerConfig.config.ttlSec : null; + } + + private evictOldestIfNeeded(cache: Map>): void { + while (cache.size > this.maxSize) { + const oldestKey = cache.keys().next().value as string | undefined; + if (oldestKey === undefined) { + return; + } + cache.delete(oldestKey); + } + } +} diff --git a/packages/gcache-ts/src/internal/redis-cache.ts b/packages/gcache-ts/src/internal/redis-cache.ts new file mode 100644 index 0000000..55633dc --- /dev/null +++ b/packages/gcache-ts/src/internal/redis-cache.ts @@ -0,0 +1,294 @@ +import { performance } from "node:perf_hooks"; + +import { CacheLayer, DEFAULT_WATERMARK_TTL_SEC, type CacheConfigProvider, type CacheRampSampler } from "../config.js"; +import { invalidationPrefix, redisClusterHashTag, type GCacheKey } from "../key.js"; +import type { GCacheMetricsAdapter } from "../metrics.js"; +import { labelsFor } from "../metrics.js"; +import { JsonSerializer, type Serializer } from "../serializer.js"; +import type { CacheGetResult } from "./cache-result.js"; +import { resolveLayerConfigResult } from "./runtime-config.js"; + +export type Awaitable = T | Promise; +export type RedisStoredValue = string | Buffer; + +export interface RedisCommandClient { + get(key: string): Awaitable; + del(key: string): Awaitable; + flushAll?(): Awaitable; + flushall?(): Awaitable; + setEx?(key: string, ttlSec: number, value: RedisStoredValue): Awaitable; + setex?(key: string, ttlSec: number, value: RedisStoredValue): Awaitable; + set?(key: string, value: RedisStoredValue, options: { EX: number }): Awaitable; +} + +export type RedisClientFactory = () => Awaitable; + +export interface RedisConfig { + readonly client?: RedisCommandClient; + readonly createClient?: RedisClientFactory; + readonly keyPrefix?: string; + readonly serializer?: Serializer; + readonly watermarkTtlSec?: number; +} + +export interface RedisValueEnvelope { + readonly version: 1; + readonly createdAtMs: number; + readonly expiresAtMs: number; + readonly encoding: "utf8" | "base64"; + readonly payload: string; +} + +interface RedisCacheOptions { + readonly configProvider: CacheConfigProvider; + readonly rampSampler: CacheRampSampler; + readonly redis: RedisConfig; + readonly metrics: GCacheMetricsAdapter | null; +} + +const ENVELOPE_VERSION = 1; +const defaultSerializer = new JsonSerializer(); + +export class RedisCache { + private readonly configProvider: CacheConfigProvider; + private readonly rampSampler: CacheRampSampler; + private readonly keyPrefix: string; + private readonly defaultSerializer: Serializer; + private readonly watermarkTtlSec: number; + private readonly createClient: RedisClientFactory | null; + private readonly metrics: GCacheMetricsAdapter | null; + private clientPromise: Promise | null; + + constructor(options: RedisCacheOptions) { + this.configProvider = options.configProvider; + this.rampSampler = options.rampSampler; + this.keyPrefix = options.redis.keyPrefix ?? ""; + this.defaultSerializer = options.redis.serializer ?? defaultSerializer; + this.watermarkTtlSec = options.redis.watermarkTtlSec ?? DEFAULT_WATERMARK_TTL_SEC; + this.metrics = options.metrics; + + if (options.redis.client === undefined && options.redis.createClient === undefined) { + throw new Error("Redis config requires either client or createClient"); + } + + this.createClient = options.redis.createClient ?? null; + this.clientPromise = options.redis.client === undefined ? null : Promise.resolve(options.redis.client); + } + + async get(key: GCacheKey): Promise { + const result = await this.getResult(key); + return result.status === "hit" ? result.value : undefined; + } + + async getResult(key: GCacheKey): Promise> { + const layerConfig = await this.resolveRemoteLayerConfig(key); + if (layerConfig.status === "disabled") { + return layerConfig; + } + + const client = await this.resolveClient(); + const redisKey = this.redisKey(key); + const watermarkMs = key.trackForInvalidation ? await this.getWatermarkMs(client, key) : null; + const raw = await client.get(redisKey); + if (raw === null) { + return { status: "miss", config: layerConfig.config, ...(watermarkIsActive(watermarkMs) ? { skipCacheWrite: true } : {}) }; + } + + let envelope: RedisValueEnvelope; + try { + envelope = this.parseEnvelope(raw); + } catch { + await client.del(redisKey); + return { status: "miss", config: layerConfig.config, ...(watermarkIsActive(watermarkMs) ? { skipCacheWrite: true } : {}) }; + } + if (envelope.expiresAtMs <= Date.now()) { + await client.del(redisKey); + return { status: "miss", config: layerConfig.config, ...(watermarkIsActive(watermarkMs) ? { skipCacheWrite: true } : {}) }; + } + if (watermarkMs !== null && watermarkMs >= envelope.createdAtMs) { + await client.del(redisKey); + return { status: "miss", config: layerConfig.config, ...(watermarkIsActive(watermarkMs) ? { skipCacheWrite: true } : {}) }; + } + + const start = performance.now(); + try { + const value = (await this.serializerFor(key).load(this.decodePayload(envelope))) as T; + return { status: "hit", value }; + } finally { + this.recordMetric((metrics) => metrics.observeSerialization({ ...labelsFor(key, CacheLayer.REMOTE), operation: "load" }, elapsedSeconds(start))); + } + } + + async put(key: GCacheKey, value: T, config?: { readonly ttlSec: number }): Promise { + const ttlSec = config?.ttlSec ?? await this.resolveRemoteTtlSec(key); + if (ttlSec === null) { + return true; + } + + const client = await this.resolveClient(); + if (key.trackForInvalidation && watermarkIsActive(await this.getWatermarkMs(client, key))) { + return false; + } + + const now = Date.now(); + const start = performance.now(); + let payload: string | Buffer; + try { + payload = await this.serializerFor(key).dump(value); + } finally { + this.recordMetric((metrics) => metrics.observeSerialization({ ...labelsFor(key, CacheLayer.REMOTE), operation: "dump" }, elapsedSeconds(start))); + } + this.recordMetric((metrics) => metrics.observeSize(labelsFor(key, CacheLayer.REMOTE), payloadSize(payload))); + const envelope: RedisValueEnvelope = { + version: ENVELOPE_VERSION, + createdAtMs: now, + expiresAtMs: now + ttlSec * 1000, + encoding: Buffer.isBuffer(payload) ? "base64" : "utf8", + payload: Buffer.isBuffer(payload) ? payload.toString("base64") : payload, + }; + + await this.setWithTtl(client, this.redisKey(key), JSON.stringify(envelope), ttlSec); + return true; + } + + async delete(key: GCacheKey): Promise { + const client = await this.resolveClient(); + return (await client.del(this.redisKey(key))) > 0; + } + + async invalidate(keyType: string, id: string, futureBufferMs = 0, urnPrefix = "urn"): Promise { + const client = await this.resolveClient(); + const watermarkMs = Date.now() + futureBufferMs; + await this.setWithTtl(client, this.redisWatermarkKey(urnPrefix, keyType, id), String(watermarkMs), this.watermarkTtlSec); + } + + async flushAll(): Promise { + const client = await this.resolveClient(); + const flushAll = client.flushAll ?? client.flushall; + if (flushAll === undefined) { + throw new Error("Redis client does not implement flushAll/flushall"); + } + await flushAll.call(client); + } + + redisKey(key: GCacheKey): string { + return `${this.keyPrefix}${key.urn}`; + } + + redisWatermarkKey(urnPrefix: string, keyType: string, id: string): string { + return `${this.keyPrefix}${redisClusterHashTag(invalidationPrefix(urnPrefix, keyType, id))}#watermark`; + } + + private async getWatermarkMs(client: RedisCommandClient, key: GCacheKey): Promise { + const raw = await client.get(this.redisWatermarkKeyFromKey(key)); + if (raw === null) { + return null; + } + const value = Number(Buffer.isBuffer(raw) ? raw.toString("utf8") : raw); + if (!Number.isFinite(value)) { + throw new Error("Invalid GCache Redis watermark"); + } + return value; + } + + private redisWatermarkKeyFromKey(key: GCacheKey): string { + return this.redisWatermarkKey(key.urnPrefix, key.keyType, key.id); + } + + private async resolveClient(): Promise { + if (this.clientPromise === null) { + if (this.createClient === null) { + throw new Error("Redis client has not been configured"); + } + this.clientPromise = Promise.resolve(this.createClient()); + } + try { + return await this.clientPromise; + } catch (error) { + if (this.createClient !== null) { + this.clientPromise = null; + } + throw error; + } + } + + private serializerFor(key: GCacheKey): Serializer { + return key.serializer ?? this.defaultSerializer; + } + + private decodePayload(envelope: RedisValueEnvelope): string | Buffer { + return envelope.encoding === "base64" ? Buffer.from(envelope.payload, "base64") : envelope.payload; + } + + private parseEnvelope(raw: RedisStoredValue): RedisValueEnvelope { + const parsed = JSON.parse(Buffer.isBuffer(raw) ? raw.toString("utf8") : raw) as Partial; + if ( + parsed.version !== ENVELOPE_VERSION || + typeof parsed.createdAtMs !== "number" || + typeof parsed.expiresAtMs !== "number" || + (parsed.encoding !== "utf8" && parsed.encoding !== "base64") || + typeof parsed.payload !== "string" + ) { + throw new Error("Invalid GCache Redis envelope"); + } + return parsed as RedisValueEnvelope; + } + + private async setWithTtl( + client: RedisCommandClient, + key: string, + value: RedisStoredValue, + ttlSec: number, + ): Promise { + if (client.setEx !== undefined) { + await client.setEx(key, ttlSec, value); + return; + } + if (client.setex !== undefined) { + await client.setex(key, ttlSec, value); + return; + } + if (client.set !== undefined) { + await client.set(key, value, { EX: ttlSec }); + return; + } + throw new Error("Redis client does not implement setEx/setex/set"); + } + + private async resolveRemoteLayerConfig(key: GCacheKey) { + return await resolveLayerConfigResult({ + configProvider: this.configProvider, + key, + layer: CacheLayer.REMOTE, + rampSampler: this.rampSampler, + }); + } + + private async resolveRemoteTtlSec(key: GCacheKey): Promise { + const layerConfig = await this.resolveRemoteLayerConfig(key); + return layerConfig.status === "enabled" ? layerConfig.config.ttlSec : null; + } + + private recordMetric(record: (metrics: GCacheMetricsAdapter) => void): void { + if (this.metrics === null) { + return; + } + try { + record(this.metrics); + } catch { + // Metrics adapters must not affect cache correctness or application fallbacks. + } + } +} + +function payloadSize(payload: string | Buffer): number { + return Buffer.isBuffer(payload) ? payload.byteLength : Buffer.byteLength(payload); +} + +function elapsedSeconds(startMs: number): number { + return Math.max((performance.now() - startMs) / 1000, 0); +} + +function watermarkIsActive(watermarkMs: number | null): boolean { + return watermarkMs !== null && watermarkMs >= Date.now(); +} diff --git a/packages/gcache-ts/src/internal/runtime-config.ts b/packages/gcache-ts/src/internal/runtime-config.ts new file mode 100644 index 0000000..50fad60 --- /dev/null +++ b/packages/gcache-ts/src/internal/runtime-config.ts @@ -0,0 +1,71 @@ +import { CacheLayer, type CacheConfigProvider, type CacheRampSampler } from "../config.js"; +import type { GCacheKey } from "../key.js"; +import type { DisabledReason } from "../metrics.js"; + +export interface ResolvedLayerConfig { + readonly ttlSec: number; + readonly ramp: number; +} + +export type LayerConfigResolution = + | { readonly status: "enabled"; readonly config: ResolvedLayerConfig } + | { readonly status: "disabled"; readonly reason: DisabledReason }; + +interface ResolveLayerConfigOptions { + readonly configProvider: CacheConfigProvider; + readonly key: GCacheKey; + readonly layer: CacheLayer; + readonly rampSampler: CacheRampSampler; +} + +export async function resolveLayerConfig(options: ResolveLayerConfigOptions): Promise { + const resolution = await resolveLayerConfigResult(options); + return resolution.status === "enabled" ? resolution.config : null; +} + +export async function resolveLayerConfigResult(options: ResolveLayerConfigOptions): Promise { + const config = (await options.configProvider(options.key)) ?? options.key.defaultConfig; + if (config === null) { + return { status: "disabled", reason: "missing_config" }; + } + + const ttlSec = config.ttlSec[options.layer]; + if (ttlSec === undefined) { + return { status: "disabled", reason: "missing_config" }; + } + if (!Number.isSafeInteger(ttlSec) || ttlSec <= 0) { + return { status: "disabled", reason: "invalid_ttl" }; + } + + const configuredRamp = config.ramp[options.layer]; + if (configuredRamp === undefined) { + return { status: "disabled", reason: "missing_config" }; + } + + const ramp = clampPercentage(configuredRamp); + if (ramp <= 0) { + return { status: "disabled", reason: "ramped_down" }; + } + if (ramp >= 100) { + return { status: "enabled", config: { ttlSec, ramp } }; + } + + const sample = await options.rampSampler({ key: options.key, layer: options.layer, ramp }); + if (!Number.isFinite(sample)) { + return { status: "disabled", reason: "ramped_down" }; + } + + return clampPercentage(sample) < ramp + ? { status: "enabled", config: { ttlSec, ramp } } + : { status: "disabled", reason: "ramped_down" }; +} + +function clampPercentage(value: number): number { + if (value <= 0) { + return 0; + } + if (value >= 100) { + return 100; + } + return value; +} diff --git a/packages/gcache-ts/src/key.ts b/packages/gcache-ts/src/key.ts new file mode 100644 index 0000000..5769dd0 --- /dev/null +++ b/packages/gcache-ts/src/key.ts @@ -0,0 +1,79 @@ +import type { GCacheKeyConfig } from "./config.js"; +import type { Serializer } from "./serializer.js"; + +export interface GCacheKeyInit { + readonly keyType: string; + readonly id: string; + readonly useCase: string; + readonly args?: ReadonlyArray; + readonly urnPrefix?: string; + readonly defaultConfig?: GCacheKeyConfig | null; + readonly serializer?: Serializer | null; + readonly trackForInvalidation?: boolean; +} + +export class GCacheKey { + readonly keyType: string; + readonly id: string; + readonly useCase: string; + readonly args: ReadonlyArray; + readonly urnPrefix: string; + readonly prefix: string; + readonly urn: string; + readonly defaultConfig: GCacheKeyConfig | null; + readonly serializer: Serializer | null; + readonly trackForInvalidation: boolean; + + constructor(init: GCacheKeyInit) { + this.keyType = init.keyType; + this.id = init.id; + this.useCase = init.useCase; + this.args = init.args ?? []; + this.defaultConfig = init.defaultConfig ?? null; + this.serializer = init.serializer ?? null; + this.trackForInvalidation = init.trackForInvalidation ?? false; + this.urnPrefix = init.urnPrefix ?? "urn"; + + const rawPrefix = joinUrnComponents(this.urnPrefix, this.keyType, this.id); + this.prefix = this.trackForInvalidation ? redisClusterHashTag(invalidationPrefix(this.urnPrefix, this.keyType, this.id)) : rawPrefix; + const args = this.args.length > 0 ? `?${this.args.map(([name, value]) => `${encodeComponent(name)}=${encodeComponent(value)}`).join("&")}` : ""; + this.urn = `${this.prefix}${args}#${encodeComponent(this.useCase)}`; + } + + toString(): string { + return this.urn; + } +} + +export function normalizeArgs(args: Record): Array<[string, string]> { + return Object.entries(args) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => [name, String(value)] as [string, string]) + .sort(([left], [right]) => left.localeCompare(right)); +} + +export function invalidationPrefix(urnPrefix: string, keyType: string, id: string): string { + assertRedisHashTagComponent("urnPrefix", urnPrefix); + assertRedisHashTagComponent("keyType", keyType); + assertRedisHashTagComponent("id", id); + return joinUrnComponents(urnPrefix, keyType, id); +} + +export function redisClusterHashTag(value: string): string { + assertRedisHashTagComponent("value", value); + return `{${value}}`; +} + +function assertRedisHashTagComponent(name: string, value: string): void { + if (value.includes("{") || value.includes("}")) { + throw new Error(`Redis Cluster hash tag components must not contain braces: ${name}`); + } +} + +function joinUrnComponents(...components: readonly string[]): string { + return components.map(encodeComponent).join(":"); +} + +function encodeComponent(value: string): string { + return encodeURIComponent(value); +} diff --git a/packages/gcache-ts/src/metrics.ts b/packages/gcache-ts/src/metrics.ts new file mode 100644 index 0000000..8d528d5 --- /dev/null +++ b/packages/gcache-ts/src/metrics.ts @@ -0,0 +1,205 @@ +import { Counter, Histogram, type Registry, register as defaultRegistry } from "prom-client"; + +import { CacheLayer } from "./config.js"; +import type { GCacheKey } from "./key.js"; + +export type MetricLayer = CacheLayer | "noop"; +export type DisabledReason = "context" | "missing_config" | "invalid_ttl" | "ramped_down" | "config_error"; + +export interface CacheMetricLabels { + readonly useCase: string; + readonly keyType: string; + readonly layer: MetricLayer; +} + +export interface DisabledMetricLabels extends CacheMetricLabels { + readonly reason: DisabledReason; +} + +export interface ErrorMetricLabels extends CacheMetricLabels { + readonly error: string; + readonly inFallback: boolean; +} + +export interface SerializationMetricLabels extends CacheMetricLabels { + readonly operation: "dump" | "load"; +} + +export interface InvalidationMetricLabels { + readonly keyType: string; + readonly layer: CacheLayer; +} + +export interface GCacheMetricsAdapter { + request(labels: CacheMetricLabels): void; + miss(labels: CacheMetricLabels): void; + disabled(labels: DisabledMetricLabels): void; + error(labels: ErrorMetricLabels): void; + invalidation(labels: InvalidationMetricLabels): void; + observeGet(labels: CacheMetricLabels, seconds: number): void; + observeFallback(labels: CacheMetricLabels, seconds: number): void; + observeSerialization(labels: SerializationMetricLabels, seconds: number): void; + observeSize(labels: CacheMetricLabels, bytes: number): void; +} + +export interface PrometheusMetricsOptions { + readonly prefix?: string; + readonly registry?: Registry; +} + +type CounterLabels = "use_case" | "key_type" | "layer"; +type DisabledLabels = CounterLabels | "reason"; +type ErrorLabels = CounterLabels | "error" | "in_fallback"; +type SerializationLabels = CounterLabels | "operation"; +type InvalidationLabels = "key_type" | "layer"; + +const TIMER_BUCKETS = [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; +const SIZE_BUCKETS = [100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000]; + +export class PrometheusGCacheMetrics implements GCacheMetricsAdapter { + private readonly requestCounter: Counter; + private readonly missCounter: Counter; + private readonly disabledCounter: Counter; + private readonly errorCounter: Counter; + private readonly invalidationCounter: Counter; + private readonly getTimer: Histogram; + private readonly fallbackTimer: Histogram; + private readonly serializationTimer: Histogram; + private readonly sizeHistogram: Histogram; + + constructor(options: PrometheusMetricsOptions = {}) { + const registry = options.registry ?? defaultRegistry; + const prefix = options.prefix ?? ""; + + this.disabledCounter = counter(registry, { + name: `${prefix}gcache_disabled_counter`, + help: "Requests where GCache skipped a cache layer.", + labelNames: ["use_case", "key_type", "layer", "reason"] as const, + }); + this.missCounter = counter(registry, { + name: `${prefix}gcache_miss_counter`, + help: "GCache cache misses.", + labelNames: ["use_case", "key_type", "layer"] as const, + }); + this.requestCounter = counter(registry, { + name: `${prefix}gcache_request_counter`, + help: "Total GCache cache-layer requests.", + labelNames: ["use_case", "key_type", "layer"] as const, + }); + this.errorCounter = counter(registry, { + name: `${prefix}gcache_error_counter`, + help: "Errors during GCache cache operations or fallback execution.", + labelNames: ["use_case", "key_type", "layer", "error", "in_fallback"] as const, + }); + this.invalidationCounter = counter(registry, { + name: `${prefix}gcache_invalidation_counter`, + help: "GCache invalidation/delete calls by key type and layer.", + labelNames: ["key_type", "layer"] as const, + }); + this.getTimer = histogram(registry, { + name: `${prefix}gcache_get_timer`, + help: "GCache cache get latency in seconds.", + labelNames: ["use_case", "key_type", "layer"] as const, + buckets: TIMER_BUCKETS, + }); + this.fallbackTimer = histogram(registry, { + name: `${prefix}gcache_fallback_timer`, + help: "Time spent in the underlying fallback function in seconds.", + labelNames: ["use_case", "key_type", "layer"] as const, + buckets: TIMER_BUCKETS, + }); + this.serializationTimer = histogram(registry, { + name: `${prefix}gcache_serialization_timer`, + help: "GCache serialization latency in seconds.", + labelNames: ["use_case", "key_type", "layer", "operation"] as const, + buckets: TIMER_BUCKETS, + }); + this.sizeHistogram = histogram(registry, { + name: `${prefix}gcache_size_histogram`, + help: "Serialized GCache value sizes in bytes.", + labelNames: ["use_case", "key_type", "layer"] as const, + buckets: SIZE_BUCKETS, + }); + } + + request(labels: CacheMetricLabels): void { + this.requestCounter.inc(cacheLabels(labels)); + } + + miss(labels: CacheMetricLabels): void { + this.missCounter.inc(cacheLabels(labels)); + } + + disabled(labels: DisabledMetricLabels): void { + this.disabledCounter.inc({ ...cacheLabels(labels), reason: labels.reason }); + } + + error(labels: ErrorMetricLabels): void { + this.errorCounter.inc({ + ...cacheLabels(labels), + error: labels.error, + in_fallback: String(labels.inFallback), + }); + } + + invalidation(labels: InvalidationMetricLabels): void { + this.invalidationCounter.inc({ key_type: labels.keyType, layer: labels.layer }); + } + + observeGet(labels: CacheMetricLabels, seconds: number): void { + this.getTimer.observe(cacheLabels(labels), seconds); + } + + observeFallback(labels: CacheMetricLabels, seconds: number): void { + this.fallbackTimer.observe(cacheLabels(labels), seconds); + } + + observeSerialization(labels: SerializationMetricLabels, seconds: number): void { + this.serializationTimer.observe({ ...cacheLabels(labels), operation: labels.operation }, seconds); + } + + observeSize(labels: CacheMetricLabels, bytes: number): void { + this.sizeHistogram.observe(cacheLabels(labels), bytes); + } +} + +export function createPrometheusGCacheMetrics(options: PrometheusMetricsOptions = {}): GCacheMetricsAdapter { + return new PrometheusGCacheMetrics(options); +} + +export function labelsFor(key: GCacheKey, layer: MetricLayer): CacheMetricLabels { + return { useCase: key.useCase, keyType: key.keyType, layer }; +} + +export function errorName(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + +function cacheLabels(labels: CacheMetricLabels): Record { + return { + use_case: labels.useCase, + key_type: labels.keyType, + layer: labels.layer, + }; +} + +function counter( + registry: Registry, + config: { readonly name: string; readonly help: string; readonly labelNames: readonly T[] }, +): Counter { + return (registry.getSingleMetric(config.name) as Counter | undefined) ?? + new Counter({ ...config, registers: [registry] }); +} + +function histogram( + registry: Registry, + config: { + readonly name: string; + readonly help: string; + readonly labelNames: readonly T[]; + readonly buckets: readonly number[]; + }, +): Histogram { + return (registry.getSingleMetric(config.name) as Histogram | undefined) ?? + new Histogram({ ...config, buckets: [...config.buckets], registers: [registry] }); +} diff --git a/packages/gcache-ts/src/serializer.ts b/packages/gcache-ts/src/serializer.ts new file mode 100644 index 0000000..dbf760c --- /dev/null +++ b/packages/gcache-ts/src/serializer.ts @@ -0,0 +1,14 @@ +export interface Serializer { + dump(value: T): Promise; + load(value: string | Buffer): Promise; +} + +export class JsonSerializer implements Serializer { + async dump(value: T): Promise { + return JSON.stringify(value); + } + + async load(value: string | Buffer): Promise { + return JSON.parse(Buffer.isBuffer(value) ? value.toString("utf8") : value) as T; + } +} diff --git a/packages/gcache-ts/test/gcache-config-ramp.test.ts b/packages/gcache-ts/test/gcache-config-ramp.test.ts new file mode 100644 index 0000000..7c1e763 --- /dev/null +++ b/packages/gcache-ts/test/gcache-config-ramp.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it, vi } from "vitest"; + +import { CacheLayer, GCache, GCacheKeyConfig, type RedisCommandClient, type RedisStoredValue } from "../src/index.js"; + +class FakeRedis implements RedisCommandClient { + readonly values = new Map(); + getCalls = 0; + setCalls = 0; + + async get(key: string): Promise { + this.getCalls += 1; + return this.values.get(key) ?? null; + } + + async setEx(key: string, _ttlSec: number, value: RedisStoredValue): Promise { + this.setCalls += 1; + this.values.set(key, value); + } + + async del(key: string): Promise { + return this.values.delete(key) ? 1 : 0; + } +} + +const configFor = (ttlSec: Partial>, ramp: Partial>) => + new GCacheKeyConfig({ ttlSec, ramp }); + +describe("GCache runtime config and ramp controls", () => { + it("falls back to decorator defaultConfig when the provider returns null", async () => { + // Given a runtime config provider that has no dynamic config for this key. + const cacheConfigProvider = vi.fn(async () => null); + const gcache = new GCache({ cacheConfigProvider }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "ProviderFallbackDefaultConfig", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the same key is read twice inside an enabled scope. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then the decorator defaultConfig keeps the local cache active. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(calls).toBe(1); + expect(cacheConfigProvider).toHaveBeenCalled(); + }); + + it("applies runtime config changes to subsequent calls", async () => { + // Given a provider whose config can change without redeploying the cached function. + let runtimeConfig: GCacheKeyConfig | null = GCacheKeyConfig.enabled(60); + const gcache = new GCache({ cacheConfigProvider: async () => runtimeConfig }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "DynamicProviderConfig", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the provider disables local caching after the first cached read. + const first = await gcache.enable(async () => await getUser("123")); + runtimeConfig = configFor({}, {}); + const second = await gcache.enable(async () => await getUser("123")); + + // Then the second call honors the new disabled config instead of returning the existing local entry. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 2 }); + expect(calls).toBe(2); + }); + + it("treats ramp 0 and 100 as deterministic layer controls", async () => { + // Given one local key is ramped out and another is fully ramped in. + const rampSampler = vi.fn(() => { + throw new Error("0/100 ramps should not need random sampling"); + }); + const gcache = new GCache({ rampSampler }); + let disabledCalls = 0; + const disabled = gcache.cached({ + keyType: "user_id", + useCase: "LocalRampZero", + id: ([userId]: [string]) => userId, + defaultConfig: configFor({ [CacheLayer.LOCAL]: 60 }, { [CacheLayer.LOCAL]: 0 }), + })(async (userId: string) => ({ userId, calls: ++disabledCalls })); + let enabledCalls = 0; + const enabled = gcache.cached({ + keyType: "user_id", + useCase: "LocalRampHundred", + id: ([userId]: [string]) => userId, + defaultConfig: configFor({ [CacheLayer.LOCAL]: 60 }, { [CacheLayer.LOCAL]: 100 }), + })(async (userId: string) => ({ userId, calls: ++enabledCalls })); + + // When each key is read twice. + const disabledFirst = await gcache.enable(async () => await disabled("123")); + const disabledSecond = await gcache.enable(async () => await disabled("123")); + const enabledFirst = await gcache.enable(async () => await enabled("456")); + const enabledSecond = await gcache.enable(async () => await enabled("456")); + + // Then ramp 0 disables the layer, ramp 100 enables it, and neither path samples randomness. + expect(disabledFirst).toEqual({ userId: "123", calls: 1 }); + expect(disabledSecond).toEqual({ userId: "123", calls: 2 }); + expect(enabledFirst).toEqual({ userId: "456", calls: 1 }); + expect(enabledSecond).toEqual({ userId: "456", calls: 1 }); + expect(rampSampler).not.toHaveBeenCalled(); + }); + + it("uses the injected sampler to make ramp 50 behavior testable", async () => { + // Given one sampler lands inside ramp 50 and another lands just outside it. + const passingSampler = vi.fn(() => 49); + const blockedSampler = vi.fn(() => 50); + const passingCache = new GCache({ rampSampler: passingSampler }); + const blockedCache = new GCache({ rampSampler: blockedSampler }); + let passingCalls = 0; + const passing = passingCache.cached({ + keyType: "user_id", + useCase: "LocalRampFiftyPassing", + id: ([userId]: [string]) => userId, + defaultConfig: configFor({ [CacheLayer.LOCAL]: 60 }, { [CacheLayer.LOCAL]: 50 }), + })(async (userId: string) => ({ userId, calls: ++passingCalls })); + let blockedCalls = 0; + const blocked = blockedCache.cached({ + keyType: "user_id", + useCase: "LocalRampFiftyBlocked", + id: ([userId]: [string]) => userId, + defaultConfig: configFor({ [CacheLayer.LOCAL]: 60 }, { [CacheLayer.LOCAL]: 50 }), + })(async (userId: string) => ({ userId, calls: ++blockedCalls })); + + // When both caches read the same key twice. + const passingFirst = await passingCache.enable(async () => await passing("123")); + const passingSecond = await passingCache.enable(async () => await passing("123")); + const blockedFirst = await blockedCache.enable(async () => await blocked("123")); + const blockedSecond = await blockedCache.enable(async () => await blocked("123")); + + // Then the sampled-in key caches and the sampled-out key falls through. + expect(passingFirst).toEqual({ userId: "123", calls: 1 }); + expect(passingSecond).toEqual({ userId: "123", calls: 1 }); + expect(blockedFirst).toEqual({ userId: "123", calls: 1 }); + expect(blockedSecond).toEqual({ userId: "123", calls: 2 }); + expect(passingSampler).toHaveBeenCalledWith(expect.objectContaining({ layer: CacheLayer.LOCAL, ramp: 50 })); + expect(blockedSampler).toHaveBeenCalledWith(expect.objectContaining({ layer: CacheLayer.LOCAL, ramp: 50 })); + }); + + it("uses the injected sampler for remote ramp 50 behavior", async () => { + // Given remote-only config with one sampler inside ramp 50 and another just outside it. + const passingRedis = new FakeRedis(); + const blockedRedis = new FakeRedis(); + const passingSampler = vi.fn(() => 49); + const blockedSampler = vi.fn(() => 50); + const passingCache = new GCache({ + redis: { client: passingRedis }, + rampSampler: passingSampler, + cacheConfigProvider: async () => configFor({ [CacheLayer.REMOTE]: 60 }, { [CacheLayer.REMOTE]: 50 }), + }); + const blockedCache = new GCache({ + redis: { client: blockedRedis }, + rampSampler: blockedSampler, + cacheConfigProvider: async () => configFor({ [CacheLayer.REMOTE]: 60 }, { [CacheLayer.REMOTE]: 50 }), + }); + let passingCalls = 0; + const passing = passingCache.cached({ + keyType: "user_id", + useCase: "RemoteRampFiftyPassing", + id: ([userId]: [string]) => userId, + })(async (userId: string) => ({ userId, calls: ++passingCalls })); + let blockedCalls = 0; + const blocked = blockedCache.cached({ + keyType: "user_id", + useCase: "RemoteRampFiftyBlocked", + id: ([userId]: [string]) => userId, + })(async (userId: string) => ({ userId, calls: ++blockedCalls })); + + // When both remote-only caches read the same key twice. + const passingFirst = await passingCache.enable(async () => await passing("123")); + const passingSecond = await passingCache.enable(async () => await passing("123")); + const blockedFirst = await blockedCache.enable(async () => await blocked("123")); + const blockedSecond = await blockedCache.enable(async () => await blocked("123")); + + // Then the sampled-in key uses Redis and the sampled-out key never touches Redis. + expect(passingFirst).toEqual({ userId: "123", calls: 1 }); + expect(passingSecond).toEqual({ userId: "123", calls: 1 }); + expect(blockedFirst).toEqual({ userId: "123", calls: 1 }); + expect(blockedSecond).toEqual({ userId: "123", calls: 2 }); + expect(passingRedis.getCalls).toBe(2); + expect(passingRedis.setCalls).toBe(1); + expect(blockedRedis.getCalls).toBe(0); + expect(blockedRedis.setCalls).toBe(0); + expect(passingSampler).toHaveBeenCalledWith(expect.objectContaining({ layer: CacheLayer.REMOTE, ramp: 50 })); + expect(blockedSampler).toHaveBeenCalledWith(expect.objectContaining({ layer: CacheLayer.REMOTE, ramp: 50 })); + }); + + it("uses one ramp sample for a local miss and write decision", async () => { + // Given the first ramp sample admits the read path and any immediate second sample would reject the write path. + const rampSampler = vi.fn().mockReturnValueOnce(49).mockReturnValueOnce(50); + const gcache = new GCache({ rampSampler }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "LocalRampSingleSample", + id: ([userId]: [string]) => userId, + defaultConfig: configFor({ [CacheLayer.LOCAL]: 60 }, { [CacheLayer.LOCAL]: 50 }), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the key misses once. + const first = await gcache.enable(async () => await getUser("123")); + + // Then the sampled-in miss writes local cache without resampling during the same call. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(rampSampler).toHaveBeenCalledTimes(1); + + // When the next read is sampled into the local layer again. + rampSampler.mockReset(); + rampSampler.mockReturnValueOnce(49); + const second = await gcache.enable(async () => await getUser("123")); + + // Then it can hit the value written by the original sampled-in miss. + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(rampSampler).toHaveBeenCalledTimes(1); + }); + + it("uses one ramp sample for a remote miss and write decision", async () => { + // Given the first remote ramp sample admits the read path and any immediate second sample would reject the write path. + const redis = new FakeRedis(); + const rampSampler = vi.fn().mockReturnValueOnce(49).mockReturnValueOnce(50); + const gcache = new GCache({ + redis: { client: redis }, + rampSampler, + cacheConfigProvider: async () => configFor({ [CacheLayer.REMOTE]: 60 }, { [CacheLayer.REMOTE]: 50 }), + }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "RemoteRampSingleSample", + id: ([userId]: [string]) => userId, + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the Redis key misses once. + const first = await gcache.enable(async () => await getUser("123")); + + // Then the sampled-in miss writes Redis without resampling during the same call. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(redis.setCalls).toBe(1); + expect(rampSampler).toHaveBeenCalledTimes(1); + + // When the next read is sampled into the remote layer again. + rampSampler.mockReset(); + rampSampler.mockReturnValueOnce(49); + const second = await gcache.enable(async () => await getUser("123")); + + // Then it can hit the value written by the original sampled-in miss. + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(rampSampler).toHaveBeenCalledTimes(1); + }); + + it("treats non-finite and fractional TTLs as invalid config", async () => { + // Given invalid TTL values are configured for local and remote layers. + const redis = new FakeRedis(); + const gcache = new GCache({ redis: { client: redis } }); + const badTtls = [Number.NaN, Number.POSITIVE_INFINITY, 0.5]; + + // When each cached function is called twice. + for (const ttl of badTtls) { + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: `InvalidTtl${String(ttl)}`, + id: ([userId]: [string]) => userId, + defaultConfig: configFor({ [CacheLayer.LOCAL]: ttl, [CacheLayer.REMOTE]: ttl }, { [CacheLayer.LOCAL]: 100, [CacheLayer.REMOTE]: 100 }), + })(async (userId: string) => ({ userId, ttl: String(ttl), calls: ++calls })); + + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + expect(first.calls).toBe(1); + expect(second.calls).toBe(2); + } + + // Then no invalid TTL reaches Redis. + expect(redis.getCalls).toBe(0); + expect(redis.setCalls).toBe(0); + }); + + it("disables missing local config while allowing the remote layer to work", async () => { + // Given runtime config only enables the remote layer. + const redis = new FakeRedis(); + const gcache = new GCache({ + redis: { client: redis }, + cacheConfigProvider: async () => configFor({ [CacheLayer.REMOTE]: 60 }, { [CacheLayer.REMOTE]: 100 }), + }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "RemoteOnlyRuntimeConfig", + id: ([userId]: [string]) => userId, + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the same key is read twice through a Redis-backed cache. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then local is skipped, Redis stores the fallback, and the second read comes from Redis. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(calls).toBe(1); + expect(redis.getCalls).toBe(2); + expect(redis.setCalls).toBe(1); + }); + + it("disables missing remote config while allowing the local layer to work", async () => { + // Given Redis exists but runtime config only enables the local layer. + const redis = new FakeRedis(); + const gcache = new GCache({ + redis: { client: redis }, + cacheConfigProvider: async () => configFor({ [CacheLayer.LOCAL]: 60 }, { [CacheLayer.LOCAL]: 100 }), + }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "LocalOnlyRuntimeConfig", + id: ([userId]: [string]) => userId, + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the same key is read twice through a Redis-backed cache. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then Redis is skipped and the second read comes from local cache. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(calls).toBe(1); + expect(redis.getCalls).toBe(0); + expect(redis.setCalls).toBe(0); + }); + + it("fails open when the runtime config provider throws", async () => { + // Given the runtime config provider is temporarily unavailable. + const providerError = new Error("config provider unavailable"); + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const gcache = new GCache({ + logger, + cacheConfigProvider: vi.fn(async () => { + throw providerError; + }), + }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "ConfigProviderThrows", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the cached function is called while config lookup fails. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then no provider error escapes and no value is accidentally cached. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 2 }); + expect(logger.error).toHaveBeenCalledWith("Error getting value from local cache", providerError); + }); +}); diff --git a/packages/gcache-ts/test/gcache-invalidation.test.ts b/packages/gcache-ts/test/gcache-invalidation.test.ts new file mode 100644 index 0000000..ab8bd55 --- /dev/null +++ b/packages/gcache-ts/test/gcache-invalidation.test.ts @@ -0,0 +1,376 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CacheLayer, + DEFAULT_WATERMARK_TTL_SEC, + GCache, + GCacheKey, + GCacheKeyConfig, + invalidationPrefix, + redisClusterHashTag, + type CacheMetricLabels, + type DisabledMetricLabels, + type ErrorMetricLabels, + type GCacheMetricsAdapter, + type InvalidationMetricLabels, + type RedisCommandClient, + type RedisStoredValue, + type RedisValueEnvelope, + type SerializationMetricLabels, +} from "../src/index.js"; + +class FakeRedis implements RedisCommandClient { + readonly values = new Map(); + getCalls = 0; + setCalls = 0; + delCalls = 0; + failSet = false; + failWatermarkGet = false; + + async get(key: string): Promise { + this.getCalls += 1; + if (this.failWatermarkGet && key.endsWith("#watermark")) { + throw new Error("watermark read failed"); + } + + const entry = this.values.get(key); + if (entry === undefined) { + return null; + } + if (entry.expiresAtMs <= Date.now()) { + this.values.delete(key); + return null; + } + return entry.value; + } + + async setEx(key: string, ttlSec: number, value: RedisStoredValue): Promise { + this.setCalls += 1; + if (this.failSet) { + throw new Error("redis set failed"); + } + this.values.set(key, { value, ttlSec, expiresAtMs: Date.now() + ttlSec * 1000 }); + } + + async del(key: string): Promise { + this.delCalls += 1; + return this.values.delete(key) ? 1 : 0; + } + + raw(key: string): string { + const value = this.values.get(key)?.value; + if (typeof value !== "string") { + throw new Error(`missing string value for ${key}`); + } + return value; + } +} + +class RecordingMetrics implements GCacheMetricsAdapter { + readonly events: Array<{ readonly name: string; readonly labels: Record; readonly value?: number }> = []; + + request(labels: CacheMetricLabels): void { + this.record("request", labels); + } + + miss(labels: CacheMetricLabels): void { + this.record("miss", labels); + } + + disabled(labels: DisabledMetricLabels): void { + this.record("disabled", labels); + } + + error(labels: ErrorMetricLabels): void { + this.record("error", labels); + } + + invalidation(labels: InvalidationMetricLabels): void { + this.record("invalidation", labels); + } + + observeGet(labels: CacheMetricLabels, seconds: number): void { + this.record("get", labels, seconds); + } + + observeFallback(labels: CacheMetricLabels, seconds: number): void { + this.record("fallback", labels, seconds); + } + + observeSerialization(labels: SerializationMetricLabels, seconds: number): void { + this.record("serialization", labels, seconds); + } + + observeSize(labels: CacheMetricLabels, bytes: number): void { + this.record("size", labels, bytes); + } + + private record(name: string, labels: object, value?: number): void { + this.events.push({ name, labels: { ...labels }, ...(value === undefined ? {} : { value }) }); + } +} + +const remoteOnly = (ttlSec = 60) => + new GCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: ttlSec }, + ramp: { [CacheLayer.REMOTE]: 100 }, + }); + +const localAndRemote = (ttlSec = 60) => GCacheKeyConfig.enabled(ttlSec); + +const valueKey = (useCase: string, args = ""): string => `{urn:user_id:123}${args}#${useCase}`; +const watermarkKey = "{urn:user_id:123}#watermark"; + +describe("GCache targeted invalidation watermarks", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("invalidates older Redis values for all tracked use cases sharing the same key type and id", async () => { + // Given two invalidation-tracked use cases have older Redis values for the same user id. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T18:00:00.000Z")); + const redis = new FakeRedis(); + const gcache = new GCache({ redis: { client: redis } }); + let profileVersion = 1; + let permissionsVersion = 1; + const getProfile = gcache.cached({ + keyType: "user_id", + useCase: "InvalidateProfile", + id: ([userId]: [string]) => userId, + trackForInvalidation: true, + defaultConfig: remoteOnly(), + })(async (userId: string) => ({ userId, profileVersion })); + const getPermissions = gcache.cached({ + keyType: "user_id", + useCase: "InvalidatePermissions", + id: ([userId]: [string]) => userId, + trackForInvalidation: true, + defaultConfig: remoteOnly(), + })(async (userId: string) => ({ userId, permissionsVersion })); + await gcache.enable(async () => { + await getProfile("123"); + await getPermissions("123"); + }); + profileVersion = 2; + permissionsVersion = 2; + + // When the shared key type/id is invalidated and both use cases read again after the watermark timestamp. + vi.setSystemTime(new Date("2026-05-12T18:00:00.001Z")); + await gcache.invalidate("user_id", "123"); + vi.setSystemTime(new Date("2026-05-12T18:00:00.002Z")); + const [profile, permissions] = await gcache.enable(async () => [await getProfile("123"), await getPermissions("123")]); + + // Then both stale Redis values are ignored and refreshed independently through fallback. + const profileEnvelope = JSON.parse(redis.raw(valueKey("InvalidateProfile"))) as RedisValueEnvelope; + const permissionsEnvelope = JSON.parse(redis.raw(valueKey("InvalidatePermissions"))) as RedisValueEnvelope; + expect(profile).toEqual({ userId: "123", profileVersion: 2 }); + expect(permissions).toEqual({ userId: "123", permissionsVersion: 2 }); + expect(JSON.parse(profileEnvelope.payload)).toEqual({ userId: "123", profileVersion: 2 }); + expect(JSON.parse(permissionsEnvelope.payload)).toEqual({ userId: "123", permissionsVersion: 2 }); + expect(redis.delCalls).toBe(2); + }); + + it("does not write Redis or local cache while a future invalidation window is active", async () => { + // Given a tracked Redis cache has an active future-buffer watermark. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T18:10:00.000Z")); + const redis = new FakeRedis(); + const gcache = new GCache({ redis: { client: redis } }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "FutureBufferUser", + id: ([userId]: [string]) => userId, + trackForInvalidation: true, + defaultConfig: localAndRemote(), + })(async (userId: string) => ({ userId, calls: ++calls })); + await gcache.invalidate("user_id", "123", { futureBufferMs: 1_000 }); + + // When the fallback runs during the active invalidation window. + vi.setSystemTime(new Date("2026-05-12T18:10:00.500Z")); + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then GCache returns fallback values but leaves only the watermark in Redis and does not populate local cache. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 2 }); + expect([...redis.values.keys()]).toEqual([watermarkKey]); + }); + + it("does not write Redis or local cache when a future invalidation arrives during fallback", async () => { + // Given a tracked cache miss starts before any invalidation watermark exists. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T18:15:00.000Z")); + const redis = new FakeRedis(); + const gcache = new GCache({ redis: { client: redis } }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "FutureBufferFallbackRace", + id: ([userId]: [string]) => userId, + trackForInvalidation: true, + defaultConfig: localAndRemote(), + })(async (userId: string) => { + calls += 1; + await gcache.invalidate("user_id", userId, { futureBufferMs: 1_000 }); + return { userId, calls }; + }); + + // When the fallback writes a future-buffer watermark before returning its result. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then the fallback values return but neither Redis nor local cache stores stale in-flight results. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 2 }); + expect([...redis.values.keys()]).toEqual([watermarkKey]); + }); + + it("refreshes malformed tracked Redis entries when no active watermark is present", async () => { + // Given a tracked key has a malformed Redis value but no watermark-read failure. + const redis = new FakeRedis(); + const redisKey = valueKey("TrackedMalformedEnvelope"); + redis.values.set(redisKey, { value: JSON.stringify({ version: 2, payload: "bad" }), ttlSec: 60, expiresAtMs: Date.now() + 60_000 }); + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const gcache = new GCache({ redis: { client: redis }, logger }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "TrackedMalformedEnvelope", + id: ([userId]: [string]) => userId, + trackForInvalidation: true, + defaultConfig: localAndRemote(), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the malformed value is read twice. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then GCache treats the bad envelope as a refreshable miss instead of a persistent cache bypass. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(calls).toBe(1); + expect(logger.warn).not.toHaveBeenCalledWith("Error getting value from Redis cache", expect.any(Error)); + }); + + it("keeps delimiter-containing tracked prefixes distinct", async () => { + // Given two tracked key prefixes would collide if keyType/id were joined raw with colons. + const first = invalidationPrefix("urn", "tenant:acme", "user"); + const second = invalidationPrefix("urn", "tenant", "acme:user"); + + // When the prefixes are converted into Redis Cluster hash tags. + const firstHashTag = redisClusterHashTag(first); + const secondHashTag = redisClusterHashTag(second); + + // Then the tags remain distinct and safe for targeted invalidation. + expect(first).not.toBe(second); + expect(firstHashTag).not.toBe(secondHashTag); + }); + + it("constructs Redis Cluster-compatible hash-tagged keys for tracked values and watermarks", async () => { + // Given Redis uses a key prefix and GCache uses a multi-part URN prefix. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T18:20:00.000Z")); + const redis = new FakeRedis(); + const gcache = new GCache({ + urnPrefix: "urn:galileo:test", + redis: { client: redis, keyPrefix: "gcache:", watermarkTtlSec: 42 }, + }); + const getUser = gcache.cached({ + keyType: "User", + useCase: "ClusterSlotUser", + id: ([userId]: [string, string]) => userId, + args: ([, locale]: [string, string]) => ({ locale }), + trackForInvalidation: true, + defaultConfig: remoteOnly(), + })(async (userId: string, locale: string) => ({ userId, locale })); + + // When a tracked value and its watermark are written. + await gcache.enable(async () => await getUser("123", "en")); + await gcache.invalidate("User", "123"); + + // Then both Redis keys share the same hash tag, custom prefix, and configured watermark TTL. + expect([...redis.values.keys()].sort()).toEqual([ + "gcache:{urn%3Agalileo%3Atest:User:123}#watermark", + "gcache:{urn%3Agalileo%3Atest:User:123}?locale=en#ClusterSlotUser", + ]); + expect(redis.values.get("gcache:{urn%3Agalileo%3Atest:User:123}#watermark")?.ttlSec).toBe(42); + expect(DEFAULT_WATERMARK_TTL_SEC).toBe(3600 * 4); + expect(redisClusterHashTag(invalidationPrefix("urn", "user_id", "123"))).toBe("{urn:user_id:123}"); + expect(() => new GCacheKey({ keyType: "user_id", id: "{123}", useCase: "BadTrackedKey", trackForInvalidation: true })).toThrow( + /hash tag/, + ); + }); + + it("documents the local-cache consistency limitation by preserving a stale local hit after Redis invalidation", async () => { + // Given a tracked mutable value is cached in both local and Redis layers. + const redis = new FakeRedis(); + const gcache = new GCache({ redis: { client: redis } }); + let version = 1; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "LocalInvalidationLimit", + id: ([userId]: [string]) => userId, + trackForInvalidation: true, + defaultConfig: localAndRemote(), + })(async (userId: string) => ({ userId, version })); + const before = await gcache.enable(async () => await getUser("123")); + version = 2; + + // When Redis is invalidated but the same process still has a local cache hit. + await gcache.invalidate("user_id", "123"); + const after = await gcache.enable(async () => await getUser("123")); + + // Then the local hit can remain stale, which is why strong invalidation should disable local cache. + expect(before).toEqual({ userId: "123", version: 1 }); + expect(after).toEqual({ userId: "123", version: 1 }); + }); + + it("fails open and records errors when watermark writes or reads fail", async () => { + // Given invalidation watermark writes fail but metrics and logging are enabled. + const writeRedis = new FakeRedis(); + writeRedis.failSet = true; + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const writeMetrics = new RecordingMetrics(); + const writeGCache = new GCache({ redis: { client: writeRedis }, logger, metrics: writeMetrics }); + + // When targeted invalidation cannot write its watermark. + await writeGCache.invalidate("user_id", "123"); + + // Then the API fails open, logs the operational failure, and records invalidation plus error metrics. + expect(logger.warn).toHaveBeenCalledWith("Error writing GCache invalidation watermark", expect.any(Error)); + expect(writeMetrics.events).toContainEqual({ name: "invalidation", labels: { keyType: "user_id", layer: CacheLayer.REMOTE } }); + expect(writeMetrics.events).toContainEqual({ + name: "error", + labels: { useCase: "watermark", keyType: "user_id", layer: CacheLayer.REMOTE, error: "Error", inFallback: false }, + }); + + // Given watermark reads fail for a tracked cached function. + const readRedis = new FakeRedis(); + readRedis.failWatermarkGet = true; + const readMetrics = new RecordingMetrics(); + const readGCache = new GCache({ redis: { client: readRedis }, logger, metrics: readMetrics }); + let calls = 0; + const getUser = readGCache.cached({ + keyType: "user_id", + useCase: "WatermarkReadFailOpen", + id: ([userId]: [string]) => userId, + trackForInvalidation: true, + defaultConfig: localAndRemote(), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the cached function runs while the watermark read is unavailable. + const first = await readGCache.enable(async () => await getUser("123")); + const second = await readGCache.enable(async () => await getUser("123")); + + // Then fallback results still return, the stale-risky result is not cached, and an error metric is recorded. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 2 }); + expect(readRedis.values.size).toBe(0); + expect(readMetrics.events).toContainEqual({ + name: "error", + labels: { useCase: "WatermarkReadFailOpen", keyType: "user_id", layer: CacheLayer.REMOTE, error: "Error", inFallback: false }, + }); + }); +}); diff --git a/packages/gcache-ts/test/gcache-local.test.ts b/packages/gcache-ts/test/gcache-local.test.ts new file mode 100644 index 0000000..4a179ad --- /dev/null +++ b/packages/gcache-ts/test/gcache-local.test.ts @@ -0,0 +1,473 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CacheLayer, + GCache, + GCacheKey, + GCacheKeyConfig, + JsonSerializer, + UseCaseIsAlreadyRegisteredError, + UseCaseNameIsReservedError, +} from "../src/index.js"; + +describe("GCache local-only MVP", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("keeps caching disabled by default", async () => { + // Given a cached function with a valid default local configuration. + const gcache = new GCache(); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "GetUserDefaultDisabled", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the function is called outside an enabled context. + const first = await getUser("123"); + const second = await getUser("123"); + + // Then the fallback executes every time. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 2 }); + expect(calls).toBe(2); + }); + + it("caches values inside an enabled context", async () => { + // Given a cached function called with the same cache key. + const gcache = new GCache(); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "GetUserEnabled", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the function is called twice inside gcache.enable(). + const [first, second] = await gcache.enable(async () => [await getUser("123"), await getUser("123")]); + + // Then the fallback only executes once and the second call returns the cached value. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(calls).toBe(1); + }); + + it("restores the previous enabled value after nested disable scopes", async () => { + // Given caching is enabled in an outer scope. + const gcache = new GCache(); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "GetUserNestedDisable", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When a nested disabled scope calls the cached function. + const result = await gcache.enable(async () => { + const first = await getUser("123"); + const disabled = await gcache.disable(async () => await getUser("123")); + const after = await getUser("123"); + return { first, disabled, after }; + }); + + // Then the disabled scope bypasses cache and the outer scope resumes using the cached value. + expect(result.first).toEqual({ userId: "123", calls: 1 }); + expect(result.disabled).toEqual({ userId: "123", calls: 2 }); + expect(result.after).toEqual({ userId: "123", calls: 1 }); + expect(calls).toBe(2); + }); + + it("does not leak enabled context across parallel async flows", async () => { + // Given one flow enables caching while another flow does not. + const gcache = new GCache(); + let calls = 0; + const getValue = gcache.cached({ + keyType: "tenant_id", + useCase: "ParallelContextIsolation", + id: ([tenantId]: [string]) => tenantId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (tenantId: string) => ({ tenantId, calls: ++calls })); + + // When both flows run concurrently. + const [enabledFlow, disabledFlow] = await Promise.all([ + gcache.enable(async () => [await getValue("enabled"), await getValue("enabled")] as const), + (async () => [await getValue("disabled"), await getValue("disabled")] as const)(), + ]); + + // Then enabled state is isolated to the enabled async flow. + expect(enabledFlow[0]).toEqual(enabledFlow[1]); + expect(enabledFlow[0]?.tenantId).toBe("enabled"); + expect(disabledFlow[0]?.tenantId).toBe("disabled"); + expect(disabledFlow[1]?.tenantId).toBe("disabled"); + expect(disabledFlow[0]?.calls).not.toBe(disabledFlow[1]?.calls); + expect(calls).toBe(3); + }); + + it("preserves enabled context through Promise.all within a scope", async () => { + // Given an enabled context with concurrent cache lookups for the same key. + const gcache = new GCache(); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "PromiseAllContext", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When one call populates cache before Promise.all repeats the same lookup. + const first = await gcache.enable(async () => await getUser("123")); + const parallel = await gcache.enable(async () => await Promise.all([getUser("123"), getUser("123")])); + + // Then all calls in the enabled async scopes can read the cached value. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(parallel).toEqual([ + { userId: "123", calls: 1 }, + { userId: "123", calls: 1 }, + ]); + expect(calls).toBe(1); + }); + + it("keeps delimiter-containing ids and args in distinct local cache keys", async () => { + // Given two calls would collide if key components were concatenated without escaping. + const gcache = new GCache(); + let calls = 0; + const search = gcache.cached({ + keyType: "user_id", + useCase: "DelimiterSafeLocalKeys", + id: ([userId]: [string, string | undefined]) => userId, + args: ([, filter]: [string, string | undefined]) => ({ filter }), + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string, filter?: string) => ({ userId, filter, calls: ++calls })); + + // When an id containing a query delimiter is followed by a structurally different key. + const [first, second, firstAgain, secondAgain] = await gcache.enable(async () => [ + await search("123?filter=active", undefined), + await search("123", "active"), + await search("123?filter=active", undefined), + await search("123", "active"), + ]); + + // Then each logical key gets its own cached value instead of sharing a colliding URN. + expect(first).toEqual({ userId: "123?filter=active", filter: undefined, calls: 1 }); + expect(second).toEqual({ userId: "123", filter: "active", calls: 2 }); + expect(firstAgain).toEqual(first); + expect(secondAgain).toEqual(second); + expect(calls).toBe(2); + }); + + it("uses sorted explicit args as part of the cache key", async () => { + // Given a cached function with explicit key args in non-sorted declaration order. + const gcache = new GCache(); + let calls = 0; + const search = gcache.cached({ + keyType: "user_id", + useCase: "SearchPosts", + id: ([userId]: [string, number, string]) => userId, + args: ([, page, filter]) => ({ page, filter }), + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string, page: number, filter: string) => ({ userId, page, filter, calls: ++calls })); + + // When calls vary by explicit args. + const results = await gcache.enable(async () => [ + await search("123", 1, "active"), + await search("123", 1, "active"), + await search("123", 2, "active"), + await search("123", 1, "archived"), + ]); + + // Then only identical explicit args share the same cached value. + expect(results).toEqual([ + { userId: "123", page: 1, filter: "active", calls: 1 }, + { userId: "123", page: 1, filter: "active", calls: 1 }, + { userId: "123", page: 2, filter: "active", calls: 2 }, + { userId: "123", page: 1, filter: "archived", calls: 3 }, + ]); + expect(calls).toBe(3); + }); + + it("expires local cache entries after their ttl", async () => { + // Given a cached function with a one second local TTL. + vi.useFakeTimers(); + const gcache = new GCache(); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "LocalTtlExpiration", + id: ([userId]: [string]) => userId, + defaultConfig: new GCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 1 }, + ramp: { [CacheLayer.LOCAL]: 100 }, + }), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the same key is called before and after TTL expiration. + const first = await gcache.enable(async () => await getUser("123")); + vi.advanceTimersByTime(999); + const beforeTtl = await gcache.enable(async () => await getUser("123")); + vi.advanceTimersByTime(2); + const afterTtl = await gcache.enable(async () => await getUser("123")); + + // Then the cached value is reused before TTL and refreshed after TTL. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(beforeTtl).toEqual({ userId: "123", calls: 1 }); + expect(afterTtl).toEqual({ userId: "123", calls: 2 }); + expect(calls).toBe(2); + }); + + it("fails open when key construction fails", async () => { + // Given a cached function whose key builder throws. + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const gcache = new GCache({ logger }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "KeyConstructionFailure", + id: () => { + throw new Error("bad id"); + }, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async () => ({ calls: ++calls })); + + // When the cached function is called in an enabled scope. + const first = await gcache.enable(async () => await getUser()); + const second = await gcache.enable(async () => await getUser()); + + // Then the fallback still succeeds and no value is cached. + expect(first).toEqual({ calls: 1 }); + expect(second).toEqual({ calls: 2 }); + expect(logger.error).toHaveBeenCalledWith("Could not construct GCache key", expect.any(Error)); + }); + + it("falls through when local cache config is missing", async () => { + // Given a cached function without any key config. + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const gcache = new GCache({ logger }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "MissingConfigFailure", + id: ([userId]: [string]) => userId, + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the cached function is called in an enabled scope. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then the local layer is disabled and fallback still succeeds without treating missing config as an error. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 2 }); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("supports delete and flushAll for local entries", async () => { + // Given two cached values in the local cache. + const gcache = new GCache(); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "DeleteAndFlush", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + await gcache.enable(async () => { + await getUser("123"); + await getUser("456"); + }); + + // When one key is deleted and then the cache is flushed. + const deleted = await gcache.delete(new GCacheKey({ keyType: "user_id", id: "123", useCase: "DeleteAndFlush" })); + const afterDelete = await gcache.enable(async () => [await getUser("123"), await getUser("456")]); + await gcache.flushAll(); + const afterFlush = await gcache.enable(async () => [await getUser("123"), await getUser("456")]); + + // Then only the deleted key refreshes before flush and all keys refresh after flush. + expect(deleted).toBe(true); + expect(afterDelete).toEqual([ + { userId: "123", calls: 3 }, + { userId: "456", calls: 2 }, + ]); + expect(afterFlush).toEqual([ + { userId: "123", calls: 4 }, + { userId: "456", calls: 5 }, + ]); + }); + + it("rejects duplicate and reserved use cases", () => { + // Given a GCache instance with one registered use case. + const gcache = new GCache(); + gcache.cached({ + keyType: "user_id", + useCase: "UniqueUseCase", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => userId); + + // When another function registers the same use case or the reserved watermark use case. + const duplicate = () => + gcache.cached({ + keyType: "user_id", + useCase: "UniqueUseCase", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => userId); + const reserved = () => + gcache.cached({ + keyType: "user_id", + useCase: "watermark", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => userId); + + // Then GCache rejects both registrations. + expect(duplicate).toThrow(UseCaseIsAlreadyRegisteredError); + expect(reserved).toThrow(UseCaseNameIsReservedError); + }); + + it("supports withEnabled and withDisabled aliases", async () => { + // Given a cached function and the readability aliases. + const gcache = new GCache(); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "AliasScopes", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When withEnabled and withDisabled are nested. + const result = await gcache.withEnabled(async () => { + const first = await getUser("123"); + const disabled = await gcache.withDisabled(async () => await getUser("123")); + const after = await getUser("123"); + return { first, disabled, after }; + }); + + // Then they behave like enable and disable. + expect(result).toEqual({ + first: { userId: "123", calls: 1 }, + disabled: { userId: "123", calls: 2 }, + after: { userId: "123", calls: 1 }, + }); + }); + + it("treats non-positive local ttl as disabled local config", async () => { + // Given a cached function with an invalid local TTL. + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const gcache = new GCache({ logger }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "InvalidLocalTtl", + id: ([userId]: [string]) => userId, + defaultConfig: new GCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 0 }, + ramp: { [CacheLayer.LOCAL]: 100 }, + }), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the function is called in an enabled scope. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then the local cache is bypassed and the fallback still succeeds. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 2 }); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("evicts the oldest local entry when max size is exceeded", async () => { + // Given a local cache with room for one entry. + const gcache = new GCache({ localMaxSize: 1 }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "LocalMaxSizeEviction", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When two different keys are cached. + await gcache.enable(async () => { + await getUser("123"); + await getUser("456"); + }); + const newestStillCached = await gcache.enable(async () => await getUser("456")); + const oldestRefreshed = await gcache.enable(async () => await getUser("123")); + const newestRefreshedAfterSecondEviction = await gcache.enable(async () => await getUser("456")); + + // Then the newest key is initially cached, the oldest key refreshes, and max-size eviction continues to apply. + expect(newestStillCached).toEqual({ userId: "456", calls: 2 }); + expect(oldestRefreshed).toEqual({ userId: "123", calls: 3 }); + expect(newestRefreshedAfterSecondEviction).toEqual({ userId: "456", calls: 4 }); + }); + + it("round-trips values through the JSON serializer", async () => { + // Given the default JSON serializer. + const serializer = new JsonSerializer<{ id: string; enabled: boolean }>(); + + // When a value is dumped and loaded from both string and Buffer payloads. + const dumped = await serializer.dump({ id: "123", enabled: true }); + const loadedFromString = await serializer.load(dumped); + const loadedFromBuffer = await serializer.load(Buffer.from(dumped)); + + // Then the serializer preserves the JSON-safe value. + expect(loadedFromString).toEqual({ id: "123", enabled: true }); + expect(loadedFromBuffer).toEqual({ id: "123", enabled: true }); + }); + + it("builds stable human-readable URNs for simple components", () => { + // Given cache args that are not already sorted. + const key = new GCacheKey({ + keyType: "user_id", + id: "123", + useCase: "GetPosts", + args: [ + ["filter", "active"], + ["page", "2"], + ], + }); + + // When the key is rendered. + const rendered = key.toString(); + + // Then it keeps the structured key format used for debugging and grouping. + expect(rendered).toBe("urn:user_id:123?filter=active&page=2#GetPosts"); + }); + + it("keeps delimiter-containing URN components and args distinct", () => { + // Given keys whose raw components would collide without escaping delimiter characters. + const prefixWithDelimiter = new GCacheKey({ keyType: "user_id", id: "123", useCase: "GetPosts", urnPrefix: "urn:gcache" }); + const argValueWithDelimiter = new GCacheKey({ + keyType: "user_id", + id: "123", + useCase: "GetPosts", + args: [["filter", "active&page=2"]], + }); + const splitArgs = new GCacheKey({ + keyType: "user_id", + id: "123", + useCase: "GetPosts", + args: [ + ["filter", "active"], + ["page", "2"], + ], + }); + const argValueWithFragment = new GCacheKey({ keyType: "user_id", id: "123", useCase: "GetPosts", args: [["filter", "active#Other"]] }); + const useCaseWithFragment = new GCacheKey({ keyType: "user_id", id: "123", useCase: "Other", args: [["filter", "active"]] }); + + // When the keys are rendered. + // Then delimiter-bearing components are encoded, while simple components remain readable. + expect(prefixWithDelimiter.toString()).toBe("urn%3Agcache:user_id:123#GetPosts"); + expect(argValueWithDelimiter.toString()).not.toBe(splitArgs.toString()); + expect(argValueWithDelimiter.toString()).toContain("filter=active%26page%3D2"); + expect(argValueWithFragment.toString()).not.toBe(useCaseWithFragment.toString()); + expect(argValueWithFragment.toString()).toContain("filter=active%23Other#GetPosts"); + }); +}); diff --git a/packages/gcache-ts/test/gcache-metrics.test.ts b/packages/gcache-ts/test/gcache-metrics.test.ts new file mode 100644 index 0000000..b67c6fe --- /dev/null +++ b/packages/gcache-ts/test/gcache-metrics.test.ts @@ -0,0 +1,330 @@ +import { Registry } from "prom-client"; +import { describe, expect, it, vi } from "vitest"; + +import { + CacheLayer, + GCache, + GCacheKeyConfig, + type CacheMetricLabels, + type DisabledMetricLabels, + type ErrorMetricLabels, + type GCacheMetricsAdapter, + type InvalidationMetricLabels, + type RedisCommandClient, + type RedisStoredValue, + type SerializationMetricLabels, +} from "../src/index.js"; + +class FakeRedis implements RedisCommandClient { + readonly values = new Map(); + failGet = false; + + async get(key: string): Promise { + if (this.failGet) { + throw new Error("redis unavailable"); + } + return this.values.get(key) ?? null; + } + + async setEx(key: string, _ttlSec: number, value: RedisStoredValue): Promise { + this.values.set(key, value); + } + + async del(key: string): Promise { + return this.values.delete(key) ? 1 : 0; + } +} + +class RecordingMetrics implements GCacheMetricsAdapter { + readonly events: Array<{ readonly name: string; readonly labels: Record; readonly value?: number }> = []; + + request(labels: CacheMetricLabels): void { + this.record("request", labels); + } + + miss(labels: CacheMetricLabels): void { + this.record("miss", labels); + } + + disabled(labels: DisabledMetricLabels): void { + this.record("disabled", labels); + } + + error(labels: ErrorMetricLabels): void { + this.record("error", labels); + } + + invalidation(labels: InvalidationMetricLabels): void { + this.record("invalidation", labels); + } + + observeGet(labels: CacheMetricLabels, seconds: number): void { + this.record("get", labels, seconds); + } + + observeFallback(labels: CacheMetricLabels, seconds: number): void { + this.record("fallback", labels, seconds); + } + + observeSerialization(labels: SerializationMetricLabels, seconds: number): void { + this.record("serialization", labels, seconds); + } + + observeSize(labels: CacheMetricLabels, bytes: number): void { + this.record("size", labels, bytes); + } + + private record(name: string, labels: object, value?: number): void { + this.events.push({ name, labels: { ...labels }, ...(value === undefined ? {} : { value }) }); + } +} + +const localOnly = (ttlSec = 60) => + new GCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: ttlSec }, + ramp: { [CacheLayer.LOCAL]: 100 }, + }); + +const remoteOnly = () => + new GCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 60 }, + ramp: { [CacheLayer.REMOTE]: 100 }, + }); + +describe("GCache observability metrics", () => { + it("reuses existing Prometheus collectors when multiple caches share a registry", async () => { + // Given two GCache instances use the same custom Prometheus registry and metric names. + const registry = new Registry(); + const firstCache = new GCache({ metricsRegistry: registry }); + const secondCache = new GCache({ metricsRegistry: registry }); + const first = firstCache.cached({ + keyType: "user_id", + useCase: "DuplicateMetricRegistrationFirst", + id: ([userId]: [string]) => userId, + defaultConfig: localOnly(), + })(async (userId: string) => ({ userId })); + const second = secondCache.cached({ + keyType: "user_id", + useCase: "DuplicateMetricRegistrationSecond", + id: ([userId]: [string]) => userId, + defaultConfig: localOnly(), + })(async (userId: string) => ({ userId })); + + // When both caches emit request metrics. + await firstCache.enable(async () => await first("123")); + await secondCache.enable(async () => await second("456")); + + // Then construction does not throw duplicate-registration errors and both samples land in one collector. + await expect(sumMetric(registry, "gcache_request_counter")).resolves.toBe(2); + await expect(registry.getSingleMetricAsString("gcache_request_counter")).resolves.toContain( + "gcache_request_counter", + ); + }); + + it("supports an injected metrics adapter without requiring Prometheus", async () => { + // Given a custom in-memory metrics adapter. + const metrics = new RecordingMetrics(); + const gcache = new GCache({ metrics }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "CustomMetricsAdapter", + id: ([userId]: [string]) => userId, + defaultConfig: localOnly(), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When a local miss is followed by a local hit. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then the adapter receives behavioral request/miss/timer events for the local layer. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(events(metrics, "request", { useCase: "CustomMetricsAdapter", layer: CacheLayer.LOCAL })).toHaveLength(2); + expect(events(metrics, "miss", { useCase: "CustomMetricsAdapter", layer: CacheLayer.LOCAL })).toHaveLength(1); + expect(events(metrics, "fallback", { useCase: "CustomMetricsAdapter", layer: CacheLayer.LOCAL })).toHaveLength(1); + expect(events(metrics, "get", { useCase: "CustomMetricsAdapter", layer: CacheLayer.LOCAL })).toHaveLength(2); + }); + + it("fails open when an injected metrics adapter throws", async () => { + // Given a custom metrics adapter throws for every metric call. + const throwingMetrics: GCacheMetricsAdapter = { + request: () => { throw new Error("metrics unavailable"); }, + miss: () => { throw new Error("metrics unavailable"); }, + disabled: () => { throw new Error("metrics unavailable"); }, + error: () => { throw new Error("metrics unavailable"); }, + invalidation: () => { throw new Error("metrics unavailable"); }, + observeGet: () => { throw new Error("metrics unavailable"); }, + observeFallback: () => { throw new Error("metrics unavailable"); }, + observeSerialization: () => { throw new Error("metrics unavailable"); }, + observeSize: () => { throw new Error("metrics unavailable"); }, + }; + const gcache = new GCache({ metrics: throwingMetrics }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "ThrowingMetricsFailOpen", + id: ([userId]: [string]) => userId, + defaultConfig: localOnly(), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When metrics emission fails around a cache miss and hit. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then metrics failures do not break application fallback or cache behavior. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + }); + + it("classifies disabled cache skips by reason", async () => { + // Given one cache call is outside context and other enabled calls have disabled layer config. + const metrics = new RecordingMetrics(); + const gcache = new GCache({ metrics }); + const contextDisabled = gcache.cached({ + keyType: "user_id", + useCase: "DisabledByContext", + id: ([userId]: [string]) => userId, + defaultConfig: localOnly(), + })(async (userId: string) => userId); + const missingConfig = gcache.cached({ + keyType: "user_id", + useCase: "DisabledByMissingConfig", + id: ([userId]: [string]) => userId, + })(async (userId: string) => userId); + const invalidTtl = gcache.cached({ + keyType: "user_id", + useCase: "DisabledByInvalidTtl", + id: ([userId]: [string]) => userId, + defaultConfig: localOnly(0), + })(async (userId: string) => userId); + const rampedDown = gcache.cached({ + keyType: "user_id", + useCase: "DisabledByRamp", + id: ([userId]: [string]) => userId, + defaultConfig: new GCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 60 }, + ramp: { [CacheLayer.LOCAL]: 0 }, + }), + })(async (userId: string) => userId); + + // When each path is called. + await contextDisabled("123"); + await gcache.enable(async () => { + await missingConfig("123"); + await invalidTtl("123"); + await rampedDown("123"); + }); + + // Then disabled metrics preserve the operational reason labels. + expect(events(metrics, "disabled", { useCase: "DisabledByContext", layer: "noop", reason: "context" })).toHaveLength(1); + expect( + events(metrics, "disabled", { useCase: "DisabledByMissingConfig", layer: CacheLayer.LOCAL, reason: "missing_config" }), + ).toHaveLength(1); + expect(events(metrics, "disabled", { useCase: "DisabledByInvalidTtl", layer: CacheLayer.LOCAL, reason: "invalid_ttl" })).toHaveLength(1); + expect(events(metrics, "disabled", { useCase: "DisabledByRamp", layer: CacheLayer.LOCAL, reason: "ramped_down" })).toHaveLength(1); + }); + + it("labels cache errors separately from fallback errors", async () => { + // Given one Redis-backed cache has a cache read failure and another has a fallback failure. + const metrics = new RecordingMetrics(); + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const failingRedis = new FakeRedis(); + failingRedis.failGet = true; + const cacheFailure = new GCache({ redis: { client: failingRedis }, metrics, logger }); + const readThroughFailure = cacheFailure.cached({ + keyType: "user_id", + useCase: "CacheErrorClassification", + id: ([userId]: [string]) => userId, + defaultConfig: remoteOnly(), + })(async (userId: string) => ({ userId })); + const fallbackCache = new GCache({ redis: { client: new FakeRedis() }, metrics, logger }); + const fallbackFailure = fallbackCache.cached({ + keyType: "user_id", + useCase: "FallbackErrorClassification", + id: ([userId]: [string]) => userId, + defaultConfig: remoteOnly(), + })(async () => { + throw new TypeError("database failed"); + }); + + // When the cache error fails open and the fallback error escapes. + await cacheFailure.enable(async () => await readThroughFailure("123")); + await expect(fallbackCache.enable(async () => await fallbackFailure("123"))).rejects.toThrow("database failed"); + + // Then error labels identify whether the failure came from cache plumbing or from the fallback. + expect( + events(metrics, "error", { + useCase: "CacheErrorClassification", + layer: CacheLayer.REMOTE, + error: "Error", + inFallback: false, + }), + ).toHaveLength(1); + expect( + events(metrics, "error", { + useCase: "FallbackErrorClassification", + layer: CacheLayer.REMOTE, + error: "TypeError", + inFallback: true, + }), + ).toHaveLength(1); + }); + + it("exports Prometheus counters and histograms for requests, misses, fallbacks, gets, serialization, and size", async () => { + // Given a custom Prometheus registry and a Redis-backed cached function. + const registry = new Registry(); + const redis = new FakeRedis(); + const gcache = new GCache({ redis: { client: redis }, metricsRegistry: registry, metricsPrefix: "test_" }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "PrometheusMetricExport", + id: ([userId]: [string]) => userId, + defaultConfig: remoteOnly(), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the first read misses Redis and the second read hits Redis. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then Prometheus contains Python-aligned metric families with the expected label values. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + await expect(sumMetric(registry, "test_gcache_request_counter", { use_case: "PrometheusMetricExport", layer: "remote" })).resolves.toBe(2); + await expect(sumMetric(registry, "test_gcache_miss_counter", { use_case: "PrometheusMetricExport", layer: "remote" })).resolves.toBe(1); + await expect(sumMetric(registry, "test_gcache_get_timer", { use_case: "PrometheusMetricExport", layer: "remote" })).resolves.toBeGreaterThan(0); + await expect(sumMetric(registry, "test_gcache_fallback_timer", { use_case: "PrometheusMetricExport", layer: "remote" })).resolves.toBeGreaterThan(0); + await expect( + sumMetric(registry, "test_gcache_serialization_timer", { use_case: "PrometheusMetricExport", layer: "remote" }), + ).resolves.toBeGreaterThan(0); + await expect(sumMetric(registry, "test_gcache_size_histogram", { use_case: "PrometheusMetricExport", layer: "remote" })).resolves.toBeGreaterThan(0); + }); +}); + +function events( + metrics: RecordingMetrics, + name: string, + labels: Record, +): Array<{ readonly name: string; readonly labels: Record; readonly value?: number }> { + return metrics.events.filter( + (event) => event.name === name && Object.entries(labels).every(([key, value]) => event.labels[key] === value), + ); +} + +async function sumMetric( + registry: Registry, + name: string, + labels: Record = {}, +): Promise { + const metrics = (await registry.getMetricsAsJSON()) as Array<{ + readonly name: string; + readonly values: Array<{ readonly value: number; readonly labels: Record }>; + }>; + const metric = metrics.find((candidate) => candidate.name === name); + return ( + metric?.values + .filter((sample) => Object.entries(labels).every(([key, value]) => sample.labels[key] === value)) + .reduce((total, sample) => total + sample.value, 0) ?? 0 + ); +} diff --git a/packages/gcache-ts/test/gcache-observability-internals.test.ts b/packages/gcache-ts/test/gcache-observability-internals.test.ts new file mode 100644 index 0000000..f123985 --- /dev/null +++ b/packages/gcache-ts/test/gcache-observability-internals.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; + +import { CacheLayer, GCacheKey, GCacheKeyConfig, type RedisCommandClient, type RedisStoredValue } from "../src/index.js"; +import { LocalCache } from "../src/internal/local-cache.js"; +import { RedisCache, type RedisValueEnvelope } from "../src/internal/redis-cache.js"; +import { resolveLayerConfig } from "../src/internal/runtime-config.js"; +import { errorName } from "../src/metrics.js"; + +class MemoryRedis implements RedisCommandClient { + readonly values = new Map(); + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async setEx(key: string, _ttlSec: number, value: RedisStoredValue): Promise { + this.values.set(key, value); + } + + async del(key: string): Promise { + return this.values.delete(key) ? 1 : 0; + } +} + +const key = (defaultConfig: GCacheKeyConfig | null = GCacheKeyConfig.enabled(60)) => + new GCacheKey({ keyType: "user_id", id: "123", useCase: "ObservabilityInternals", defaultConfig }); + +describe("GCache observability internal compatibility paths", () => { + it("keeps LocalCache get/getIfPresent compatibility while exposing disabled reads", async () => { + // Given a local cache with enabled config and a second key with no config. + const cache = new LocalCache(async () => null, () => 0, 10); + const enabledKey = key(); + const disabledKey = key(null); + let calls = 0; + + // When get() populates a value and getIfPresent() reads it back. + const first = await cache.get(enabledKey, async () => ({ calls: ++calls })); + const hit = await cache.getIfPresent<{ calls: number }>(enabledKey); + const disabled = await cache.getIfPresentResult(disabledKey); + + // Then compatibility helpers still behave like the pre-metrics API, and disabled state is explicit. + expect(first).toEqual({ calls: 1 }); + expect(hit).toEqual({ calls: 1 }); + expect(disabled).toEqual({ status: "disabled", reason: "missing_config" }); + expect(calls).toBe(1); + }); + + it("keeps RedisCache get compatibility and skips writes when remote config is disabled", async () => { + // Given a Redis cache with one valid stored envelope and one key without remote config. + const redis = new MemoryRedis(); + const redisCache = new RedisCache({ + configProvider: async () => null, + rampSampler: () => 0, + redis: { client: redis }, + metrics: null, + }); + const enabledKey = key(); + const disabledKey = new GCacheKey({ + keyType: "user_id", + id: "456", + useCase: "ObservabilityInternals", + defaultConfig: new GCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 60 }, + ramp: { [CacheLayer.LOCAL]: 100 }, + }), + }); + redis.values.set( + enabledKey.urn, + JSON.stringify({ + version: 1, + createdAtMs: Date.now(), + expiresAtMs: Date.now() + 60_000, + encoding: "utf8", + payload: JSON.stringify({ source: "redis" }), + } satisfies RedisValueEnvelope), + ); + + // When the compatibility get() reads Redis and put() sees disabled remote config. + const hit = await redisCache.get<{ source: string }>(enabledKey); + await redisCache.put(disabledKey, { source: "fallback" }); + + // Then get() unwraps the value and the disabled remote write is skipped. + expect(hit).toEqual({ source: "redis" }); + expect(redis.values.has(disabledKey.urn)).toBe(false); + }); + + it("preserves runtime-config and error-name edge behavior used by metrics", async () => { + // Given configs for missing ramp and non-finite ramp samples. + const missingRamp = new GCacheKeyConfig({ ttlSec: { [CacheLayer.LOCAL]: 60 }, ramp: {} }); + const partialRamp = new GCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 60 }, + ramp: { [CacheLayer.LOCAL]: 50 }, + }); + + // When runtime config is resolved through compatibility and sampled disabled paths. + const noConfig = await resolveLayerConfig({ + configProvider: async () => null, + key: key(null), + layer: CacheLayer.LOCAL, + rampSampler: vi.fn(), + }); + const noRamp = await resolveLayerConfig({ + configProvider: async () => missingRamp, + key: key(), + layer: CacheLayer.LOCAL, + rampSampler: vi.fn(), + }); + const nonFiniteSample = await resolveLayerConfig({ + configProvider: async () => partialRamp, + key: key(), + layer: CacheLayer.LOCAL, + rampSampler: () => Number.NaN, + }); + + // Then disabled config returns null and non-Error throws get stable metric labels. + expect(noConfig).toBeNull(); + expect(noRamp).toBeNull(); + expect(nonFiniteSample).toBeNull(); + expect(errorName("string failure")).toBe("string"); + }); +}); diff --git a/packages/gcache-ts/test/gcache-redis.test.ts b/packages/gcache-ts/test/gcache-redis.test.ts new file mode 100644 index 0000000..fd137d0 --- /dev/null +++ b/packages/gcache-ts/test/gcache-redis.test.ts @@ -0,0 +1,452 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CacheLayer, + GCache, + GCacheKey, + GCacheKeyConfig, + type RedisCommandClient, + type RedisStoredValue, + type RedisValueEnvelope, + type Serializer, +} from "../src/index.js"; + +class FakeRedis implements RedisCommandClient { + readonly values = new Map(); + getCalls = 0; + setCalls = 0; + delCalls = 0; + flushAllCalls = 0; + failGet = false; + failSet = false; + failDel = false; + failFlushAll = false; + + async get(key: string): Promise { + this.getCalls += 1; + if (this.failGet) { + throw new Error("redis get failed"); + } + + const entry = this.values.get(key); + if (entry === undefined) { + return null; + } + if (entry.expiresAtMs <= Date.now()) { + this.values.delete(key); + return null; + } + return entry.value; + } + + async setEx(key: string, ttlSec: number, value: RedisStoredValue): Promise { + this.setCalls += 1; + if (this.failSet) { + throw new Error("redis set failed"); + } + this.values.set(key, { value, expiresAtMs: Date.now() + ttlSec * 1000 }); + } + + async del(key: string): Promise { + this.delCalls += 1; + if (this.failDel) { + throw new Error("redis del failed"); + } + return this.values.delete(key) ? 1 : 0; + } + + async flushAll(): Promise { + this.flushAllCalls += 1; + if (this.failFlushAll) { + throw new Error("redis flushAll failed"); + } + this.values.clear(); + } + + raw(key: string): string { + const value = this.values.get(key)?.value; + if (typeof value !== "string") { + throw new Error(`missing string value for ${key}`); + } + return value; + } +} + +const keyFor = (id: string, useCase: string): GCacheKey => new GCacheKey({ keyType: "user_id", id, useCase }); + +describe("GCache Redis TTL layer", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("reads local miss from Redis and populates the local layer", async () => { + // Given one process has already written a value into the shared Redis cache. + const redis = new FakeRedis(); + const writer = new GCache({ redis: { client: redis } }); + let writerCalls = 0; + const writeUser = writer.cached({ + keyType: "user_id", + useCase: "RedisLocalPopulate", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++writerCalls })); + await writer.enable(async () => await writeUser("123")); + + const reader = new GCache({ redis: { client: redis } }); + let readerCalls = 0; + const readUser = reader.cached({ + keyType: "user_id", + useCase: "RedisLocalPopulate", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++readerCalls })); + redis.getCalls = 0; + + // When a second process reads the same key twice. + const first = await reader.enable(async () => await readUser("123")); + redis.failGet = true; + const second = await reader.enable(async () => await readUser("123")); + + // Then the first read comes from Redis and the second read comes from the populated local cache. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(readerCalls).toBe(0); + expect(redis.getCalls).toBe(1); + }); + + it("writes Redis misses with a timestamped versioned envelope", async () => { + // Given an enabled Redis-backed cache with deterministic time. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T17:00:00.000Z")); + const redis = new FakeRedis(); + const gcache = new GCache({ redis: { client: redis, keyPrefix: "gcache:" } }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "RedisEnvelopeWrite", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(30), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When Redis misses and the fallback succeeds. + const value = await gcache.enable(async () => await getUser("123")); + const redisKey = `gcache:${keyFor("123", "RedisEnvelopeWrite").urn}`; + const envelope = JSON.parse(redis.raw(redisKey)) as RedisValueEnvelope; + + // Then GCache stores the fallback result in a TS-specific Redis envelope with TTL metadata. + expect(value).toEqual({ userId: "123", calls: 1 }); + expect(envelope).toMatchObject({ + version: 1, + createdAtMs: Date.parse("2026-05-12T17:00:00.000Z"), + expiresAtMs: Date.parse("2026-05-12T17:00:30.000Z"), + encoding: "utf8", + }); + expect(JSON.parse(envelope.payload)).toEqual({ userId: "123", calls: 1 }); + }); + + it("retries a lazy Redis client factory after a transient rejection", async () => { + // Given the first lazy Redis connection attempt fails but a later attempt can succeed. + const redis = new FakeRedis(); + let factoryCalls = 0; + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const gcache = new GCache({ + redis: { + createClient: async () => { + factoryCalls += 1; + if (factoryCalls === 1) { + throw new Error("redis boot failed"); + } + return redis; + }, + }, + logger, + }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "RedisClientFactoryRetry", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the first call fails open and the second call retries Redis. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then the rejected client promise does not poison the GCache instance forever. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 1 }); + expect(factoryCalls).toBe(2); + expect(redis.setCalls).toBe(1); + }); + + it("uses a lazy Redis client factory once", async () => { + // Given Redis is configured with a client factory instead of an eager client. + const redis = new FakeRedis(); + let factoryCalls = 0; + const gcache = new GCache({ + redis: { + createClient: async () => { + factoryCalls += 1; + return redis; + }, + }, + }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "RedisClientFactory", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When multiple cache operations need Redis. + await gcache.enable(async () => { + await getUser("123"); + await getUser("456"); + }); + + // Then the factory is lazy and reused for subsequent Redis commands. + expect(factoryCalls).toBe(1); + expect(redis.setCalls).toBe(2); + }); + + it("fails open when Redis operations fail before fallback", async () => { + // Given Redis is unavailable and local caching is not configured for this key. + const redis = new FakeRedis(); + redis.failGet = true; + redis.failSet = true; + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const gcache = new GCache({ redis: { client: redis }, logger }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "RedisFailOpen", + id: ([userId]: [string]) => userId, + defaultConfig: new GCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 0, [CacheLayer.REMOTE]: 60 }, + ramp: { [CacheLayer.LOCAL]: 100, [CacheLayer.REMOTE]: 100 }, + }), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When the cached function is called while cache reads and writes fail. + const first = await gcache.enable(async () => await getUser("123")); + const second = await gcache.enable(async () => await getUser("123")); + + // Then application fallback results are still returned and no Redis error escapes. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(second).toEqual({ userId: "123", calls: 2 }); + expect(logger.warn).toHaveBeenCalledWith("Error getting value from Redis cache", expect.any(Error)); + expect(logger.warn).toHaveBeenCalledWith("Error putting value in Redis cache", expect.any(Error)); + }); + + it("round-trips Redis values through a custom serializer", async () => { + // Given a custom serializer is configured for a cached function. + const redis = new FakeRedis(); + const serializer: Serializer<{ id: string; source: string }> = { + dump: vi.fn(async (value) => Buffer.from(`${value.id}|${value.source}`, "utf8")), + load: vi.fn(async (value) => { + const [id, source] = Buffer.isBuffer(value) ? value.toString("utf8").split("|") : value.split("|"); + return { id: id ?? "", source: source ?? "" }; + }), + }; + const writer = new GCache({ redis: { client: redis } }); + const readFromWriter = writer.cached({ + keyType: "user_id", + useCase: "RedisCustomSerializer", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + serializer, + })(async (userId: string) => ({ id: userId, source: "fallback" })); + await writer.enable(async () => await readFromWriter("123")); + + const reader = new GCache({ redis: { client: redis } }); + let readerCalls = 0; + const readFromRedis = reader.cached({ + keyType: "user_id", + useCase: "RedisCustomSerializer", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + serializer, + })(async (userId: string) => ({ id: userId, source: `fallback-${++readerCalls}` })); + + // When another process reads the value from Redis. + const value = await reader.enable(async () => await readFromRedis("123")); + const envelope = JSON.parse(redis.raw(keyFor("123", "RedisCustomSerializer").urn)) as RedisValueEnvelope; + + // Then the custom serializer handles the Redis payload instead of JSON serialization. + expect(value).toEqual({ id: "123", source: "fallback" }); + expect(readerCalls).toBe(0); + expect(envelope.encoding).toBe("base64"); + expect(serializer.dump).toHaveBeenCalledOnce(); + expect(serializer.load).toHaveBeenCalledOnce(); + }); + + it("refreshes stale or malformed Redis envelopes by falling through to fallback", async () => { + // Given Redis contains an expired envelope for one key and a malformed envelope for another. + const redis = new FakeRedis(); + const staleKey = keyFor("stale", "RedisBadEnvelope").urn; + const badKey = keyFor("bad", "RedisBadEnvelope").urn; + redis.values.set(staleKey, { + expiresAtMs: Date.now() + 60_000, + value: JSON.stringify({ + version: 1, + createdAtMs: Date.now() - 2_000, + expiresAtMs: Date.now() - 1_000, + encoding: "utf8", + payload: JSON.stringify({ stale: true }), + } satisfies RedisValueEnvelope), + }); + redis.values.set(badKey, { + expiresAtMs: Date.now() + 60_000, + value: JSON.stringify({ version: 2, payload: "not valid for v1" }), + }); + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const gcache = new GCache({ redis: { client: redis }, logger }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "RedisBadEnvelope", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When both keys are read through the Redis chain. + const stale = await gcache.enable(async () => await getUser("stale")); + const malformed = await gcache.enable(async () => await getUser("bad")); + + // Then expired and malformed entries are deleted, fail open, and fallback results are cached again. + expect(stale).toEqual({ userId: "stale", calls: 1 }); + expect(malformed).toEqual({ userId: "bad", calls: 2 }); + expect(redis.values.get(staleKey)).toBeDefined(); + expect(redis.values.get(badKey)).toBeDefined(); + expect(logger.warn).not.toHaveBeenCalledWith("Error getting value from Redis cache", expect.any(Error)); + }); + + it("falls through when remote config is missing and fails open on Redis maintenance errors", async () => { + // Given Redis is configured but the key has no remote TTL and maintenance commands fail. + const redis = new FakeRedis(); + redis.failDel = true; + redis.failFlushAll = true; + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const gcache = new GCache({ redis: { client: redis }, logger }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "RedisMissingRemoteTtl", + id: ([userId]: [string]) => userId, + defaultConfig: new GCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 0 }, + ramp: { [CacheLayer.LOCAL]: 100 }, + }), + })(async (userId: string) => ({ userId, calls: ++calls })); + + // When cache reads/writes and explicit maintenance operations cannot use Redis safely. + const value = await gcache.enable(async () => await getUser("123")); + const deleted = await gcache.delete(keyFor("123", "RedisMissingRemoteTtl")); + await gcache.flushAll(); + + // Then missing remote config disables Redis reads/writes, while maintenance failures are logged without escaping. + expect(value).toEqual({ userId: "123", calls: 1 }); + expect(deleted).toBe(false); + expect(redis.getCalls).toBe(0); + expect(redis.setCalls).toBe(0); + expect(logger.warn).toHaveBeenCalledWith("Error deleting value from Redis cache", expect.any(Error)); + expect(logger.warn).toHaveBeenCalledWith("Error flushing Redis cache", expect.any(Error)); + }); + + it("supports Redis setex, set with EX, lowercase flushall, and missing-command failures", async () => { + // Given lightweight Redis-compatible clients expose different command spellings. + const setexValues = new Map(); + const setexClient: RedisCommandClient = { + get: async (key) => setexValues.get(key) ?? null, + setex: async (key, _ttlSec, value) => { + setexValues.set(key, value); + }, + del: async (key) => (setexValues.delete(key) ? 1 : 0), + flushall: async () => setexValues.clear(), + }; + const setValues = new Map(); + const setClient: RedisCommandClient = { + get: async (key) => setValues.get(key) ?? null, + set: async (key, value, options) => { + expect(options).toEqual({ EX: 60 }); + setValues.set(key, value); + }, + del: async (key) => (setValues.delete(key) ? 1 : 0), + flushAll: async () => setValues.clear(), + }; + const missingSetClient = { + get: async () => null, + del: async () => 0, + flushAll: async () => undefined, + } satisfies RedisCommandClient; + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + // When values are written through each command shape. + for (const [client, useCase] of [ + [setexClient, "RedisSetexCommand"], + [setClient, "RedisSetCommand"], + [missingSetClient, "RedisMissingSetCommand"], + ] as const) { + const gcache = new GCache({ redis: { client }, logger }); + const getValue = gcache.cached({ + keyType: "user_id", + useCase, + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId })); + await gcache.enable(async () => await getValue("123")); + await gcache.flushAll(); + } + + // Then compatible command spellings work and an incomplete client fails open on writes. + expect(setexValues.size).toBe(0); + expect(setValues.size).toBe(0); + expect(logger.warn).toHaveBeenCalledWith("Error putting value in Redis cache", expect.any(Error)); + }); + + it("rejects Redis config without a client or client factory", () => { + // Given a Redis config that cannot create commands. + const construct = () => new GCache({ redis: {} }); + + // When the cache is constructed, then the invalid Redis configuration is rejected. + expect(construct).toThrow("Redis config requires either client or createClient"); + }); + + it("deletes and flushes entries across local and Redis layers", async () => { + // Given two cached values exist in both local and Redis layers. + const redis = new FakeRedis(); + const gcache = new GCache({ redis: { client: redis } }); + let calls = 0; + const getUser = gcache.cached({ + keyType: "user_id", + useCase: "RedisDeleteAndFlush", + id: ([userId]: [string]) => userId, + defaultConfig: GCacheKeyConfig.enabled(60), + })(async (userId: string) => ({ userId, calls: ++calls })); + await gcache.enable(async () => { + await getUser("123"); + await getUser("456"); + }); + + // When one key is deleted and then all cache layers are flushed. + const deleted = await gcache.delete(keyFor("123", "RedisDeleteAndFlush")); + const afterDelete = await gcache.enable(async () => [await getUser("123"), await getUser("456")]); + await gcache.flushAll(); + const afterFlush = await gcache.enable(async () => [await getUser("123"), await getUser("456")]); + + // Then delete reaches both layers and flushAll clears both layers. + expect(deleted).toBe(true); + expect(afterDelete).toEqual([ + { userId: "123", calls: 3 }, + { userId: "456", calls: 2 }, + ]); + expect(afterFlush).toEqual([ + { userId: "123", calls: 4 }, + { userId: "456", calls: 5 }, + ]); + expect(redis.delCalls).toBeGreaterThanOrEqual(1); + expect(redis.flushAllCalls).toBe(1); + }); +}); diff --git a/packages/gcache-ts/tsconfig.json b/packages/gcache-ts/tsconfig.json new file mode 100644 index 0000000..5de5876 --- /dev/null +++ b/packages/gcache-ts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "types": ["node", "vitest/globals"], + "skipLibCheck": true + }, + "include": ["src", "test"] +} diff --git a/packages/gcache-ts/vitest.config.ts b/packages/gcache-ts/vitest.config.ts new file mode 100644 index 0000000..9d53ab6 --- /dev/null +++ b/packages/gcache-ts/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["test/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + exclude: ["src/index.ts"], + thresholds: { + lines: 95, + functions: 95, + branches: 90, + statements: 95, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b49165e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1792 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + packages/gcache-ts: + dependencies: + prom-client: + specifier: ^15.1.3 + version: 15.1.3 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.12.4 + '@vitest/coverage-v8': + specifier: ^4.0.14 + version: 4.1.6(vitest@4.1.6) + tsup: + specifier: ^8.5.1 + version: 8.5.1(postcss@8.5.14)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.14 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(vite@8.0.12(@types/node@24.12.4)(esbuild@0.27.7)) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@oxc-project/types@0.129.0': + resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} + + '@rolldown/binding-android-arm64@1.0.0': + resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0': + resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0': + resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0': + resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0': + resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0': + resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0': + resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0': + resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0': + resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0': + resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0': + resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0': + resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0': + resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0': + resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@vitest/coverage-v8@4.1.6': + resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} + peerDependencies: + '@vitest/browser': 4.1.6 + vitest: 4.1.6 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rolldown@1.0.0: + resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + vite@8.0.12: + resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@opentelemetry/api@1.9.1': {} + + '@oxc-project/types@0.129.0': {} + + '@rolldown/binding-android-arm64@1.0.0': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0': + optional: true + + '@rolldown/pluginutils@1.0.0': {} + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.6 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(vite@8.0.12(@types/node@24.12.4)(esbuild@0.27.7)) + + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.12(@types/node@24.12.4)(esbuild@0.27.7))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.12(@types/node@24.12.4)(esbuild@0.27.7) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + acorn@8.16.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + bintrees@1.0.2: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@6.2.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.3 + + fsevents@2.3.3: + optional: true + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + joycon@3.1.1: {} + + js-tokens@10.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + object-assign@4.1.1: {} + + obug@2.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.14): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.14 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.2 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rolldown@1.0.0: + dependencies: + '@oxc-project/types': 0.129.0 + '@rolldown/pluginutils': 1.0.0 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0 + '@rolldown/binding-darwin-arm64': 1.0.0 + '@rolldown/binding-darwin-x64': 1.0.0 + '@rolldown/binding-freebsd-x64': 1.0.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0 + '@rolldown/binding-linux-arm64-gnu': 1.0.0 + '@rolldown/binding-linux-arm64-musl': 1.0.0 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0 + '@rolldown/binding-linux-s390x-gnu': 1.0.0 + '@rolldown/binding-linux-x64-gnu': 1.0.0 + '@rolldown/binding-linux-x64-musl': 1.0.0 + '@rolldown/binding-openharmony-arm64': 1.0.0 + '@rolldown/binding-wasm32-wasi': 1.0.0 + '@rolldown/binding-win32-arm64-msvc': 1.0.0 + '@rolldown/binding-win32-x64-msvc': 1.0.0 + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + semver@7.8.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: + optional: true + + tsup@8.5.1(postcss@8.5.14)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.14) + resolve-from: 5.0.0 + rollup: 4.60.3 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.14 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@7.16.0: {} + + vite@8.0.12(@types/node@24.12.4)(esbuild@0.27.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + esbuild: 0.27.7 + fsevents: 2.3.3 + + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(vite@8.0.12(@types/node@24.12.4)(esbuild@0.27.7)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.12(@types/node@24.12.4)(esbuild@0.27.7)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.12(@types/node@24.12.4)(esbuild@0.27.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.4 + '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/poetry.lock b/poetry.lock index 11443cf..3008563 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -774,21 +774,21 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.3" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev", "test"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} @@ -1223,4 +1223,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "a1ef6f5c45f57c7780313f36a0bc71e2448960326a2ddb907faacb0b543e73bd" +content-hash = "fa9cb49f956559f19ef411c6e775f637d31e15628603b0270f7ec22c292fdd1f" diff --git a/pyproject.toml b/pyproject.toml index 46a8f73..1523c35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ uvloop = ">=0.21.0,<1.0.0" [tool.poetry.group.test.dependencies] -pytest = "^8.4.1" +pytest = "^9.0.3" pytest-xdist = "^3.6.1" coverage = "^7.13.5" pytest-cov = "^7.0.0"