Unify referenced result validation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import { config } from "../config.js";
|
||||
import { readJsonFile } from "../utils/fileStore.js";
|
||||
import {
|
||||
type ResultReferenceKind,
|
||||
type ResultReferenceRecord,
|
||||
type ResultReferenceSource,
|
||||
type RetrievalContext,
|
||||
RESULT_REFERENCE_KIND,
|
||||
type ResultReferenceStore,
|
||||
} from "./store.js";
|
||||
|
||||
type ResolveOptions = {
|
||||
expectedKind?: ResultReferenceKind;
|
||||
maxItems?: number;
|
||||
};
|
||||
|
||||
type RegisterResultReferenceInput = {
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
schemaVersion: number;
|
||||
sessionId: string;
|
||||
source: ResultReferenceSource;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
export type RenderJunctionPayload = {
|
||||
node_area_map: Record<string, string>;
|
||||
area_ids?: string[];
|
||||
area_colors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class ResultReferenceResolver {
|
||||
constructor(private readonly store: ResultReferenceStore) {}
|
||||
|
||||
async register(input: RegisterResultReferenceInput) {
|
||||
const normalizedData = normalizeDataForKind(
|
||||
input.kind,
|
||||
input.data,
|
||||
input.schemaVersion,
|
||||
);
|
||||
if (!normalizedData) {
|
||||
throw new Error(`invalid payload for result ref kind '${input.kind}'`);
|
||||
}
|
||||
return this.store.store({
|
||||
actorKey: input.actorKey,
|
||||
clientSessionId: input.clientSessionId,
|
||||
data: normalizedData,
|
||||
kind: input.kind,
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
schemaVersion: input.schemaVersion,
|
||||
sessionId: input.sessionId,
|
||||
source: input.source,
|
||||
traceId: input.traceId,
|
||||
});
|
||||
}
|
||||
|
||||
async registerRenderPayloadFile(
|
||||
filePath: string,
|
||||
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
|
||||
) {
|
||||
const raw = await readJsonFile<unknown>(filePath);
|
||||
if (raw === null) {
|
||||
throw new Error(`render payload file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const payload = extractRenderJunctionPayload(raw);
|
||||
if (!payload) {
|
||||
throw new Error("render payload file does not contain a valid junction render payload");
|
||||
}
|
||||
|
||||
return this.register({
|
||||
...input,
|
||||
data: payload,
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
schemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async getAuthorized(resultRef: string, context: RetrievalContext, options: ResolveOptions = {}) {
|
||||
const record = await this.getResolvedRecord(resultRef, context, options);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
result_ref: record.resultRef,
|
||||
result_size_bytes: record.sizeBytes,
|
||||
stored_at: record.createdAt,
|
||||
data: projectData(record.data, options.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS),
|
||||
preview: record.preview,
|
||||
kind: record.kind,
|
||||
schema_version: record.schemaVersion,
|
||||
source: record.source,
|
||||
};
|
||||
}
|
||||
|
||||
async getFullAuthorized(
|
||||
resultRef: string,
|
||||
context: RetrievalContext,
|
||||
options: ResolveOptions = {},
|
||||
) {
|
||||
const record = await this.getResolvedRecord(resultRef, context, options);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
result_ref: record.resultRef,
|
||||
result_size_bytes: record.sizeBytes,
|
||||
stored_at: record.createdAt,
|
||||
data: record.data,
|
||||
preview: record.preview,
|
||||
kind: record.kind,
|
||||
schema_version: record.schemaVersion,
|
||||
source: record.source,
|
||||
};
|
||||
}
|
||||
|
||||
private async getResolvedRecord(
|
||||
resultRef: string,
|
||||
context: RetrievalContext,
|
||||
options: ResolveOptions,
|
||||
): Promise<ResultReferenceRecord | null> {
|
||||
const record = await this.store.getAuthorizedRecord(resultRef, context);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
if (options.expectedKind && record.kind !== options.expectedKind) {
|
||||
return null;
|
||||
}
|
||||
const normalizedData = normalizeDataForKind(
|
||||
record.kind,
|
||||
record.data,
|
||||
record.schemaVersion,
|
||||
);
|
||||
if (!normalizedData) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
data: normalizedData,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const extractRenderJunctionPayload = (
|
||||
value: unknown,
|
||||
): RenderJunctionPayload | null => {
|
||||
const candidate = unwrapReferencePayload(value);
|
||||
if (!candidate || !isRecord(candidate.node_area_map)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
|
||||
if (Object.keys(nodeAreaMap).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const areaIds = Array.isArray(candidate.area_ids)
|
||||
? candidate.area_ids.map((entry) => String(entry).trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const areaColors = isRecord(candidate.area_colors)
|
||||
? normalizeStringRecord(candidate.area_colors)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
node_area_map: nodeAreaMap,
|
||||
...(areaIds && areaIds.length > 0 ? { area_ids: areaIds } : {}),
|
||||
...(areaColors && Object.keys(areaColors).length > 0
|
||||
? { area_colors: areaColors }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDataForKind = (
|
||||
kind: ResultReferenceKind,
|
||||
data: unknown,
|
||||
schemaVersion: number,
|
||||
): unknown | null => {
|
||||
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
|
||||
return null;
|
||||
}
|
||||
if (kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) {
|
||||
return extractRenderJunctionPayload(data);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if ("data" in value && value.data !== undefined && value.data !== null) {
|
||||
return isRecord(value.data) ? value.data : null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizeStringRecord = (value: Record<string, unknown>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, entry]) => [String(key), String(entry ?? "").trim()])
|
||||
.filter(([, entry]) => entry.length > 0),
|
||||
);
|
||||
|
||||
const projectData = (data: unknown, maxItems: number) => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.slice(0, maxItems);
|
||||
}
|
||||
if (isRecord(data)) {
|
||||
return Object.fromEntries(Object.entries(data).slice(0, maxItems));
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
Reference in New Issue
Block a user