import { SelectQuery } from '../core/ast'; import { SmartList, SmartListRecord } from '../core/smart-list'; import { UserContext } from '../core/context'; import { MutationGovernanceSnapshot } from '../core/mutation-policy'; import { MutationRequest, QueryIntent, QueryRequest } from '../core/request-intent'; import { NOOP_RUNTIME_TELEMETRY, injectRuntimeContext, observeRuntimeOperation, RuntimeTelemetry, } from '../core/telemetry'; function rejectRemoteHardLimit(value: unknown, path = '$'): void { if (Array.isArray(value)) { value.forEach((item, index) => rejectRemoteHardLimit(item, `${path}[${index}]`)); return; } if (!value || typeof value !== 'object') return; for (const [key, child] of Object.entries(value as Record)) { const normalized = key.replace(/[^a-zA-Z]/g, '').toLowerCase(); if (normalized === 'hardlimit' || normalized === 'hardlimitvalue' || normalized.startsWith('continuouspage') || normalized.startsWith('idsetpagination') || normalized.startsWith('paginationwithidset')) { throw new Error(`TFP_FORBIDDEN_FIELD: ${path}.${key} is server-local policy`); } rejectRemoteHardLimit(child, `${path}.${key}`); } } function serializeQuery(query: SelectQuery, nestedFacet = false, intent?: QueryIntent): Record { const request = new QueryRequest(query, intent); query = request.query; if (!Number.isSafeInteger(query.offsetValue) || query.offsetValue < 0) { throw new Error('TFP_INVALID_REQUEST: offset must be a non-negative safe integer'); } if (query.limitValue && (!Number.isSafeInteger(query.limitValue) || query.limitValue < 1)) { throw new Error('TFP_INVALID_REQUEST: limit must be a positive safe integer'); } rejectRemoteHardLimit(JSON.parse(JSON.stringify(query))); if (query.relations.length || query.joins.length) { throw new Error('TFP_INVALID_REQUEST: relations and joins are not part of canonical TFP v1'); } if (nestedFacet && query.facets.length) { throw new Error('TFP_INVALID_REQUEST: nested facets are not supported'); } return { entity: query.entity, filterCondition: query.filterCondition, limitValue: query.limitValue || undefined, offsetValue: query.offsetValue || undefined, orderItems: query.orderItems, selectItems: query.selectItems, groupByItems: query.groupByItems, aggregateItems: query.aggregateItems, facets: query.facets.map(facet => ({ facetName: facet.facetName, relationName: facet.relationName, includeAllFacets: facet.includeAllFacets, query: serializeQuery(facet.query, true, request.intent), })), commentText: query.commentText, purposeText: query.purposeText, }; } export interface TeaQLClientConfig { baseUrl: string; fetch?: typeof fetch; getHeaders?: () => Record | Promise>; runtimeTelemetry?: RuntimeTelemetry; /** Trusted local context; never serialized into the TFP request. */ userContext?: UserContext; } export class TeaQLClient { private config: TeaQLClientConfig; private fetchImpl: typeof fetch; private runtimeTelemetry: RuntimeTelemetry; private userContext: UserContext; private readonly mutationGovernanceEvents: MutationGovernanceSnapshot[] = []; constructor(config: TeaQLClientConfig) { this.config = config; // Fallback to global fetch if available this.fetchImpl = config.fetch ?? (typeof window !== 'undefined' ? window.fetch.bind(window) : fetch); this.runtimeTelemetry = config.runtimeTelemetry ?? NOOP_RUNTIME_TELEMETRY; this.userContext = config.userContext ?? new UserContext(); } setRuntimeTelemetry(telemetry: RuntimeTelemetry | undefined): this { this.runtimeTelemetry = telemetry ?? NOOP_RUNTIME_TELEMETRY; return this; } setUserContext(context: UserContext): this { if (!context) throw new TypeError('UserContext is required'); this.userContext = context; return this; } get mutationGovernanceTrace(): readonly MutationGovernanceSnapshot[] { return [...this.mutationGovernanceEvents]; } private async requestHeaders(): Promise> { const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json', }; return this.config.getHeaders ? { ...headers, ...await this.config.getHeaders() } : headers; } async executeQuery(query: SelectQuery | QueryRequest): Promise> { const request = query instanceof QueryRequest ? query : new QueryRequest(query); const payload = serializeQuery(request.query, false, request.intent); const url = `${this.config.baseUrl.replace(/\/$/, '')}/query`; return observeRuntimeOperation( this.runtimeTelemetry, { family: 'tfp', name: 'client.query', attributes: { 'teaql.tfp.role': 'client' } }, async () => { const headers = injectRuntimeContext( this.runtimeTelemetry, await this.requestHeaders(), ); const response = await this.fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(payload), }); if (!response.ok) { const errText = await response.text(); throw new Error(`TEAQL Query Error [${response.status}]: ${errText}`); } const responseJson = await response.json(); const facets: Record> = {}; for (const [name, values] of Object.entries(responseJson.facets ?? {})) { facets[name] = new SmartList(values as SmartListRecord[]); } return new SmartList(responseJson.data ?? [], { facets }); }, result => ({ attributes: { 'teaql.result.cardinality': result.length } }), ); } async *executeForStream( _query: SelectQuery, _chunkSize = 1000, ): AsyncIterable { new QueryRequest(_query); throw new Error( 'TeaQL federation does not support executeForStream over the ordinary TFP request/response protocol; use a dedicated streaming protocol', ); } async executeMutation(query: any): Promise { const request = query instanceof MutationRequest ? query : new MutationRequest(query); query = request.mutation; const payload = { entity: query.entity, action: query.action, payload: query.payload, id: query.id, expectedVersion: query.expectedVersion, comment: query.comment, }; rejectRemoteHardLimit(payload); // Client policy is defense in depth for browser/Node applications. The // receiving TeaQL server remains authoritative and evaluates its own policy. const mutationGovernance = this.userContext.enterMutationPolicy(payload); this.mutationGovernanceEvents.push(mutationGovernance); return observeRuntimeOperation( this.runtimeTelemetry, { family: 'tfp', name: 'client.mutation', attributes: { 'teaql.tfp.role': 'client' } }, async () => { const headers = injectRuntimeContext( this.runtimeTelemetry, await this.requestHeaders(), ); const response = await this.fetchImpl(`${this.config.baseUrl.replace(/\/$/, '')}/mutate`, { method: 'POST', headers, body: JSON.stringify(payload), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`TeaQL Mutation failed: ${response.status} ${errorText}`); } return response.json(); }, ); } }