fix: reduce workbench read-model fact scans (#1676)

This commit is contained in:
Lyon
2026-06-20 10:02:38 +08:00
committed by GitHub
parent 4adbfd3988
commit 57ba729fca
3 changed files with 154 additions and 31 deletions
+37 -8
View File
@@ -313,11 +313,17 @@ test("workbench read model exposes session, messages, turn, and trace without wr
assert.equal(sessions.body.sessions[0].turnSummary.traceId, traceId);
assert.equal(factQueries[0].projectId, undefined);
assert.equal(factQueries[0].limit, 21);
assert.equal(factQueries[0].sessionOrder, "updated_desc");
assert.deepEqual(factQueries[0].families, ["sessions"]);
assert.deepEqual(factQueries[1].families, ["messages", "parts", "turns"]);
assert.deepEqual(factQueries[1].sessionIds, [session.id]);
assert.deepEqual(factQueries[2].families, ["checkpoints"]);
assert.deepEqual(factQueries[2].traceIds, [traceId]);
const staleProject = await getJson(port, `/v1/workbench/sessions?projectId=prj_stale_filter&includeSessionId=${encodeURIComponent(session.id)}`);
assert.equal(staleProject.status, 400);
assert.equal(staleProject.body.error.code, "workbench_authority_removed");
assert.equal(factQueries.length, 1);
assert.equal(factQueries.length, 3);
const detail = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}`);
assert.equal(detail.status, 200);
@@ -1369,21 +1375,44 @@ function normalizeTestEvents(events, session, { traceId }) {
}
function filterFacts(facts, params = {}) {
const families = testFactFamilySet(params);
const filtered = {
sessions: facts.sessions.filter((record) => matchesFact(record, params, ["sessionId", "ownerUserId", "projectId", "conversationId", "threadId", "status"]) && (!params.traceId || record.lastTraceId === params.traceId)),
messages: facts.messages.filter((record) => matchesFact(record, params, ["messageId", "sessionId", "turnId", "traceId", "role", "status"])),
parts: facts.parts.filter((record) => matchesFact(record, params, ["partId", "messageId", "sessionId", "turnId", "traceId", "partType", "status"])),
turns: facts.turns.filter((record) => matchesFact(record, params, ["turnId", "sessionId", "traceId", "messageId", "status"])),
traceEvents: facts.traceEvents.filter((record) => matchesFact(record, params, ["id", "traceId", "sessionId", "turnId", "messageId", "eventType"])),
checkpoints: facts.checkpoints.filter((record) => matchesFact(record, params, ["traceId", "sessionId", "turnId", "runId", "commandId", "projectionStatus", "projectionHealth"]))
sessions: families.has("sessions") ? facts.sessions.filter((record) => matchesFact(record, params, ["sessionId", "ownerUserId", "projectId", "conversationId", "threadId", "status"]) && matchesTraceFact(record.lastTraceId, params)) : [],
messages: families.has("messages") ? facts.messages.filter((record) => matchesFact(record, params, ["messageId", "sessionId", "turnId", "traceId", "role", "status"])) : [],
parts: families.has("parts") ? facts.parts.filter((record) => matchesFact(record, params, ["partId", "messageId", "sessionId", "turnId", "traceId", "partType", "status"])) : [],
turns: families.has("turns") ? facts.turns.filter((record) => matchesFact(record, params, ["turnId", "sessionId", "traceId", "messageId", "status"])) : [],
traceEvents: families.has("traceEvents") ? facts.traceEvents.filter((record) => matchesFact(record, params, ["id", "traceId", "sessionId", "turnId", "messageId", "eventType"])) : [],
checkpoints: families.has("checkpoints") ? facts.checkpoints.filter((record) => matchesFact(record, params, ["traceId", "sessionId", "turnId", "runId", "commandId", "projectionStatus", "projectionHealth"])) : []
};
if (params.sessionOrder === "updated_desc") {
filtered.sessions.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")) || String(right.sessionId ?? "").localeCompare(String(left.sessionId ?? "")));
}
const limit = Number.parseInt(params.limit ?? "", 10);
if (!Number.isInteger(limit) || limit <= 0) return filtered;
return Object.fromEntries(Object.entries(filtered).map(([key, value]) => [key, value.slice(0, limit)]));
}
function matchesFact(record, params, fields) {
return fields.every((field) => params[field] === undefined || params[field] === null || record[field] === params[field]);
return fields.every((field) => {
if (params[field] !== undefined && params[field] !== null && record[field] !== params[field]) return false;
const values = Array.isArray(params[`${field}s`]) ? params[`${field}s`].map((item) => String(item ?? "")).filter(Boolean) : [];
return values.length === 0 || values.includes(String(record[field] ?? ""));
});
}
function matchesTraceFact(value, params) {
if (params.traceId && value !== params.traceId) return false;
const traceIds = Array.isArray(params.traceIds) ? params.traceIds.map((item) => String(item ?? "")).filter(Boolean) : [];
return traceIds.length === 0 || traceIds.includes(String(value ?? ""));
}
function testFactFamilySet(params = {}) {
const all = ["sessions", "messages", "parts", "turns", "traceEvents", "checkpoints"];
const raw = params.families ?? params.factFamilies;
if (!raw) return new Set(all);
const values = Array.isArray(raw) ? raw : String(raw).split(",");
const selected = values.map((item) => String(item ?? "").trim()).filter((item) => all.includes(item));
return new Set(selected.length ? selected : all);
}
function mergeFacts(target, source) {
+48 -7
View File
@@ -20,6 +20,8 @@ const DEFAULT_PAGE_LIMIT = 50;
const DEFAULT_SESSION_LIST_LIMIT = 20;
const MAX_PAGE_LIMIT = 100;
const DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS = 15000;
const WORKBENCH_SESSION_LIST_PAGE_FAMILIES = Object.freeze(["sessions"]);
const WORKBENCH_SESSION_DETAIL_FAMILIES = Object.freeze(["sessions", "messages", "parts", "turns", "checkpoints"]);
export async function handleWorkbenchReadModelHttp(request, response, url, options = {}) {
try {
const perf = options.backendPerformance;
@@ -318,12 +320,13 @@ async function handleWorkbenchSessionList(response, url, options, actor) {
const includeSessionId = safeSessionId(url.searchParams.get("includeSessionId"));
const includeRouteId = includeSessionId ?? safeConversationId(url.searchParams.get("includeSessionId"));
const readModel = createWorkbenchReadModel(options, actor);
const pageResult = await (perf ? perf.measure("workbench_session_page_query", () => readModel.queryFacts({ ownerUserId: actor.role === "admin" ? undefined : actor.id, limit: limit + offset + 1 })) : readModel.queryFacts({ ownerUserId: actor.role === "admin" ? undefined : actor.id, limit: limit + offset + 1 }));
const pageQuery = { ownerUserId: actor.role === "admin" ? undefined : actor.id, limit: limit + offset + 1, families: WORKBENCH_SESSION_LIST_PAGE_FAMILIES, sessionOrder: "updated_desc" };
const pageResult = await (perf ? perf.measure("workbench_session_page_query", () => readModel.queryFacts(pageQuery)) : readModel.queryFacts(pageQuery));
if (pageResult.error) return sendJson(response, 503, workbenchProjectionStoreError(pageResult.error));
let pageFacts = pageResult.facts;
let naturalPage = visibleFactSessions(pageFacts, actor).slice(offset, offset + limit + 1);
if (includeRouteId && !naturalPage.some((session) => factSessionMatchesRouteId(session, includeRouteId))) {
const includeResult = await (perf ? perf.measure("workbench_session_include_query", () => queryFactsByRouteId(readModel, includeRouteId)) : queryFactsByRouteId(readModel, includeRouteId));
const includeResult = await (perf ? perf.measure("workbench_session_include_query", () => queryFactsByRouteId(readModel, includeRouteId, { families: WORKBENCH_SESSION_LIST_PAGE_FAMILIES })) : queryFactsByRouteId(readModel, includeRouteId, { families: WORKBENCH_SESSION_LIST_PAGE_FAMILIES }));
if (includeResult.error) return sendJson(response, 503, workbenchProjectionStoreError(includeResult.error));
const included = visibleFactSessions(includeResult.facts, actor)[0] ?? null;
if (included) {
@@ -332,6 +335,9 @@ async function handleWorkbenchSessionList(response, url, options, actor) {
}
}
const pageSessions = naturalPage.slice(0, limit);
const summaryResult = await (perf ? perf.measure("workbench_session_summary_query", () => queryFactsForSessionSummaries(readModel, pageSessions)) : queryFactsForSessionSummaries(readModel, pageSessions));
if (summaryResult.error) return sendJson(response, 503, workbenchProjectionStoreError(summaryResult.error));
pageFacts = mergeFactSets(pageFacts, summaryResult.facts);
const summaries = pageSessions.map((session) => factSessionSummary(session, pageFacts)).filter(Boolean);
const hasMore = naturalPage.length > limit;
sendJson(response, 200, {
@@ -355,14 +361,45 @@ function sessionMatchesRouteId(session, routeId) {
return Boolean(conversationId && session?.conversationId === conversationId);
}
async function queryFactsByRouteId(readModel, routeId) {
async function queryFactsByRouteId(readModel, routeId, query = {}) {
const baseQuery = { ...query, limit: query.limit ?? MAX_PAGE_LIMIT };
const sessionId = safeSessionId(routeId);
if (sessionId) return readModel.queryFacts({ sessionId, limit: MAX_PAGE_LIMIT });
if (sessionId) return readModel.queryFacts({ ...baseQuery, sessionId });
const conversationId = safeConversationId(routeId);
if (conversationId) return readModel.queryFacts({ conversationId, limit: MAX_PAGE_LIMIT });
if (conversationId) {
const sessionResult = await readModel.queryFacts({ ...baseQuery, families: WORKBENCH_SESSION_LIST_PAGE_FAMILIES, conversationId });
if (sessionResult.error || queryRequestsOnlySessionFacts(baseQuery)) return sessionResult;
const resolvedSessionId = factSessionId(factArray(sessionResult.facts?.sessions)[0] ?? null);
if (!resolvedSessionId) return sessionResult;
const relatedResult = await readModel.queryFacts({ ...baseQuery, sessionId: resolvedSessionId });
if (relatedResult.error) return relatedResult;
const facts = mergeFactSets(sessionResult.facts, relatedResult.facts);
return { facts, count: Object.values(facts).reduce((sum, rows) => sum + rows.length, 0), durable: relatedResult.durable ?? sessionResult.durable, persistence: relatedResult.persistence ?? sessionResult.persistence ?? null };
}
return { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
}
function queryRequestsOnlySessionFacts(query = {}) {
const families = Array.isArray(query.families) ? query.families : [];
return families.length === 1 && families[0] === "sessions";
}
async function queryFactsForSessionSummaries(readModel, sessions = []) {
const sessionIds = uniqueText(sessions.map(factSessionId));
const traceIds = uniqueText(sessions.map(factLastTraceId));
if (sessionIds.length === 0 && traceIds.length === 0) return { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
const bySession = sessionIds.length > 0
? await readModel.queryFacts({ sessionIds, families: ["messages", "parts", "turns"] })
: { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
if (bySession.error) return bySession;
const byTrace = traceIds.length > 0
? await readModel.queryFacts({ traceIds, families: ["checkpoints"] })
: { facts: emptyFactSet(), count: 0, durable: false, persistence: bySession.persistence ?? null };
if (byTrace.error) return byTrace;
const facts = mergeFactSets(bySession.facts, byTrace.facts);
return { facts, count: Object.values(facts).reduce((sum, rows) => sum + rows.length, 0), durable: bySession.durable ?? byTrace.durable, persistence: bySession.persistence ?? byTrace.persistence ?? null };
}
function visibleFactSessions(facts, actor) {
return factArray(facts?.sessions)
.filter((session) => canActorReadFactSession(session, actor))
@@ -766,9 +803,13 @@ function factArray(value) {
return Array.isArray(value) ? value : [];
}
function uniqueText(values = []) {
return [...new Set(values.map((value) => textValue(value)).filter(Boolean))];
}
async function handleWorkbenchSessionDetail(response, options, actor, sessionId) {
const readModel = createWorkbenchReadModel(options, actor);
const result = await queryFactsByRouteId(readModel, sessionId);
const result = await queryFactsByRouteId(readModel, sessionId, { families: WORKBENCH_SESSION_DETAIL_FAMILIES });
if (result.error) return sendJson(response, 503, workbenchProjectionStoreError(result.error));
const session = visibleFactSessions(result.facts, actor)[0] ?? null;
if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId }));
@@ -784,7 +825,7 @@ async function handleWorkbenchSessionDetail(response, options, actor, sessionId)
async function handleWorkbenchMessagePage(response, url, options, actor, sessionId) {
const readModel = createWorkbenchReadModel(options, actor);
const result = await queryFactsByRouteId(readModel, sessionId);
const result = await queryFactsByRouteId(readModel, sessionId, { families: WORKBENCH_SESSION_DETAIL_FAMILIES });
if (result.error) return sendJson(response, 503, workbenchProjectionStoreError(result.error));
const session = visibleFactSessions(result.facts, actor)[0] ?? null;
if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId }));
+69 -16
View File
@@ -69,6 +69,7 @@ const postgresRuntimeReadIndexes = Object.freeze([
"CREATE INDEX IF NOT EXISTS idx_workbench_projection_state_status_retry_updated ON workbench_projection_state(projection_status, next_retry_at, updated_at)",
"CREATE INDEX IF NOT EXISTS idx_workbench_projection_state_run_command ON workbench_projection_state(run_id, command_id)",
"CREATE INDEX IF NOT EXISTS idx_workbench_projection_state_session_updated ON workbench_projection_state(session_id, updated_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_workbench_sessions_updated ON workbench_sessions(updated_at DESC, session_id)",
"CREATE INDEX IF NOT EXISTS idx_workbench_sessions_owner_updated ON workbench_sessions(owner_user_id, updated_at DESC, session_id)",
"CREATE INDEX IF NOT EXISTS idx_workbench_sessions_status_updated ON workbench_sessions(status, updated_at DESC, session_id)",
"CREATE INDEX IF NOT EXISTS idx_workbench_messages_session_updated ON workbench_messages(session_id, updated_at ASC, message_id)",
@@ -604,6 +605,7 @@ export class CloudRuntimeStore {
}
queryWorkbenchFacts(params = {}) {
const families = workbenchFactFamilySet(params);
const sessions = [...this.workbenchSessions.values()].filter((record) => matchesWorkbenchSessionFactQuery(record, params));
const messages = [...this.workbenchMessages.values()].filter((record) => matchesQuery(record, params, ["messageId", "sessionId", "turnId", "traceId", "role", "status"]));
const parts = [...this.workbenchParts.values()].filter((record) => matchesQuery(record, params, ["partId", "messageId", "sessionId", "turnId", "traceId", "partType", "status"]));
@@ -611,12 +613,12 @@ export class CloudRuntimeStore {
const traceEvents = [...this.workbenchTraceEvents.values()].filter((record) => matchesQuery(record, params, ["id", "traceId", "sessionId", "turnId", "messageId", "eventType"]));
const checkpoints = [...this.workbenchProjectionCheckpoints.values()].filter((record) => matchesQuery(record, params, ["traceId", "sessionId", "turnId", "runId", "commandId", "projectionStatus", "projectionHealth"]));
const facts = {
sessions: limitResults(sessions.sort(compareWorkbenchFactRecords), params.limit),
messages: limitResults(messages.sort(compareWorkbenchFactRecords), params.limit),
parts: limitResults(parts.sort(compareWorkbenchPartFactRecords), params.limit),
turns: limitResults(turns.sort(compareWorkbenchFactRecords), params.limit),
traceEvents: limitResults(traceEvents.sort(compareWorkbenchTraceEventFacts), params.limit),
checkpoints: limitResults(checkpoints.sort(compareWorkbenchFactRecords), params.limit)
sessions: families.has("sessions") ? limitResults(sortWorkbenchFactRows(sessions, params, "sessions", compareWorkbenchFactRecords), params.limit) : [],
messages: families.has("messages") ? limitResults(sortWorkbenchFactRows(messages, params, "messages", compareWorkbenchFactRecords), params.limit) : [],
parts: families.has("parts") ? limitResults(sortWorkbenchFactRows(parts, params, "parts", compareWorkbenchPartFactRecords), params.limit) : [],
turns: families.has("turns") ? limitResults(sortWorkbenchFactRows(turns, params, "turns", compareWorkbenchFactRecords), params.limit) : [],
traceEvents: families.has("traceEvents") ? limitResults(sortWorkbenchFactRows(traceEvents, params, "traceEvents", compareWorkbenchTraceEventFacts), params.limit) : [],
checkpoints: families.has("checkpoints") ? limitResults(sortWorkbenchFactRows(checkpoints, params, "checkpoints", compareWorkbenchFactRecords), params.limit) : []
};
return {
facts,
@@ -1212,13 +1214,14 @@ export class PostgresCloudRuntimeStore {
async queryWorkbenchFacts(params = {}) {
await this.assertReadyForDurableReads("workbench.facts.query");
const families = workbenchFactFamilySet(params);
const facts = {
sessions: await this.queryWorkbenchFactRows("workbench.sessions.query", "workbench_sessions", "session_json", params, { sessionId: "session_id", ownerUserId: "owner_user_id", projectId: "project_id", conversationId: "conversation_id", threadId: "thread_id", traceId: "last_trace_id", lastTraceId: "last_trace_id", status: "status" }),
messages: await this.queryWorkbenchFactRows("workbench.messages.query", "workbench_messages", "message_json", params, { messageId: "message_id", sessionId: "session_id", turnId: "turn_id", traceId: "trace_id", role: "role", status: "status" }),
parts: await this.queryWorkbenchFactRows("workbench.parts.query", "workbench_parts", "part_json", params, { partId: "part_id", messageId: "message_id", sessionId: "session_id", turnId: "turn_id", traceId: "trace_id", partType: "part_type", status: "status" }),
turns: await this.queryWorkbenchFactRows("workbench.turns.query", "workbench_turns", "turn_json", params, { turnId: "turn_id", sessionId: "session_id", traceId: "trace_id", messageId: "message_id", status: "status" }),
traceEvents: await this.queryWorkbenchFactRows("workbench.trace-events.query", "workbench_trace_events", "event_json", params, { id: "id", traceId: "trace_id", sessionId: "session_id", turnId: "turn_id", messageId: "message_id", eventType: "event_type" }),
checkpoints: await this.queryWorkbenchFactRows("workbench.projection-checkpoints.query", "workbench_projection_checkpoints", "checkpoint_json", params, { traceId: "trace_id", sessionId: "session_id", turnId: "turn_id", runId: "run_id", commandId: "command_id", projectionStatus: "projection_status", projectionHealth: "projection_health" })
sessions: families.has("sessions") ? await this.queryWorkbenchFactRows("workbench.sessions.query", "workbench_sessions", "session_json", params, { sessionId: "session_id", ownerUserId: "owner_user_id", projectId: "project_id", conversationId: "conversation_id", threadId: "thread_id", traceId: "last_trace_id", lastTraceId: "last_trace_id", status: "status" }) : [],
messages: families.has("messages") ? await this.queryWorkbenchFactRows("workbench.messages.query", "workbench_messages", "message_json", params, { messageId: "message_id", sessionId: "session_id", turnId: "turn_id", traceId: "trace_id", role: "role", status: "status" }) : [],
parts: families.has("parts") ? await this.queryWorkbenchFactRows("workbench.parts.query", "workbench_parts", "part_json", params, { partId: "part_id", messageId: "message_id", sessionId: "session_id", turnId: "turn_id", traceId: "trace_id", partType: "part_type", status: "status" }) : [],
turns: families.has("turns") ? await this.queryWorkbenchFactRows("workbench.turns.query", "workbench_turns", "turn_json", params, { turnId: "turn_id", sessionId: "session_id", traceId: "trace_id", messageId: "message_id", status: "status" }) : [],
traceEvents: families.has("traceEvents") ? await this.queryWorkbenchFactRows("workbench.trace-events.query", "workbench_trace_events", "event_json", params, { id: "id", traceId: "trace_id", sessionId: "session_id", turnId: "turn_id", messageId: "message_id", eventType: "event_type" }) : [],
checkpoints: families.has("checkpoints") ? await this.queryWorkbenchFactRows("workbench.projection-checkpoints.query", "workbench_projection_checkpoints", "checkpoint_json", params, { traceId: "trace_id", sessionId: "session_id", turnId: "turn_id", runId: "run_id", commandId: "command_id", projectionStatus: "projection_status", projectionHealth: "projection_health" }) : []
};
return withPersistence({ facts, count: Object.values(facts).reduce((sum, rows) => sum + rows.length, 0) }, this.summary());
}
@@ -1228,12 +1231,20 @@ export class PostgresCloudRuntimeStore {
const clauses = [];
for (const [field, column] of Object.entries(columnMap)) {
const value = textOr(params[field], "");
if (!value) continue;
queryParams.push(value);
clauses.push(`${column} = $${queryParams.length}`);
if (value) {
queryParams.push(value);
clauses.push(`${column} = $${queryParams.length}`);
}
const values = textList(params[`${field}s`]);
if (values.length > 0) {
queryParams.push(values);
clauses.push(`${column} = ANY($${queryParams.length})`);
}
}
const limit = positiveIntegerOrNull(params.limit);
let sql = `SELECT ${jsonColumn} FROM ${table}${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at ASC`;
const family = workbenchFactFamilyForTable(table);
const orderDirection = workbenchFactOrder(params, family) === "updated_desc" ? "DESC" : "ASC";
let sql = `SELECT ${jsonColumn} FROM ${table}${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at ${orderDirection}`;
if (limit) {
queryParams.push(limit);
sql += ` LIMIT $${queryParams.length}`;
@@ -2048,6 +2059,39 @@ function compareWorkbenchTraceEventFacts(left, right) {
String(left?.id ?? "").localeCompare(String(right?.id ?? ""));
}
function sortWorkbenchFactRows(records, params = {}, family, fallbackCompare) {
const sorted = [...records];
if (workbenchFactOrder(params, family) === "updated_desc") {
return sorted.sort((left, right) => String(right?.updatedAt ?? right?.createdAt ?? "").localeCompare(String(left?.updatedAt ?? left?.createdAt ?? "")) || fallbackCompare(left, right));
}
return sorted.sort(fallbackCompare);
}
const WORKBENCH_FACT_FAMILIES = Object.freeze(["sessions", "messages", "parts", "turns", "traceEvents", "checkpoints"]);
function workbenchFactFamilySet(params = {}) {
const raw = params.families ?? params.factFamilies;
if (raw === undefined || raw === null || raw === "") return new Set(WORKBENCH_FACT_FAMILIES);
const values = Array.isArray(raw) ? raw : String(raw).split(",");
const selected = values.map((value) => String(value ?? "").trim()).filter((value) => WORKBENCH_FACT_FAMILIES.includes(value));
return new Set(selected.length > 0 ? selected : WORKBENCH_FACT_FAMILIES);
}
function workbenchFactFamilyForTable(table) {
if (table === "workbench_sessions") return "sessions";
if (table === "workbench_messages") return "messages";
if (table === "workbench_parts") return "parts";
if (table === "workbench_turns") return "turns";
if (table === "workbench_trace_events") return "traceEvents";
if (table === "workbench_projection_checkpoints") return "checkpoints";
return "unknown";
}
function workbenchFactOrder(params = {}, family = "") {
const value = params[`${family}Order`] ?? params.order;
return value === "updated_desc" ? "updated_desc" : "updated_asc";
}
function traceEventLevel(value) {
const text = textOr(value, "info").toLowerCase();
if (["failed", "failure", "error", "timeout"].includes(text)) return "error";
@@ -2100,6 +2144,11 @@ function textOr(value, fallback) {
return text || fallback;
}
function textList(value) {
const raw = Array.isArray(value) ? value : [];
return [...new Set(raw.map((item) => textOr(item, "")).filter(Boolean))];
}
function asObject(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new HwlabProtocolError(`${label} must be a JSON object`, {
@@ -2162,6 +2211,10 @@ function matchesQuery(record, params, fields) {
if (params[field] !== undefined && params[field] !== null && record[field] !== params[field]) {
return false;
}
const values = textList(params[`${field}s`]);
if (values.length > 0 && !values.includes(textOr(record[field], ""))) {
return false;
}
}
return true;
}