Files
reverse-engineering-anything/src/application/environment/environmentStateStore.ts
T
977c9727e6 feat(environment): give more write tools a state target (#6385)
* fix(environment): keep read-only POSTs out of the write list

A POST that repeats the latest read of its path, tunnels only read
operations, names a lookup in its path's last meaningful word, or renews
a session no longer counts as a state write: it is replayed, does not cut
initial windows, and does not end observation searches. A tool stays a
write when a related read line changes across one of its calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): take an entity an unplanned write made when a read first shows it

A successful item read of an entity the gate's copy never held, after a
write no built plan models, puts the entity into the copy instead of
failing the read, so later writes to it are checked as usual. An entity a
modelled write touched is never taken back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): plan a create into the type its returned id is read under

A POST whose path names no state type becomes a create into the type
whose item read first names an id its response returned, when the id is
new to the state; serving creates in that read's collection. A successful
POST that returned an id a type's reads only show after it also ends that
type's initial window, so entities a capture created stay out of the
initial state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): end a type's initial reads at the write its reads disagree across

When a type's reads disagree across a write under another root, the
type's initial window now ends at the earliest write within that change
and the state is extracted again, instead of leaving every view of the
type replayed. The cut composes with the creation cut; the earlier wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): replay request lines that show entities in another form

A read line that shows the same entities in another form, by an extra
query parameter that adds or drops members, or conflicts with a base
line on a shared entity, is a variant: it leaves the view's checks and
the entity observations, the view names it with the query keys that pick
it out, and a call matching those keys replays its recording. A filter
role whose name matches no field needs two agreeing values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): specify and record the state-target changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): keep a read-only POST a write when its evidence is incomplete

Body-level container operations are skipped like query ones, a lookup is named only
by the path's last literal segment and never alongside a write word, a tool with a
redirected or unanswered call is not reclassified, and the mutation veto is resolved
over all candidates at once so another read-only call cannot explain a read change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): answer a field selection only when the state shows it exactly

Serving tests a field selection before any variant key: a projection value a replayed
read gave, or one no checked line gave other than `*`, replays. A list sets aside a
line asking for some fields only before the identity proof, and a dropped variant's
membership change no longer rewrites the type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): hold adopted entities and linked creates to what the recording shows

An adopted entity is charged against the view's budget like a created one. A linked
create holds only for its exemplar's path arguments: serving refuses others and the
gate counts such a call as unmodelled. A response's identity-named id links at any
length, but a short one only to a type read before the call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the review fixes' measurements

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): release a discarded planning pass before planning again

A rewrite cut clears the first pass, so the budget rewinds to a checkpoint taken after
the write cutoffs and created ids before the state is extracted again. Rewrite spans are
charged and appended without spreading them into an argument list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): link no create whose id an earlier read showed, and read only whole batches

A linked create is rejected when an earlier list of its type or read of its collection
showed the returned id. Body ids are walked without spreading children into an argument
list. An RPC call reads only when every operations entry names an operation of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): let the gate skip exactly the reads serving replays

Serving's replay decision moves to the domain as replayReason, and the write gate uses it:
a read serving replays can neither approve nor veto a write, and a read of every field
beside a replayed selection is checked. A selection read missing a required field is
still skipped, since part of an entity can neither confirm nor check it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the second review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): read writes, links and creation cuts only from evidence that rules out other causes

- A read-only POST repeats only a read that finished before it began; the veto compares
  the latest read finished before a call with the first begun after it, and counts a
  recorded absence on one side as a change. `modify` and `replace` are write words.
- A redirected or unanswered write lets a later item read adopt the entity it may have made.
- A creation cut counts a read still running when the POST began as after it, and a list
  read shows its listed entities' own ids, not those they nest.
- A linked create's item read must come before any other tool's write.
- Line shapes are charged to the budget as they are built.
- A `fields=*` list line is whole, not a field selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the third review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): tie linked creates to their returned ids and order state by the lines left

- A linked create is observed only in a read of an id its response returned,
  envelopes included, and serving takes its recorded path values in either spelling.
- A list read shows a suffix-named identity (`projectId`) distinct across its entities.
- A read-only POST compares the read that finished last before it began.
- An entity's first sighting comes from the lines left after variant lines are dropped.
- The pairwise variant pass stops at a work limit, leaving every line checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the fourth review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): keep foreign keys, stale reads and double decoding out of write evidence

- A list read counts a suffix-named field as its entities' ids only when no
  id-named field identifies them, so an `ownerId` beside `id` cuts no window.
- A read-only POST is compared with the read that finished last among those no
  other write follows, so a consuming call stays a write.
