Files
rea/src/domain/analysisView/environmentStateWriteSorted.ts
T

352 lines
13 KiB
TypeScript

import type { JsonValue } from "../jsonValue.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import {
ownMember,
setOwnMember,
at,
isObject,
isStringArray,
listItems,
replaceAt,
scalarText,
type JsonObject,
type StateEntity,
type StateItemView,
type StateListView,
type StateRecordFacts,
type StateView,
} from "./environmentStateRules.js";
import {
entityTarget,
type CheckOutcome,
type SimulatedState,
} from "./environmentStateWriteChecks.js";
import type { WriteIo } from "./environmentStateWriteEffects.js";
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
/** Whether a string array is in code-point order. */
const inOrder = (values: readonly string[]): boolean =>
values.every(
(value, index) => index === 0 || compare(values[index - 1]!, value) <= 0,
);
/** A string array with at least two distinct values, the only kind that shows an order. */
const ordered = (value: JsonValue | undefined): value is string[] =>
value !== undefined && isStringArray(value) && new Set(value).size >= 2;
/** Whether no object a read shows lists the field as a string array out of order. */
const listedSorted = (shown: readonly JsonObject[], field: string): boolean =>
shown.every((object) => {
const value = ownMember(object, field);
return !ordered(value) || inOrder(value);
});
/** An entity's value of a field as one view shows it: its own recorded value, else the stored one. */
const shownBy = (
entity: StateEntity,
tool: string,
field: string,
): JsonValue | undefined => {
const own = entity.view_fields.find(
(candidate) => candidate.tool === tool,
)?.fields;
return own !== undefined && Object.hasOwn(own, field)
? own[field]
: ownMember(entity.fields, field);
};
/** Fields every initial entity shows, as the view renders it, as a sorted string array or null, one with two distinct strings. */
export const sortableFields = (
view: Pick<StateItemView | StateListView, "tool" | "fields">,
entities: readonly StateEntity[],
): string[] =>
view.fields.filter((field) => {
let distinct = false;
for (const entity of entities) {
const value = shownBy(entity, view.tool, field);
if (value === undefined || value === null) continue;
if (!isStringArray(value) || !inOrder(value)) return false;
distinct ||= new Set(value).size >= 2;
}
return distinct;
});
/**
* Whether a sorted read of a written array could as well list values by when the application first
* held them: those the initial state held first, then the rest in written order, as an application
* ordering by tag id or creation would.
*/
const creationOrderFits = (
shown: readonly string[],
written: readonly string[],
existing: ReadonlySet<string>,
): boolean => {
const firstNew = shown.findIndex((value) => !existing.has(value));
const fresh = written.filter((value) => !existing.has(value));
return (
firstNew < 0 ||
(shown.slice(firstNew).every((value) => !existing.has(value)) &&
shown.slice(firstNew).every((value, index) => value === fresh[index]))
);
};
interface Candidates {
readonly type: string;
readonly fields: string[];
}
/**
* Array fields a view lists sorted however they were written, as APIs that keep tags as sets do.
* A field is learned only from a read the gate counts that fails with the stored order and passes
* with it sorted, where the order could not be the written one or the order the application first
* held the values in; one counted read listing it unsorted rules it out for that view, learned or
* not. The stored value keeps the written order, so a write's response still echoes it; every view
* of the type that could sort the field sorts it once one does.
*/
export class SortedArrays {
readonly #candidates = new Map<string, Candidates>();
readonly #adopted = new Map<string, string[]>();
/** Retired views retain their former sort fields only for checking later values. */
readonly #retired = new Map<string, string[]>();
/** Values each type's initial entities held, per candidate field. */
readonly #existing = new Map<string, Set<string>>();
constructor(
views: Iterable<StateView>,
entities: readonly StateEntity[],
private readonly facts: {
readonly state: SimulatedState;
readonly io: Pick<WriteIo, "withBody" | "retain">;
readonly idField: (type: string) => string | undefined;
},
) {
for (const view of views) {
if (view.shape !== "item" && view.shape !== "list") continue;
const own = entities.filter((entity) => entity.type === view.type);
const fields = sortableFields(view, own);
if (fields.length === 0) continue;
this.#candidates.set(view.tool, { type: view.type, fields });
for (const field of fields) this.#noteExisting(view.type, field, own);
facts.io.retain(
ENTRY_BYTES +
fields.reduce((bytes, field) => bytes + textBytes(field.length), 0),
);
}
}
/** The serving projection: recorded after ordering was disproved, otherwise with learned sorts. */
view(view: StateView): StateView {
if (view.shape !== "item" && view.shape !== "list") return view;
if (this.#retired.has(view.tool))
return {
tool: view.tool,
shape: "recorded",
reason: view.shape === "item" ? "fields-differ" : "list-differs",
};
return this.comparisonView(view);
}
/** A retired view still describes where values were observed, but cannot supply serving proof. */
comparisonView(view: StateView): StateView {
if (view.shape !== "item" && view.shape !== "list") return view;
const fields = this.#retired.get(view.tool) ?? this.#adopted.get(view.tool);
return fields === undefined ? view : { ...view, sorted_arrays: fields };
}
/** The serving projections exported after simulation. */
views(views: readonly StateView[]): StateView[] {
return views.map((view) => this.view(view));
}
/**
* Note what a read the gate counts shows, then retry it if it failed: the outcome with the fewest
* candidate fields sorted that make it pass, which the type's views then keep; the outcome as it
* was, without any learned field the read lists unsorted, when none does, the passing check is
* blocked or the read's order is ambiguous.
*/
retry(
view: StateView,
read: { readonly record: StateRecordFacts; readonly outcome: CheckOutcome },
check: (
view: StateView,
comparisonBody?: (body: JsonValue) => JsonValue,
) => CheckOutcome | undefined,
blocked: (targets: readonly string[]) => boolean,
): CheckOutcome {
if (view.shape !== "item" && view.shape !== "list") return read.outcome;
const retired = this.#retired.get(view.tool);
if (retired !== undefined)
return read.outcome.pass
? read.outcome
: (check({ ...view, sorted_arrays: retired }, (body) =>
this.#comparisonBody(view, retired, body),
) ?? read.outcome);
// A view with no candidate or learned field left has nothing to note or retry, so its read is never parsed.
if (!this.#candidates.has(view.tool) && !this.#adopted.has(view.tool))
return read.outcome;
const shown = this.#shown(view, read.record);
this.#ruleOut(view.tool, shown);
const adopted = this.#adopted.get(view.tool) ?? [];
// Retire ordering proof, but still check values independently of the withdrawn array order.
if (this.#unlearn(view.tool, shown))
return (
check({ ...view, sorted_arrays: adopted }, (body) =>
this.#comparisonBody(view, adopted, body),
) ?? read.outcome
);
const current = view;
const failed = read.outcome;
const candidates = this.#candidates.get(view.tool)?.fields;
if (failed.pass || candidates === undefined) return failed;
const passing = (fields: readonly string[]) => {
const outcome = check({
...current,
sorted_arrays: [...(current.sorted_arrays ?? []), ...fields],
});
return outcome?.pass === true &&
!blocked([...outcome.targets, ...outcome.unheld])
? outcome
: undefined;
};
let outcome = passing(candidates);
if (outcome === undefined) return failed;
let needed = candidates;
for (const field of candidates) {
const fewer = needed.filter((name) => name !== field);
const retried = fewer.length === 0 ? undefined : passing(fewer);
if (retried !== undefined) [needed, outcome] = [fewer, retried];
}
const targets = new Set(outcome.targets);
if (!needed.every((field) => this.#telling(view, shown, field, targets)))
return failed;
this.#adopt(view.type, needed);
return outcome;
}
/** Compare a retired view's values as multisets only for its formerly sorted string-array fields. */
#comparisonBody(
view: StateItemView | StateListView,
fields: readonly string[],
body: JsonValue,
): JsonValue {
const normalize = (value: JsonValue): JsonValue => {
if (!isObject(value)) return value;
const object = { ...value };
for (const field of fields) {
const array = ownMember(object, field);
if (array !== undefined && isStringArray(array))
setOwnMember(object, field, array.toSorted(compare));
}
return object;
};
const value = at(body, view.pointer);
if (value === undefined) return body;
return replaceAt(
body,
view.pointer,
view.shape === "list" && Array.isArray(value)
? value.map(normalize)
: normalize(value),
);
}
/** The entity objects a read recorded at the view's pointer. */
#shown(view: StateItemView | StateListView, record: StateRecordFacts) {
return this.facts.io.withBody(record.ordinal, (body): JsonObject[] => {
if (body === undefined || body === null) return [];
if (view.shape === "list")
return (listItems(body, view.pointer) ?? []).filter(isObject);
const object = at(body, view.pointer);
return isObject(object) ? [object] : [];
});
}
/** A read listing a candidate field unsorted shows its view does not sort it. */
#ruleOut(tool: string, shown: readonly JsonObject[]): void {
const entry = this.#candidates.get(tool);
if (entry === undefined) return;
const left = entry.fields.filter((field) => listedSorted(shown, field));
if (left.length === 0) this.#candidates.delete(tool);
else if (left.length < entry.fields.length)
this.#candidates.set(tool, { ...entry, fields: left });
}
/** Contradictory ordering makes the view recorded and invalidates its earlier confirmations. */
#unlearn(tool: string, shown: readonly JsonObject[]): boolean {
const adopted = this.#adopted.get(tool) ?? [];
const kept = adopted.filter((field) => listedSorted(shown, field));
if (kept.length === adopted.length) return false;
this.#retired.set(tool, adopted);
this.#adopted.delete(tool);
this.#candidates.delete(tool);
return true;
}
/** Whether a read shows a written array of an entity it covers in an order neither the written one nor the order the values were first held in. */
#telling(
view: StateItemView | StateListView,
shown: readonly JsonObject[],
field: string,
targets: ReadonlySet<string>,
): boolean {
const idField = this.facts.idField(view.type) ?? "";
const existing = this.#existing.get(`${view.type}\u0000${field}`)!;
return shown.some((object) => {
const value = ownMember(object, field);
const id = scalarText(ownMember(object, idField));
if (!ordered(value) || id === undefined) return false;
return this.facts.state.named(id).some((entity) => {
const written = ownMember(entity.fields, field);
return (
targets.has(
entityTarget(entity.type, entity.collection, entity.id),
) &&
written !== undefined &&
isStringArray(written) &&
written.length === value.length &&
written.some((item, index) => item !== value[index]) &&
!creationOrderFits(value, written, existing)
);
});
});
}
/** Sort the fields in every view of the type that could sort them. */
#adopt(type: string, fields: readonly string[]): void {
for (const [tool, entry] of this.#candidates) {
if (entry.type !== type) continue;
const taken = entry.fields.filter((field) => fields.includes(field));
if (taken.length === 0) continue;
this.#adopted.set(tool, [...(this.#adopted.get(tool) ?? []), ...taken]);
const left = entry.fields.filter((field) => !taken.includes(field));
if (left.length === 0) this.#candidates.delete(tool);
else this.#candidates.set(tool, { ...entry, fields: left });
}
}
#noteExisting(
type: string,
field: string,
entities: readonly StateEntity[],
): void {
const key = `${type}\u0000${field}`;
let values = this.#existing.get(key);
if (values === undefined) this.#existing.set(key, (values = new Set()));
const held = entities.flatMap((entity) =>
[
ownMember(entity.fields, field),
...entity.view_fields.map((view) => ownMember(view.fields, field)),
].flatMap((value) =>
value !== undefined && value !== null && isStringArray(value)
? value
: [],
),
);
for (const item of held)
if (!values.has(item)) {
this.facts.io.retain(textBytes(item.length));
values.add(item);
}
}
}