* feat(environment): explain values an application derives from other fields
Prompt-built environments withheld creates whose responses carry values the
application derives deterministically, which the write model kept as
constants or could not match across calls:
- A composed string is literal text plus parts read from the fields other
rules write: a field's value, its slug, or a clock's calendar digits. A
create composes a string only when another entity of the type spells it
the same way and none spells it otherwise, and never from two sources
serving could tell apart; an update composes an unsent change from members
stored as sent and unchanged fields that also spell its value before the
call. A composed field no longer pins the members it reads.
- A trimmed clock format drops trailing fraction zeros, as Go spells
nanoseconds. It is learned from the type's values, a later create or a
later update, and spreads to the plan's other clocks unless a value shows
a fixed width.
- A large integer no entity of the type holds, among distinct ones of the
same length and kind, is a generated unique value, served as a seeded
integer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the derived-values verification
Gates, tests, write plans over 69 recorded captures and served write tasks on the baseline and on this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): learn a create plan from every call
A later create the plan does not take is explained on its own and merged when both calls show the same fields, every field both rule they rule alike, and a field neither rules holds one value: members either call sent are accepted, only those every call sent are required, and an echoed member a call left out takes the value that call stored, kept in the body's defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): sorted arrays, referenced entities and nested view keys
- An echo is `sorted` when the application stored the sent strings in
code-point order; serving stores them sorted, and a plain echo and a
sorted one agree in update and create plans.
- A `reference` is an object field holding fields of the entity a sent
member names, as the state holds it. The member is accepted with any
value; replay and serving look the entity up, and serving refuses a call
naming one the environment does not hold.
- A view names in `nested_fields` the keys it always shows of objects it
shows with fewer keys than an entity stores; a stored object it never
recorded renders with them. The view spelling learner moves to its own
module.
- Every later create, not only one the plan refuses, is merged into its
plan when it agrees, keeping the plan's rule order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep clock spellings, linked ids and word boundaries honest
- A later update's clocks take the plan's spelling, sibling-trimmed
clocks included, wherever the call's values fit it, so an update whose
later call trims only one of two clocks is served.
- A held value that ends its fraction in a zero keeps a create's clock
at a fixed width, whatever order the held spellings come in.
- A large integer that another field of the created entity also holds
is linked, as a repeated token is: it is never unique, and the create
is withheld as unexplained-change.
- Composed parts are delimited by whole characters through the shared
joinsBefore/joinsAfter, so an astral letter joins a match to its word.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): keep a same-width fixed clock out of trimming
A held .123456780 beside a held .12345678 keeps the create's clock at
nine digits in either order; the cases fail on 8ce1127b5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the create-calls verification
Gates, tests, write plans over 69 recorded captures and served write tasks on the derived-values baseline and on this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): note the create-calls census on current main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): check composed parts and clock fits before they shape a plan
- A composed string whose part has nothing to spell refuses the create
or update through the apply Result instead of keeping a stale value.
- A composition learned on a later call is rebuilt from each earlier
call's fields, so an earlier call that left the value otherwise
contradicts it.
- An update adopts its fitted clock spellings only after the
unknown-entity and contradiction checks, so a call on an entity an
unmodelled write left unknown no longer trims a served plan's clock.
- Sibling trimming on a later create also checks that call's fields,
so a sibling clock it shows with a trailing zero stays fixed-width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): split the derived-values view tests by rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep fixed-width, zone and generated-integer evidence through a plan
- A plan keeps the first value that showed each clock at a fixed width,
from held entities and every accepted create or update. No later fit
or sibling trim widens that field, and a later call that shows it
shorter is withheld.
- Two stamped clocks are one composed source only when they share a
zone offset. A held entity that holds the target field as anything
the parts do not spell, a non-string included, vetoes the
composition. The token index is built once, and only when a string is
left to compose.
- A generated integer that another field repeats as a number at any
depth is linked. Compare treats two integers of one JSON kind and
length under a unique rule as generated values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep optional references across create calls and in answers
- A reference a later create added stays while further creates leave its
member out, and agrees with a later call that shows it again.
- A create's answer shows optional reference and echo fields only when the
call wrote them, whichever recorded call the answer came from.
- An update that re-sends the id of the entity a field already repeats keeps
the plan's reference instead of pinning the member.
- An id naming entities of one type in several collections teaches no
reference, and serving or replay refuses a call that names one.
- An echo is not promoted to sorted once a call stored its strings out of
code-point order as sent.
- Nested view keys compare as sets and keep the first recording's order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(setup): share registration prewarm host preparation
* fix(environment): retain write evidence and match trimmed response clocks
* fix(environment): withhold creates whose generated integer another string repeats
A unique integer whose digits show in another top-level string that no
template or composed rule rebuilds from it (an echoed serialText, or a
token like folder-<id>) withholds the create as unexplained-change,
since serving would seed the integer and keep the string. A templated
copy is still served.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): respect earlier clock widths and left-out members in later creates
- The create merge widens a clock to trimmed only against the value that
showed its width: a held entity or accepted call at a fixed width keeps it
untrimmed.
- A later create that left out a member the plan fills, with a default or a
reference's absence, while its observation shows that field otherwise is
unexplained-change, so the plan cannot serve the stale value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): hold later creates and compare to the composed and integer forms
- A later create whose response spells a composed field otherwise than
the plan rebuilds it is withheld as unexplained-change.
- A unique field learned from a generated integer accepts only an
integer of the same length and kind from a later create, as serving
seeds one.
- Compare counts a composed field as equal when each side's response
spells it from its own fields, which compare under their own rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): preserve composed parts and unique generator families
* fix(environment): reject contradictory create references
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
781 lines
29 KiB
TypeScript
781 lines
29 KiB
TypeScript
import type { JsonValue } from "../jsonValue.js";
|
|
import {
|
|
at,
|
|
changesState,
|
|
isObject,
|
|
isStateWrite,
|
|
listsAll,
|
|
normalizedPath,
|
|
type JsonObject,
|
|
type ParsedRequestBody,
|
|
type StateEntity,
|
|
type StateRecordFacts,
|
|
type StateToolFacts,
|
|
type StateType,
|
|
type StateView,
|
|
} from "./environmentStateRules.js";
|
|
import { UnseenEntities } from "./environmentStateWriteAdoption.js";
|
|
import { FailedReadBlame } from "./environmentStateWriteBlame.js";
|
|
import { addressedWrite } from "./environmentStateWriteAddress.js";
|
|
import { withoutMembers } from "./environmentStateResponseMembers.js";
|
|
import { linkedTarget } from "./environmentStateWriteLinks.js";
|
|
import { updatedEntity } from "./environmentStateWriteApply.js";
|
|
import { projectedRead } from "./environmentStateWriteObserve.js";
|
|
import { OmittedFields } from "./environmentStateWriteOmitted.js";
|
|
import { SortedArrays } from "./environmentStateWriteSorted.js";
|
|
import { sameQuery } from "./environmentStateWriteStamps.js";
|
|
import {
|
|
checkDocumentRead,
|
|
checkItemRead,
|
|
checkListRead,
|
|
collectionTarget,
|
|
entityTarget,
|
|
isAbsence,
|
|
itemTarget,
|
|
succeeded,
|
|
withBodies,
|
|
showsPlacement,
|
|
SimulatedState,
|
|
type CheckOutcome,
|
|
} from "./environmentStateWriteChecks.js";
|
|
import {
|
|
checkedLines,
|
|
servedLine,
|
|
type CheckedLines,
|
|
type ServedLine,
|
|
} from "./environmentStateCheckedLines.js";
|
|
import {
|
|
exercisedBy,
|
|
interference,
|
|
type WriteHistory,
|
|
forgetChecked,
|
|
runningAt,
|
|
shownFields,
|
|
shownWrites,
|
|
touchTarget,
|
|
WriteEffects,
|
|
type PendingTargets,
|
|
type TouchedTargets,
|
|
writtenFieldChecks,
|
|
} from "./environmentStateWriteAttribution.js";
|
|
import { applyBulk } from "./environmentStateWriteBulk.js";
|
|
import { applyCreate } from "./environmentStateWriteCreate.js";
|
|
import { applyDocument } from "./environmentStateWriteDocument.js";
|
|
import { applyDelete } from "./environmentStateWriteDelete.js";
|
|
import {
|
|
applyUpdate,
|
|
built,
|
|
type EffectContext,
|
|
type EffectResult,
|
|
type WriteDraft,
|
|
type WriteSimulationInput,
|
|
type WriteStep,
|
|
} from "./environmentStateWriteEffects.js";
|
|
import {
|
|
classifyWrite,
|
|
prefixRelated,
|
|
unservedPlan,
|
|
type StateWritePlan,
|
|
} from "./environmentStateWriteRules.js";
|
|
import { WriteTaint } from "./environmentStateWriteTaint.js";
|
|
import { writeVerdict } from "./environmentStateWriteVerdict.js";
|
|
|
|
const APPLY = {
|
|
create: applyCreate,
|
|
update: applyUpdate,
|
|
delete: applyDelete,
|
|
document: applyDocument,
|
|
} as const;
|
|
|
|
/**
|
|
* Replay the capture's timeline on a copy of the initial state: modelled writes apply their
|
|
* effects, other writes taint related types, and each read the served state would answer is
|
|
* compared with its recording and attributed to the writes it exercises.
|
|
*/
|
|
export class EnvironmentStateWriteSimulation {
|
|
readonly #input: WriteSimulationInput;
|
|
readonly #state: SimulatedState;
|
|
readonly #drafts = new Map<string, WriteDraft>();
|
|
readonly #toolOf = new Map<number, StateToolFacts>();
|
|
readonly #types = new Map<string, StateType>();
|
|
/** Each list or document view's checked request lines, with the recording a call of the line is answered on. */
|
|
readonly #checkedLines: ReadonlyMap<string, CheckedLines>;
|
|
/** Reads a write's effect was learned from, with the tools that borrowed each. */
|
|
readonly #observations = new Map<number, Set<string>>();
|
|
readonly #taint: WriteTaint;
|
|
readonly #touched: TouchedTargets = new Map();
|
|
readonly #effects = new WriteEffects();
|
|
/** Fields reads show the application leaves out, which serving then does not require. */
|
|
readonly omitted = new OmittedFields((bytes) => this.#input.io.retain(bytes));
|
|
/** Array fields reads showed a view lists sorted. */
|
|
readonly #sorted: SortedArrays;
|
|
readonly #lastCheck = new Map<string, number>();
|
|
/** When the latest modelled write to each target since its last check ended. */
|
|
readonly #touchedSince: PendingTargets = new Map();
|
|
/** Timeline indexes of writes the gate does not model. */
|
|
readonly #unmodelledAt: number[] = [];
|
|
readonly #history: WriteHistory = {
|
|
touched: this.#touched,
|
|
pending: this.#touchedSince,
|
|
checked: this.#lastCheck,
|
|
unmodelledAt: this.#unmodelledAt,
|
|
};
|
|
readonly #log: {
|
|
readonly index: number;
|
|
readonly tool: string;
|
|
readonly path: string;
|
|
}[] = [];
|
|
/** Entities a create inserted, by target, with the create's tool. */
|
|
readonly #inserted = new Map<string, string>();
|
|
/** Creates whose insert rule a passing list check relied on, so it is no longer changed. */
|
|
readonly #placed = new Set<string>();
|
|
/** Entities writes no plan models made, taken when an item read first shows them. */
|
|
readonly #unseen: UnseenEntities;
|
|
readonly #blame: FailedReadBlame;
|
|
|
|
constructor(input: WriteSimulationInput) {
|
|
this.#input = input;
|
|
this.#state = new SimulatedState(input.entities, input.documents);
|
|
this.#unseen = new UnseenEntities(input.entities, {
|
|
state: this.#state,
|
|
io: input.io,
|
|
lastCheck: this.#lastCheck,
|
|
idField: (type) => this.#types.get(type)?.id_field,
|
|
});
|
|
this.#blame = new FailedReadBlame({
|
|
state: this.#state,
|
|
io: input.io,
|
|
records: input.records,
|
|
touched: this.#touched,
|
|
pending: this.#touchedSince,
|
|
effects: this.#effects,
|
|
inserted: this.#inserted,
|
|
idField: (type) => this.#types.get(type)?.id_field,
|
|
volatileOf: input.volatileOf,
|
|
});
|
|
for (const type of input.types)
|
|
this.#types.set(type.collection_template, type);
|
|
for (const tool of input.tools)
|
|
for (const ordinal of tool.record_ordinals)
|
|
this.#toolOf.set(ordinal, tool);
|
|
this.#checkedLines = checkedLines(input);
|
|
this.#sorted = new SortedArrays(input.views.values(), input.entities, {
|
|
state: this.#state,
|
|
io: input.io,
|
|
idField: (type) => this.#types.get(type)?.id_field,
|
|
});
|
|
this.#taint = new WriteTaint(
|
|
[...this.#types.keys()],
|
|
new Map(
|
|
input.documents.map((document) => [document.tool, document.path]),
|
|
),
|
|
this.#state,
|
|
);
|
|
}
|
|
|
|
/** Every state-changing tool's plan, in manifest order, with its verdict. */
|
|
run(): StateWritePlan[] {
|
|
const documents = new Map<string, string>();
|
|
for (const tool of this.#input.tools)
|
|
if (this.#input.views.get(tool.name)?.shape === "document")
|
|
documents.set(normalizedPath(tool.path_template), tool.name);
|
|
const types = new Set(this.#types.keys());
|
|
for (const tool of this.#input.tools) {
|
|
if (
|
|
!changesState(tool.method, tool.path_template) ||
|
|
this.#input.readOnly.has(tool.name)
|
|
)
|
|
continue;
|
|
const link = linkedTarget(tool, classifyWrite(tool, types, documents), {
|
|
...this.#input,
|
|
isWrite: (record) => this.#isWrite(record),
|
|
viewOf: (ordinal) =>
|
|
this.#input.views.get(this.#toolOf.get(ordinal)?.name ?? ""),
|
|
});
|
|
const addressed =
|
|
link.effect === "none"
|
|
? addressedWrite(tool, {
|
|
...this.#input,
|
|
types,
|
|
idField: (type) => this.#types.get(type)?.id_field,
|
|
views: this.#input.views.values(),
|
|
})
|
|
: undefined;
|
|
const { effect, target } = addressed ?? link;
|
|
this.#input.io.retain(512);
|
|
this.#drafts.set(tool.name, {
|
|
plan: {
|
|
...unservedPlan(tool.name, effect, target, "no-state-target"),
|
|
reason: effect === "none" ? "no-state-target" : null,
|
|
...(addressed === undefined ? {} : { address: addressed.address }),
|
|
},
|
|
observed: undefined,
|
|
candidates: [],
|
|
confirmed: [],
|
|
contradictedBy: undefined,
|
|
sideEffect: false,
|
|
calls: 0,
|
|
updates: [],
|
|
sequences: new Map(),
|
|
steady: new Set(),
|
|
unstamped: new Set(),
|
|
kept: new Set(),
|
|
fixedWidth: new Map(),
|
|
linked: link.collection,
|
|
});
|
|
}
|
|
this.#input.records.forEach((record, index) => this.#step(record, index));
|
|
const views = new Map(
|
|
this.views([...this.#input.views.values()]).map((view) => [
|
|
view.tool,
|
|
view,
|
|
]),
|
|
);
|
|
return [...this.#drafts.values()].map((draft) => {
|
|
// A view that became recorded cannot supply proof for a served write.
|
|
let kept = 0;
|
|
for (const ordinal of draft.confirmed) {
|
|
const tool = this.#toolOf.get(ordinal)?.name;
|
|
if (tool !== undefined && views.get(tool)?.shape !== "recorded")
|
|
draft.confirmed[kept++] = ordinal;
|
|
}
|
|
draft.confirmed.length = kept;
|
|
return writeVerdict(draft, this.#input);
|
|
});
|
|
}
|
|
|
|
#step(record: StateRecordFacts, index: number): void {
|
|
const tool = this.#toolOf.get(record.ordinal);
|
|
// A redirected or unanswered write may have changed state, even made an entity, but only a 2xx one shows its effect.
|
|
if (this.#isWrite(record) && !succeeded(record)) {
|
|
this.#unmodelled(record.path, index);
|
|
this.#unseen.unplanned(index);
|
|
} else if (this.#isWrite(record)) this.#write(record, index, tool);
|
|
else if (tool?.method === "GET") this.#read(record, index, tool);
|
|
}
|
|
|
|
/** Whether a record may change state: a state write whose tool the capture does not show reading only. */
|
|
#isWrite(record: StateRecordFacts): boolean {
|
|
return (
|
|
isStateWrite(record) &&
|
|
!this.#input.readOnly.has(this.#toolOf.get(record.ordinal)?.name ?? "")
|
|
);
|
|
}
|
|
|
|
#context(
|
|
writer: string,
|
|
request?: { readonly ordinal: number; readonly body: ParsedRequestBody },
|
|
): EffectContext {
|
|
return {
|
|
isWrite: (record) => this.#isWrite(record),
|
|
state: this.#state,
|
|
io:
|
|
request === undefined
|
|
? this.#input.io
|
|
: {
|
|
...this.#input.io,
|
|
requestBody: (ordinal) =>
|
|
ordinal === request.ordinal
|
|
? request.body
|
|
: this.#input.io.requestBody(ordinal),
|
|
},
|
|
records: this.#input.records,
|
|
timed: this.#input.timed,
|
|
viewOf: (ordinal) => {
|
|
const view = this.#input.views.get(
|
|
this.#toolOf.get(ordinal)?.name ?? "",
|
|
);
|
|
return view === undefined ? undefined : this.#viewOf(view);
|
|
},
|
|
idField: (type) => this.#types.get(type)!.id_field,
|
|
observed: (ordinal) =>
|
|
this.#observations.set(
|
|
ordinal,
|
|
(this.#observations.get(ordinal) ?? new Set()).add(writer),
|
|
),
|
|
unknown: (type, collection, id) =>
|
|
this.#taint.unknown(type, collection, id),
|
|
initialDocument: (tool) =>
|
|
this.#input.documents.find((document) => document.tool === tool),
|
|
startedWith: (type) =>
|
|
this.#input.entities.filter((entity) => entity.type === type),
|
|
pending: (target) =>
|
|
[...(this.#touched.get(target)?.keys() ?? [])].some(
|
|
(name) => name !== writer,
|
|
),
|
|
};
|
|
}
|
|
|
|
#write(
|
|
record: StateRecordFacts,
|
|
index: number,
|
|
tool: StateToolFacts | undefined,
|
|
): void {
|
|
const draft = tool === undefined ? undefined : this.#drafts.get(tool.name);
|
|
const effect = draft?.plan.effect;
|
|
if (
|
|
draft === undefined ||
|
|
tool === undefined ||
|
|
effect === undefined ||
|
|
effect === "none"
|
|
) {
|
|
this.#unmodelled(record.path, index);
|
|
this.#unseen.unplanned(index);
|
|
return;
|
|
}
|
|
const observeOnly = draft.plan.reason !== null;
|
|
if (!observeOnly) draft.calls += 1;
|
|
// Serving pins the exemplar's query, so a call with another may do something else and confirms nothing.
|
|
const pinned =
|
|
!built(draft) ||
|
|
sameQuery(
|
|
this.#input.io.queryOf(draft.plan.exemplar_ordinal ?? -1),
|
|
record.query,
|
|
);
|
|
const step: WriteStep = { record, index, tool, observeOnly, pinned };
|
|
const { address } = draft.plan;
|
|
const bulk = address !== undefined && address.by !== "action";
|
|
const apply = (body?: ParsedRequestBody): void => {
|
|
const context = this.#context(
|
|
tool.name,
|
|
body === undefined ? undefined : { ordinal: record.ordinal, body },
|
|
);
|
|
const results = bulk
|
|
? applyBulk(context, draft, step)
|
|
: [APPLY[effect](context, draft, step)];
|
|
// A call of a tool whose plan was never built, or one a linked plan does not place, is no more modelled than one of a tool without a plan.
|
|
if (
|
|
results.some((result) => result.kind === "unknown") &&
|
|
(!built(draft) || draft.linked !== undefined)
|
|
)
|
|
this.#unseen.unplanned(index);
|
|
for (const result of results)
|
|
this.#settle(result, {
|
|
index,
|
|
tool: tool.name,
|
|
path: record.path,
|
|
end: record.end,
|
|
});
|
|
};
|
|
// Item deletes use their path; calls outside a built plan's query never interpret their body.
|
|
if (pinned && (effect !== "delete" || bulk))
|
|
this.#input.io.withRequestBody(record.ordinal, apply);
|
|
else apply();
|
|
}
|
|
|
|
/** Attribute an applied effect to its tool; a recorded outcome is the state's new baseline; an unknown one taints. */
|
|
#settle(
|
|
result: EffectResult,
|
|
write: {
|
|
readonly index: number;
|
|
readonly tool: string;
|
|
readonly path: string;
|
|
readonly end: number;
|
|
},
|
|
): void {
|
|
if (result.kind === "unknown") {
|
|
this.#unmodelled(write.path, write.index);
|
|
return;
|
|
}
|
|
if (result.entity !== undefined) {
|
|
this.#unseen.touched(result.entity);
|
|
const { type, collection, id } = result.entity;
|
|
this.#taint.known(type, collection, id);
|
|
}
|
|
if (result.kind === "observed") {
|
|
this.#effects.observed(this.#touched, result.targets);
|
|
this.#blame.rebased(result.targets[0]!, write.index);
|
|
// A recorded outcome replaces the entity's values, not its collection's membership.
|
|
this.#touched.delete(result.targets[0]!);
|
|
return;
|
|
}
|
|
this.#effects.supersede(this.#touched, write.tool, result);
|
|
for (const target of result.targets)
|
|
touchTarget(this.#touched, this.#touchedSince, target, {
|
|
...write,
|
|
fields: result.fields,
|
|
});
|
|
this.#log.push(write);
|
|
if (result.inserted === undefined) return;
|
|
this.#inserted.set(result.inserted, write.tool);
|
|
if (result.entity !== undefined)
|
|
this.#blame.inserted(result.entity, write.tool);
|
|
}
|
|
|
|
/**
|
|
* An item read of an entity an unmodelled write may have changed shows it again: the writes that
|
|
* changed it are checked on their own fields, and the state takes what the read recorded.
|
|
*/
|
|
#resync(
|
|
view: Extract<StateView, { shape: "item" }>,
|
|
record: StateRecordFacts,
|
|
entity: StateEntity,
|
|
): void {
|
|
const target = entityTarget(entity.type, entity.collection, entity.id);
|
|
const writes = this.#touched.get(target) ?? new Map();
|
|
this.#taint.known(entity.type, entity.collection, entity.id);
|
|
this.#touched.delete(target);
|
|
if (isAbsence(record.status)) {
|
|
this.#removeEntity(entity);
|
|
return;
|
|
}
|
|
const object = this.#checkFields(view, record, entity, writes);
|
|
if (object !== undefined)
|
|
this.#state.put(updatedEntity(entity, { ...entity.fields, ...object }));
|
|
}
|
|
|
|
/** Whether an item read shows an entity an unmodelled write made, or one left unknown, again rather than checks it. */
|
|
#resynced(view: StateView, record: StateRecordFacts, index: number): boolean {
|
|
if (view.shape !== "item") return false;
|
|
const adopted = this.#unseen.adopt(view, { record, index });
|
|
if (adopted !== undefined) {
|
|
this.#taint.known(adopted.type, adopted.collection, adopted.id);
|
|
return true;
|
|
}
|
|
const { collection, id } = itemTarget(this.#state, view.type, record);
|
|
if (!this.#taint.unknown(view.type, collection, id)) return false;
|
|
// A read begun while a modelled write to the entity still ran may show either state, so it neither checks nor shows it.
|
|
const target = entityTarget(view.type, collection, id);
|
|
if (runningAt(this.#touchedSince, target, record.start)) return true;
|
|
// A failed read that is no absence shows nothing of the entity, so it stays unknown for a usable read.
|
|
if (!succeeded(record) && !isAbsence(record.status)) return true;
|
|
const entity = this.#state.entity(view.type, collection, id);
|
|
// A recorded absence of an entity the state no longer holds is still a check of its delete.
|
|
if (entity === undefined) {
|
|
if (isAbsence(record.status)) return false;
|
|
this.#taint.known(view.type, collection, id);
|
|
return true;
|
|
}
|
|
this.#resync(view, record, entity);
|
|
return true;
|
|
}
|
|
|
|
#check(
|
|
view: StateView,
|
|
record: StateRecordFacts,
|
|
comparisonBody?: (body: JsonValue) => JsonValue,
|
|
): CheckOutcome | undefined {
|
|
if (view.shape === "recorded") return undefined;
|
|
const line = this.#line(view, record);
|
|
if (view.shape !== "item" && line === undefined) return undefined;
|
|
if (view.shape === "document")
|
|
return this.#input.io.withBody(record.ordinal, (body) =>
|
|
checkDocumentRead(this.#state, view, record, body),
|
|
);
|
|
const item = view.shape === "item";
|
|
const target = item
|
|
? itemTarget(this.#state, view.type, record)
|
|
: undefined;
|
|
const collection =
|
|
target === undefined ? line!.collection : target.collection;
|
|
if (this.#taint.under(collection)) return undefined;
|
|
if (!item) {
|
|
const ids = this.#state
|
|
.members(view.type, collection)
|
|
.map((entity) => entity.id);
|
|
// A recorded-membership list covers no collection target, so a write at the collection is checked here.
|
|
if (
|
|
this.#taint.tainted(collectionTarget(view.type, collection)) ||
|
|
this.#taint.blocksList(view.type, collection, ids)
|
|
)
|
|
return undefined;
|
|
}
|
|
// The served answer keeps the envelope of the item view's or the line's base recording.
|
|
const base = item ? view.envelope_ordinal : line!.ordinal;
|
|
const ordinals = { base, read: record.ordinal };
|
|
const minted = view.per_response_members;
|
|
return withBodies(this.#input.io.withBody, ordinals, (served, body) => {
|
|
const check = {
|
|
body:
|
|
body === undefined
|
|
? body
|
|
: withoutMembers(
|
|
comparisonBody === undefined ? body : comparisonBody(body),
|
|
minted,
|
|
),
|
|
base: served === undefined ? served : withoutMembers(served, minted),
|
|
idField: this.#types.get(view.type)?.id_field ?? "",
|
|
volatile: this.#input.volatileOf(view.type),
|
|
collection,
|
|
...(target === undefined ? {} : { itemId: target.id }),
|
|
};
|
|
return this.omitted.check(this.#state, view, record, check, (loosened) =>
|
|
loosened.shape === "item"
|
|
? checkItemRead(this.#state, loosened, record, check)
|
|
: checkListRead(this.#state, loosened, record, {
|
|
...check,
|
|
line: line!,
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
/** The checked line serving answers a list or document read's call from, as replay chooses it. */
|
|
#line(view: StateView, record: StateRecordFacts): ServedLine | undefined {
|
|
return servedLine(this.#checkedLines.get(view.tool), record);
|
|
}
|
|
|
|
/** The view as the gate currently knows it: without the fields reads showed omitted, with the arrays they showed sorted. */
|
|
#viewOf(view: StateView): StateView {
|
|
return this.#sorted.view(this.omitted.view(view));
|
|
}
|
|
|
|
/** The views as reads showed them. */
|
|
views(views: readonly StateView[]): StateView[] {
|
|
return this.#sorted.views(this.omitted.views(views));
|
|
}
|
|
|
|
#read(record: StateRecordFacts, index: number, tool: StateToolFacts): void {
|
|
const original = this.#input.views.get(tool.name);
|
|
if (original === undefined) return;
|
|
let view = this.#sorted.comparisonView(this.omitted.view(original));
|
|
if (projectedRead(view, record, this.#input.io)) return;
|
|
const borrowers = this.#observations.get(record.ordinal);
|
|
if (
|
|
this.#viewOf(original).shape !== "recorded" &&
|
|
!borrowers &&
|
|
this.#resynced(view, record, index)
|
|
)
|
|
return;
|
|
let outcome = this.#check(view, record);
|
|
if (outcome === undefined) return;
|
|
let keys = [...outcome.targets, ...outcome.unheld];
|
|
const blocked = (targets: readonly string[]) =>
|
|
targets.some(
|
|
(key) =>
|
|
this.#taint.tainted(key) ||
|
|
runningAt(this.#touchedSince, key, record.start),
|
|
);
|
|
if (blocked(keys)) return;
|
|
// A read the gate counts can show the view lists a written array sorted, or rule that out.
|
|
outcome = this.#sorted.retry(
|
|
view,
|
|
{ record, outcome },
|
|
(candidate, comparisonBody) =>
|
|
this.#check(candidate, record, comparisonBody),
|
|
blocked,
|
|
);
|
|
view = this.#viewOf(original);
|
|
keys = [...outcome.targets, ...outcome.unheld];
|
|
if (blocked(keys)) return;
|
|
let exercised = exercisedBy(this.#touched, keys, borrowers);
|
|
if (view.shape === "recorded") {
|
|
// Retiring a view withdraws ordering proof, not a remaining mismatch in values.
|
|
if (!outcome.pass)
|
|
this.#contradictOthers(
|
|
exercised,
|
|
record,
|
|
interference(this.#history, outcome),
|
|
);
|
|
return;
|
|
}
|
|
if (borrowers !== undefined) {
|
|
this.omitted.keep(view, outcome);
|
|
if (!outcome.pass)
|
|
this.#contradictOthers(
|
|
exercised,
|
|
record,
|
|
interference(this.#history, outcome),
|
|
);
|
|
return;
|
|
}
|
|
if (!outcome.pass && view.shape === "list") {
|
|
const retried = this.#retryInsert(view, record, exercised);
|
|
if (retried !== undefined) {
|
|
// Reordering can replace the entities on a query page. Confirmation
|
|
// and pending-write cleanup must use the successful check's coverage.
|
|
outcome = retried;
|
|
keys = [...outcome.targets, ...outcome.unheld];
|
|
if (blocked(keys)) return;
|
|
exercised = exercisedBy(this.#touched, keys, borrowers);
|
|
}
|
|
}
|
|
// Retain omissions only after the final check's coverage passes the ownership guards.
|
|
this.omitted.keep(view, outcome);
|
|
const interferes = interference(this.#history, outcome);
|
|
const unseen = outcome.pass
|
|
? this.#confirm(view, record, outcome, exercised)
|
|
: undefined;
|
|
if (!outcome.pass) {
|
|
const interfered = new Set([...exercised].filter(interferes));
|
|
if (interfered.size === 0)
|
|
this.#mismatch({ record, index, keys, exercised, view });
|
|
else {
|
|
// An unmodelled write after some of these writes may explain the difference: those are checked on their own members only.
|
|
this.#contradictOthers(exercised, record, interferes);
|
|
if (view.shape === "item") this.#checkMembers(view, record, interfered);
|
|
else for (const key of keys) this.#taint.fail(key);
|
|
}
|
|
if (view.shape === "item") this.#adopt(view, record);
|
|
}
|
|
const pending = {
|
|
touched: this.#touched,
|
|
since: this.#touchedSince,
|
|
effects: this.#effects,
|
|
};
|
|
for (const key of keys) {
|
|
this.#lastCheck.set(key, index);
|
|
forgetChecked(pending, key, unseen?.get(key));
|
|
}
|
|
}
|
|
|
|
/** A failed read contradicts the writes it exercised that no unmodelled write came after, as a read a write's effect was learned from still does the others. */
|
|
#contradictOthers(
|
|
exercised: ReadonlySet<string>,
|
|
record: StateRecordFacts,
|
|
interferes: (name: string) => boolean,
|
|
): void {
|
|
for (const name of exercised)
|
|
if (!interferes(name))
|
|
this.#drafts.get(name)!.contradictedBy ??= record.ordinal;
|
|
}
|
|
|
|
/** A passing read confirms the writes it exercised that it shows something of, and names, per target, those it shows nothing of. */
|
|
#confirm(
|
|
view: StateView,
|
|
record: StateRecordFacts,
|
|
outcome: CheckOutcome,
|
|
exercised: ReadonlySet<string>,
|
|
): Map<string, Set<string>> {
|
|
const shown = shownWrites(this.#touched, this.#effects, {
|
|
keys: [...outcome.targets, ...outcome.unheld],
|
|
exercised,
|
|
fields: shownFields(view, this.#input.volatileOf),
|
|
});
|
|
for (const name of shown.confirmed) {
|
|
this.#drafts.get(name)!.confirmed.push(record.ordinal);
|
|
if (showsPlacement(view, outcome, this.#inserted, name))
|
|
this.#placed.add(name);
|
|
}
|
|
return shown.unseen;
|
|
}
|
|
|
|
#unmodelled(path: string, index: number): void {
|
|
this.#taint.write(path);
|
|
this.#unmodelledAt.push(index);
|
|
}
|
|
|
|
/** Check the writes to an item read's entity on the members they sent, as a resynchronising read does. */
|
|
#checkMembers(
|
|
view: Extract<StateView, { shape: "item" }>,
|
|
record: StateRecordFacts,
|
|
only: ReadonlySet<string>,
|
|
): void {
|
|
const { collection, id } = itemTarget(this.#state, view.type, record);
|
|
const entity = this.#state.entity(view.type, collection, id);
|
|
if (entity === undefined) return;
|
|
const target = entityTarget(entity.type, entity.collection, entity.id);
|
|
const writes = [...(this.#touched.get(target) ?? [])];
|
|
const checked = new Map(writes.filter(([name]) => only.has(name)));
|
|
this.#checkFields(view, record, entity, checked);
|
|
}
|
|
|
|
/** Check the writes to an entity on the fields each wrote against an item read; the read's object, when it shows one. */
|
|
#checkFields(
|
|
view: Extract<StateView, { shape: "item" }>,
|
|
record: StateRecordFacts,
|
|
entity: StateEntity,
|
|
writes: ReadonlyMap<string, readonly string[] | null> = new Map(),
|
|
): JsonObject | undefined {
|
|
const object = this.#input.io.withBody(record.ordinal, (body) =>
|
|
body === undefined || body === null ? undefined : at(body, view.pointer),
|
|
);
|
|
if (!isObject(object)) return undefined;
|
|
for (const [name, pass] of writtenFieldChecks(entity, view, object, {
|
|
volatile: this.#input.volatileOf(view.type),
|
|
writes,
|
|
})) {
|
|
const draft = this.#drafts.get(name)!;
|
|
if (pass) draft.confirmed.push(record.ordinal);
|
|
else draft.contradictedBy ??= record.ordinal;
|
|
}
|
|
return object;
|
|
}
|
|
|
|
/** After a failed item check the state takes what the read recorded, so later checks start from it. */
|
|
#adopt(
|
|
view: Extract<StateView, { shape: "item" }>,
|
|
record: StateRecordFacts,
|
|
): void {
|
|
const { collection, id } = itemTarget(this.#state, view.type, record);
|
|
const entity = this.#state.entity(view.type, collection, id);
|
|
if (entity === undefined) {
|
|
this.#taint.fail(entityTarget(view.type, collection, id));
|
|
return;
|
|
}
|
|
const object = this.#input.io.withBody(record.ordinal, (body) =>
|
|
body === undefined || body === null ? undefined : at(body, view.pointer),
|
|
);
|
|
if (isObject(object))
|
|
this.#state.put(updatedEntity(entity, { ...entity.fields, ...object }));
|
|
else this.#removeEntity(entity);
|
|
}
|
|
|
|
/** A read showed the entity gone: the state drops it, and no write keeps a claim on its collection through it. */
|
|
#removeEntity(entity: StateEntity): void {
|
|
this.#state.remove(entity);
|
|
this.#effects.removed(this.#touched, entity);
|
|
}
|
|
|
|
/** Contradict the writes a failed check exercised, or, when none, the related writes since its targets were last checked; a failed list stops being checked. */
|
|
#mismatch(failed: {
|
|
readonly record: StateRecordFacts;
|
|
readonly index: number;
|
|
readonly keys: readonly string[];
|
|
readonly exercised: ReadonlySet<string>;
|
|
readonly view: StateView;
|
|
}): void {
|
|
const ordinal = failed.record.ordinal;
|
|
const since = Math.min(
|
|
...failed.keys.map((key) => this.#lastCheck.get(key) ?? -1),
|
|
);
|
|
const blame = this.#blame.of({ ...failed, since });
|
|
for (const name of blame.confirmed)
|
|
this.#drafts.get(name)!.confirmed.push(ordinal);
|
|
for (const name of blame.contradicted)
|
|
this.#drafts.get(name)!.contradictedBy ??= ordinal;
|
|
if (failed.exercised.size === 0)
|
|
for (const write of this.#log)
|
|
if (
|
|
write.index > since &&
|
|
prefixRelated(write.path, failed.record.path)
|
|
)
|
|
this.#drafts.get(write.tool)!.sideEffect = true;
|
|
if (failed.view.shape !== "item")
|
|
for (const key of failed.keys) this.#taint.fail(key);
|
|
}
|
|
|
|
/** The successful check under another create insertion candidate, including its possibly different query-page coverage. */
|
|
#retryInsert(
|
|
view: Extract<StateView, { shape: "list" }>,
|
|
record: StateRecordFacts,
|
|
exercised: ReadonlySet<string>,
|
|
): CheckOutcome | undefined {
|
|
const line = this.#line(view, record);
|
|
if (line === undefined || !listsAll(view, line.ordinal)) return undefined;
|
|
const members = this.#state.members(view.type, line.collection);
|
|
const key = (entity: StateEntity) =>
|
|
entityTarget(entity.type, entity.collection, entity.id);
|
|
const inserted = members.filter((entity) =>
|
|
this.#inserted.has(key(entity)),
|
|
);
|
|
const creates = new Set(
|
|
inserted.map((entity) => this.#inserted.get(key(entity))!),
|
|
);
|
|
if (creates.size !== 1) return undefined;
|
|
const [name] = creates;
|
|
const draft = this.#drafts.get(name!)!;
|
|
// An item read confirms the entity, not where a list places it.
|
|
if (!exercised.has(name!) || this.#placed.has(name!)) return undefined;
|
|
for (const candidate of draft.candidates.slice(1)) {
|
|
this.#state.reinsert(view.type, line.collection, inserted, candidate);
|
|
const outcome = this.#check(view, record);
|
|
if (outcome?.pass === true) {
|
|
draft.plan.insert = candidate;
|
|
return outcome;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|