- A linked create's source path values compare as spelled on both sides, so no
  spelling is decoded twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the fifth review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): compare linked sources by position, decode path fields once and bound read and variant scans

- A linked create's source values compare segment by segment, so a template
  naming two parameters alike cannot accept a changed first one.
- Serving fills a linked create's path fields with its arguments decoded once,
  as the gate learned them.
- The read-only check reads each related line's records once per call, between
  the nearest other writes.
- Member signatures are JSON arrays, so a name holding the separator still
  conflicts; the variant work limit counts line pairs as well as entities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the sixth review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): replay late and variant-only items, fill linked path fields from the exemplar and charge membership spans

- An item line first recorded after the initial window that names a new shaping
  parameter and shows other members is a replayed variant, like an initial one.
- An item only a replayed line showed, which the state never started with,
  answers with its own recording; one the state held and lost stays missing.
- A linked create fills its path fields from its exemplar's path decoded once,
  whichever accepted spelling the call sends.
- Each membership change span is charged against the budget before it is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the seventh review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): decide late item lines against the lines left, charge replay keys and bisect rewrite cuts

- Item lines first recorded after the initial reads are decided once variants
  are dropped, against the lines left: a new shaping parameter showing other
  members, or a selection of some fields leaving members out, replays.
- Each replay key is charged against the budget before it is kept.
- A rewrite span's earliest write is found by bisection over the writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the eighth review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): count only reads finished before a linked create began as showing its id held

A list still running when the call began may show what the call made, as the
creation cut already decides, so it no longer refuses the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): keep served creates' items missing, check same-type earlier reads, and bound late lines and read-only stretches

- An item only a replayed line showed replays only while no served create has
  added to its type, so a created and deleted item stays missing.
- A linked create's earlier item-read veto checks only reads of the candidate's
  type or collection that show the item, so another type's id does not hide it.
- An item read answered with JSON null shows no id to a creation cut.
- Late lines' member names are charged before they are kept.
- The read-only check precomputes nearest writes and passes once over each
  write-free stretch per path, skipping lines whose reads all agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the ninth review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): link creates past empty item reads, sweep repeated reads once and keep only returned ids for creation cuts

- A linked create passes over a later item read naming its id whose body shows
  no item, such as JSON null, and links the next one.
- Read-only repeated reads are found in one sweep: each path's reads wait by
  end time until a call's start passes them, and a write clears them.
- A creation cut keeps, and charges, only the shown ids some creation returned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the tenth review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(environment): compare variant lines through shared entities and parse each compared body once

The variant pass indexes a tool's lines by the entities they show and compares only lines that share one, charging
the index to the budget while it is held; the pairwise work limit still decides when a tool keeps every line checked.
Read-only decisions keep each response body's canonical digest, taken once while its parse is charged, across calls
and veto passes. The creation cut no longer parses reads at or after the write that ends the initial reads, and reads
the ids a body shows inside its lease. Adoption looks up a read's target only after an unplanned write before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the eleventh review round's checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(environment): list a type's collections only when the path's own holds no id, and parse variant base lines once

The write gate's item target lists the type's held collections, and the removed targets', only when the path's own
collection holds none of its ids, so a served item view's reads no longer walk every entity of the type. The variant
pass parses the base lines' queries once for all the variants they key, and not at all when it found none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the twelfth review round's checks and the sweep's cost

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-24 15:58:32 +08:00

472 lines
17 KiB
TypeScript

