diff --git a/packages/agent-bff/package.json b/packages/agent-bff/package.json index c57cc47ef2..3d4fc13592 100644 --- a/packages/agent-bff/package.json +++ b/packages/agent-bff/package.json @@ -31,6 +31,7 @@ "test": "jest" }, "dependencies": { + "@forestadmin/agent-client": "1.10.1", "@forestadmin/forestadmin-client": "1.40.4", "@koa/bodyparser": "^6.1.0", "jsonwebtoken": "^9.0.3", diff --git a/packages/agent-bff/src/adapters/console-metrics.ts b/packages/agent-bff/src/adapters/console-metrics.ts new file mode 100644 index 0000000000..da4e29906e --- /dev/null +++ b/packages/agent-bff/src/adapters/console-metrics.ts @@ -0,0 +1,15 @@ +import type { Logger } from '../ports/logger-port'; +import type { MetricTags, Metrics } from '../ports/metrics-port'; + +import createConsoleLogger from './console-logger'; + +export default function createConsoleMetrics(logger: Logger = createConsoleLogger()): Metrics { + return { + increment(name: string, tags?: MetricTags): void { + logger('Info', 'metric.increment', { metric: name, ...(tags ?? {}) }); + }, + gauge(name: string, value: number, tags?: MetricTags): void { + logger('Info', 'metric.gauge', { metric: name, value, ...(tags ?? {}) }); + }, + }; +} diff --git a/packages/agent-bff/src/ports/metrics-port.ts b/packages/agent-bff/src/ports/metrics-port.ts new file mode 100644 index 0000000000..5f67ce10f0 --- /dev/null +++ b/packages/agent-bff/src/ports/metrics-port.ts @@ -0,0 +1,6 @@ +export type MetricTags = Record; + +export interface Metrics { + increment(name: string, tags?: MetricTags): void; + gauge(name: string, value: number, tags?: MetricTags): void; +} diff --git a/packages/agent-bff/src/read-model/action-endpoint-resolver.ts b/packages/agent-bff/src/read-model/action-endpoint-resolver.ts new file mode 100644 index 0000000000..cc91e1afeb --- /dev/null +++ b/packages/agent-bff/src/read-model/action-endpoint-resolver.ts @@ -0,0 +1,55 @@ +import type ReadModel from './read-model'; +import type { Metrics } from '../ports/metrics-port'; +import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; + +export const ACTION_ENDPOINT_MISS = 'action_endpoint_miss'; +export const ACTION_ENDPOINT_ERROR = 'action_endpoint_error'; + +export type ActionEndpointInfo = ActionEndpointsByCollection[string][string]; + +export type ReadModelProvider = () => Promise; + +export interface ResolveActionContext { + rendering: string | number; +} + +/** + * Resolves an action to its endpoint against the current read-model. Never throws: an absent + * mapping emits the miss counter, a failure to obtain the read-model emits the error counter. + * Both counters carry `rendering`, `collection`, and `action` tags. In normal flow the action + * allow-list already excludes endpoint-less actions, so the miss path is a defensive guard. + */ +export default class ActionEndpointResolver { + constructor( + private readonly getReadModel: ReadModelProvider, + private readonly metrics: Metrics, + ) {} + + async resolve( + collection: string, + action: string, + { rendering }: ResolveActionContext, + ): Promise { + const tags = { rendering, collection, action }; + + let readModel: ReadModel; + + try { + readModel = await this.getReadModel(); + } catch { + this.metrics.increment(ACTION_ENDPOINT_ERROR, tags); + + return undefined; + } + + const info = readModel.getActionEndpoints()[collection]?.[action]; + + if (!info) { + this.metrics.increment(ACTION_ENDPOINT_MISS, tags); + + return undefined; + } + + return info; + } +} diff --git a/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts b/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts new file mode 100644 index 0000000000..032150b080 --- /dev/null +++ b/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts @@ -0,0 +1,21 @@ +import type { CapabilitiesFetcher } from './capabilities-cache'; + +import { createRemoteAgentClient } from '@forestadmin/agent-client'; + +export interface AgentCapabilitiesFetcherOptions { + agentUrl: string; + token: string; +} + +/** + * Builds a capabilities fetcher bound to a request's agent token. The cache calls it only on a + * miss, so the token of whichever request first populates a collection is the one used. + */ +export default function createAgentCapabilitiesFetcher({ + agentUrl, + token, +}: AgentCapabilitiesFetcherOptions): CapabilitiesFetcher { + const client = createRemoteAgentClient({ url: agentUrl, token }); + + return collection => client.collection(collection).capabilities(); +} diff --git a/packages/agent-bff/src/read-model/capabilities-cache.ts b/packages/agent-bff/src/read-model/capabilities-cache.ts new file mode 100644 index 0000000000..59842ccef3 --- /dev/null +++ b/packages/agent-bff/src/read-model/capabilities-cache.ts @@ -0,0 +1,68 @@ +import { ONE_DAY_MS } from './schema-cache'; + +export interface CapabilitiesResult { + fields: { name: string; type: string; operators: string[] }[]; +} + +export type CapabilitiesFetcher = (collection: string) => Promise; + +export interface CapabilitiesCacheOptions { + now?: () => number; + ttlMs?: number; +} + +interface CacheEntry { + result: CapabilitiesResult; + fetchedAt: number; +} + +/** + * Per-collection capabilities cache (24h). The fetcher is passed per call so it can be bound to + * the caller's request token. Invalidated together with the schema via `clear()`. + */ +export default class CapabilitiesCache { + private readonly now: () => number; + private readonly ttlMs: number; + + private readonly entries = new Map(); + private readonly inFlight = new Map>(); + private generation = 0; + + constructor({ now = Date.now, ttlMs = ONE_DAY_MS }: CapabilitiesCacheOptions = {}) { + this.now = now; + this.ttlMs = ttlMs; + } + + async get(collection: string, fetcher: CapabilitiesFetcher): Promise { + const entry = this.entries.get(collection); + if (entry && this.now() - entry.fetchedAt < this.ttlMs) return entry.result; + + const pending = this.inFlight.get(collection); + if (pending) return pending; + + const { generation } = this; + const fetch = fetcher(collection) + .then(result => { + // Skip the write if a clear() (schema refresh) happened while this fetch was in flight — + // its result belongs to the old schema generation and must not repopulate the cache. + if (this.generation === generation) { + this.entries.set(collection, { result, fetchedAt: this.now() }); + } + + return result; + }) + .finally(() => { + if (this.inFlight.get(collection) === fetch) this.inFlight.delete(collection); + }); + + this.inFlight.set(collection, fetch); + + return fetch; + } + + clear(): void { + this.generation += 1; + this.entries.clear(); + this.inFlight.clear(); + } +} diff --git a/packages/agent-bff/src/read-model/create-read-model.ts b/packages/agent-bff/src/read-model/create-read-model.ts new file mode 100644 index 0000000000..1aa1b64548 --- /dev/null +++ b/packages/agent-bff/src/read-model/create-read-model.ts @@ -0,0 +1,42 @@ +import type { Logger } from '../ports/logger-port'; +import type { Metrics } from '../ports/metrics-port'; + +import ActionEndpointResolver from './action-endpoint-resolver'; +import CapabilitiesCache from './capabilities-cache'; +import ForestSchemaClient from './forest-schema-client'; +import ReadModelStore from './read-model-store'; +import SchemaCache from './schema-cache'; +import createConsoleMetrics from '../adapters/console-metrics'; + +export interface CreateReadModelOptions { + forestServerUrl: string; + envSecret: string; + metrics?: Metrics; + logger?: Logger; + now?: () => number; +} + +export interface ReadModelBundle { + store: ReadModelStore; + actionEndpointResolver: ActionEndpointResolver; +} + +export default function createReadModel({ + forestServerUrl, + envSecret, + metrics, + logger, + now, +}: CreateReadModelOptions): ReadModelBundle { + const resolvedMetrics = metrics ?? createConsoleMetrics(logger); + const fetcher = new ForestSchemaClient({ forestServerUrl, envSecret }); + const schemaCache = new SchemaCache({ fetcher, metrics: resolvedMetrics, now }); + const capabilitiesCache = new CapabilitiesCache({ now }); + const store = new ReadModelStore(schemaCache, capabilitiesCache); + const actionEndpointResolver = new ActionEndpointResolver( + () => store.getReadModel(), + resolvedMetrics, + ); + + return { store, actionEndpointResolver }; +} diff --git a/packages/agent-bff/src/read-model/errors.ts b/packages/agent-bff/src/read-model/errors.ts new file mode 100644 index 0000000000..402556ee10 --- /dev/null +++ b/packages/agent-bff/src/read-model/errors.ts @@ -0,0 +1,9 @@ +export default class SchemaUnavailableError extends Error { + readonly cause?: unknown; + + constructor(cause?: unknown) { + super('The agent schema is unavailable'); + this.name = 'SchemaUnavailableError'; + this.cause = cause; + } +} diff --git a/packages/agent-bff/src/read-model/forest-schema-client.ts b/packages/agent-bff/src/read-model/forest-schema-client.ts new file mode 100644 index 0000000000..68e95e2071 --- /dev/null +++ b/packages/agent-bff/src/read-model/forest-schema-client.ts @@ -0,0 +1,24 @@ +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import { ForestHttpApi, SchemaService } from '@forestadmin/forestadmin-client'; + +export interface ForestSchemaClientOptions { + forestServerUrl: string; + envSecret: string; +} + +export interface SchemaFetcher { + fetchSchema(): Promise; +} + +export default class ForestSchemaClient implements SchemaFetcher { + private readonly schemaService: SchemaService; + + constructor({ forestServerUrl, envSecret }: ForestSchemaClientOptions) { + this.schemaService = new SchemaService(new ForestHttpApi(), { forestServerUrl, envSecret }); + } + + async fetchSchema(): Promise { + return this.schemaService.getSchema(); + } +} diff --git a/packages/agent-bff/src/read-model/read-model-store.ts b/packages/agent-bff/src/read-model/read-model-store.ts new file mode 100644 index 0000000000..d2e1dd346e --- /dev/null +++ b/packages/agent-bff/src/read-model/read-model-store.ts @@ -0,0 +1,54 @@ +import type CapabilitiesCache from './capabilities-cache'; +import type { CapabilitiesFetcher, CapabilitiesResult } from './capabilities-cache'; +import type SchemaCache from './schema-cache'; + +import ReadModel from './read-model'; + +/** + * Single owner of the coupled schema + capabilities lifecycle. A successful schema refresh (a bump + * of the cache `revision`) rebuilds the read-model and clears capabilities atomically, so the + * allow-list and capabilities never split-brain across schema generations. + */ +export default class ReadModelStore { + private readonly schemaCache: SchemaCache; + private readonly capabilitiesCache: CapabilitiesCache; + + private builtRevision = -1; + private readModel: ReadModel | null = null; + + constructor(schemaCache: SchemaCache, capabilitiesCache: CapabilitiesCache) { + this.schemaCache = schemaCache; + this.capabilitiesCache = capabilitiesCache; + } + + async getReadModel(): Promise { + const collections = await this.schemaCache.get(); + + if (this.schemaCache.revision !== this.builtRevision || !this.readModel) { + this.readModel = new ReadModel(collections); + this.builtRevision = this.schemaCache.revision; + this.capabilitiesCache.clear(); + } + + return this.readModel; + } + + async getCapabilities( + collection: string, + fetcher: CapabilitiesFetcher, + ): Promise { + // Ensure any pending schema refresh (and its capabilities invalidation) runs first. + await this.getReadModel(); + + // TODO(wiring): possible TOCTOU once this is called from request handling. A concurrent schema + // refresh can clear capabilities while this fetch is in flight, so the caller could receive + // capabilities from the previous schema generation alongside the new allow-list. When wiring + // the data endpoints, re-check `schemaCache.revision` after the fetch resolves and retry on a + // mismatch so capabilities and schema stay atomically coupled. + return this.capabilitiesCache.get(collection, fetcher); + } + + ageSeconds(): number | undefined { + return this.schemaCache.ageSeconds(); + } +} diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts new file mode 100644 index 0000000000..b4b466aa01 --- /dev/null +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -0,0 +1,114 @@ +import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; +import type { ForestSchemaCollection, ForestSchemaField } from '@forestadmin/forestadmin-client'; + +export type RelationshipType = 'BelongsTo' | 'HasOne' | 'HasMany' | 'BelongsToMany'; + +export type RelationTarget = + | { type: RelationshipType; polymorphic: false; target: string } + | { type: RelationshipType; polymorphic: true; targets: string[] }; + +function toRelationTarget(field: ForestSchemaField): RelationTarget | null { + if (!field.relationship) return null; + + const type = field.relationship; + + if (field.polymorphicReferencedModels && field.polymorphicReferencedModels.length > 0) { + return { type, polymorphic: true, targets: [...field.polymorphicReferencedModels] }; + } + + if (field.reference) { + // reference is `${foreignCollection}.${key}`; the collection name may itself contain dots + // (e.g. mongoose nested collections like `User.address`), so drop only the trailing key. + return { type, polymorphic: false, target: field.reference.split('.').slice(0, -1).join('.') }; + } + + return null; +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object') { + Object.values(value as Record).forEach(deepFreeze); + Object.freeze(value); + } + + return value; +} + +/** + * The agent read-model derived from a schema. Names are collection-scoped: relations and actions + * are keyed by `(collection, name)` so the same relation/action name on two collections stays + * distinct. The action allow-list is exactly the endpoint-map keys — endpoint-less actions are + * not exposed. + */ +export default class ReadModel { + private readonly collections: Set; + private readonly relations: Map>; + private readonly actionEndpoints: ActionEndpointsByCollection; + + constructor(collections: ForestSchemaCollection[]) { + this.collections = new Set(); + this.relations = new Map(); + this.actionEndpoints = {}; + + for (const collection of collections) { + this.collections.add(collection.name); + this.buildRelations(collection); + this.buildActionEndpoints(collection); + } + + // Freeze the exposed structures so a consumer cannot mutate the shared cached read-model. + deepFreeze(this.actionEndpoints); + this.relations.forEach(byRelation => byRelation.forEach(deepFreeze)); + } + + private buildRelations(collection: ForestSchemaCollection): void { + const relations = new Map(); + + for (const field of collection.fields ?? []) { + const target = toRelationTarget(field); + if (target) relations.set(field.field, target); + } + + this.relations.set(collection.name, relations); + } + + private buildActionEndpoints(collection: ForestSchemaCollection): void { + const actions = collection.actions ?? []; + const withEndpoint = actions.filter(action => Boolean(action.endpoint)); + + if (withEndpoint.length === 0) return; + + this.actionEndpoints[collection.name] = {}; + + for (const action of withEndpoint) { + // Copy fields/hooks so a mutating consumer can't corrupt the shared cached schema. + this.actionEndpoints[collection.name][action.name] = { + id: action.id, + name: action.name, + endpoint: action.endpoint, + hooks: { ...action.hooks, change: [...action.hooks.change] }, + fields: action.fields.map(field => ({ ...field })), + }; + } + } + + isCollectionAllowed(collection: string): boolean { + return this.collections.has(collection); + } + + isRelationAllowed(collection: string, relation: string): boolean { + return this.relations.get(collection)?.has(relation) ?? false; + } + + getRelationTarget(collection: string, relation: string): RelationTarget | undefined { + return this.relations.get(collection)?.get(relation); + } + + isActionAllowed(collection: string, action: string): boolean { + return this.actionEndpoints[collection]?.[action] !== undefined; + } + + getActionEndpoints(): ActionEndpointsByCollection { + return this.actionEndpoints; + } +} diff --git a/packages/agent-bff/src/read-model/schema-cache.ts b/packages/agent-bff/src/read-model/schema-cache.ts new file mode 100644 index 0000000000..3b8351e297 --- /dev/null +++ b/packages/agent-bff/src/read-model/schema-cache.ts @@ -0,0 +1,111 @@ +import type { SchemaFetcher } from './forest-schema-client'; +import type { Metrics } from '../ports/metrics-port'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import SchemaUnavailableError from './errors'; + +export const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +export const SCHEMA_CACHE_REFRESH_ERROR = 'schema_cache_refresh_error'; +export const SCHEMA_CACHE_AGE_SECONDS = 'schema_cache_age_seconds'; + +export interface SchemaCacheOptions { + fetcher: SchemaFetcher; + metrics: Metrics; + now?: () => number; + ttlMs?: number; +} + +interface CacheEntry { + collections: ForestSchemaCollection[]; + fetchedAt: number; +} + +/** + * Caches the agent schema for 24h. The TTL only *triggers* a refresh attempt: the last good + * schema keeps being served until a refresh succeeds. A cold cache (no last good) surfaces a + * typed `SchemaUnavailableError`. A monotonic `revision` increments on each successful refresh so + * a consumer can detect a new schema generation explicitly. + */ +export default class SchemaCache { + private readonly fetcher: SchemaFetcher; + private readonly metrics: Metrics; + private readonly now: () => number; + private readonly ttlMs: number; + + private entry: CacheEntry | null = null; + private inFlight: Promise | null = null; + private revisionValue = 0; + + constructor({ fetcher, metrics, now = Date.now, ttlMs = ONE_DAY_MS }: SchemaCacheOptions) { + this.fetcher = fetcher; + this.metrics = metrics; + this.now = now; + this.ttlMs = ttlMs; + } + + async get(): Promise { + if (this.entry && this.now() - this.entry.fetchedAt < this.ttlMs) { + this.emitAge(); + + return this.entry.collections; + } + + return this.refresh(); + } + + ageSeconds(): number | undefined { + if (!this.entry) return undefined; + + return Math.floor((this.now() - this.entry.fetchedAt) / 1000); + } + + get revision(): number { + return this.revisionValue; + } + + private async refresh(): Promise { + if (!this.inFlight) { + this.inFlight = this.doRefresh().finally(() => { + this.inFlight = null; + }); + } + + return this.inFlight; + } + + private async doRefresh(): Promise { + try { + const collections = await this.fetcher.fetchSchema(); + + // An agent always exposes collections, so an empty result is far likelier a broken response + // than a valid state. Caching it would silently deny everything for 24h — treat it as a + // failed fetch and fall through to the failure path below. + if (collections.length === 0) throw new Error('Forest returned an empty schema'); + + this.entry = { collections, fetchedAt: this.now() }; + this.revisionValue += 1; + this.emitAge(); + + return collections; + } catch (error) { + this.metrics.increment(SCHEMA_CACHE_REFRESH_ERROR); + + // Warm cache: keep serving the last good schema (stale), do not poison — the next read + // re-attempts because `fetchedAt` is unchanged and the entry stays expired. + if (this.entry) { + this.emitAge(); + + return this.entry.collections; + } + + // Cold cache: nothing to serve. + throw new SchemaUnavailableError(error); + } + } + + private emitAge(): void { + const age = this.ageSeconds(); + if (age !== undefined) this.metrics.gauge(SCHEMA_CACHE_AGE_SECONDS, age); + } +} diff --git a/packages/agent-bff/test/adapters/console-metrics.test.ts b/packages/agent-bff/test/adapters/console-metrics.test.ts new file mode 100644 index 0000000000..e0a0720a27 --- /dev/null +++ b/packages/agent-bff/test/adapters/console-metrics.test.ts @@ -0,0 +1,49 @@ +import type { Logger } from '../../src/ports/logger-port'; + +import createConsoleMetrics from '../../src/adapters/console-metrics'; + +describe('createConsoleMetrics', () => { + let logger: jest.MockedFunction; + + beforeEach(() => { + logger = jest.fn(); + }); + + describe('increment', () => { + it('should log the metric name and tags', () => { + const metrics = createConsoleMetrics(logger); + + metrics.increment('schema_cache_refresh_error', { collection: 'users', action: 'ban' }); + + expect(logger).toHaveBeenCalledWith('Info', 'metric.increment', { + metric: 'schema_cache_refresh_error', + collection: 'users', + action: 'ban', + }); + }); + + it('should log without tags when none are provided', () => { + const metrics = createConsoleMetrics(logger); + + metrics.increment('schema_cache_refresh_error'); + + expect(logger).toHaveBeenCalledWith('Info', 'metric.increment', { + metric: 'schema_cache_refresh_error', + }); + }); + }); + + describe('gauge', () => { + it('should log the metric name, value and tags', () => { + const metrics = createConsoleMetrics(logger); + + metrics.gauge('schema_cache_age_seconds', 42, { rendering: 7 }); + + expect(logger).toHaveBeenCalledWith('Info', 'metric.gauge', { + metric: 'schema_cache_age_seconds', + value: 42, + rendering: 7, + }); + }); + }); +}); diff --git a/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts b/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts new file mode 100644 index 0000000000..5e574bacce --- /dev/null +++ b/packages/agent-bff/test/read-model/action-endpoint-resolver.test.ts @@ -0,0 +1,67 @@ +import type { Metrics } from '../../src/ports/metrics-port'; +import type { ForestSchemaAction } from '@forestadmin/forestadmin-client'; + +import { action, collection, makeMetrics } from './fixtures'; +import ActionEndpointResolver, { + ACTION_ENDPOINT_ERROR, + ACTION_ENDPOINT_MISS, +} from '../../src/read-model/action-endpoint-resolver'; +import SchemaUnavailableError from '../../src/read-model/errors'; +import ReadModel from '../../src/read-model/read-model'; + +function modelWith(actions: ForestSchemaAction[]): ReadModel { + return new ReadModel([collection('users', [], actions)]); +} + +describe('ActionEndpointResolver', () => { + let metrics: jest.Mocked; + + beforeEach(() => { + metrics = makeMetrics(); + }); + + describe('hit', () => { + it('should return the endpoint info for a mapped action', async () => { + const model = modelWith([action('ban', '/forest/users/actions/ban')]); + const resolver = new ActionEndpointResolver(async () => model, metrics); + + const info = await resolver.resolve('users', 'ban', { rendering: 7 }); + + expect(info?.endpoint).toBe('/forest/users/actions/ban'); + expect(metrics.increment).not.toHaveBeenCalled(); + }); + }); + + describe('miss', () => { + it('should emit the miss counter with rendering/collection/action tags and not throw', async () => { + const model = modelWith([action('ban', '/forest/users/actions/ban')]); + const resolver = new ActionEndpointResolver(async () => model, metrics); + + const info = await resolver.resolve('users', 'unknown', { rendering: 7 }); + + expect(info).toBeUndefined(); + expect(metrics.increment).toHaveBeenCalledWith(ACTION_ENDPOINT_MISS, { + rendering: 7, + collection: 'users', + action: 'unknown', + }); + }); + }); + + describe('error', () => { + it('should emit the error counter when the read-model provider throws and not throw', async () => { + const resolver = new ActionEndpointResolver(async () => { + throw new SchemaUnavailableError(); + }, metrics); + + const info = await resolver.resolve('users', 'ban', { rendering: 7 }); + + expect(info).toBeUndefined(); + expect(metrics.increment).toHaveBeenCalledWith(ACTION_ENDPOINT_ERROR, { + rendering: 7, + collection: 'users', + action: 'ban', + }); + }); + }); +}); diff --git a/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts b/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts new file mode 100644 index 0000000000..5f42f8acd7 --- /dev/null +++ b/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts @@ -0,0 +1,21 @@ +import { createRemoteAgentClient } from '@forestadmin/agent-client'; + +import createAgentCapabilitiesFetcher from '../../src/read-model/agent-capabilities-fetcher'; + +jest.mock('@forestadmin/agent-client'); + +describe('createAgentCapabilitiesFetcher', () => { + it('should build a client for the agent url/token and fetch capabilities per collection', async () => { + const capabilities = jest.fn().mockResolvedValue({ fields: [{ name: 'email' }] }); + const collection = jest.fn().mockReturnValue({ capabilities }); + (createRemoteAgentClient as jest.Mock).mockReturnValue({ collection }); + + const fetcher = createAgentCapabilitiesFetcher({ agentUrl: 'https://agent', token: 'tok' }); + const result = await fetcher('users'); + + expect(createRemoteAgentClient).toHaveBeenCalledWith({ url: 'https://agent', token: 'tok' }); + expect(collection).toHaveBeenCalledWith('users'); + expect(capabilities).toHaveBeenCalledTimes(1); + expect(result).toEqual({ fields: [{ name: 'email' }] }); + }); +}); diff --git a/packages/agent-bff/test/read-model/capabilities-cache.test.ts b/packages/agent-bff/test/read-model/capabilities-cache.test.ts new file mode 100644 index 0000000000..87d48e23df --- /dev/null +++ b/packages/agent-bff/test/read-model/capabilities-cache.test.ts @@ -0,0 +1,112 @@ +import type { CapabilitiesResult } from '../../src/read-model/capabilities-cache'; + +import CapabilitiesCache from '../../src/read-model/capabilities-cache'; +import { ONE_DAY_MS } from '../../src/read-model/schema-cache'; + +function caps(name: string): CapabilitiesResult { + return { fields: [{ name, type: 'String', operators: ['equal'] }] }; +} + +describe('CapabilitiesCache', () => { + let clock: number; + const now = () => clock; + + beforeEach(() => { + clock = 1_000_000; + }); + + it('should fetch on first read and cache the raw result', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest.fn().mockResolvedValue(caps('email')); + + const result = await cache.get('users', fetcher); + + expect(fetcher).toHaveBeenCalledWith('users'); + expect(result).toEqual(caps('email')); + }); + + it('should serve from cache without re-fetching within 24h', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest.fn().mockResolvedValue(caps('email')); + + await cache.get('users', fetcher); + clock += ONE_DAY_MS - 1; + await cache.get('users', fetcher); + + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('should re-fetch after 24h', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest.fn().mockResolvedValue(caps('email')); + + await cache.get('users', fetcher); + clock += ONE_DAY_MS; + await cache.get('users', fetcher); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('should cache per collection independently', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest + .fn() + .mockImplementation((collection: string) => Promise.resolve(caps(collection))); + + const users = await cache.get('users', fetcher); + const orders = await cache.get('orders', fetcher); + + expect(users.fields[0].name).toBe('users'); + expect(orders.fields[0].name).toBe('orders'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('should re-fetch a collection after clear() (schema refresh invalidation)', async () => { + const cache = new CapabilitiesCache({ now }); + const fetcher = jest.fn().mockResolvedValue(caps('email')); + + await cache.get('users', fetcher); + cache.clear(); + await cache.get('users', fetcher); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('should dedupe concurrent reads of the same collection into one fetch', async () => { + const cache = new CapabilitiesCache({ now }); + let resolveFetch!: (value: CapabilitiesResult) => void; + const fetcher = jest.fn().mockReturnValue( + new Promise(resolve => { + resolveFetch = resolve; + }), + ); + + const a = cache.get('users', fetcher); + const b = cache.get('users', fetcher); + resolveFetch(caps('email')); + + await Promise.all([a, b]); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('should not repopulate the cache when a fetch that started before clear() resolves', async () => { + const cache = new CapabilitiesCache({ now }); + let resolveStale!: (value: CapabilitiesResult) => void; + const staleFetcher = jest.fn().mockReturnValue( + new Promise(resolve => { + resolveStale = resolve; + }), + ); + + const inFlight = cache.get('users', staleFetcher); + cache.clear(); + resolveStale(caps('stale')); + await inFlight; + + const freshFetcher = jest.fn().mockResolvedValue(caps('fresh')); + const result = await cache.get('users', freshFetcher); + + expect(freshFetcher).toHaveBeenCalledTimes(1); + expect(result.fields[0].name).toBe('fresh'); + }); +}); diff --git a/packages/agent-bff/test/read-model/create-read-model.test.ts b/packages/agent-bff/test/read-model/create-read-model.test.ts new file mode 100644 index 0000000000..6827492995 --- /dev/null +++ b/packages/agent-bff/test/read-model/create-read-model.test.ts @@ -0,0 +1,83 @@ +import { SchemaService } from '@forestadmin/forestadmin-client'; + +import { action, collection, column, makeMetrics } from './fixtures'; +import { ACTION_ENDPOINT_MISS } from '../../src/read-model/action-endpoint-resolver'; +import createReadModel from '../../src/read-model/create-read-model'; + +jest.mock('@forestadmin/forestadmin-client'); + +describe('createReadModel', () => { + const getSchema = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (SchemaService as unknown as jest.Mock).mockImplementation(() => ({ getSchema })); + getSchema.mockResolvedValue([ + collection('users', [column('id')], [action('ban', '/forest/users/actions/ban')]), + ]); + }); + + it('should wire a store whose read-model reflects the fetched schema', async () => { + const { store } = createReadModel({ + forestServerUrl: 'x', + envSecret: 'y', + metrics: makeMetrics(), + }); + + const model = await store.getReadModel(); + + expect(model.isCollectionAllowed('users')).toBe(true); + expect(model.isActionAllowed('users', 'ban')).toBe(true); + }); + + it('should wire an action-endpoint resolver that resolves mapped actions', async () => { + const { actionEndpointResolver } = createReadModel({ + forestServerUrl: 'x', + envSecret: 'y', + metrics: makeMetrics(), + }); + + const info = await actionEndpointResolver.resolve('users', 'ban', { rendering: 1 }); + + expect(info?.endpoint).toBe('/forest/users/actions/ban'); + }); + + it('should wire the provided metrics into the resolver', async () => { + const metrics = makeMetrics(); + const { actionEndpointResolver } = createReadModel({ + forestServerUrl: 'x', + envSecret: 'y', + metrics, + }); + + await actionEndpointResolver.resolve('users', 'missing', { rendering: 3 }); + + expect(metrics.increment).toHaveBeenCalledWith(ACTION_ENDPOINT_MISS, { + rendering: 3, + collection: 'users', + action: 'missing', + }); + }); + + it('should route metrics through the console logger when none is provided', async () => { + const info = jest.spyOn(console, 'info').mockImplementation(() => undefined); + + try { + const { actionEndpointResolver } = createReadModel({ forestServerUrl: 'x', envSecret: 'y' }); + await actionEndpointResolver.resolve('users', 'missing', { rendering: 9 }); + + const logged = info.mock.calls.map(call => JSON.parse(call[0] as string)); + expect(logged).toContainEqual( + expect.objectContaining({ + message: 'metric.increment', + metric: ACTION_ENDPOINT_MISS, + rendering: 9, + collection: 'users', + action: 'missing', + }), + ); + } finally { + info.mockRestore(); + } + }); +}); diff --git a/packages/agent-bff/test/read-model/fixtures.ts b/packages/agent-bff/test/read-model/fixtures.ts new file mode 100644 index 0000000000..f51b1db7eb --- /dev/null +++ b/packages/agent-bff/test/read-model/fixtures.ts @@ -0,0 +1,58 @@ +import type { Metrics } from '../../src/ports/metrics-port'; +import type { + ForestSchemaAction, + ForestSchemaCollection, + ForestSchemaField, +} from '@forestadmin/forestadmin-client'; + +export function makeMetrics(): jest.Mocked { + return { increment: jest.fn(), gauge: jest.fn() }; +} + +export function column(field: string): ForestSchemaField { + return { + field, + type: 'String', + enum: null, + reference: null, + isReadOnly: false, + isRequired: false, + isPrimaryKey: field === 'id', + }; +} + +export function relation( + field: string, + relationship: 'BelongsTo' | 'HasOne' | 'HasMany' | 'BelongsToMany', + reference: string, +): ForestSchemaField { + return { ...column(field), reference, relationship }; +} + +export function polymorphic(field: string, models: string[]): ForestSchemaField { + return { ...column(field), relationship: 'BelongsTo', polymorphicReferencedModels: models }; +} + +export function action(name: string, endpoint: string): ForestSchemaAction { + return { + id: `${name}-id`, + name, + type: 'single', + endpoint, + download: false, + fields: [], + hooks: { load: false, change: [] }, + }; +} + +export function collection( + name: string, + fields: ForestSchemaField[], + actions: ForestSchemaAction[] = [], +): ForestSchemaCollection { + return { name, fields, actions }; +} + +export function makeSchema(name: string): ForestSchemaCollection[] { + return [collection(name, [])]; +} diff --git a/packages/agent-bff/test/read-model/forest-schema-client.test.ts b/packages/agent-bff/test/read-model/forest-schema-client.test.ts new file mode 100644 index 0000000000..00fa10701d --- /dev/null +++ b/packages/agent-bff/test/read-model/forest-schema-client.test.ts @@ -0,0 +1,41 @@ +import { ForestHttpApi, SchemaService } from '@forestadmin/forestadmin-client'; + +import ForestSchemaClient from '../../src/read-model/forest-schema-client'; + +jest.mock('@forestadmin/forestadmin-client'); + +describe('ForestSchemaClient', () => { + const getSchema = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (SchemaService as unknown as jest.Mock).mockImplementation(() => ({ getSchema })); + }); + + it('should construct a SchemaService with a ForestHttpApi and the server options', () => { + const client = new ForestSchemaClient({ + forestServerUrl: 'https://api.test', + envSecret: 'secret', + }); + + expect(client).toBeInstanceOf(ForestSchemaClient); + expect(SchemaService).toHaveBeenCalledWith(expect.any(ForestHttpApi), { + forestServerUrl: 'https://api.test', + envSecret: 'secret', + }); + }); + + it('should delegate fetchSchema to SchemaService.getSchema', async () => { + const collections = [{ name: 'users', fields: [], actions: [] }]; + getSchema.mockResolvedValue(collections); + const client = new ForestSchemaClient({ + forestServerUrl: 'https://api.test', + envSecret: 'secret', + }); + + const result = await client.fetchSchema(); + + expect(getSchema).toHaveBeenCalledTimes(1); + expect(result).toBe(collections); + }); +}); diff --git a/packages/agent-bff/test/read-model/read-model-store.test.ts b/packages/agent-bff/test/read-model/read-model-store.test.ts new file mode 100644 index 0000000000..15b0d269ac --- /dev/null +++ b/packages/agent-bff/test/read-model/read-model-store.test.ts @@ -0,0 +1,127 @@ +import type { SchemaFetcher } from '../../src/read-model/forest-schema-client'; + +import { makeMetrics, makeSchema } from './fixtures'; +import CapabilitiesCache from '../../src/read-model/capabilities-cache'; +import ReadModelStore from '../../src/read-model/read-model-store'; +import SchemaCache, { ONE_DAY_MS } from '../../src/read-model/schema-cache'; + +describe('ReadModelStore', () => { + let clock: number; + const now = () => clock; + + function build(fetchSchema: jest.Mock): ReadModelStore { + const metrics = makeMetrics(); + const schemaCache = new SchemaCache({ + fetcher: { fetchSchema } as SchemaFetcher, + metrics, + now, + }); + const capabilitiesCache = new CapabilitiesCache({ now }); + + return new ReadModelStore(schemaCache, capabilitiesCache); + } + + beforeEach(() => { + clock = 1_000_000; + }); + + describe('getReadModel', () => { + it('should build the read-model from the fetched schema', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + + const model = await store.getReadModel(); + + expect(model.isCollectionAllowed('users')).toBe(true); + }); + + it('should reuse the same read-model instance on a cache hit', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + + const a = await store.getReadModel(); + const b = await store.getReadModel(); + + expect(a).toBe(b); + }); + + it('should rebuild the read-model after a schema refresh', async () => { + const fetchSchema = jest + .fn() + .mockResolvedValueOnce(makeSchema('users')) + .mockResolvedValueOnce(makeSchema('orders')); + const store = build(fetchSchema); + + const before = await store.getReadModel(); + clock += ONE_DAY_MS; + const after = await store.getReadModel(); + + expect(before).not.toBe(after); + expect(after.isCollectionAllowed('orders')).toBe(true); + expect(after.isCollectionAllowed('users')).toBe(false); + }); + }); + + describe('capabilities coupling', () => { + it('should fetch capabilities and cache them', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + const capsFetcher = jest.fn().mockResolvedValue({ fields: [] }); + + await store.getCapabilities('users', capsFetcher); + await store.getCapabilities('users', capsFetcher); + + expect(capsFetcher).toHaveBeenCalledTimes(1); + }); + + it('should invalidate capabilities when the schema refreshes', async () => { + const fetchSchema = jest + .fn() + .mockResolvedValueOnce(makeSchema('users')) + .mockResolvedValueOnce(makeSchema('users')); + const store = build(fetchSchema); + const capsFetcher = jest.fn().mockResolvedValue({ fields: [] }); + + await store.getCapabilities('users', capsFetcher); + clock += ONE_DAY_MS; + await store.getCapabilities('users', capsFetcher); + + expect(capsFetcher).toHaveBeenCalledTimes(2); + }); + + it('should not rebuild the read-model or clear capabilities when a refresh fails', async () => { + const fetchSchema = jest + .fn() + .mockResolvedValueOnce(makeSchema('users')) + .mockRejectedValueOnce(new Error('boom')); + // Capabilities TTL kept longer than the schema TTL so this asserts the *clear* (not a + // capabilities TTL expiry) does not happen on a warm schema-refresh failure. + const schemaCache = new SchemaCache({ + fetcher: { fetchSchema } as SchemaFetcher, + metrics: makeMetrics(), + now, + }); + const capabilitiesCache = new CapabilitiesCache({ now, ttlMs: ONE_DAY_MS * 10 }); + const store = new ReadModelStore(schemaCache, capabilitiesCache); + const capsFetcher = jest.fn().mockResolvedValue({ fields: [] }); + + const before = await store.getReadModel(); + await store.getCapabilities('users', capsFetcher); + + clock += ONE_DAY_MS; + const after = await store.getReadModel(); + await store.getCapabilities('users', capsFetcher); + + expect(after).toBe(before); + expect(capsFetcher).toHaveBeenCalledTimes(1); + }); + }); + + describe('ageSeconds', () => { + it('should reflect the schema cache age of the last good schema', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + + await store.getReadModel(); + clock += 7_000; + + expect(store.ageSeconds()).toBe(7); + }); + }); +}); diff --git a/packages/agent-bff/test/read-model/read-model.test.ts b/packages/agent-bff/test/read-model/read-model.test.ts new file mode 100644 index 0000000000..242bee7c58 --- /dev/null +++ b/packages/agent-bff/test/read-model/read-model.test.ts @@ -0,0 +1,193 @@ +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import { action, collection, column, polymorphic, relation } from './fixtures'; +import ReadModel from '../../src/read-model/read-model'; + +describe('ReadModel', () => { + describe('collection allow-list', () => { + it('should report a schema collection as allowed and an absent one as not allowed', () => { + const model = new ReadModel([collection('users', [column('id')])]); + + expect(model.isCollectionAllowed('users')).toBe(true); + expect(model.isCollectionAllowed('secrets')).toBe(false); + }); + }); + + describe('relation allow-list and targets', () => { + it('should allow-list a relation and resolve its foreign collection from the reference', () => { + const model = new ReadModel([ + collection('users', [column('id'), relation('company', 'BelongsTo', 'companies.id')]), + ]); + + expect(model.isRelationAllowed('users', 'company')).toBe(true); + expect(model.getRelationTarget('users', 'company')).toEqual({ + type: 'BelongsTo', + polymorphic: false, + target: 'companies', + }); + }); + + it('should not allow-list a relation absent from the collection', () => { + const model = new ReadModel([collection('users', [column('id')])]); + + expect(model.isRelationAllowed('users', 'company')).toBe(false); + expect(model.getRelationTarget('users', 'company')).toBeUndefined(); + }); + + it('should allow-list all four relationship types', () => { + const model = new ReadModel([ + collection('users', [ + relation('company', 'BelongsTo', 'companies.id'), + relation('profile', 'HasOne', 'profiles.userId'), + relation('orders', 'HasMany', 'orders.userId'), + relation('roles', 'BelongsToMany', 'roles.id'), + ]), + ]); + + expect(model.getRelationTarget('users', 'company')?.type).toBe('BelongsTo'); + expect(model.getRelationTarget('users', 'profile')?.type).toBe('HasOne'); + expect(model.getRelationTarget('users', 'orders')?.type).toBe('HasMany'); + expect(model.getRelationTarget('users', 'roles')?.type).toBe('BelongsToMany'); + }); + + it('should resolve a dotted foreign collection name (e.g. mongoose nested) as the target', () => { + const model = new ReadModel([ + collection('users', [relation('address', 'HasOne', 'users.address.id')]), + ]); + + expect(model.getRelationTarget('users', 'address')).toEqual({ + type: 'HasOne', + polymorphic: false, + target: 'users.address', + }); + }); + + it('should ignore a relation field that has neither a reference nor polymorphic targets', () => { + const model = new ReadModel([ + collection('users', [{ ...column('mystery'), relationship: 'HasMany' }]), + ]); + + expect(model.isRelationAllowed('users', 'mystery')).toBe(false); + expect(model.getRelationTarget('users', 'mystery')).toBeUndefined(); + }); + + it('should flag a polymorphic relation and store its target list', () => { + const model = new ReadModel([ + collection('comments', [polymorphic('commentable', ['posts', 'videos'])]), + ]); + + expect(model.getRelationTarget('comments', 'commentable')).toEqual({ + type: 'BelongsTo', + polymorphic: true, + targets: ['posts', 'videos'], + }); + }); + + it('should return frozen relation targets that a consumer cannot mutate', () => { + const model = new ReadModel([ + collection('comments', [polymorphic('commentable', ['posts'])]), + ]); + + const target = model.getRelationTarget('comments', 'commentable'); + + expect(Object.isFrozen(target)).toBe(true); + }); + }); + + describe('collection-scoped keying', () => { + it('should keep same-named relations on different collections distinct', () => { + const model = new ReadModel([ + collection('a', [relation('owner', 'BelongsTo', 'people.id')]), + collection('b', [relation('owner', 'BelongsTo', 'orgs.id')]), + ]); + + expect(model.getRelationTarget('a', 'owner')).toMatchObject({ target: 'people' }); + expect(model.getRelationTarget('b', 'owner')).toMatchObject({ target: 'orgs' }); + }); + + it('should keep same-named actions on different collections distinct', () => { + const model = new ReadModel([ + collection('a', [column('id')], [action('ban', '/forest/a/actions/ban')]), + collection('b', [column('id')], [action('ban', '/forest/b/actions/ban')]), + ]); + + expect(model.getActionEndpoints().a.ban.endpoint).toBe('/forest/a/actions/ban'); + expect(model.getActionEndpoints().b.ban.endpoint).toBe('/forest/b/actions/ban'); + }); + }); + + describe('action endpoint map and allow-list', () => { + it('should map an action with an endpoint and allow-list it', () => { + const model = new ReadModel([ + collection('users', [column('id')], [action('ban', '/forest/users/actions/ban')]), + ]); + + expect(model.isActionAllowed('users', 'ban')).toBe(true); + expect(model.getActionEndpoints().users.ban).toEqual({ + id: 'ban-id', + name: 'ban', + endpoint: '/forest/users/actions/ban', + hooks: { load: false, change: [] }, + fields: [], + }); + }); + + it('should not expose an action that has no endpoint', () => { + const model = new ReadModel([collection('users', [column('id')], [action('ban', '')])]); + + expect(model.isActionAllowed('users', 'ban')).toBe(false); + expect(model.getActionEndpoints().users).toBeUndefined(); + }); + + it('should defensively copy action fields and hooks so a consumer cannot mutate the cache', () => { + const original = action('ban', '/forest/users/actions/ban'); + original.fields = [{ field: 'reason', type: 'String' }]; + original.hooks = { load: true, change: ['dep'] }; + const model = new ReadModel([collection('users', [], [original])]); + + const stored = model.getActionEndpoints().users.ban; + + expect(stored.fields).toEqual([{ field: 'reason', type: 'String' }]); + expect(stored.fields).not.toBe(original.fields); + expect(stored.fields[0]).not.toBe(original.fields[0]); + expect(stored.hooks.change).not.toBe(original.hooks.change); + }); + + it('should handle a collection with no actions key', () => { + const model = new ReadModel([{ name: 'users', fields: [] }]); + + expect(model.isActionAllowed('users', 'ban')).toBe(false); + }); + + it('should return a frozen action-endpoint map that a consumer cannot mutate', () => { + const model = new ReadModel([ + collection('users', [], [action('ban', '/forest/users/actions/ban')]), + ]); + + const endpoints = model.getActionEndpoints(); + + expect(Object.isFrozen(endpoints)).toBe(true); + expect(Object.isFrozen(endpoints.users.ban)).toBe(true); + expect(() => { + endpoints.users.ban.endpoint = 'mutated'; + }).toThrow(); + }); + + it('should not throw on a malformed collection with no fields key', () => { + const model = new ReadModel([{ name: 'weird' } as unknown as ForestSchemaCollection]); + + expect(model.isCollectionAllowed('weird')).toBe(true); + expect(model.isRelationAllowed('weird', 'x')).toBe(false); + }); + }); + + describe('queries on an unknown collection', () => { + it('should report everything as not allowed', () => { + const model = new ReadModel([collection('users', [])]); + + expect(model.isRelationAllowed('ghost', 'x')).toBe(false); + expect(model.getRelationTarget('ghost', 'x')).toBeUndefined(); + expect(model.isActionAllowed('ghost', 'x')).toBe(false); + }); + }); +}); diff --git a/packages/agent-bff/test/read-model/schema-cache.test.ts b/packages/agent-bff/test/read-model/schema-cache.test.ts new file mode 100644 index 0000000000..b0cfc1fe7a --- /dev/null +++ b/packages/agent-bff/test/read-model/schema-cache.test.ts @@ -0,0 +1,245 @@ +import type { Metrics } from '../../src/ports/metrics-port'; +import type { SchemaFetcher } from '../../src/read-model/forest-schema-client'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import { makeMetrics, makeSchema } from './fixtures'; +import SchemaUnavailableError from '../../src/read-model/errors'; +import SchemaCache, { + ONE_DAY_MS, + SCHEMA_CACHE_AGE_SECONDS, + SCHEMA_CACHE_REFRESH_ERROR, +} from '../../src/read-model/schema-cache'; + +describe('SchemaCache', () => { + let fetcher: { fetchSchema: jest.Mock, []> }; + let metrics: jest.Mocked; + let clock: number; + const now = () => clock; + + beforeEach(() => { + clock = 1_000_000; + metrics = makeMetrics(); + fetcher = { fetchSchema: jest.fn() }; + }); + + function build(): SchemaCache { + return new SchemaCache({ fetcher: fetcher as SchemaFetcher, metrics, now }); + } + + describe('cold cache', () => { + it('should fetch on first read and return the collections', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValue(schema); + + const result = await build().get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(1); + expect(result).toBe(schema); + }); + + it('should throw SchemaUnavailableError and emit the error counter when the first fetch fails', async () => { + fetcher.fetchSchema.mockRejectedValue(new Error('boom')); + const cache = build(); + + await expect(cache.get()).rejects.toBeInstanceOf(SchemaUnavailableError); + expect(metrics.increment).toHaveBeenCalledWith(SCHEMA_CACHE_REFRESH_ERROR); + }); + + it('should re-attempt the fetch on the next read after a cold failure (no poisoning)', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(schema); + const cache = build(); + + await expect(cache.get()).rejects.toBeInstanceOf(SchemaUnavailableError); + const result = await cache.get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(2); + expect(result).toBe(schema); + }); + + it('should report ageSeconds undefined until a first good schema exists', async () => { + fetcher.fetchSchema.mockRejectedValue(new Error('boom')); + const cache = build(); + + await expect(cache.get()).rejects.toBeInstanceOf(SchemaUnavailableError); + + expect(cache.ageSeconds()).toBeUndefined(); + }); + }); + + describe('warm cache within TTL', () => { + it('should serve from cache without re-fetching before 24h', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValue(schema); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS - 1; + const result = await cache.get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(1); + expect(result).toBe(schema); + }); + + it('should re-fetch after 24h', async () => { + const first = makeSchema('users'); + const second = makeSchema('users-v2'); + fetcher.fetchSchema.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + const result = await cache.get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(2); + expect(result).toBe(second); + }); + + it('should return the same array reference on a cache hit', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValue(schema); + const cache = build(); + + const a = await cache.get(); + const b = await cache.get(); + + expect(a).toBe(b); + }); + }); + + describe('warm cache refresh failure', () => { + it('should keep serving the last good schema and emit the error counter', async () => { + const good = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValueOnce(good).mockRejectedValueOnce(new Error('boom')); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + const result = await cache.get(); + + expect(result).toBe(good); + expect(metrics.increment).toHaveBeenCalledWith(SCHEMA_CACHE_REFRESH_ERROR); + }); + + it('should re-attempt on the next read and serve the fresh schema once it succeeds', async () => { + const good = makeSchema('users'); + const fresh = makeSchema('users-v2'); + fetcher.fetchSchema + .mockResolvedValueOnce(good) + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(fresh); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + await cache.get(); + const result = await cache.get(); + + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(3); + expect(result).toBe(fresh); + }); + }); + + describe('concurrent reads', () => { + it('should dedupe an in-flight fetch so concurrent cold reads fetch once', async () => { + const schema = makeSchema('users'); + let resolveFetch!: (value: ForestSchemaCollection[]) => void; + fetcher.fetchSchema.mockReturnValue( + new Promise(resolve => { + resolveFetch = resolve; + }), + ); + const cache = build(); + + const a = cache.get(); + const b = cache.get(); + resolveFetch(schema); + + expect(await a).toBe(schema); + expect(await b).toBe(schema); + expect(fetcher.fetchSchema).toHaveBeenCalledTimes(1); + }); + }); + + describe('age gauge', () => { + it('should emit schema_cache_age_seconds reflecting the last good age on read', async () => { + const schema = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValue(schema); + const cache = build(); + + await cache.get(); + clock += 5_000; + metrics.gauge.mockClear(); + await cache.get(); + + expect(metrics.gauge).toHaveBeenCalledWith(SCHEMA_CACHE_AGE_SECONDS, 5); + }); + }); + + describe('empty schema', () => { + it('should treat an empty schema as a failed fetch on a cold cache', async () => { + fetcher.fetchSchema.mockResolvedValue([]); + const cache = build(); + + await expect(cache.get()).rejects.toBeInstanceOf(SchemaUnavailableError); + expect(metrics.increment).toHaveBeenCalledWith(SCHEMA_CACHE_REFRESH_ERROR); + expect(cache.ageSeconds()).toBeUndefined(); + }); + + it('should keep serving the last good schema when a refresh returns empty', async () => { + const good = makeSchema('users'); + fetcher.fetchSchema.mockResolvedValueOnce(good).mockResolvedValueOnce([]); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + const result = await cache.get(); + + expect(result).toBe(good); + expect(metrics.increment).toHaveBeenCalledWith(SCHEMA_CACHE_REFRESH_ERROR); + }); + }); + + describe('revision', () => { + it('should start at 0 before any successful fetch', () => { + expect(build().revision).toBe(0); + }); + + it('should increment on each successful refresh', async () => { + fetcher.fetchSchema + .mockResolvedValueOnce(makeSchema('users')) + .mockResolvedValueOnce(makeSchema('users-v2')); + const cache = build(); + + await cache.get(); + expect(cache.revision).toBe(1); + + clock += ONE_DAY_MS; + await cache.get(); + expect(cache.revision).toBe(2); + }); + + it('should not increment on a cache hit', async () => { + fetcher.fetchSchema.mockResolvedValue(makeSchema('users')); + const cache = build(); + + await cache.get(); + await cache.get(); + + expect(cache.revision).toBe(1); + }); + + it('should not increment on a warm refresh failure', async () => { + fetcher.fetchSchema + .mockResolvedValueOnce(makeSchema('users')) + .mockRejectedValueOnce(new Error('boom')); + const cache = build(); + + await cache.get(); + clock += ONE_DAY_MS; + await cache.get(); + + expect(cache.revision).toBe(1); + }); + }); +});