feat(environment): answer list filters, search, sorting and paging from the state

- A list view learns, from the lines its whole collection does not
  explain, what each query parameter does: a filter on a field, a
  case-insensitive search over fields, a sort, a direction, a page size,
  an offset, a page number, or nothing for the values recorded. A line
  is answered by evaluating them only when that reproduces what it
  recorded, and the view's `query` names those lines and the envelope
  members that count matches before paging.
- Serving evaluates the query over the live state for those lines and
  for calls whose every query argument has a role, so filtered and
  sorted lists show created, changed and deleted entities and new
  argument values; other calls replay as before.
- The write gate checks a read on such a line against the query over
  its copy of the state. Only a line that sorts or pages its whole
  collection covers the collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-20 22:56:58 -07:00
1 parent 847b0931a3
commit ae8efbb439
8 files changed
+760 -30

No files matched your search

@@ -19,6 +19,10 @@ import {
type StateView,
} from "../../domain/analysisView/environmentStateRules.js";
import { isAbsence } from "../../domain/analysisView/environmentStateWriteChecks.js";
import {
evaluateQuery,
renderQueryList,
} from "../../domain/analysisView/environmentStateListQuery.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisInputError,
@@ -64,7 +68,7 @@ export type StateProvenance =
readonly view: "list";
readonly type: string;
readonly collection: string;
readonly membership: "all" | "recorded";
readonly membership: "all" | "query" | "recorded";
readonly listed: number;
}
| {
@@ -407,7 +411,53 @@ interface ToolCall {
readonly exact: boolean;
}
/** A list call renders the members of the checked line it names from the state. */
/** A call's query arguments by parameter name. */
const callQuery = (
tool: EnvironmentManifestTool,
args: ReplayArguments,
): Map<string, string> =>
new Map(
[...environmentToolArguments(tool).query].flatMap(
([name, argument]): [string, string][] => {
const value = args[argument];
return value === undefined ? [] : [[name, String(value)]];
},
),
);
/** For a list with a query, a call whose every query argument has a role answers on a query or whole line of the collection its path arguments name. */
const queryLine = (
source: EnvironmentSource,
tool: EnvironmentManifestTool,
view: StateListView,
args: ReplayArguments,
): RecordedExchange | undefined => {
const query = view.query;
const segments = callSegments(tool, args);
if (
query === undefined ||
segments === undefined ||
[...callQuery(tool, args).keys()].some(
(name) => query.parameters[name] === undefined,
)
)
return undefined;
const lines = new Map<number, RecordedExchange>();
for (const ordinal of [...query.record_ordinals, ...allLines(view)]) {
const exchange = source.exchanges.get(ordinal);
if (
exchange !== undefined &&
collectionMatch(collectionPathOf(exchange, false), segments) !== undefined
)
lines.set(ordinal, exchange);
}
return selectReplayExchange(tool, lines, args)?.exchange;
};
/**
* A list call renders from the state on its line: every entity of the collection on a whole line, the query's matches
* on a line the query answers or for a call it computes, else the line's recorded members still held.
*/
const answerList = (
serving: StateServing,
{ tool, args, exact }: ToolCall,
@@ -422,17 +472,34 @@ const answerList = (
return fromRecording(source, tool, args, "body-unusable");
const collection = collectionPathOf(base, false);
const type = store.type(view.type);
const all = listsAll(view, base.ordinal);
const values = callQuery(tool, args);
// A named query line, or a call with query arguments answered on a line it did not name, evaluates the query.
const queried =
view.query !== undefined &&
(exact
? view.query.record_ordinals.includes(base.ordinal)
: values.size > 0);
const all = !queried && listsAll(view, base.ordinal);
const computed = queried
? evaluateQuery(
view.query!.parameters,
values,
store.inCollection(view.type, collection),
)
: undefined;
if (queried && computed === undefined)
return fromRecording(source, tool, args, "unrecorded-request");
const members: readonly StateEntity[] = all
? store.inCollection(view.type, collection)
: recorded.flatMap((item) => {
: (computed?.page ??
recorded.flatMap((item) => {
const id = scalarText(item[type?.id_field ?? ""]);
const entity =
id === undefined
? undefined
: store.entity(view.type, collection, id);
return entity === undefined ? [] : [entity];
});
}));
const volatile = new Set(type?.volatile_fields);
const items: JsonObject[] = [];
for (const entity of members) {
@@ -440,19 +507,23 @@ const answerList = (
if (!shown.ok) return shown;
items.push(shown.value);
}
return stateAnswer(
serving,
{ exchange: base, exact },
renderStateList(view, envelope, items),
{
source: "state",
view: "list",
type: view.type,
collection,
membership: all ? "all" : "recorded",
listed: items.length,
},
);
const body =
view.query === undefined || (!all && computed === undefined)
? renderStateList(view, envelope, items)
: renderQueryList(
{ ...view, query: view.query },
envelope,
items,
computed?.total ?? items.length,
);
return stateAnswer(serving, { exchange: base, exact }, body, {
source: "state",
view: "list",
type: view.type,
collection,
membership: all ? "all" : computed === undefined ? "recorded" : "query",
listed: items.length,
});
};
/** A document call answers the state's value for the document on its checked line's status and provenance. */
@@ -487,7 +558,8 @@ const answerCheckedLine = (
const base =
named ??
(view.shape === "list"
? unfilteredLine(serving.source, tool, allLines(view), args)
? (unfilteredLine(serving.source, tool, allLines(view), args) ??
queryLine(serving.source, tool, view, args))
: undefined);
if (base === undefined)
return fromRecording(serving.source, tool, args, "unrecorded-request");
@@ -363,7 +363,7 @@ describe("paged list lines", () => {
});
describe("list lines", () => {
it("lists a created book on the line that lists every book, while a filtered line keeps its recorded members", () => {
it("lists a created book on the line that lists every book, and a filtered line by its filter", () => {
const books = servedFrom(LIST_LINE_ROWS);
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
expect(books.answer("get_books")).toMatchObject({
@@ -372,7 +372,7 @@ describe("list lines", () => {
});
expect(books.answer("get_books", { name: "First Book" })).toMatchObject({
body: bookPage([FIRST_BOOK]),
state: { source: "state", membership: "recorded", listed: 1 },
state: { source: "state", membership: "query", listed: 1 },
});
});
});
@@ -53,6 +53,7 @@ import {
type StateWritePlan,
} from "./environmentStateWriteRules.js";
import { WriteTaint } from "./environmentStateWriteTaint.js";
import { queryValues } from "./environmentStateListQuery.js";
/** What the write gate reads: the manifest tools, the timeline, the read views and the initial state. */
export interface WriteSimulationInput {
@@ -349,6 +350,9 @@ export class EnvironmentStateWriteSimulation {
: checkListRead(this.#state, view, record, {
...check,
all: listsAll(view, base),
query: view.query?.record_ordinals.includes(base)
? queryValues(record.query)
: undefined,
});
});
}
+113 -7
View File
@@ -20,6 +20,15 @@ import {
type StateToolFacts,
type StateView,
} from "./environmentStateRules.js";
import {
evaluateQuery,
learnQuery,
lineChoices,
queryValues,
renderQueryList,
type ListQuery,
type QueryLine,
} from "./environmentStateListQuery.js";
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
/** A read tool whose recordings locate entities of one type at one pointer. */
@@ -248,7 +257,69 @@ const continues = (name: string, value: JsonValue | undefined): boolean =>
value !== false &&
value !== "";
/** Whether one latest list recording renders from its own envelope and the listed entities' state. */
/** The query that lines which are not whole follow, with the item totals that count its matches before paging. */
const listQuery = (
context: StateGateContext,
plan: StatePlan,
whole: ReadonlySet<number>,
): ListQuery | undefined => {
const field = context.idField(plan.type);
const lines: QueryLine[] = [];
const counts = new Map<number, [string, number][]>();
for (const record of plan.latest) {
const values = whole.has(record.ordinal)
? undefined
: queryValues(record.query);
if (values === undefined || values.size === 0) continue;
context.withBody(record.ordinal, (body) => {
const items =
body === undefined ? undefined : listItems(body, plan.pointer);
if (body === undefined || items === undefined) return;
const choices = lineChoices(values, items);
context.retain(ENTRY_BYTES * (items.length + choices.length * 4 + 2));
lines.push({
ordinal: record.ordinal,
values,
choices,
listed: items.map((item) => scalarText(item[field]) ?? ""),
entities: context.inCollection(
plan.type,
collectionPathOf(record, false),
),
});
const parent = plan.pointer === "" || !isObject(body) ? {} : body;
counts.set(
record.ordinal,
Object.keys(parent).flatMap((name): [string, number][] => {
const count = isItemTotal(name) ? countOf(parent[name]) : undefined;
return count === undefined ? [] : [[name, count]];
}),
);
});
}
const learned = learnQuery(lines);
if (learned === undefined) return undefined;
// Totals count every match on each line the query answers; a `number`-of-items member counts the page.
let totals: string[] | undefined;
for (const line of lines) {
if (!learned.record_ordinals.includes(line.ordinal)) continue;
const { total } = evaluateQuery(
learned.parameters,
line.values,
line.entities,
)!;
const names = (counts.get(line.ordinal) ?? [])
.filter(
([name, count]) =>
count === total && !nameTokens(name).includes("number"),
)
.map(([name]) => name);
totals = (totals ?? names).filter((name) => names.includes(name));
}
return { ...learned, totals: (totals ?? []).sort(compare) };
};
/** Whether one latest list recording renders from its own envelope and the state: the whole collection, the query's matches, or the listed entities. */
const listReproduces = (
context: StateGateContext,
plan: StatePlan,
@@ -258,10 +329,12 @@ const listReproduces = (
readonly pointer: string;
readonly counters: string[];
readonly whole: ReadonlySet<number>;
readonly query: ListQuery | undefined;
},
record: StateRecordFacts,
): boolean => {
const field = context.idField(plan.type);
const collection = collectionPathOf(record, false);
return context.withBody(record.ordinal, (body) => {
const items =
body === undefined ? undefined : listItems(body, plan.pointer);
@@ -269,16 +342,24 @@ const listReproduces = (
const observed = new Map(
items.map((item) => [scalarText(item[field]) ?? "", item]),
);
const computed = view.query?.record_ordinals.includes(record.ordinal)
? evaluateQuery(
view.query.parameters,
queryValues(record.query) ?? new Map(),
context.inCollection(plan.type, collection),
)
: undefined;
const members = view.whole.has(record.ordinal)
? context.inCollection(plan.type, collectionPathOf(record, false))
: items.flatMap((item) => {
? context.inCollection(plan.type, collection)
: (computed?.page ??
items.flatMap((item) => {
const entity = context.entity(
plan.type,
collectionPathOf(record, false),
collection,
scalarText(item[field]) ?? "",
);
return entity === undefined ? [] : [entity];
});
}));
const rendered = members.map((entity) =>
renderStateEntity(
entity,
@@ -287,7 +368,17 @@ const listReproduces = (
observed.get(entity.id),
),
);
return same(renderStateList(view, body, rendered), body);
const answer =
view.query === undefined ||
(computed === undefined && !view.whole.has(record.ordinal))
? renderStateList(view, body, rendered)
: renderQueryList(
{ ...view, query: view.query },
body,
rendered,
computed?.total ?? rendered.length,
);
return same(answer, body);
});
};
@@ -299,13 +390,27 @@ export const checkListPlan = (
const union = new FieldUnion();
const { whole, counters } = listFacts(context, plan, union);
context.retain(union.bytes);
const view = {
const base = {
tool: plan.tool.name,
fields: union.fields,
pointer: plan.pointer,
counters,
whole,
};
let query = listQuery(context, plan, whole);
// A line the query explains but whose envelope or fields the state does not reproduce keeps its recorded members.
if (query !== undefined) {
const failing = plan.latest.filter(
(record) =>
query!.record_ordinals.includes(record.ordinal) &&
!listReproduces(context, plan, { ...base, query }, record),
);
const kept = query.record_ordinals.filter(
(ordinal) => !failing.some((record) => record.ordinal === ordinal),
);
query = kept.length === 0 ? undefined : { ...query, record_ordinals: kept };
}
const view = { ...base, query };
const all = plan.latest.every((record) => whole.has(record.ordinal));
if (
!plan.latest.every((record) => listReproduces(context, plan, view, record))
@@ -327,6 +432,7 @@ export const checkListPlan = (
.filter((ordinal) => whole.has(ordinal)),
}),
counters,
...(query === undefined ? {} : { query }),
checked_record_ordinals: plan.latest.map((record) => record.ordinal),
};
};
@@ -0,0 +1,421 @@
import type { JsonValue } from "../jsonValue.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import {
isObject,
pointerTo,
renderStateList,
replaceAt,
scalarText,
type JsonObject,
type StateEntity,
type StateListView,
} from "./environmentStateRules.js";
/** What one list query parameter does to a list, learned from the recorded lines it explains. */
export type QueryParameter =
| { readonly role: "filter"; readonly field: string }
| { readonly role: "search"; readonly fields: string[] }
| { readonly role: "sort" }
| { readonly role: "direction" }
| { readonly role: "limit" }
| { readonly role: "offset" }
| { readonly role: "page"; readonly first: number }
| { readonly role: "inert"; readonly values: string[] };
/** A list view's query model: each parameter's role, the checked lines it answers, and the members that count matches before paging. */
export interface ListQuery {
readonly parameters: Record<string, QueryParameter>;
readonly record_ordinals: number[];
readonly totals: string[];
}
/** One recorded line a query may explain: its query values, the identities it listed in order, the roles each parameter may play and the collection's entities. */
export interface QueryLine {
readonly ordinal: number;
readonly values: ReadonlyMap<string, string>;
readonly listed: readonly string[];
readonly choices: readonly (readonly [string, readonly QueryParameter[]])[];
readonly entities: readonly StateEntity[];
}
const SORT = new Set([
"order",
"orderby",
"order_by",
"ordering",
"sort",
"sortby",
"sort_by",
]);
const DIRECTION = new Set([
"dir",
"direction",
"order",
"orderdirection",
"order_direction",
"sortdir",
"sort_dir",
"sortorder",
"sort_order",
]);
const LIMIT = new Set([
"count",
"itemsperpage",
"limit",
"page_size",
"pagesize",
"per_page",
"perpage",
"size",
"take",
]);
const OFFSET = new Set(["_start", "offset", "skip", "start"]);
const SEARCH = new Set([
"keyword",
"keywords",
"q",
"query",
"search",
"search_term",
"searchterm",
"term",
]);
const DIRECTIONS = new Set(["asc", "desc"]);
const IDENTIFYING = ["id", "slug", "name"];
/** At most this many role combinations are tried for one line. */
const MAX_COMBINATIONS = 256;
/** A request's query values, decoded, or undefined when a parameter repeats. */
export const queryValues = (
query: string | undefined,
): Map<string, string> | undefined => {
const values = new Map<string, string>();
for (const [name, value] of new URLSearchParams(query ?? "")) {
if (values.has(name)) return undefined;
values.set(name, value);
}
return values;
};
const textOf = (value: JsonValue | undefined): string | undefined =>
typeof value === "boolean" ? String(value) : scalarText(value);
/** Whether a field holds a value: equal as text, or in an array directly or as an object's id, slug or name. */
const holds = (field: JsonValue | undefined, value: string): boolean => {
const names = (item: JsonValue | undefined) =>
isObject(item) && IDENTIFYING.some((key) => textOf(item[key]) === value);
return Array.isArray(field)
? field.some((item) => textOf(item) === value || names(item))
: textOf(field) === value || names(field);
};
const contains = (
entity: StateEntity,
fields: readonly string[],
term: string,
): boolean => {
const needle = term.toLowerCase();
return (
needle === "" ||
fields.some((field) => {
const value = entity.fields[field];
return typeof value === "string" && value.toLowerCase().includes(needle);
})
);
};
const normalized = (name: string): string =>
name.toLowerCase().replace(/[^a-z0-9]/gu, "");
/** The field a sort value names and its direction: `name`, `-name`, `name:desc`, `name-ASC`; names match ignoring case and separators. */
const sortKey = (
value: string,
names: readonly string[],
): { readonly field: string; readonly descending: boolean } | undefined => {
const field = (spelled: string) => {
if (names.includes(spelled)) return spelled;
const found = names.filter(
(name) => normalized(name) === normalized(spelled),
);
return found.length === 1 ? found[0] : undefined;
};
const whole = field(value);
if (whole !== undefined) return { field: whole, descending: false };
if (value.startsWith("-")) {
const named = field(value.slice(1));
return named === undefined ? undefined : { field: named, descending: true };
}
const suffixed = /^(.+?)[\s:_-](asc|desc)$/iu.exec(value);
const named = suffixed === null ? undefined : field(suffixed[1]!);
return named === undefined
? undefined
: { field: named, descending: suffixed![2]!.toLowerCase() === "desc" };
};
/** Ascending order: numbers by value, text ignoring case, missing and null values last. */
const ascending = (
left: JsonValue | undefined,
right: JsonValue | undefined,
): number => {
const absent = (value: JsonValue | undefined) =>
value === undefined || value === null;
if (absent(left) || absent(right))
return absent(left) ? (absent(right) ? 0 : 1) : -1;
if (typeof left === "number" && typeof right === "number")
return left - right;
const a = textOf(left) ?? JSON.stringify(left);
const b = textOf(right) ?? JSON.stringify(right);
return compare(a.toLowerCase(), b.toLowerCase()) || compare(a, b);
};
const integer = (value: string): number | undefined =>
/^-?\d{1,9}$/u.test(value) ? Number(value) : undefined;
/** The entities a query selects, in its order, and how many matched before paging; undefined when a value cannot be applied. */
export const evaluateQuery = (
parameters: Readonly<Record<string, QueryParameter>>,
values: ReadonlyMap<string, string>,
entities: readonly StateEntity[],
): { readonly page: StateEntity[]; readonly total: number } | undefined => {
let matched = [...entities];
let sort: string | undefined;
let direction: string | undefined;
let limit: number | undefined;
let offset = 0;
let page: { readonly index: number; readonly first: number } | undefined;
for (const [name, value] of values) {
const parameter = parameters[name];
const number = integer(value);
if (parameter === undefined) return undefined;
if (parameter.role === "filter")
matched = matched.filter((entity) =>
holds(entity.fields[parameter.field], value),
);
else if (parameter.role === "search")
matched = matched.filter((entity) =>
contains(entity, parameter.fields, value),
);
else if (parameter.role === "sort") sort = value;
else if (parameter.role === "direction") {
if (!DIRECTIONS.has(value.toLowerCase())) return undefined;
direction = value.toLowerCase();
} else if (parameter.role === "inert") {
if (!parameter.values.includes(value)) return undefined;
} else if (number === undefined) return undefined;
else if (parameter.role === "limit")
limit = number > 0 ? number : undefined;
else if (parameter.role === "offset" && number >= 0) offset = number;
else if (parameter.role === "page" && number >= parameter.first)
page = { index: number, first: parameter.first };
else return undefined;
}
if (sort !== undefined) {
const names = [
...new Set(entities.flatMap((entity) => Object.keys(entity.fields))),
];
const key = sortKey(sort, names);
if (key === undefined) return undefined;
const descending =
direction === undefined ? key.descending : direction === "desc";
matched = matched
.map((entity, index) => ({ entity, index }))
.sort(
(left, right) =>
(descending ? -1 : 1) *
ascending(
left.entity.fields[key.field],
right.entity.fields[key.field],
) || left.index - right.index,
)
.map(({ entity }) => entity);
}
if (page !== undefined && page.index > page.first) {
if (limit === undefined) return undefined;
offset += (page.index - page.first) * limit;
}
return {
page: matched.slice(
offset,
limit === undefined ? undefined : offset + limit,
),
total: matched.length,
};
};
/** Fields every listed item holds the value in, those named like the parameter first. */
const filterFields = (
items: readonly JsonObject[],
name: string,
value: string,
): string[] =>
items.length === 0
? []
: Object.keys(items[0]!)
.filter((field) => items.every((item) => holds(item[field], value)))
.sort(
(left, right) =>
Number(normalized(right) === normalized(name)) -
Number(normalized(left) === normalized(name)),
)
.slice(0, 4);
/** String fields that contain the term in at least one listed item. */
const searchFields = (items: readonly JsonObject[], term: string): string[] =>
term === ""
? []
: [
...new Set(
items.flatMap((item) =>
Object.keys(item).filter((field) => {
const value = item[field];
return (
typeof value === "string" &&
value.toLowerCase().includes(term.toLowerCase())
);
}),
),
),
].sort(compare);
/** The roles a parameter may play on a line that listed `items`, most specific first; inert last. */
const choicesFor = (
name: string,
value: string,
items: readonly JsonObject[],
): QueryParameter[] => {
const key = name.toLowerCase();
const choices: QueryParameter[] = [];
if (DIRECTION.has(key) && DIRECTIONS.has(value.toLowerCase()))
choices.push({ role: "direction" });
else if (SORT.has(key)) choices.push({ role: "sort" });
else if (LIMIT.has(key)) choices.push({ role: "limit" });
else if (OFFSET.has(key)) choices.push({ role: "offset" });
else if (key === "page")
choices.push({ role: "page", first: 1 }, { role: "page", first: 0 });
else if (SEARCH.has(key))
choices.push({ role: "search", fields: searchFields(items, value) });
if (choices.length === 0 || SEARCH.has(key))
for (const field of filterFields(items, name, value))
choices.push({ role: "filter", field });
choices.push({ role: "inert", values: [value] });
return choices;
};
/** The roles each of a line's parameters may play, read from the items it listed. */
export const lineChoices = (
values: ReadonlyMap<string, string>,
items: readonly JsonObject[],
): [string, QueryParameter[]][] =>
[...values].map(([name, value]) => [name, choicesFor(name, value, items)]);
const listsAsRecorded = (
line: QueryLine,
parameters: Readonly<Record<string, QueryParameter>>,
): boolean => {
const result = evaluateQuery(parameters, line.values, line.entities);
return (
result !== undefined &&
result.page.length === line.listed.length &&
result.page.every((entity, index) => entity.id === line.listed[index])
);
};
/** The first combination of roles, most specific first, that lists exactly what the line recorded. */
export const explainLine = (
line: QueryLine,
): Record<string, QueryParameter> | undefined => {
const options = line.choices;
let tried = 0;
const search = (
index: number,
chosen: Record<string, QueryParameter>,
): Record<string, QueryParameter> | undefined => {
if (index === options.length) {
tried += 1;
return listsAsRecorded(line, chosen) ? chosen : undefined;
}
const [name, choices] = options[index]!;
for (const choice of choices) {
if (tried >= MAX_COMBINATIONS) return undefined;
const found = search(index + 1, { ...chosen, [name]: choice });
if (found !== undefined) return found;
}
return undefined;
};
return search(0, {});
};
/** One role per parameter across the lines that explained it: inert values gather, search fields unite, and any other disagreement drops the parameter. */
const mergeRoles = (
explained: readonly Record<string, QueryParameter>[],
): Record<string, QueryParameter> => {
const roles = new Map<string, QueryParameter[]>();
for (const parameters of explained)
for (const [name, role] of Object.entries(parameters))
roles.set(name, [...(roles.get(name) ?? []), role]);
const merged: Record<string, QueryParameter> = {};
for (const [name, seen] of roles) {
const active = seen.filter((role) => role.role !== "inert");
if (active.length === 0) {
merged[name] = {
role: "inert",
values: [
...new Set(
seen.flatMap((role) => (role.role === "inert" ? role.values : [])),
),
].sort(compare),
};
continue;
}
const [first] = active;
if (first!.role === "search") {
if (active.every((role) => role.role === "search"))
merged[name] = {
role: "search",
fields: [
...new Set(
active.flatMap((role) =>
role.role === "search" ? role.fields : [],
),
),
].sort(compare),
};
continue;
}
if (active.every((role) => JSON.stringify(role) === JSON.stringify(first)))
merged[name] = first!;
}
return merged;
};
/** The query model that lists exactly what each explained line recorded, or undefined when none does. */
export const learnQuery = (
lines: readonly QueryLine[],
): Pick<ListQuery, "parameters" | "record_ordinals"> | undefined => {
const explained = lines.flatMap((line) => {
const parameters = explainLine(line);
return parameters === undefined ? [] : [parameters];
});
if (explained.length === 0) return undefined;
const parameters = mergeRoles(explained);
const record_ordinals = lines
.filter((line) => listsAsRecorded(line, parameters))
.map((line) => line.ordinal);
return record_ordinals.length === 0
? undefined
: { parameters, record_ordinals };
};
/** A query answer: the base line's envelope with the selected entities, counters at the number listed and totals at the number matched. */
export const renderQueryList = (
view: Pick<StateListView, "pointer" | "counters"> & {
readonly query: Pick<ListQuery, "totals">;
},
base: JsonValue,
items: readonly JsonObject[],
total: number,
): JsonValue =>
view.query.totals.reduce(
(body, name) => replaceAt(body, pointerTo(name), total),
renderStateList(view, base, items),
);
@@ -1,3 +1,4 @@
import type { ListQuery } from "./environmentStateListQuery.js";
import { canonicalJson } from "../comparisonSemantics.js";
import { parseUrlQueryParameterNames } from "../recordedTransportUrl.js";
import type { JsonValue } from "../jsonValue.js";
@@ -104,6 +105,8 @@ export type StateListView = EntityViewPlan & {
/** With recorded membership, the checked lines that each list their whole collection. */
readonly all_record_ordinals?: number[] | undefined;
readonly counters: string[];
/** The query the view's other lines follow, when one explains any of them. */
readonly query?: ListQuery | undefined;
};
/** Whether a list view answers on a checked line with every entity of the collection. */
@@ -21,6 +21,61 @@ export const environmentStateViewRequestSchema = z.strictObject({
});
const jsonObjectFieldsSchema = z.record(z.string(), jsonValueSchema);
const queryParameterSchema = z.discriminatedUnion("role", [
z.strictObject({
role: z.literal("filter"),
field: z
.string()
.describe(
"Entities whose field equals the value as text, or whose array field holds it directly or as an object's id, slug or name.",
),
}),
z.strictObject({
role: z.literal("search"),
fields: z
.array(z.string())
.describe(
"Entities with a string field containing the value, ignoring case; an empty value matches every entity.",
),
}),
z
.strictObject({ role: z.literal("sort") })
.describe(
"Orders by the field the value names (`name`, `-name`, `name:desc`, `name-ASC`): numbers by value, text ignoring case, null last ascending.",
),
z
.strictObject({ role: z.literal("direction") })
.describe("asc or desc for the sort parameter."),
z
.strictObject({ role: z.literal("limit") })
.describe("Page size; zero or a negative value lists every match."),
z
.strictObject({ role: z.literal("offset") })
.describe("Matches skipped before the page."),
z.strictObject({
role: z.literal("page"),
first: z.number().int().describe("The number of the first page."),
}),
z.strictObject({
role: z.literal("inert"),
values: z
.array(z.string())
.describe("Recorded values that change nothing the view lists; other values are not computed."),
}),
]);
const listQuerySchema = z.strictObject({
parameters: z
.record(z.string(), queryParameterSchema)
.describe(
"What each query parameter does. A call whose every parameter has a role is answered by evaluating them over the state.",
),
record_ordinals: z
.array(z.number().int().nonnegative())
.describe("Checked lines the query reproduces, answered by evaluating it."),
totals: z
.array(z.string())
.describe("Envelope members that count every match before paging."),
});
const checkedSchema = z
.array(z.number().int().nonnegative())
.describe(
@@ -69,6 +124,11 @@ const viewSchema = z.discriminatedUnion("shape", [
counters: z
.array(z.string())
.describe("Envelope members that count the listed entities."),
query: listQuerySchema
.optional()
.describe(
"How the view's other checked lines select and order entities, when filters, a search, a sort or paging explain them; absent when none does.",
),
}),
z.strictObject({
tool: z.string(),
@@ -28,6 +28,7 @@ import {
type StateToolFacts,
type StateView,
} from "./environmentStateRules.js";
import { evaluateQuery, renderQueryList } from "./environmentStateListQuery.js";
/** A query as replay compares a call's: decoded values by name, in any order. A query repeating a name, which no call's arguments can carry, keeps its spelling. */
const queryKey = (query: string | undefined): string => {
@@ -392,6 +393,7 @@ const listCheck = (
view: StateListView,
check: ListBodies,
members: readonly StateEntity[],
total = members.length,
): boolean => {
if (
members.some(
@@ -406,7 +408,64 @@ const listCheck = (
const rendered = members.map((entity) =>
renderStateEntity(entity, view, check.volatile, observed.get(entity.id)),
);
return same(renderStateList(view, check.base, rendered), check.body);
const answer =
view.query === undefined
? renderStateList(view, check.base, rendered)
: renderQueryList(
{ ...view, query: view.query },
check.base,
rendered,
total,
);
return same(answer, check.body);
};
/**
* A list read on a line the query answers: the query's matches in the state, in its order and page. Only a line whose
* parameters all sort or page, and that shows every match, covers the collection; any other covers the entities it lists.
*/
const checkQueryList = (
state: SimulatedState,
view: StateListView,
check: ListBodies,
values: ReadonlyMap<string, string>,
): CheckOutcome | undefined => {
const { collection } = check;
const result = evaluateQuery(
view.query!.parameters,
values,
state.members(view.type, collection),
);
if (result === undefined) return undefined;
const selects = [...values.keys()].some((name) => {
const role = view.query!.parameters[name]?.role;
return role === "filter" || role === "search" || role === "inert";
});
const whole = !selects && result.page.length === result.total;
const held = new Set(result.page.map((entity) => entity.id));
return {
pass: listCheck(view, check, result.page, result.total),
targets: [
...(whole ? [collectionTarget(view.type, collection)] : []),
...result.page.map((entity) =>
entityTarget(view.type, collection, entity.id),
),
],
// What the base line or the read listed that the state no longer holds: the read shows whether it went.
unheld: [
...new Set(
[...check.baseItems, ...check.items].map(
(item) => scalarText(item[check.idField]) ?? "",
),
),
]
.filter(
(id) =>
!held.has(id) &&
state.entity(view.type, collection, id) === undefined,
)
.map((id) => entityTarget(view.type, collection, id)),
};
};
/** A list read of every entity of a collection: membership, order, fields and counters must render from the state. */
@@ -465,6 +524,8 @@ export const checkListRead = (
readonly collection: string;
/** Whether the base line lists every entity of its collection. */
readonly all: boolean;
/** The read's query values, when the query answers its line. */
readonly query?: ReadonlyMap<string, string> | undefined;
},
): CheckOutcome | undefined => {
if (!succeeded(record)) return undefined;
@@ -478,7 +539,10 @@ export const checkListRead = (
const bodies = { ...check, body, base, items: items ?? [], baseItems };
const outcome = check.all
? checkAllList(state, view, bodies)
: checkRecordedList(state, view, bodies);
: check.query === undefined
? checkRecordedList(state, view, bodies)
: checkQueryList(state, view, bodies, check.query);
if (outcome === undefined) return undefined;
return items === undefined ? { ...outcome, pass: false } : outcome;
};