import type {
StateEntity,
StateType,
} from "../../domain/analysisView/environmentStateRules.js";
import { nextSequence } from "../../domain/analysisView/environmentStateWriteValues.js";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { jsonByteLength } from "../../domain/jsonSerialization.js";
import { err, ok, type Result } from "../../domain/result.js";
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
import type { EnvironmentState } from "./environmentState.js";
import {
parseStateDocument,
STATE_INTAKE_LIMIT_BYTES,
type HeldDocument,
type StateContent,
} from "./environmentStateDocument.js";
/** Maximum logical budget for collection spellings added by served creates since reset/load. */
export const ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES = 1024 * 1024;
const collectionHistoryCost = (
state: EnvironmentState,
type: string,
collection: string,
): number => {
// Account key bytes without building temporary key strings or a Set.
let sequenceBytes = 0;
for (const plan of state.writes.values()) {
if (!plan.served || plan.target !== type) continue;
for (const rule of plan.rules) {
if (rule.rule === "sequence" && rule.scope === "collection")
sequenceBytes +=
64 +
Buffer.byteLength(plan.tool, "utf8") +
1 +
Buffer.byteLength(rule.field, "utf8") +
1 +
Buffer.byteLength(collection, "utf8");
else if (rule.rule === "identity" && rule.generator === "sequence")
sequenceBytes +=
64 +
Buffer.byteLength(type, "utf8") +
1 +
Buffer.byteLength(rule.field, "utf8") +
1 +
Buffer.byteLength(collection, "utf8");
}
}
return 64 + Buffer.byteLength(collection, "utf8") + sequenceBytes;
};
/** What `data/get` reports: the current state as a `rea-state/v1` document. */
export interface StateSnapshot {
readonly rea_state: {
readonly version: 1;
readonly state_evidence_id: string;
readonly types: readonly StateType[];
readonly entities: readonly StateEntity[];
readonly documents: readonly HeldDocument[];
};
}
/** The bytes JSON writes for these array items and the commas between them. */
const itemsBytes = (items: readonly unknown[]): number =>
items.reduce<number>((sum, item) => sum + jsonByteLength(item), 0) +
Math.max(0, items.length - 1);
/** What `data/get` reports for some content of a state. */
const snapshotOf = (
state: EnvironmentState,
entities: readonly StateEntity[],
documents: readonly HeldDocument[],
): StateSnapshot => ({
rea_state: {
version: 1,
state_evidence_id: state.evidenceId,
types: state.types,
entities,
documents,
},
});
/** What `data/get` writes for a state as the Evidence holds it, in UTF-8 bytes, summed item by item. */
export const initialStateBytes = (state: EnvironmentState): number =>
jsonByteLength(snapshotOf(state, [], [])) +
itemsBytes(state.entities) +
itemsBytes(state.documents);
/**
* The live state one served environment answers from, shared by every MCP
* session and the data plane. Mutations are synchronous and swap the whole
* state, so every answer reads one consistent state. A mutation that would take
* what `data/get` reports past the size `data/add` loads is refused, so that
* report always loads back.
*/
export class EnvironmentStateStore {
#entities: ReadonlyMap<string, StateEntity> = new Map();
#collections: ReadonlyMap<string, readonly StateEntity[]> = new Map();
#typeCollections: ReadonlyMap<string, readonly string[]> = new Map();
#documents: ReadonlyMap<string, HeldDocument> = new Map();
#content: StateContent = { entities: [], documents: [] };
readonly #types: ReadonlyMap<string, StateType>;
readonly #clockStart: number;
/** What `data/get` writes for the current state, in UTF-8 bytes; kept by each mutation rather than reserialized. */
#bytes = 0;
/** The bytes of `data/get` with no entities or documents. */
readonly #frameBytes: number;
readonly #initialBytes: number;
/** Served writes since the state was last reset or loaded: the clock's seconds and the generators' counter. */
#writes = 0;
/** The largest value each sequence has generated since the last reset or load, so a deleted entity's value is not reused. */
readonly #sequences = new Map<string, number>();
/** The entities the state held when it was last reset or loaded, whose values no sequence reuses either. */
#started: readonly StateEntity[] = [];
/** The types a served create has added an entity to since the last reset or load. */
readonly #createdTypes = new Set<string>();
/** Baseline and post-start spellings kept known after their entities are deleted. */
readonly #knownCollections = new Map<string, Set<string>>();
/** Logical UTF-8 key bytes plus a 64-byte allowance per collection and potential sequence-key record. */
#collectionHistoryBytes = 0;
/** The collections of the Evidence's own state, which stay known after any load. */
#evidenceCollections: ReadonlyMap<string, readonly string[]> = new Map();
constructor(readonly state: EnvironmentState) {
this.#types = new Map(
state.types.map((type) => [type.collection_template, type]),
);
this.#clockStart =
state.writeClock === null ? 0 : Date.parse(state.writeClock);
this.#frameBytes = jsonByteLength(snapshotOf(state, [], []));
this.#initialBytes = initialStateBytes(state);
this.reset();
}
/** Restore the initial state the Evidence holds; the clock and counter start again. */
reset(): void {
this.#install(this.state, this.#initialBytes);
this.#evidenceCollections = this.#typeCollections;
this.#restart();
}
/** Validate a caller-owned document and copy its JSON values before retaining them; the clock and counter start again. */
replace(document: unknown): Result<void, AnalysisInputError> {
return this.#replace(document, false);
}
/** Validate and retain a JSON document created exclusively by the serving boundary. */
replaceOwnedDocument(document: unknown): Result<void, AnalysisInputError> {
return this.#replace(document, true);
}
#replace(
document: unknown,
owned: boolean,
): Result<void, AnalysisInputError> {
const content = parseStateDocument(this.state, document, owned);
if (!content.ok) return content;
// data/get writes members a document may omit, so the state it reports must still be one data/add loads.
const bytes = this.#contentBytes(content.value);
if (bytes > STATE_INTAKE_LIMIT_BYTES)
return err(
new AnalysisInputError(
ENVIRONMENT_SERVE_OPERATION,
{
userMessage: `The state would exceed ${String(STATE_INTAKE_LIMIT_BYTES)} bytes as data/get writes it.`,
},
[
{
path: [],
reason: "out_of_range",
message: `would exceed ${String(STATE_INTAKE_LIMIT_BYTES)} bytes as data/get writes it`,
maximum: STATE_INTAKE_LIMIT_BYTES,
},
],
),
);
this.#install(content.value, bytes);
this.#restart();
return ok(undefined);
}
/** The write a served call would be: its number since the last reset or load, and its clock instant, one second past the previous applied write. */
pendingWrite(): { readonly count: number; readonly clockMs: number } {
const count = this.#writes + 1;
return { count, clockMs: this.#clockStart + count * 1000 };
}
/** Count an applied write and keep the sequence values it took, so a refused call takes none. */
commitWrite(sequences: ReadonlyMap<string, number>): void {
this.#writes += 1;
for (const [key, value] of sequences) {
const taken = this.#sequences.get(key);
// No earlier value leaves this one in place, including when it is negative.
this.#sequences.set(
key,
taken === undefined ? value : Math.max(taken, value),
);
}
}
/** The next value of a sequence: the next held integer, else one past the later of that and the last value taken. */
sequenceAfter(key: string, held: number | undefined): number {
return nextSequence(held, this.#sequences.get(key));
}
#restart(): void {
this.#writes = 0;
this.#sequences.clear();
this.#started = this.#content.entities;
this.#createdTypes.clear();
this.#knownCollections.clear();
this.#collectionHistoryBytes = 0;
for (const [type, collections] of this.#typeCollections)
for (const collection of collections)
this.#rememberCollection(type, collection);
for (const [type, collections] of this.#evidenceCollections)
for (const collection of collections)
this.#rememberCollection(type, collection);
}
#rememberCollection(type: string, collection: string): void {
const known = this.#knownCollections.get(type) ?? new Set<string>();
known.add(collection);
this.#knownCollections.set(type, known);
}
/** The entities of a type the state held when it was last reset or loaded. */
/** Whether a served create has added an entity of the type since the last reset or load. */
createdInto(type: string): boolean {
return this.#createdTypes.has(type);
}
startedWith(type: string): readonly StateEntity[] {
return this.#started.filter((entity) => entity.type === type);
}
/** Every held entity of a type, in state order. */
ofType(type: string): readonly StateEntity[] {
return this.#content.entities.filter((entity) => entity.type === type);
}
/** Add an entity at `index` of its collection's order, unless the state would outgrow what `data/add` loads. */
insert(
entity: StateEntity,
index: number,
): Result<void, AnalysisResourceConstraintError> {
const entities = this.#content.entities;
const bytes = this.#grown(
jsonByteLength(entity) + (entities.length > 0 ? 1 : 0),
);
if (!bytes.ok) return bytes;
const known = this.#knownCollections.get(entity.type);
const newCollectionBytes =
known?.has(entity.collection) === true
? 0
: collectionHistoryCost(this.state, entity.type, entity.collection);
if (newCollectionBytes > 0) {
const historyBytes = this.#collectionHistoryBytes + newCollectionBytes;
if (historyBytes > ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES)
return err(
new AnalysisResourceConstraintError(
ENVIRONMENT_SERVE_OPERATION,
"capacity",
"Served collection history would exceed its deterministic accounting budget",
{
max_collection_history_bytes:
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
collection_history_bytes: historyBytes,
},
{
retryable: false,
remediationAction:
"Reset the served state or load a smaller state to clear generated collection history, then use fewer distinct collection paths.",
},
),
);
}
const members = this.inCollection(entity.type, entity.collection);
const anchor =
index < members.length
? entities.indexOf(members[index]!)
: members.length > 0
? entities.indexOf(members.at(-1)!) + 1
: entities.length;
this.#install(
{
...this.#content,
entities: [
...entities.slice(0, anchor),
entity,
...entities.slice(anchor),
],
},
bytes.value,
);
if (newCollectionBytes > 0) {
this.#rememberCollection(entity.type, entity.collection);
this.#collectionHistoryBytes += newCollectionBytes;
}
this.#createdTypes.add(entity.type);
return ok(undefined);
}
/** Replace a held entity's values in place, unless the state would outgrow what `data/add` loads. */
update(entity: StateEntity): Result<void, AnalysisResourceConstraintError> {
const key = compositeKey([entity.type, entity.collection, entity.id]);
const held = this.#entities.get(key);
if (held === undefined) return ok(undefined);
const bytes = this.#grown(jsonByteLength(entity) - jsonByteLength(held));
if (!bytes.ok) return bytes;
this.#install(
{
...this.#content,
entities: this.#content.entities.map((candidate) =>
candidate === held ? entity : candidate,
),
},
bytes.value,
);
return ok(undefined);
}
remove(entity: StateEntity): void {
const held = this.#entities.get(
compositeKey([entity.type, entity.collection, entity.id]),
);
if (held === undefined) return;
const entities = this.#content.entities.filter(
(candidate) => candidate !== held,
);
this.#install(
{ ...this.#content, entities },
this.#bytes - jsonByteLength(held) - (entities.length > 0 ? 1 : 0),
);
}
/** Set a held document's value, unless the state would outgrow what `data/add` loads; a value that is not the Evidence's cites no record. */
setDocument(
tool: string,
value: JsonValue,
): Result<void, AnalysisResourceConstraintError> {
const held = this.#documents.get(tool);
if (held === undefined) return ok(undefined);
const written = { tool: held.tool, path: held.path, value };
const bytes = this.#grown(jsonByteLength(written) - jsonByteLength(held));
if (!bytes.ok) return bytes;
this.#install(
{
...this.#content,
documents: this.#content.documents.map((document) =>
document === held ? written : document,
),
},
bytes.value,
);
return ok(undefined);
}
/** The state's bytes after a change of `delta`, or a refusal when a change that grows it would pass the intake limit. */
#grown(delta: number): Result<number, AnalysisResourceConstraintError> {
const bytes = this.#bytes + delta;
if (delta <= 0 || bytes <= STATE_INTAKE_LIMIT_BYTES) return ok(bytes);
return err(
new AnalysisResourceConstraintError(
ENVIRONMENT_SERVE_OPERATION,
"capacity",
"The write would take the served state past the size data/add loads",
{ max_state_bytes: STATE_INTAKE_LIMIT_BYTES, state_bytes: bytes },
{
retryable: false,
remediationAction:
"Delete entities, or restore a smaller state with data/reset or data/add.",
},
),
);
}
type(collectionTemplate: string): StateType | undefined {
return this.#types.get(collectionTemplate);
}
entity(
type: string,
collection: string,
id: string,
): StateEntity | undefined {
return this.#entities.get(compositeKey([type, collection, id]));
}
/** One collection's entities in state order. */
inCollection(type: string, collection: string): readonly StateEntity[] {
return this.#collections.get(compositeKey([type, collection])) ?? [];
}
/** The collections holding entities of one type, in state order of their first entity. */
collectionsOf(type: string): readonly string[] {
return this.#typeCollections.get(type) ?? [];
}
/** Every collection spelling in the active, baseline, Evidence, or generated history, with active entities first. */
knownCollectionsOf(type: string): readonly string[] {
const known = new Set([
...this.collectionsOf(type),
...(this.#knownCollections.get(type) ?? []),
]);
return [...known];
}
document(tool: string): HeldDocument | undefined {
return this.#documents.get(tool);
}
snapshot(): StateSnapshot {
return snapshotOf(
this.state,
this.#content.entities,
this.#content.documents,
);
}
/** What `data/get` writes for the current state, in UTF-8 bytes. */
byteLength(): number {
return this.#bytes;
}
/** What `data/get` writes for some content, summed item by item so the whole state is never one string. */
#contentBytes(content: StateContent): number {
return (
this.#frameBytes +
itemsBytes(content.entities) +
itemsBytes(content.documents)
);
}
/** Build current-state indexes without copying baseline collections into generated history. */
#install(content: StateContent, bytes: number): void {
const entities = new Map<string, StateEntity>();
const collections = new Map<string, StateEntity[]>();
const typeCollections = new Map<string, string[]>();
for (const entity of content.entities) {
entities.set(
compositeKey([entity.type, entity.collection, entity.id]),
entity,
);
const key = compositeKey([entity.type, entity.collection]);
const members = collections.get(key);
if (members !== undefined) {
members.push(entity);
continue;
}
collections.set(key, [entity]);
const named = typeCollections.get(entity.type);
if (named === undefined)
typeCollections.set(entity.type, [entity.collection]);
else named.push(entity.collection);
}
this.#entities = entities;
this.#collections = collections;
this.#typeCollections = typeCollections;
this.#documents = new Map(
content.documents.map((document) => [document.tool, document]),
);
this.#content = content;
this.#bytes = bytes;
}
}