fix(environment): refuse writes the state could not hold or reload

Served writes now refuse, before changing the state:
- a document write with members or JSON types its plan did not accept
  (the same body check entity writes use), or at a path other than the
  one the state holds the document at;
- a body or resulting value data/add would refuse (a number inexact as
  data/get writes it, a __proto__ member, nesting past the limit), so
  data/get output always loads back, increments included;
- a create, update or document write that would take the state past the
  32 MiB data/add intake limit (resource_constraint), with the state's
  serialized size kept incrementally rather than reserialized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-20 13:10:22 -07:00
1 parent 138ea4b198
commit 6d21b62e84
7 files changed
+500 -109

No files matched your search

@@ -6,6 +6,7 @@ import {
same,
scalarText,
segmentsOf,
type JsonObject,
type StateDocument,
type StateEntity,
type StateType,
@@ -15,14 +16,22 @@ import {
type AnalysisInputIssue,
} from "../../domain/analysisErrorCore.js";
import { canonicalJson } from "../../domain/comparisonSemantics.js";
import { parseExactJson } from "../../domain/exactJsonNumber.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import {
jsonValueSchema,
jsonValueValidationIssue,
type JsonValue,
} from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import type { EnvironmentState } from "./environmentState.js";
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
import { REA_STATE_FORMAT } from "./environmentTools.js";
/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */
export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024;
/** A document as the state holds it; only a value equal to the Evidence's cites the record it came from. */
export type HeldDocument = Omit<StateDocument, "record_ordinal"> & {
readonly record_ordinal?: number;
@@ -131,6 +140,18 @@ const prototypeMemberPath = (root: unknown): Path | undefined => {
return undefined;
};
/** Whether `data/add` accepts a value as a `fields` member or a document's `value`: bounded nesting, no `__proto__` member, and numbers exact as `data/get` writes them. */
export const isLoadableValue = (value: JsonValue): boolean =>
jsonValueValidationIssue(value) === undefined &&
prototypeMemberPath(value) === undefined &&
parseExactJson(JSON.stringify(value)) !== undefined;
/** The first member of an object `data/add` would refuse as `fields`, if any: a `__proto__` member, or one whose value does not load. */
export const unloadableMember = (object: JsonObject): string | undefined =>
Object.keys(object).find(
(member) => member === "__proto__" || !isLoadableValue(object[member]!),
);
/** The first schema problem, located without naming the caller's data. */
const schemaProblem = (
issues: readonly z.core.$ZodIssue[],
@@ -2,13 +2,18 @@ import type {
StateEntity,
StateType,
} from "../../domain/analysisView/environmentStateRules.js";
import type { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import {
AnalysisResourceConstraintError,
type AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { ok, type Result } from "../../domain/result.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";
@@ -24,10 +29,20 @@ export interface StateSnapshot {
};
}
const jsonBytes = (value: unknown): number =>
Buffer.byteLength(JSON.stringify(value), "utf8");
/** 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 + jsonBytes(item), 0) +
Math.max(0, items.length - 1);
/**
* 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.
* 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();
@@ -37,6 +52,11 @@ export class EnvironmentStateStore {
#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. */
@@ -48,12 +68,14 @@ export class EnvironmentStateStore {
);
this.#clockStart =
state.writeClock === null ? 0 : Date.parse(state.writeClock);
this.#frameBytes = jsonBytes(this.#snapshotOf([], []));
this.#initialBytes = this.#contentBytes(state);
this.reset();
}
/** Restore the initial state the Evidence holds; the clock and counter start again. */
reset(): void {
this.#install(this.state);
this.#install(this.state, this.#initialBytes);
this.#restart();
}
@@ -61,7 +83,7 @@ export class EnvironmentStateStore {
replace(document: unknown): Result<void, AnalysisInputError> {
const content = parseStateDocument(this.state, document);
if (!content.ok) return content;
this.#install(content.value);
this.#install(content.value, this.#contentBytes(content.value));
this.#restart();
return ok(undefined);
}
@@ -94,9 +116,16 @@ export class EnvironmentStateStore {
return this.#content.entities.filter((entity) => entity.type === type);
}
/** Add an entity at `index` of its collection's order. */
insert(entity: StateEntity, index: number): void {
/** 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(
jsonBytes(entity) + (entities.length > 0 ? 1 : 0),
);
if (!bytes.ok) return bytes;
const members = this.inCollection(entity.type, entity.collection);
const anchor =
index < members.length
@@ -104,49 +133,92 @@ export class EnvironmentStateStore {
: members.length > 0
? entities.indexOf(members.at(-1)!) + 1
: entities.length;
this.#install({
...this.#content,
entities: [
...entities.slice(0, anchor),
entity,
...entities.slice(anchor),
],
});
this.#install(
{
...this.#content,
entities: [
...entities.slice(0, anchor),
entity,
...entities.slice(anchor),
],
},
bytes.value,
);
return ok(undefined);
}
/** Replace a held entity's values in place. */
update(entity: StateEntity): void {
/** 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]);
this.#install({
...this.#content,
entities: this.#content.entities.map((held) =>
compositeKey([held.type, held.collection, held.id]) === key
? entity
: held,
),
});
const held = this.#entities.get(key);
if (held === undefined) return ok(undefined);
const bytes = this.#grown(jsonBytes(entity) - jsonBytes(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 key = compositeKey([entity.type, entity.collection, entity.id]);
this.#install({
...this.#content,
entities: this.#content.entities.filter(
(held) => compositeKey([held.type, held.collection, held.id]) !== key,
),
});
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 - jsonBytes(held) - (entities.length > 0 ? 1 : 0),
);
}
/** Set a held document's value; a value that is not the Evidence's cites no record. */
setDocument(tool: string, value: JsonValue): void {
this.#install({
...this.#content,
documents: this.#content.documents.map((document) =>
document.tool === tool
? { tool: document.tool, path: document.path, value }
: document,
/** 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(jsonBytes(written) - jsonBytes(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 {
@@ -176,19 +248,40 @@ export class EnvironmentStateStore {
}
snapshot(): StateSnapshot {
return this.#snapshotOf(this.#content.entities, this.#content.documents);
}
/** What `data/get` writes for the current state, in UTF-8 bytes. */
byteLength(): number {
return this.#bytes;
}
#snapshotOf(
entities: readonly StateEntity[],
documents: readonly HeldDocument[],
): StateSnapshot {
return {
rea_state: {
version: 1,
state_evidence_id: this.state.evidenceId,
types: this.state.types,
entities: this.#content.entities,
documents: this.#content.documents,
entities,
documents,
},
};
}
/** 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 every index before assigning any, so a reader never sees half a state. */
#install(content: StateContent): void {
#install(content: StateContent, bytes: number): void {
const entities = new Map<string, StateEntity>();
const collections = new Map<string, StateEntity[]>();
const typeCollections = new Map<string, string[]>();
@@ -216,5 +309,6 @@ export class EnvironmentStateStore {
content.documents.map((document) => [document.tool, document]),
);
this.#content = content;
this.#bytes = bytes;
}
}
@@ -1,13 +1,9 @@
import { describe, expect, it } from "vitest";
import {
timedCaptureEvidence,
type TimedCaptureRow,
} from "../../../tests/fixtures/analysisView.js";
import {
environmentStateServeFixture,
environmentStateServeInputs,
} from "../../../tests/fixtures/environmentServe.js";
servedFrom,
type Served,
} from "../../../tests/fixtures/environmentStateServing.js";
import {
BOOK_INIT_ROWS,
BOOK_NOT_FOUND,
@@ -21,52 +17,14 @@ import {
renamedBook,
type Book,
} from "../../../tests/fixtures/environmentStateWrites.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import type { ReplayArguments } from "./environmentReplay.js";
import { servedCatalog } from "./environmentState.js";
import { EnvironmentStateStore } from "./environmentStateStore.js";
import { environmentToolAnswer } from "./environmentToolAnswer.js";
import { environmentToolDefinitions } from "./environmentTools.js";
/** A served environment over the state derived from a capture of `rows`, answering tool calls as the server does. */
const servedFrom = (rows: readonly TimedCaptureRow[] = BOOK_ROWS) => {
const { source, state } = environmentStateServeInputs(
environmentStateServeFixture(timedCaptureEvidence(rows)),
);
const store = new EnvironmentStateStore(state);
const respond = environmentToolAnswer(source, store);
const call = (name: string, args: ReplayArguments) => {
const tool = source.manifest.tools.find(
(candidate) => candidate.name === name,
);
if (tool === undefined) throw new Error(`The manifest lacks ${name}`);
return respond(tool, args);
};
return {
source,
state,
store,
answer: (name: string, args: ReplayArguments = {}) => {
const result = call(name, args);
if (!result.ok) throw result.error;
return result.value;
},
refusal: (name: string, args: ReplayArguments = {}) => {
const result = call(name, args);
if (result.ok) throw new Error(`Expected ${name} to be refused`);
return projectAnalysisError(result.error);
},
/** What data/get reports, as text, so whole states compare byte for byte. */
snapshot: () => JSON.stringify(store.snapshot()),
};
};
/** The book the first served create of "New Book" makes: the next id, one second past the served clock's start. */
const NEW_BOOK = book(3, "New Book", bookClock(13));
const CREATE_NEW_BOOK = { body: { name: "New Book" } };
type Served = ReturnType<typeof servedFrom>;
const entityOf = (books: Served, id: string) =>
books.store.snapshot().rea_state.entities.find((entity) => entity.id === id);
@@ -1,4 +1,5 @@
import {
bodyProblem,
createdFields,
updatedEntity,
updatedFields,
@@ -22,6 +23,7 @@ import {
spelledLike,
} from "../../domain/analysisView/environmentStateWriteValues.js";
import {
at,
isObject,
scalarText,
type JsonObject,
@@ -49,6 +51,11 @@ import {
type StateProvenance,
type StateServing,
} from "./environmentStateAnswer.js";
import {
isLoadableValue,
unloadableMember,
type HeldDocument,
} from "./environmentStateDocument.js";
import { environmentToolArguments } from "./environmentTools.js";
/** Attempts at a generated UUID or token before a collision with held values is refused. */
@@ -79,7 +86,17 @@ const pathValues = (
return values;
};
/** The call's body: an object, nothing, or a refusal for any other value. */
/** A refusal for a value `data/add` would refuse, so `data/get` always reports a state that loads back. */
const unloadable = (
tool: EnvironmentManifestTool,
holder: string,
): AnalysisError =>
refused(
tool,
`${holder} would hold a value that is not exactly representable JSON, which data/add could not load back.`,
);
/** The call's body: an object, nothing, or a refusal for any other value or one holding a value the state cannot hold. */
const callBody = ({
tool,
args,
@@ -87,9 +104,12 @@ const callBody = ({
const name = environmentToolArguments(tool).body;
const value = name === undefined ? undefined : args[name];
if (value === undefined) return ok(undefined);
return isObject(value as JsonValue)
if (!isObject(value as JsonValue))
return err(refused(tool, `Tool ${tool.name} takes a JSON object body.`));
const member = unloadableMember(value as JsonObject);
return member === undefined
? ok(value as JsonObject)
: err(refused(tool, `Tool ${tool.name} takes a JSON object body.`));
: err(unloadable(tool, `Member ${member}`));
};
/** The entity a recorded observation shows: the values a create keeps and an update's constants. */
@@ -257,6 +277,8 @@ const answerCreate = (
});
const fields = createdFields(plan, observed, call, values.generate);
if (!fields.ok) return err(refused(tool, fields.error));
const field = unloadableMember(fields.value);
if (field !== undefined) return err(unloadable(tool, `Field ${field}`));
const id = scalarText(fields.value[idField]);
if (
id === undefined ||
@@ -282,7 +304,7 @@ const answerCreate = (
state,
);
if (!answer.ok) return answer;
serving.store.insert(
const inserted = serving.store.insert(
{
type,
collection,
@@ -297,6 +319,7 @@ const answerCreate = (
fields.value,
),
);
if (!inserted.ok) return inserted;
serving.store.commitWrite(values.taken);
return answer;
};
@@ -346,6 +369,9 @@ const answerUpdate = (
values.generate,
);
if (!written.ok) return err(refused(tool, written.error));
// An increment can pass the exact integers.
const field = unloadableMember(written.value);
if (field !== undefined) return err(unloadable(tool, `Field ${field}`));
const answer = writeAnswer(
serving,
request,
@@ -358,10 +384,10 @@ const answerUpdate = (
id: entity.id,
},
);
if (answer.ok) {
serving.store.update(updatedEntity(entity, written.value));
serving.store.commitWrite(values.taken);
}
if (!answer.ok) return answer;
const updated = serving.store.update(updatedEntity(entity, written.value));
if (!updated.ok) return updated;
serving.store.commitWrite(values.taken);
return answer;
};
@@ -391,19 +417,56 @@ const answerDelete = (
return answer;
};
/** The held document a call changes: only at the path the state holds it, with members the capture sent as the JSON types the document holds. */
const documentTarget = (
serving: StateServing,
{ tool, plan, args }: WriteRequest,
call: WriteCall,
): Result<HeldDocument, AnalysisError> => {
const held = serving.store.document(plan.target ?? "");
const segments = callSegments(tool, args);
if (held === undefined || plan.body === null)
return err(
refused(tool, `The state holds no document for ${plan.target ?? ""}.`),
);
if (segments === undefined)
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
// Document reads answer only on their recorded line, so a write elsewhere has no document to change.
if (collectionMatch(held.path, segments) === undefined)
return err(
refused(
tool,
`The state holds this document only at ${held.path}; the environment models writes to it only there.`,
),
);
const pointer = plan.observation?.pointer ?? "";
const members = (value: JsonValue | undefined): JsonObject => {
const object = value === undefined ? undefined : at(value, pointer);
return isObject(object) ? object : {};
};
const initial = serving.store.state.documents.find(
(document) => document.tool === held.tool,
);
// A member a replacing write dropped keeps the type the capture showed.
const problem = bodyProblem(plan.body, call, {
entity: { ...members(initial?.value), ...members(held.value) },
});
return problem === undefined ? ok(held) : err(refused(tool, problem));
};
const answerDocument = (
serving: StateServing,
request: WriteRequest,
call: WriteCall,
): Result<StateAnswer, AnalysisError> => {
const { tool, plan } = request;
const held = serving.store.document(plan.target ?? "");
if (held === undefined)
return err(
refused(tool, `The state holds no document for ${plan.target ?? ""}.`),
);
const target = documentTarget(serving, request, call);
if (!target.ok) return target;
const held = target.value;
const written = writtenDocument(plan, held.value, call);
if (!written.ok) return err(refused(tool, written.error));
if (!isLoadableValue(written.value))
return err(unloadable(tool, "The document"));
const state = {
source: "state",
view: "document-write",
@@ -423,10 +486,10 @@ const answerDocument = (
);
})()
: writeAnswer(serving, request, { fields: undefined, id: "" }, state);
if (answer.ok) {
serving.store.setDocument(plan.target ?? "", written.value);
serving.store.commitWrite(new Map());
}
if (!answer.ok) return answer;
const set = serving.store.setDocument(held.tool, written.value);
if (!set.ok) return set;
serving.store.commitWrite(new Map());
return answer;
};
@@ -0,0 +1,209 @@
import { describe, expect, it } from "vitest";
import type { TimedCaptureRow } from "../../../tests/fixtures/analysisView.js";
import {
servedFrom,
type Served,
} from "../../../tests/fixtures/environmentStateServing.js";
import {
FIRST_BOOK,
SECOND_BOOK,
renamedBook,
type Book,
} from "../../../tests/fixtures/environmentStateWrites.js";
import { MAX_JSON_DEPTH, type JsonValue } from "../../domain/jsonValue.js";
import type { ReplayArguments } from "./environmentReplay.js";
import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js";
const DARK = { theme: "dark", meta: { n: 1 } };
const LIGHT = { theme: "light", meta: { n: 2 } };
/** One organisation's settings document, read, patched with both members and read again. */
const SETTINGS_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/orgs/1/settings", DARK],
["PATCH", "/api/orgs/1/settings", LIGHT, 200, LIGHT],
["GET", "/api/orgs/1/settings", LIGHT],
];
const READ_SETTINGS = "get_orgs_settings_by_id";
const PATCH_SETTINGS = "patch_orgs_settings_by_id";
/** Every refused call is a tool error naming its problem, and leaves the state as it was. */
const expectRefusedUnchanged = (
served: Served,
refused: readonly (readonly [string, ReplayArguments, string])[],
): void => {
const before = served.snapshot();
for (const [name, args, message] of refused) {
expect(served.refusal(name, args)).toMatchObject({
code: "invalid_request",
message: expect.stringContaining(message),
});
expect(served.snapshot()).toBe(before);
}
};
/** What data/get reports loads back through data/add byte for byte. */
const expectReloadsUnchanged = (served: Served): void => {
const before = served.snapshot();
expect(served.store.replace(JSON.parse(before)).ok).toBe(true);
expect(served.snapshot()).toBe(before);
};
const nested = (depth: number): JsonValue =>
Array.from({ length: depth }).reduce<JsonValue>((inner) => [inner], 1);
describe("served document writes", () => {
it("accepts only the members the capture sent, as the JSON types the document holds", () => {
const settings = servedFrom(SETTINGS_ROWS);
expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({
served: true,
effect: "document",
});
expectRefusedUnchanged(settings, [
[
PATCH_SETTINGS,
{ id: "1", body: { theme: 5, extra: true } },
"Member theme must be a JSON string",
],
[
PATCH_SETTINGS,
{ id: "1", body: { extra: true } },
"Member extra is not one the capture showed stored",
],
]);
const blue = { theme: "blue", meta: DARK.meta };
expect(
settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "blue" } }),
).toMatchObject({
body: blue,
state: { view: "document-write", path: "/api/orgs/1/settings" },
});
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(blue);
});
it("changes the document only at the path the state holds it", () => {
const settings = servedFrom(SETTINGS_ROWS);
expectRefusedUnchanged(settings, [
[
PATCH_SETTINGS,
{ id: "2", body: { theme: "blue" } },
"holds this document only at /api/orgs/1/settings",
],
]);
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(DARK);
});
});
describe("values the state can load back", () => {
it("refuses a body value data/add would refuse, whatever member carries it", () => {
const settings = servedFrom(SETTINGS_ROWS);
const unexact =
"Member meta would hold a value that is not exactly representable JSON";
expectRefusedUnchanged(settings, [
[PATCH_SETTINGS, { id: "1", body: { meta: { n: 1e20 } } }, unexact],
[
PATCH_SETTINGS,
{
id: "1",
body: JSON.parse('{"meta":{"__proto__":{"n":1}}}') as JsonValue,
},
unexact,
],
[
PATCH_SETTINGS,
{ id: "1", body: { meta: { n: nested(MAX_JSON_DEPTH) } } },
unexact,
],
]);
settings.answer(PATCH_SETTINGS, { id: "1", body: { meta: { n: 3 } } });
expectReloadsUnchanged(settings);
});
it("refuses an increment past the exact integers, so data/get still loads back", () => {
const versioned = (entry: Book, version: number): Book => ({
...entry,
version,
});
const last = Number.MAX_SAFE_INTEGER;
const edition = versioned(renamedBook(SECOND_BOOK, "Second Edition", 3), 2);
const books = servedFrom([
[
"GET",
"/api/books",
[versioned(FIRST_BOOK, last - 1), versioned(SECOND_BOOK, 1)],
],
["GET", "/api/books/1", versioned(FIRST_BOOK, last - 1)],
["GET", "/api/books/2", versioned(SECOND_BOOK, 1)],
["PATCH", "/api/books/2", edition, 200, { name: "Second Edition" }],
["GET", "/api/books/2", edition],
]);
const rename = (name: string) => ({ id: "1", body: { name } });
expect(
books.answer("patch_books_by_id", rename("Renamed")).body,
).toMatchObject({ version: last });
expectRefusedUnchanged(books, [
[
"patch_books_by_id",
rename("Renamed Again"),
"Field version would hold a value that is not exactly representable JSON",
],
]);
expectReloadsUnchanged(books);
});
});
describe("the state size limit", () => {
it("keeps the size data/get writes as each write changes the state", () => {
const books = servedFrom();
const settings = servedFrom(SETTINGS_ROWS);
const tracked = (served: Served) =>
expect(served.store.byteLength()).toBe(
Buffer.byteLength(served.snapshot(), "utf8"),
);
tracked(books);
books.answer("post_books", { body: { name: "Ünïcode Bøok" } });
tracked(books);
books.answer("patch_books_by_id", { id: "3", body: { name: "Short" } });
tracked(books);
books.answer("delete_books_by_id", { id: "1" });
books.answer("delete_books_by_id", { id: "2" });
books.answer("delete_books_by_id", { id: "3" });
tracked(books);
books.answer("post_books", { body: { name: "Only Book" } });
tracked(books);
settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "dark blue" } });
tracked(settings);
expect(settings.store.replace(JSON.parse(settings.snapshot())).ok).toBe(
true,
);
tracked(settings);
});
it("refuses a create that would take data/get past what data/add loads", () => {
const books = servedFrom();
// Each answer renders the book, so each stays under the reply body limit.
const create = { body: { name: "a".repeat(100_000) } };
let refused: ReturnType<Served["call"]> | undefined;
for (let made = 0; made < 200 && refused === undefined; made += 1) {
const result = books.call("post_books", create);
if (!result.ok) refused = result;
}
const before = books.snapshot();
expect(Buffer.byteLength(before, "utf8")).toBeLessThanOrEqual(
STATE_INTAKE_LIMIT_BYTES,
);
expect(refused?.ok === false && refused.error).toMatchObject({
resource: "capacity",
reportedLimits: { max_state_bytes: STATE_INTAKE_LIMIT_BYTES },
});
expect(books.refusal("post_books", create)).toMatchObject({
code: "resource_constraint",
});
expect(books.snapshot()).toBe(before);
expectReloadsUnchanged(books);
// A smaller state has room again.
books.answer("delete_books_by_id", { id: "3" });
expect(books.answer("post_books", create).status).toBe(201);
});
});
+2 -2
View File
@@ -1,6 +1,7 @@
import type { FileHandle } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { STATE_INTAKE_LIMIT_BYTES } from "../../application/environment/environmentStateDocument.js";
import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js";
import { isExactJsonNumber } from "../../domain/exactJsonNumber.js";
import { err, ok, type Result } from "../../domain/result.js";
@@ -44,8 +45,7 @@ export const CANCELLED: RpcError = {
data: { reason: "cancelled" },
};
/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */
export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024;
export { STATE_INTAKE_LIMIT_BYTES };
/** An https state download must finish within this time. */
export const STATE_FETCH_TIMEOUT_MS = 30_000;
+46
View File
@@ -0,0 +1,46 @@
import type { ReplayArguments } from "../../src/application/environment/environmentReplay.js";
import { EnvironmentStateStore } from "../../src/application/environment/environmentStateStore.js";
import { environmentToolAnswer } from "../../src/application/environment/environmentToolAnswer.js";
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
import { timedCaptureEvidence, type TimedCaptureRow } from "./analysisView.js";
import {
environmentStateServeFixture,
environmentStateServeInputs,
} from "./environmentServe.js";
import { BOOK_ROWS } from "./environmentStateWrites.js";
/** A served environment over the state derived from a capture of `rows`, answering tool calls as the server does. */
export const servedFrom = (rows: readonly TimedCaptureRow[] = BOOK_ROWS) => {
const { source, state } = environmentStateServeInputs(
environmentStateServeFixture(timedCaptureEvidence(rows)),
);
const store = new EnvironmentStateStore(state);
const respond = environmentToolAnswer(source, store);
const call = (name: string, args: ReplayArguments) => {
const tool = source.manifest.tools.find(
(candidate) => candidate.name === name,
);
if (tool === undefined) throw new Error(`The manifest lacks ${name}`);
return respond(tool, args);
};
return {
source,
state,
store,
call,
answer: (name: string, args: ReplayArguments = {}) => {
const result = call(name, args);
if (!result.ok) throw result.error;
return result.value;
},
refusal: (name: string, args: ReplayArguments = {}) => {
const result = call(name, args);
if (result.ok) throw new Error(`Expected ${name} to be refused`);
return projectAnalysisError(result.error);
},
/** What data/get reports, as text, so whole states compare byte for byte. */
snapshot: () => JSON.stringify(store.snapshot()),
};
};
export type Served = ReturnType<typeof servedFrom>;