fix(environment): own JSON fields across capture, state and replay

This commit is contained in:
morluto committed 2026-10-26 18:27:30 +08:00
1 parent cc5a96933a
commit e54bf1006e
57 files changed
+1019 -389

No files matched your search

+3 -2
View File
@@ -871,8 +871,9 @@ names a `document` view once, at its initial document's `path`. The `version`,
and the `state_evidence_id` and `types` that `data/get` reports, must match
the served state, so a `data/get` result loads back unchanged. A number without
an exact native value, or whose serialized spelling would not be read as exact
(an integer from 2^53 below 1e21, such as `1e20`, in any spelling), or a
`__proto__` member is refused rather than altered.
(an integer from 2^53 below 1e21, such as `1e20`, in any spelling), is refused
rather than altered. Caller data preserves all own JSON members, including
`__proto__`; unknown members of the state document's structure are refused.
An invalid document is `-32602` naming the first problem by JSON pointer, as in
`/rea_state/entities/3/collection` (also in `error.data.path`); a problem
inside caller data (`fields`, `value`, `types`) is located at that member, so
@@ -110,7 +110,9 @@ this increment: write tools are not served from the state.
differs from the entity's id (refused at that view's `fields`),
documents naming document views once at the initial path, and the optional
`state_evidence_id` and `types` that `data/get` reports equal to the served
ones. Refuse numbers without an exact native value and `__proto__` members.
ones. Refuse numbers without an exact native value. Preserve all own JSON
members in caller data, including `__proto__`, and reject unknown structural
members before projecting the document's declared fields.
Name the first problem by JSON pointer, never deeper than a caller-data
member (`fields`, `value`, `types`), and leave the state unchanged.
- Keep record citations Evidence provenance: refuse `record_ordinals` or a
@@ -133,8 +133,8 @@ later increments.
- Keep an item or list plan only when it reproduces each request line's latest
initial recording from the state, comparing JSON values without regard to
member order; otherwise record the tool with a reason. Parse bodies only when
JSON-like, UTF-8, at most 256 KiB, exactly representable and free of
`__proto__` members.
JSON-like, UTF-8, at most 256 KiB and exactly representable. Preserve all own
JSON members, including `__proto__`, through extraction and normalization.
- Mark the Evidence inferred; state unknowns for authorization, response
semantics and write effects.
- Charge the manifest's admission and grouping, every record line, every body
@@ -150,7 +150,7 @@ the exemplar.
- **Request body.** The exemplar's request body must be a JSON object that
could enter the state: a JSON-like media type, complete UTF-8 text within
256 KiB, exact numbers, no `__proto__` member. Otherwise the reason is
256 KiB and exact numbers. All own JSON member names are retained. Otherwise the reason is
`body-unusable`. A `DELETE` takes no body. A create's or update's body
whose only member wraps one object in a one-element array (Ghost's
`{"posts":[{...}]}`) is that object, and the plan records the member as
@@ -587,7 +587,7 @@ changes it atomically, so every MCP session and `data/get` see one state.
- a create in another collection when the plan is tied to its own;
- a document write at a path other than the one the state holds it at;
- a body member or a resulting value `data/add` would refuse (a number not
exact as `data/get` writes it, a `__proto__` member, nesting past the
exact as `data/get` writes it or nesting past the
limit), increments included, so `data/get` always loads back;
- a create, update or document write that would take what `data/get` reports
past the 32 MiB `data/add` intake limit, as a `resource_constraint` error;
+3 -2
View File
@@ -696,8 +696,9 @@ more than one query) and `rendered-oversize` (a document value whose JSON
renders to more than 256 KiB). A document holds the value of its one `path`, so for such
a template it says nothing about the template's other paths. A body is usable
when its content type is
JSON-like, it is UTF-8 text of at most 256 KiB, every number survives native
conversion exactly, and no member is named `__proto__`.
JSON-like, it is UTF-8 text of at most 256 KiB, and every number survives native
conversion exactly. JSON member names, including `__proto__`, remain ordinary
own data members through extraction and serving.
A list view also learns, from its checked lines that do not list the whole
collection, what each query parameter does, and names the result in `query`.
@@ -0,0 +1,116 @@
import { expect, it } from "vitest";
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
import { matchProbeRequest } from "./environmentCompareMatch.js";
import {
selectsRecordedLine,
selectReplayExchange,
} from "./environmentReplay.js";
import { callQuery } from "./environmentStateCallLines.js";
import { pinnedRequest } from "./environmentStateWriteQuery.js";
it.each(["__proto__", "constructor", "toString"])(
"preserves an own %s probe argument and treats omission as absent",
(name) => {
const { source } = servedFrom();
const original = source.manifest.tools[0]!;
const tool = {
...original,
method: "GET",
path_template: "/api/books",
params: [],
query_parameter_names: [name],
};
const manifest = { ...source.manifest, tools: [tool] };
const call = matchProbeRequest(manifest, {
method: "GET",
path: "/api/books",
query: `?${name}=selected`,
body: undefined,
});
expect(call).toBeDefined();
expect(Object.hasOwn(call!.args, name)).toBe(true);
expect(call!.args[name]).toBe("selected");
expect([...callQuery(tool, {})]).toEqual([]);
expect([...callQuery(tool, call!.args)]).toEqual([[name, "selected"]]);
const exchange = {
...source.exchanges.values().next().value!,
path: "/api/books",
query: `?${name}=selected`,
};
const exchanges = new Map([[exchange.ordinal, exchange]]);
expect(selectsRecordedLine(tool, exchange, {})).toBe(false);
expect(selectReplayExchange(tool, exchanges, call!.args)).toMatchObject({
exact: true,
});
},
);
it.each(["__proto__", "constructor"])(
"fills an omitted pinned %s argument as an own member",
(name) => {
const serving = servedFrom();
const tool = {
...serving.source.manifest.tools[0]!,
params: [],
query_parameter_names: [name],
};
const exchange = {
...serving.source.exchanges.values().next().value!,
query: `?${name}=selected`,
};
const source = {
...serving.source,
exchanges: new Map([[exchange.ordinal, exchange]]),
};
const plan = {
...serving.state.writes.values().next().value!,
exemplar_ordinal: exchange.ordinal,
};
for (const args of [{}, Object.fromEntries([[name, "selected"]])]) {
const result = pinnedRequest(
{ source, store: serving.store },
{ tool, plan, args },
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(Object.hasOwn(result.value.args, name)).toBe(true);
expect(result.value.args[name]).toBe("selected");
}
},
);
it("keeps a prototype-named path argument and every own body member in a probe", () => {
const { source } = servedFrom();
const tool = {
...source.manifest.tools[0]!,
method: "PATCH",
path_template: "/api/books/{__proto__}",
params: [
{
name: "__proto__",
index: 2,
rule: "numeric" as const,
basis: "inferred" as const,
observed_values: ["1"],
},
],
query_parameter_names: [],
};
const body = JSON.parse(
'{"__proto__":{"selected":true},"constructor":"value"}',
);
const call = matchProbeRequest(
{ ...source.manifest, tools: [tool] },
{
method: "PATCH",
path: "/api/books/3",
query: null,
body,
},
);
expect(call).toBeDefined();
expect(Object.hasOwn(call!.args, "__proto__")).toBe(true);
expect(call!.args["__proto__"]).toBe("3");
expect(call!.args.body).toBe(body);
expect(JSON.stringify(call!.args.body)).toBe(JSON.stringify(body));
});
@@ -1,4 +1,5 @@
import {
ownMember,
decodedSegment,
same,
} from "../../domain/analysisView/environmentStateRules.js";
@@ -240,7 +241,7 @@ const answersCall = (
const body = probeArguments(call.tool, run.bodies).body;
// A tool with no body parameter matches; one that takes a body matches only the body this call sent, including none.
if (body === undefined) return true;
return same(run.recordedBodies.get(exchange.ordinal), args[body]);
return same(run.recordedBodies.get(exchange.ordinal), ownMember(args, body));
};
/** A served tool's input check: the first problem with a call's arguments, or undefined. */
@@ -348,7 +349,7 @@ const compareStep = (
});
const held = heldIds(run.store);
const unknownPath = call.pathArguments.some((name) => {
const value = call.args[name];
const value = ownMember(call.args, name);
if (typeof value !== "string") return false;
const id = decodedSegment(value);
return !run.map.hasEnv(id) && !held.has(id);
@@ -215,12 +215,12 @@ export const matchProbeRequest = (
if (best === undefined) return undefined;
const { tool } = best;
const names = probeArguments(tool, bodies);
const args: Record<string, JsonValue> = {};
const args = new Map<string, JsonValue>();
const pathArguments: string[] = [];
for (const param of tool.params) {
const name = names.path.get(param.name) ?? param.name;
// The environment decodes a path id once. Passing the raw segment keeps `book%2531` from becoming `book1`.
args[name] = segments[param.index]!;
args.set(name, segments[param.index]!);
pathArguments.push(name);
}
const ignoredQuery: string[] = [];
@@ -238,12 +238,12 @@ export const matchProbeRequest = (
}
}
for (const [name, values] of queryValues)
args[name] = values.length === 1 ? values[0]! : values;
args.set(name, values.length === 1 ? values[0]! : values);
if (names.body !== undefined && request.body !== undefined)
args[names.body] = request.body;
args.set(names.body, request.body);
return {
tool,
args,
args: Object.fromEntries(args),
pathArguments,
pagingArguments,
ignoredQuery,
@@ -1,6 +1,9 @@
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { pathSegmentMatches } from "../../domain/analysisView/environmentStateRules.js";
import {
ownMember,
pathSegmentMatches,
} from "../../domain/analysisView/environmentStateRules.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import {
iterateUrlQueryParameters,
@@ -62,7 +65,7 @@ const scoreExchange = (
): MatchScore => {
const providedPaths: { index: number; value: string }[] = [];
for (const param of tool.params) {
const value = args[pathArguments.get(param.name) ?? param.name];
const value = ownMember(args, pathArguments.get(param.name) ?? param.name);
if (typeof value === "string")
providedPaths.push({ index: param.index, value });
}
@@ -84,7 +87,7 @@ const scoreExchange = (
}
const supplied = new Map<string, readonly string[]>();
for (const [name, argument] of queryArguments) {
const value = args[argument];
const value = ownMember(args, argument);
if (typeof value === "string") supplied.set(name, [value]);
else if (
Array.isArray(value) &&
@@ -128,7 +131,8 @@ const scoreExchange = (
const occurrence = observedOccurrences.get(name) ?? 0;
observedOccurrences.set(name, occurrence + 1);
const argument = queryArguments.get(name);
const value = argument === undefined ? undefined : args[argument];
const value =
argument === undefined ? undefined : ownMember(args, argument);
const normalized =
typeof value === "string"
? occurrence === 0
@@ -201,7 +205,7 @@ export const selectReplayExchange = (
if (best === undefined) return undefined;
const suppliedQueryNames = new Set(
[...argumentsMap.query].flatMap(([name, argument]) =>
args[argument] === undefined ? [] : [name],
ownMember(args, argument) === undefined ? [] : [name],
),
);
const queryWasSupplied = suppliedQueryNames.size > 0;
@@ -266,8 +270,10 @@ export const selectsRecordedLine = (
if (
!tool.params.every(
(param) =>
typeof args[argumentsMap.path.get(param.name) ?? param.name] ===
"string",
typeof ownMember(
args,
argumentsMap.path.get(param.name) ?? param.name,
) === "string",
) ||
!scoreExchange(
tool,
@@ -287,7 +293,7 @@ export const selectsRecordedLine = (
}
for (const [name, values] of recorded) {
const argument = argumentsMap.query.get(name);
const supplied = args[argument ?? ""];
const supplied = ownMember(args, argument ?? "");
if (
argument === undefined ||
(typeof supplied === "string"
@@ -29,7 +29,10 @@ import {
loadEnvironmentState,
prepareServedState,
} from "./environmentState.js";
import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js";
import {
STATE_INTAKE_LIMIT_BYTES,
parseStateDocument,
} from "./environmentStateDocument.js";
import {
EnvironmentStateStore,
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
@@ -679,24 +682,20 @@ describe("rea-state/v1 validation", () => {
path: "rea_state/types",
message: "differs from the served types",
});
const prototypeMember = (at: "entity" | "fields") =>
const prototypeMember = () =>
firstProblem(
JSON.parse(
JSON.stringify(documentWith(() => undefined)).replace(
at === "entity" ? '"type":' : '"fields":{',
at === "entity"
? '"__proto__":{"x":1},"type":'
: '"fields":{"__proto__":{"x":1},',
'"type":',
'"__proto__":{"x":1},"type":',
),
),
);
expect(prototypeMember("entity")).toEqual({
expect(prototypeMember()).toEqual({
path: "rea_state/entities/0",
message: "has a member rea-state/v1 does not define",
});
expect(prototypeMember("fields")).toMatchObject({
path: "rea_state/entities/0/fields",
});
expect(firstProblem([])).toMatchObject({ path: "" });
expect(
firstProblem({ rea_state: { version: 2, entities: [], documents: [] } }),
@@ -736,3 +735,70 @@ describe("wide state value validation", () => {
expect(retainedObject).toEqual(wideObject);
});
});
it("retains arbitrary dictionary keys and owned JSON references at state intake", () => {
const dictionary = JSON.parse(
'{"__proto__":{"constructor":{"prototype":"kept"}},"constructor":7,"prototype":8}',
);
const document = documentWith((value) => {
const entity = value.rea_state.entities[0]!;
entity.fields = { ...(entity.fields as object), ...dictionary };
});
const entity = document.rea_state.entities[0]!;
const fields = entity.fields;
const owned = parseStateDocument(state, document, true);
expect(owned.ok).toBe(true);
if (!owned.ok) throw owned.error;
expect(owned.value.entities[0]?.fields).toBe(fields);
const copied = parseStateDocument(state, document);
expect(copied.ok).toBe(true);
if (!copied.ok) throw copied.error;
expect(copied.value.entities[0]?.fields).not.toBe(fields);
expect(copied.value.entities[0]?.fields).toEqual(fields);
expect(Object.hasOwn(copied.value.entities[0]!.fields, "__proto__")).toBe(
true,
);
const store = new EnvironmentStateStore(state);
expect(store.replace(document).ok).toBe(true);
const snapshot = JSON.stringify(store.snapshot());
expect(store.replace(JSON.parse(snapshot)).ok).toBe(true);
expect(JSON.stringify(store.snapshot())).toBe(snapshot);
});
it.each(["__proto__", "constructor", "prototype"])(
"refuses unknown structural member %s before projection",
(key) => {
const document = documentWith(() => undefined);
const structures = [
document,
document.rea_state,
document.rea_state.entities[0]!,
];
const view = { tool: "fixture", fields: {} };
document.rea_state.entities[0]!.view_fields = [view];
const held = { tool: "fixture", path: "/fixture", value: {} };
document.rea_state.documents = [held];
structures.push(view, held);
for (const structure of structures) {
Object.defineProperty(structure, key, {
value: 1,
configurable: true,
enumerable: true,
});
expect(firstProblem(document).message).toBe(
"has a member rea-state/v1 does not define",
);
Reflect.deleteProperty(structure, key);
}
},
);
it("keeps missing fields distinct from invalid JSON field contents", () => {
const document = documentWith((value) => {
Reflect.deleteProperty(value.rea_state.entities[0]!, "fields");
});
expect(firstProblem(document)).toEqual({
path: "rea_state/entities/0/fields",
message: "is required",
});
});
@@ -1,6 +1,7 @@
import { hasRepeatedQueryArgument } from "./environmentTools.js";
import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js";
import {
ownMember,
collectionMatch,
collectionPathOf,
decodedSegment,
@@ -439,7 +440,7 @@ const listSelection = (
}
const idField = store.type(view.type)?.id_field ?? "";
const members = line.recorded.flatMap((item) => {
const id = scalarText(item[idField]);
const id = scalarText(ownMember(item, idField));
const entity =
id === undefined ? undefined : store.entity(view.type, collection, id);
return entity === undefined ? [] : [entity];
@@ -9,9 +9,10 @@ import {
writtenBulkResponse,
} from "../../domain/analysisView/environmentStateWriteApply.js";
import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js";
import type {
JsonObject,
StateEntity,
import {
ownMember,
type JsonObject,
type StateEntity,
} from "../../domain/analysisView/environmentStateRules.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { JsonValue } from "../../domain/jsonValue.js";
@@ -57,7 +58,8 @@ const namedTargets = (
const { address } = plan;
const type = plan.target ?? "";
const name = environmentToolArguments(tool, true).body;
const body = name === undefined ? undefined : (args[name] as JsonValue);
const body =
name === undefined ? undefined : (ownMember(args, name) as JsonValue);
if (body !== undefined && !isLoadableValue(body))
return err(unloadable(tool, "The body"));
const named =
@@ -1,4 +1,5 @@
import {
ownMember,
allLines,
collectionPathOf,
collectionMatch,
@@ -35,7 +36,7 @@ export const callSegments = (
segments.push({ text, supplied: false });
continue;
}
const value = args[names.get(param.name) ?? param.name];
const value = ownMember(args, names.get(param.name) ?? param.name);
if (typeof value !== "string") return undefined;
segments.push({ text: value, supplied: true });
}
@@ -74,7 +75,9 @@ export const unfilteredLine = (
const segments = callSegments(tool, args);
if (
segments === undefined ||
[...query.values()].some((argument) => args[argument] !== undefined)
[...query.values()].some(
(argument) => ownMember(args, argument) !== undefined,
)
)
return undefined;
const lines = new Map<number, RecordedExchange>();
@@ -98,7 +101,7 @@ export const callQuery = (
new Map(
[...environmentToolArguments(tool).query].flatMap(
([name, argument]): [string, string][] => {
const value = args[argument];
const value = ownMember(args, argument);
return value === undefined ? [] : [[name, String(value)]];
},
),
@@ -20,6 +20,8 @@ import { canonicalJson } from "../../domain/comparisonSemantics.js";
import { parseExactJson } from "../../domain/exactJsonNumber.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import {
jsonObjectSchema,
ownedJsonObjectSchema,
jsonValueSchema,
jsonValueValidationIssue,
ownedJsonValueSchema,
@@ -45,30 +47,58 @@ export interface StateContent {
readonly documents: readonly HeldDocument[];
}
const stateDocumentSchemaFor = (jsonValue: z.ZodType<JsonValue, unknown>) => {
const fieldsSchema = z.record(z.string(), jsonValue);
/** Check source-owned structural keys before Zod projects the declared fields. */
const strictStateObject = <Shape extends z.ZodRawShape>(shape: Shape) => {
const schema = z.strictObject(shape);
return z
.unknown()
.superRefine((value, context) => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return;
if (types.isProxy(value)) {
context.addIssue({
code: "custom",
message: "Expected an ordinary state object",
});
return;
}
const keys = Object.getOwnPropertyNames(value).filter(
(key) => !Object.hasOwn(schema.shape, key),
);
if (keys.length > 0)
context.addIssue({ code: "unrecognized_keys", keys });
})
.pipe(schema);
};
const stateDocumentSchemaFor = (
jsonValue: z.ZodType<JsonValue, unknown>,
fieldsSchema: z.ZodType<JsonObject, unknown>,
) => {
const ordinalSchema = z.number().int().nonnegative();
return z.strictObject({
rea_state: z.strictObject({
return strictStateObject({
rea_state: strictStateObject({
version: z.literal(1),
// `data/get` reports these; accepting them back lets its output be loaded unchanged.
state_evidence_id: z.string().optional(),
types: z.array(jsonValue).optional(),
entities: z.array(
z.strictObject({
strictStateObject({
type: z.string(),
collection: z.string(),
id: z.string(),
fields: fieldsSchema,
view_fields: z
.array(z.strictObject({ tool: z.string(), fields: fieldsSchema }))
.array(
strictStateObject({ tool: z.string(), fields: fieldsSchema }),
)
.optional(),
record_ordinals: z.array(ordinalSchema).optional(),
}),
),
documents: z.array(
z.strictObject({
strictStateObject({
tool: z.string(),
path: z.string(),
value: jsonValue,
@@ -79,8 +109,14 @@ const stateDocumentSchemaFor = (jsonValue: z.ZodType<JsonValue, unknown>) => {
});
};
const stateDocumentSchema = stateDocumentSchemaFor(jsonValueSchema);
const ownedStateDocumentSchema = stateDocumentSchemaFor(ownedJsonValueSchema);
const stateDocumentSchema = stateDocumentSchemaFor(
jsonValueSchema,
jsonObjectSchema,
);
const ownedStateDocumentSchema = stateDocumentSchemaFor(
ownedJsonValueSchema,
ownedJsonObjectSchema,
);
type ParsedDocument = z.output<typeof stateDocumentSchema>["rea_state"];
type ParsedEntity = ParsedDocument["entities"][number];
@@ -116,99 +152,14 @@ const memberProblem = (path: Path): AnalysisInputIssue => {
);
};
/** The path of the first `__proto__` member in document order; schema parsing would drop such a member rather than refuse it. */
const prototypeMemberPath = (root: unknown): Path | undefined => {
interface Cursor {
readonly value: unknown;
readonly parent: Cursor | undefined;
readonly key: string | number;
readonly entries?: Generator<string>;
index: number;
}
const pathOf = (node: Cursor): (string | number)[] => {
const path: (string | number)[] = [];
for (let at: Cursor = node; at.parent !== undefined; at = at.parent)
path.push(at.key);
return path.reverse();
};
function* ownKeys(value: Record<string, unknown>): Generator<string> {
// `for...in` is lazy. The ownership check keeps inherited enumerable
// properties out, matching Object.entries without allocating all entries.
for (const key in value) if (Object.hasOwn(value, key)) yield key;
}
const cursor = (
value: unknown,
parent: Cursor | undefined,
key: string | number,
): Cursor => ({
value,
parent,
key,
index: 0,
...(!Array.isArray(value) && typeof value === "object" && value !== null
? { entries: ownKeys(value as Record<string, unknown>) }
: {}),
});
const pending: Cursor[] = [cursor(root, undefined, "")];
while (pending.length > 0) {
const node = pending[pending.length - 1]!;
const { value } = node;
if (typeof value !== "object" || value === null) {
pending.pop();
continue;
}
// The canonical JSON validator reports proxies without invoking their traps.
if (types.isProxy(value)) {
pending.pop();
continue;
}
const array = Array.isArray(value);
if (!array && Object.hasOwn(value, "__proto__"))
return [...pathOf(node), "__proto__"];
if (array) {
if (node.index >= value.length) {
pending.pop();
continue;
}
const index = node.index++;
const descriptor = Object.getOwnPropertyDescriptor(value, index);
if (
descriptor?.enumerable !== true ||
!Object.hasOwn(descriptor, "value")
)
continue;
const child: unknown = descriptor.value;
if (typeof child === "object" && child !== null)
pending.push(cursor(child, node, index));
continue;
}
const next = node.entries?.next();
if (next === undefined || next.done) {
pending.pop();
continue;
}
const key = next.value;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor?.enumerable !== true || !Object.hasOwn(descriptor, "value"))
continue;
const child: unknown = descriptor.value;
if (typeof child === "object" && child !== null)
pending.push(cursor(child, node, key));
}
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. */
/** Whether `data/add` accepts a value as a `fields` member or a document's `value`: bounded nesting 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. */
/** The first member of an object `data/add` would refuse as `fields`, if any: one whose value does not load. */
export const unloadableMember = (object: JsonObject): string | undefined =>
Object.keys(object).find(
(member) => member === "__proto__" || !isLoadableValue(object[member]!),
);
Object.keys(object).find((member) => !isLoadableValue(object[member]!));
/** The first schema problem, located without naming the caller's data. */
const schemaProblem = (
@@ -224,6 +175,15 @@ const schemaProblem = (
first.reason === "unknown_argument"
)
return memberProblem(first.path);
if (
content >= 0 &&
first.path[content] === "fields" &&
first.reason === "invalid_value"
)
return problem(
first.path.slice(0, content + 1),
"holds a value that is not exactly representable JSON",
);
if (first.reason === "missing_argument")
return { ...first, message: "is required" };
return {
@@ -443,19 +403,15 @@ export const parseStateDocument = (
value: unknown,
owned = false,
): Result<StateContent, AnalysisInputError> => {
const prototype = prototypeMemberPath(value);
const parsed = (
owned ? ownedStateDocumentSchema : stateDocumentSchema
).safeParse(value);
const initial = new Map(
state.entities.map((entity) => [entityKey(entity), entity]),
);
const issue =
prototype !== undefined
? memberProblem(prototype)
: parsed.success
? contentProblem(state, parsed.data.rea_state, initial)
: schemaProblem(parsed.error.issues, value);
const issue = parsed.success
? contentProblem(state, parsed.data.rea_state, initial)
: schemaProblem(parsed.error.issues, value);
if (issue !== undefined || !parsed.success)
return err(
new AnalysisInputError(
@@ -38,6 +38,7 @@ import {
spelledLike,
} from "../../domain/analysisView/environmentStateWriteValues.js";
import {
ownMember,
collectionMatch,
decodedSegment,
isObject,
@@ -97,7 +98,7 @@ export const pathValues = (
.split("/");
const values = new Map<string, string>();
for (const param of tool.params) {
const value = args[names.get(param.name) ?? param.name];
const value = ownMember(args, names.get(param.name) ?? param.name);
const segment = template[param.index] ?? "";
if (typeof value === "string") values.set(segment.slice(1, -1), value);
}
@@ -121,7 +122,7 @@ const callBody = ({
plan,
}: WriteRequest): Result<JsonObject | undefined, AnalysisError> => {
const name = environmentToolArguments(tool).body;
const sent = name === undefined ? undefined : args[name];
const sent = name === undefined ? undefined : ownMember(args, name);
if (sent === undefined) return ok(undefined);
const envelope = plan.body?.envelope ?? "";
const value = unenveloped(sent as JsonValue, plan.body?.envelope);
@@ -240,7 +241,7 @@ export const generator = (
make: (attempt: number) => JsonValue,
): Result<JsonValue, string> => {
const held = new Set(
ofType.map((entity) => scalarText(entity.fields[field])),
ofType.map((entity) => scalarText(ownMember(entity.fields, field))),
);
for (let attempt = 0; attempt < GENERATION_ATTEMPTS; attempt += 1) {
const value = make(attempt);
@@ -254,7 +255,7 @@ export const generator = (
target: GeneratedTarget,
rule: GeneratedRule,
): Result<JsonValue, string> => {
const exemplar = target.observed[rule.field] ?? "";
const exemplar = ownMember(target.observed, rule.field) ?? "";
const seed = (attempt: number) =>
`${store.state.evidenceId}:${request.tool.name}:${rule.field}:${String(write.count)}:${String(attempt)}`;
switch (rule.rule) {
@@ -268,7 +269,7 @@ export const generator = (
);
return rule.rule === "nested-clock"
? nestedClockValue(
(target.current ?? target.observed)[rule.field],
ownMember(target.current ?? target.observed, rule.field),
rule,
value,
)
@@ -299,7 +300,7 @@ export const generator = (
case "renewed":
return ok(
renewUuids(
target.current?.[rule.field] ?? null,
ownMember(target.current, rule.field) ?? null,
rule.pointers,
renewed,
),
@@ -384,7 +385,7 @@ const pathArguments = (
const names = environmentToolArguments(tool).path;
return new Map(
tool.params.flatMap((param) => {
const value = args[names.get(param.name) ?? param.name];
const value = ownMember(args, names.get(param.name) ?? param.name);
return typeof value === "string" ? [[param.index, value] as const] : [];
}),
);
@@ -497,7 +498,7 @@ const answerCreate = (
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]);
const id = scalarText(ownMember(fields.value, idField));
if (
id === undefined ||
serving.store.entity(type, collection, id) !== undefined
@@ -28,8 +28,10 @@ export const pinnedRequest = (
);
if (!query.ok) return err(refused(tool, `Tool ${tool.name} ${query.error}`));
const names = environmentToolArguments(tool).query;
const filled = { ...args };
for (const [name, value] of query.value)
filled[names.get(name) ?? name] ??= value;
return ok({ ...request, args: filled });
const filled = new Map(Object.entries(args));
for (const [name, value] of query.value) {
const argument = names.get(name) ?? name;
if (filled.get(argument) == null) filled.set(argument, value);
}
return ok({ ...request, args: Object.fromEntries(filled) });
};
@@ -120,20 +120,27 @@ describe("served document writes", () => {
});
describe("values the state can load back", () => {
it("preserves nested prototype-named data through writes and reload", () => {
const settings = servedFrom(SETTINGS_ROWS);
const meta = JSON.parse(
'{"__proto__":{"n":1},"constructor":2,"prototype":3}',
);
expect(
settings.answer(PATCH_SETTINGS, { id: "1", body: { meta } }).body,
).toEqual({ theme: "dark", meta });
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual({
theme: "dark",
meta,
});
expectReloadsUnchanged(settings);
});
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) } } },
@@ -1,4 +1,5 @@
import {
ownMember,
isObject,
isQueryPost,
listItems,
@@ -318,7 +319,7 @@ export const hasRepeatedQueryArgument = (
args: Readonly<Record<string, unknown>>,
): boolean => {
for (const argument of environmentToolArguments(tool).query.values()) {
const value = args[argument];
const value = ownMember(args, argument);
if (Array.isArray(value) && value.length > 1) return true;
}
return false;
@@ -22,7 +22,6 @@ import { EnvironmentStateExtraction } from "./EnvironmentStateExtraction.js";
import type { TemplateRule } from "./environmentManifestRules.js";
import {
deeperThan,
hasPrototypeKey,
isStateWrite,
STATE_BODY_LIMIT_BYTES,
type ParsedBody,
@@ -68,7 +67,7 @@ type StateFields = Pick<
*/
const STATE_NESTING_DEPTH = 16;
/** A recorded response body as state JSON: a JSON-like media type, complete UTF-8 text within the state body limit, exact numbers and no `__proto__` member; otherwise undefined. */
/** A recorded response body as state JSON: a JSON-like media type, complete UTF-8 text within the state body limit, exact numbers; otherwise undefined. */
export const stateBodyJson = (
contentType: string | null,
body: RecordedResponseBody,
@@ -84,7 +83,6 @@ export const stateBodyJson = (
return undefined;
const value = parseExactJson(body.bodyText);
return value === undefined ||
hasPrototypeKey(value) ||
deeperThan(value, MAX_JSON_DEPTH - STATE_NESTING_DEPTH)
? undefined
: value;
@@ -5,10 +5,10 @@ import {
compositeKey,
} from "../unicodeCodePointOrder.js";
import {
ownMember,
apiBaseLengths,
at,
collectionPathOf,
hasPrototypeKey,
identifies,
IDENTITY_NAMES,
identityCandidates,
@@ -675,7 +675,8 @@ export class EnvironmentStateExtraction {
const field = this.#identity.get(plan.type)!.field;
let changed = previous.ids.size !== objects.length;
for (const object of objects)
if (!previous.ids.delete(scalarText(object[field])!)) changed = true;
if (!previous.ids.delete(scalarText(ownMember(object, field))!))
changed = true;
if (previous.ids.size > 0) changed = true;
if (
earlier !== undefined &&
@@ -691,7 +692,8 @@ export class EnvironmentStateExtraction {
});
}
previous.ids.clear();
for (const object of objects) previous.ids.add(scalarText(object[field])!);
for (const object of objects)
previous.ids.add(scalarText(ownMember(object, field))!);
previous.record = record;
}
@@ -703,7 +705,7 @@ export class EnvironmentStateExtraction {
index: number,
): void {
const field = this.#identity.get(plan.type)!.field;
const id = scalarText(object[field]);
const id = scalarText(ownMember(object, field));
if (id === undefined) return;
const collection = collectionPathOf(record, plan.item);
const key = entityKey(plan.type, collection, id);
@@ -2,6 +2,7 @@ import { identifierWords } from "./environmentStateWords.js";
import type { JsonValue } from "../jsonValue.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import {
ownMember,
at,
collectionPathOf,
isObject,
@@ -246,7 +247,7 @@ const listQuery = (
ordinal: record.ordinal,
values,
choices,
listed: items.map((item) => scalarText(item[field]) ?? ""),
listed: items.map((item) => scalarText(ownMember(item, field)) ?? ""),
entities: context.inCollection(
plan.type,
collectionPathOf(record, false),
@@ -317,7 +318,7 @@ const listReproduces = (
body === undefined ? undefined : listItems(body, plan.pointer);
if (body === undefined || items === undefined) return false;
const observed = new Map(
items.map((item) => [scalarText(item[field]) ?? "", item]),
items.map((item) => [scalarText(ownMember(item, field)) ?? "", item]),
);
const computed = view.query?.record_ordinals.includes(record.ordinal)
? evaluateQuery(
@@ -333,7 +334,7 @@ const listReproduces = (
const entity = context.entity(
plan.type,
collection,
scalarText(item[field]) ?? "",
scalarText(ownMember(item, field)) ?? "",
);
return entity === undefined ? [] : [entity];
}));
@@ -8,6 +8,7 @@ import {
mintedFor,
} from "./environmentStateResponseMembers.js";
import {
ownMember,
collectionPathOf,
isObject,
listItems,
@@ -62,7 +63,7 @@ export const listFacts = (
Object.keys(parent).some((name) => continues(name, parent[name]))
)
return;
const ids = items.map((item) => scalarText(item[field]) ?? "");
const ids = items.map((item) => scalarText(ownMember(item, field)) ?? "");
const expected = context
.inCollection(plan.type, collectionPathOf(record, false))
.map((entity) => entity.id);
@@ -3,6 +3,7 @@ import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js
import { literalSubstringPredicate } from "../analysisSearch.js";
import { directionOf, orderRole } from "./environmentStateOrderParameters.js";
import {
ownMember,
isObject,
recordedItemCount,
formatItemCount,
@@ -81,7 +82,8 @@ const textOf = (value: JsonValue | undefined): string | undefined =>
/** Whether a field holds a value: equal as text, or in an array directly or as an object's id, slug or name. */
const holds = (field: JsonValue | undefined, value: string): boolean => {
const names = (item: JsonValue | undefined) =>
isObject(item) && IDENTIFYING.some((key) => textOf(item[key]) === value);
isObject(item) &&
IDENTIFYING.some((key) => textOf(ownMember(item, key)) === value);
return Array.isArray(field)
? field.some((item) => textOf(item) === value || names(item))
: textOf(field) === value || names(field);
@@ -95,7 +97,7 @@ const contains = (
const matches = literalSubstringPredicate(term, false);
return (entity) =>
fields.some((field) => {
const value = entity.fields[field];
const value = ownMember(entity.fields, field);
return typeof value === "string" && matches(value);
});
};
@@ -181,7 +183,7 @@ const readCall = (
if (PAGING_ROLES.has(parameter.role)) taken.add(parameter.role);
if (parameter.role === "filter")
call.selections.push((entity) =>
holds(entity.fields[parameter.field], value),
holds(ownMember(entity.fields, parameter.field), value),
);
else if (parameter.role === "search") {
if (!plainSearch(value)) return undefined;
@@ -234,8 +236,8 @@ const sorted = (
(left, right) =>
sign *
ascending(
left.entity.fields[key.field],
right.entity.fields[key.field],
ownMember(left.entity.fields, key.field),
ownMember(right.entity.fields, key.field),
) || left.index - right.index,
)
.map(({ entity }) => entity);
@@ -245,7 +247,10 @@ const sorted = (
) =>
left !== undefined &&
right !== undefined &&
ascending(left.fields[key.field], right.fields[key.field]) === 0;
ascending(
ownMember(left.fields, key.field),
ownMember(right.fields, key.field),
) === 0;
return {
entities: ordered,
positional: ordered.filter(
@@ -331,7 +336,9 @@ const filterFields = (
items.length === 0
? []
: Object.keys(items[0]!)
.filter((field) => items.every((item) => holds(item[field], value)))
.filter((field) =>
items.every((item) => holds(ownMember(item, field), value)),
)
.sort(
(left, right) =>
Number(normalized(right) === normalized(name)) -
@@ -428,7 +435,9 @@ const withoutIdleFilters = (
Object.entries(chosen).map(([name, role]) => {
const value = line.values.get(name)!;
return role.role === "filter" &&
line.entities.every((entity) => holds(entity.fields[role.field], value))
line.entities.every((entity) =>
holds(ownMember(entity.fields, role.field), value),
)
? [name, { role: "inert", values: [value] }]
: [name, role];
}),
@@ -547,7 +556,9 @@ const trustedRoles = (
if (role.role !== "filter" || namedLike(role.field, name)) return true;
const values = new Set(
explained
.filter((entry) => entry.parameters[name]?.role === "filter")
.filter(
(entry) => ownMember(entry.parameters, name)?.role === "filter",
)
.map((entry) => entry.line.values.get(name)),
);
return values.size >= 2;
@@ -33,7 +33,7 @@ export interface StateToolFacts {
readonly record_ordinals: readonly number[];
}
/** A record's response body as JSON, or unusable when absent, cut, not JSON, not exactly representable or carrying a `__proto__` member. */
/** A record's response body as JSON, or unusable when absent, cut, not JSON, not exactly representable. */
export type ParsedBody =
| { readonly kind: "json"; readonly value: JsonValue }
| { readonly kind: "unusable" };
@@ -265,6 +265,29 @@ export const scalarText = (value: JsonValue | undefined): string | undefined =>
: typeof value === "number"
? String(value)
: undefined;
/** A JSON dictionary lookup observes only its own members, preserving absent fields. */
export const ownMember = <Value>(
object: Readonly<Record<string, Value>> | undefined,
member: string,
): Value | undefined =>
object !== undefined && Object.hasOwn(object, member)
? object[member]
: undefined;
/** Write one JSON member without interpreting its name as an inherited setter. */
export const setOwnMember = (
object: JsonObject,
member: string,
value: JsonValue,
): void => {
Object.defineProperty(object, member, {
value,
enumerable: true,
configurable: true,
writable: true,
});
};
/** Equal JSON values; object member order carries no meaning. */
export const same = (
left: JsonValue | undefined,
@@ -463,15 +486,6 @@ export const deeperThan = (value: JsonValue, limit: number): boolean => {
return false;
};
/** JSON parsers keep a `__proto__` member that plain object construction would turn into a prototype. */
export const hasPrototypeKey = (value: JsonValue): boolean =>
Array.isArray(value)
? value.some(hasPrototypeKey)
: isObject(value) &&
Object.entries(value).some(
([name, item]) => name === "__proto__" || hasPrototypeKey(item),
);
export const pointerTo = (name: string): string =>
`/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`;
/** The unescaped reference tokens of a pointer. */
@@ -487,7 +501,7 @@ const memberOf = (
body: JsonValue | undefined,
name: string,
): JsonValue | undefined =>
isObject(body) && Object.hasOwn(body, name) ? body[name] : undefined;
isObject(body) ? ownMember(body, name) : undefined;
/** The element of a one-element array. */
const soleElement = (value: JsonValue | undefined): JsonValue | undefined =>
@@ -570,7 +584,7 @@ export const identifies = (
// Stop at the first missing or repeated value, so a sparse list costs one read per field.
const seen = new Set<string>();
for (const item of items) {
const value = scalarText(item[name]);
const value = scalarText(ownMember(item, name));
if (value === undefined || seen.has(value)) return false;
seen.add(value);
}
@@ -603,7 +617,9 @@ export const locateList = (
if (Array.isArray(body)) return body.every(isObject) ? "" : undefined;
if (
!isObject(body) ||
IDENTITY_NAMES.some((name) => scalarText(body[name]) !== undefined)
IDENTITY_NAMES.some(
(name) => scalarText(ownMember(body, name)) !== undefined,
)
)
return undefined;
const arrays = Object.entries(body).filter(
@@ -1790,3 +1790,32 @@ it("validates every own render-metadata dictionary entry through the public sche
expect(parse({ kind: "epoch", unit: "invalid" }, ["id"]).success).toBe(false);
expect(parse(clock, [1]).success).toBe(false);
});
it("preserves own prototype-named entity and document fields through capture projection and public normalization", () => {
const first = JSON.parse(
'{"id":1,"__proto__":{"label":"one"},"constructor":"first","meta":{"__proto__":"nested"}}',
) as JsonObject;
const second = JSON.parse(
'{"id":2,"__proto__":{"label":"two"},"constructor":"second","meta":{"__proto__":"nested"}}',
) as JsonObject;
const document = JSON.parse('{"__proto__":{"theme":"dark"}}') as JsonObject;
const projected = stateOf(
captureOf([
["GET", "/api/items", [first, second]],
["GET", "/api/items/1", first],
["GET", "/api/settings", document],
]),
);
const state = analysisViewResultSchema.parse(
JSON.parse(JSON.stringify(projected)),
);
if (state.kind !== "environment-state") throw new Error("Expected state");
const entity = state.entities.find((entry) => entry.id === "1")!;
expect(JSON.stringify(entity.fields)).toBe(JSON.stringify(first));
expect(
Object.getOwnPropertyDescriptor(entity.fields, "__proto__")?.value,
).toEqual({ label: "one" });
expect(state.documents).toContainEqual(
expect.objectContaining({ value: document }),
);
});
@@ -1,6 +1,6 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { jsonValueSchema } from "../jsonValue.js";
import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js";
import { jsonRecordSchema } from "../jsonRecord.js";
import { ok, type Result } from "../result.js";
import type {
@@ -23,7 +23,6 @@ export const environmentStateViewRequestSchema = z.strictObject({
),
});
const jsonObjectFieldsSchema = z.record(z.string(), jsonValueSchema);
const queryParameterSchema = z.discriminatedUnion("role", [
z.strictObject({
role: z.literal("filter"),
@@ -471,9 +470,9 @@ export const environmentStateFields = {
type: z.string(),
collection: z.string(),
id: z.string(),
fields: jsonObjectFieldsSchema,
fields: jsonObjectSchema,
view_fields: z.array(
z.strictObject({ tool: z.string(), fields: jsonObjectFieldsSchema }),
z.strictObject({ tool: z.string(), fields: jsonObjectSchema }),
),
record_ordinals: z.array(z.number().int().nonnegative()),
}),
@@ -1,5 +1,10 @@
import type { JsonValue } from "../jsonValue.js";
import { isObject, same, type JsonObject } from "./environmentStateRules.js";
import {
ownMember,
isObject,
same,
type JsonObject,
} from "./environmentStateRules.js";
import { readClock, type ClockFormat } from "./environmentStateWriteValues.js";
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
@@ -82,7 +87,7 @@ export class ClockSpellings {
note(stored: JsonObject, shown: JsonObject): void {
for (const [field, value] of Object.entries(shown)) {
if (isObject(value)) this.#shape(field, value, stored[field]);
if (isObject(value)) this.#shape(field, value, ownMember(stored, field));
const now = readClock(value);
if (now === undefined) continue;
const was = Object.hasOwn(stored, field)
@@ -1,5 +1,6 @@
import type { JsonValue } from "../jsonValue.js";
import {
ownMember,
at,
requestJson,
isObject,
@@ -156,7 +157,8 @@ export const addressOf = (
)
return { by: "ids", member, kind };
return value.every(
(entry) => isObject(entry) && scalarText(entry[idField]) !== undefined,
(entry) =>
isObject(entry) && scalarText(ownMember(entry, idField)) !== undefined,
)
? { by: "elements", member }
: undefined;
@@ -235,7 +237,9 @@ export const namedEntities = (
)
return undefined;
const entries = (
address.member === null ? body : (body as JsonObject)[address.member]
address.member === null
? body
: ownMember(body as JsonObject, address.member)
) as JsonValue[];
const shared =
address.member === null || !isObject(body)
@@ -246,7 +250,10 @@ export const namedEntities = (
const named = entries.map((entry) =>
address.by === "ids"
? { id: scalarText(entry)!, body: shared }
: { id: scalarText((entry as JsonObject)[idField])!, body: entry },
: {
id: scalarText(ownMember(entry as JsonObject, idField))!,
body: entry,
},
) as NamedEntity[];
return new Set(named.map(({ id }) => id)).size === named.length
? named
@@ -1,5 +1,6 @@
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
import {
ownMember,
at,
isObject,
stateValueRetentionBytes,
@@ -88,7 +89,7 @@ export class UnseenEntities {
if (
!isObject(object) ||
idField === undefined ||
scalarText(object[idField]) !== id
scalarText(ownMember(object, idField)) !== id
)
return undefined;
const entity = {
@@ -754,3 +754,46 @@ it("refuses a create whose composed address reads a member the call does not sen
);
expect(create({ title: "New Folder" })).toEqual(err(UNSPELLED));
});
it.each(["__proto__", "constructor"])(
"creates and updates an absent own %s field without changing object inheritance",
(field) => {
const plan = {
rules: [{ field, rule: "echo" as const }],
body: {
accepted: [field],
fixed: [],
required: [],
identity: null,
kinds: [],
},
};
const sent = Object.fromEntries([[field, { label: "stored" }]]);
const call = callWith(sent);
const created = createdFields(plan, { id: 1 }, call, generator().generate);
const updated = updatedFields(
plan,
{
before: { id: 1 },
observed: Object.fromEntries([
["id", 1],
[field, { label: "old" }],
]),
},
{ ...call, id: "1" },
generator().generate,
);
for (const result of [created, updated]) {
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error);
expect(
Object.getOwnPropertyDescriptor(result.value, field)?.value,
).toEqual({ label: "stored" });
expect(Object.getPrototypeOf(result.value)).toBe(Object.prototype);
expect(JSON.parse(JSON.stringify(result.value))).toEqual({
id: 1,
...sent,
});
}
},
);
@@ -1,6 +1,8 @@
import type { JsonValue } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import {
ownMember,
setOwnMember,
at,
isObject,
pointerTo,
@@ -74,13 +76,6 @@ export type GenerateValue = (
const kindOf = (value: JsonValue | undefined): string =>
value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
/** JSON objects contain only their own members, even when a name also exists on Object.prototype. */
const ownMember = (
object: JsonObject,
member: string,
): JsonValue | undefined =>
Object.hasOwn(object, member) ? object[member] : undefined;
/** The JSON types `stored` holds members in, for those `observed` shows no value of. */
export const storedKinds = (
members: readonly string[],
@@ -197,7 +192,9 @@ export const recordedGeneratedValue = (
value: JsonValue | undefined,
): Applied<JsonValue | undefined> => {
if (rule.rule !== "nested-clock") return ok(value);
const clock = isObject(value) ? value[rule.members[0]!] : undefined;
const clock = isObject(value)
? ownMember(value, rule.members[0]!)
: undefined;
if (
clock === undefined ||
JSON.stringify(readClock(clock)?.format) !== JSON.stringify(rule.format)
@@ -251,7 +248,7 @@ const ruledValue = (
? err(
`The call names no ${rule.from} path value, which ${rule.field} takes.`,
)
: ok(pathValue(observed[rule.field], value));
: ok(pathValue(ownMember(observed, rule.field), value));
}
default:
return ok(undefined);
@@ -264,9 +261,9 @@ const templated = (
written: JsonObject,
rule: Extract<FieldRule, { rule: "template" }>,
): JsonValue | undefined => {
const text = observed[rule.field];
const token = scalarText(observed[rule.from]);
const replacement = scalarText(written[rule.from]);
const text = ownMember(observed, rule.field);
const token = scalarText(ownMember(observed, rule.from));
const replacement = scalarText(ownMember(written, rule.from));
return typeof text === "string" &&
token !== undefined &&
replacement !== undefined
@@ -283,14 +280,16 @@ const derive = (
): string | undefined => {
for (const rule of rules) {
if (rule.rule === "slug") {
const from = sent[rule.from];
if (typeof from === "string") written[rule.field] = slugOf(from);
const from = ownMember(sent, rule.from);
if (typeof from === "string")
setOwnMember(written, rule.field, slugOf(from));
} else if (rule.rule === "template") {
const value = templated(observed, written, rule);
if (value !== undefined) written[rule.field] = value;
} else if (rule.rule === "stamp") written[rule.field] = rule.value;
if (value !== undefined) setOwnMember(written, rule.field, value);
} else if (rule.rule === "stamp")
setOwnMember(written, rule.field, rule.value);
else if (rule.rule === "derived" && Object.hasOwn(sent, rule.member))
written[rule.field] = rule.value;
setOwnMember(written, rule.field, rule.value);
}
// Composed strings read the fields the rules above wrote.
for (const rule of rules)
@@ -302,7 +301,7 @@ const derive = (
);
return `The write composes ${JSON.stringify(rule.field)} from ${listed([...sources])}, and the entity holds no value one of them can spell.`;
}
written[rule.field] = value;
setOwnMember(written, rule.field, value);
}
return undefined;
};
@@ -341,9 +340,10 @@ export const createdFields = (
for (const rule of plan.rules) {
const value = ruledValue(rule, observed, call, generate, referenced);
if (!value.ok) return value;
if (value.value !== undefined) written[rule.field] = value.value;
if (value.value !== undefined)
setOwnMember(written, rule.field, value.value);
else if (rule.rule === "echo" && defaults.has(rule.field))
written[rule.field] = defaults.get(rule.field)!;
setOwnMember(written, rule.field, defaults.get(rule.field)!);
else if (rule.rule === "echo" || rule.rule === "reference")
delete written[rule.field];
}
@@ -404,15 +404,20 @@ export const updatedFields = (
const written: JsonObject = { ...before };
for (const rule of plan.rules) {
const stated = statedValue(rule, sent);
if (stated !== undefined) written[rule.field] = stated;
if (stated !== undefined) setOwnMember(written, rule.field, stated);
else if (rule.rule === "reference" && Object.hasOwn(sent, rule.member)) {
const value = referencedValue(rule, sent, referenced);
if (!value.ok) return value;
if (value.value !== undefined) written[rule.field] = value.value;
if (value.value !== undefined)
setOwnMember(written, rule.field, value.value);
} else if (rule.rule === "increment") {
const was = integerOf(before[rule.field]);
const was = integerOf(ownMember(before, rule.field));
if (was !== undefined)
written[rule.field] = spelledLike(before[rule.field]!, was + 1);
setOwnMember(
written,
rule.field,
spelledLike(ownMember(before, rule.field)!, was + 1),
);
} else if (
regenerated(rule) ||
// Child rows are renewed only on an entity that holds them.
@@ -420,7 +425,8 @@ export const updatedFields = (
) {
const value = generate(rule);
if (!value.ok) return value;
if (value.value !== undefined) written[rule.field] = value.value;
if (value.value !== undefined)
setOwnMember(written, rule.field, value.value);
}
}
const unspelled = derive(
@@ -532,10 +538,10 @@ export const writtenResponse = (
pointer,
Object.fromEntries(
fields.map((field) => {
const value = written.fields![field]!;
const value = ownMember(written.fields!, field)!;
const clock =
written.clocks?.includes(field) === true
? readClock(shown[field])
? readClock(ownMember(shown, field))
: undefined;
return [
field,
@@ -1,4 +1,5 @@
import {
ownMember,
renderStateEntity,
same,
type JsonObject,
@@ -412,7 +413,9 @@ export const writtenFieldChecks = (
if (fields === null || compared.length === 0) continue;
outcomes.set(
tool,
compared.every((field) => same(shown[field], observed[field])),
compared.every((field) =>
same(ownMember(shown, field), ownMember(observed, field)),
),
);
}
return outcomes;
@@ -1,5 +1,6 @@
import type { JsonValue } from "../jsonValue.js";
import {
ownMember,
at,
isObject,
locateItem,
@@ -271,7 +272,7 @@ const fieldBlame = (
!Object.hasOwn(read.object, field) ||
read.volatile.has(field) ||
others.has(field) ||
same(response[field], read.object[field]),
same(response[field], ownMember(read.object, field)),
)
);
};
@@ -323,11 +324,13 @@ const lackingFields = (
const unruled = new Set<string>();
let unowned = false;
for (const field of fields) {
const value = read.object[field] ?? null;
const value = ownMember(read.object, field) ?? null;
const owners = [...writes.partial.keys()].filter((name) => {
const response = writes.responses.get(name);
return (
value !== null && response !== undefined && same(response[field], value)
value !== null &&
response !== undefined &&
same(ownMember(response, field), value)
);
});
if (owners.length === 0) unowned = true;
@@ -349,13 +352,13 @@ const names = (
? EMBED_SHORT_ID_MATCH_LENGTH
: EMBED_MATCH_MIN_LENGTH;
return (
scalarText(item[idField]) === id &&
scalarText(ownMember(item, idField)) === id &&
Object.entries(item).some(
([key, member]) =>
key !== idField &&
typeof member === "string" &&
member.length >= length &&
entity[key] === member,
ownMember(entity, key) === member,
)
);
};
@@ -383,7 +386,7 @@ const embeds = (
entity: JsonObject,
idField: string,
): boolean => {
const id = scalarText(entity[idField]);
const id = scalarText(ownMember(entity, idField));
let found = false;
if (id !== undefined)
eachObject(value, (item) => {
@@ -422,7 +425,9 @@ const embeddingCreates = (
if (entity.type === read.type && entity.id === read.entity?.id) continue;
const held = heldOf(entity);
const id =
held === undefined ? undefined : scalarText(held.fields[held.idField]);
held === undefined
? undefined
: scalarText(ownMember(held.fields, held.idField));
if (held === undefined || id === undefined) continue;
idFields.add(held.idField);
const key = `${held.idField}\u0000${id}`;
@@ -434,11 +439,11 @@ const embeddingCreates = (
const tools = new Set<string>();
if (byId.size === 0) return [];
for (const [field, value] of Object.entries(read.object)) {
const before = read.entity?.fields[field];
const before = ownMember(read.entity?.fields, field);
if (same(before, value)) continue;
eachObject(value, (item) => {
for (const idField of idFields) {
const id = scalarText(item[idField]);
const id = scalarText(ownMember(item, idField));
for (const entity of id === undefined
? []
: (byId.get(`${idField}\u0000${id}`) ?? []))
@@ -1,7 +1,7 @@
import { jsonTextCodeUnits } from "../jsonSerialization.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import type { JsonValue } from "../jsonValue.js";
import { same, type JsonObject } from "./environmentStateRules.js";
import { ownMember, same, type JsonObject } from "./environmentStateRules.js";
import { storedKinds } from "./environmentStateWriteApply.js";
import { composedValue } from "./environmentStateWriteComposed.js";
import type { explainUpdate } from "./environmentStateWriteExplain.js";
@@ -48,7 +48,7 @@ export const disagreesWithCall = (
(rule) =>
(unsentChange(rule) &&
Object.hasOwn(call.after, rule.field) &&
!same(call.written[rule.field], call.after[rule.field])) ||
!same(ownMember(call.written, rule.field), call.after[rule.field])) ||
(regenerates(rule) && call.kept.includes(rule.field)),
);
@@ -60,8 +60,8 @@ export const steadyFields = (
): string[] =>
rules.flatMap((rule) =>
rule.rule === "set" &&
same(before[rule.field], rule.value) &&
same(after[rule.field], rule.value)
same(ownMember(before, rule.field), rule.value) &&
same(ownMember(after, rule.field), rule.value)
? [rule.field]
: [],
);
@@ -265,7 +265,8 @@ export const appliedUpdate = (
);
const derived = Object.keys(written).filter(
(field) =>
!Object.hasOwn(before, field) || !same(written[field], before[field]),
!Object.hasOwn(before, field) ||
!same(written[field], ownMember(before, field)),
);
const changed = {
fields: [...new Set([...sent, ...derived])],
@@ -1,5 +1,6 @@
import type { JsonValue } from "../jsonValue.js";
import {
ownMember,
isObject,
same,
type JsonObject,
@@ -52,8 +53,8 @@ export const changeRule = (
field: string,
slugs: ReadonlyMap<string, string>,
): FieldRule | undefined => {
const before = evidence.before[field];
const after = evidence.after[field]!;
const before = ownMember(evidence.before, field);
const after = ownMember(evidence.after, field)!;
if (readClock(after) !== undefined && isClockNear(after, evidence.startMs))
return { field, rule: "clock", format: readClock(after)!.format };
if (freshUuid(evidence, field)) return { field, rule: "unique" };
@@ -93,7 +94,7 @@ const actionRule = (
evidence: UpdateEvidence,
field: string,
): FieldRule | undefined => {
const after = evidence.after[field]!;
const after = ownMember(evidence.after, field)!;
return evidence.action === true &&
(after === null || typeof after !== "object") &&
readClock(after) === undefined
@@ -123,8 +124,8 @@ export const heldClock = (
sent: JsonValue,
): boolean =>
inCallSecond(sent, evidence.startMs) &&
same(evidence.before[member], sent) &&
same(evidence.after[member], sent);
same(ownMember(evidence.before, member), sent) &&
same(ownMember(evidence.after, member), sent);
/** A field naming when its entity was made (`createdAt`, `row_created_timestamp`), which no update stamps. */
const CREATION_CLOCK = /creat(?:ed|ion)/iu;
@@ -145,13 +146,13 @@ const sentSlugSource = (
evidence: UpdateEvidence,
member: string,
): string | undefined => {
const stored = evidence.after[member];
const stored = ownMember(evidence.after, member);
if (typeof stored !== "string") return undefined;
return Object.entries(evidence.call.body).find(
([other, value]) =>
other !== member &&
typeof value === "string" &&
same(evidence.after[other], value) &&
same(ownMember(evidence.after, other), value) &&
slugOf(value) === stored,
)?.[0];
};
@@ -164,9 +165,9 @@ const restampedClock = (
): boolean =>
!CREATION_CLOCK.test(member) &&
readClock(sent) !== undefined &&
same(evidence.before[member], sent) &&
readClock(evidence.after[member]) !== undefined &&
isClockNear(evidence.after[member]!, evidence.startMs);
same(ownMember(evidence.before, member), sent) &&
readClock(ownMember(evidence.after, member)) !== undefined &&
isClockNear(ownMember(evidence.after, member)!, evidence.startMs);
/** How an update stored a sent member: stamped anew as the one held clock it sent back other than a creation clock, echoed, stamped anew or slugged from another member in place of what it sent, renewed as a fresh UUID, or as a constant. */
export const sentRule = (
@@ -174,26 +175,30 @@ export const sentRule = (
member: string,
held: readonly string[],
): FieldRule => {
const sent = evidence.call.body[member]!;
const sent = ownMember(evidence.call.body, member)!;
if (held.length === 1 && held[0] === member && !CREATION_CLOCK.test(member))
return {
field: member,
rule: "held-clock",
format: readClock(sent)!.format,
};
if (same(evidence.after[member], sent))
if (same(ownMember(evidence.after, member), sent))
return { field: member, rule: "echo" };
if (same(evidence.after[member], sortedStrings(sent)))
if (same(ownMember(evidence.after, member), sortedStrings(sent)))
return { field: member, rule: "echo", sorted: true };
if (restampedClock(evidence, member, sent))
return {
field: member,
rule: "clock",
format: readClock(evidence.after[member])!.format,
format: readClock(ownMember(evidence.after, member))!.format,
};
const from = sentSlugSource(evidence, member);
if (from !== undefined) return { field: member, rule: "slug", from };
return freshUuid(evidence, member)
? { field: member, rule: "unique" }
: { field: member, rule: "constant", value: evidence.after[member]! };
: {
field: member,
rule: "constant",
value: ownMember(evidence.after, member)!,
};
};
@@ -2,6 +2,7 @@ import { ServedOrder } from "./environmentStateWriteOrder.js";
import type { JsonValue } from "../jsonValue.js";
import { compositeKey } from "../unicodeCodePointOrder.js";
import {
ownMember,
at,
decodedSegment,
isObject,
@@ -408,7 +409,10 @@ const listCheck = (
)
return { pass: false };
const observed = new Map(
check.items.map((item) => [scalarText(item[check.idField]) ?? "", item]),
check.items.map((item) => [
scalarText(ownMember(item, check.idField)) ?? "",
item,
]),
);
const rendered = members.map((entity) =>
renderStateEntity(entity, view, check.volatile, observed.get(entity.id)),
@@ -450,7 +454,7 @@ const listedTargets = (
const targets: string[] = [];
const unheld: string[] = [];
const ids = [...check.baseItems, ...check.items].map(
(item) => scalarText(item[check.idField]) ?? "",
(item) => scalarText(ownMember(item, check.idField)) ?? "",
);
for (const id of new Set(ids))
(state.entity(view.type, check.collection, id) === undefined
@@ -525,7 +529,7 @@ const checkAllList = (
),
],
unheld: check.items
.map((item) => scalarText(item[check.idField]) ?? "")
.map((item) => scalarText(ownMember(item, check.idField)) ?? "")
.filter((id) => !held.has(id))
.map((id) => entityTarget(view.type, collection, id)),
};
@@ -538,7 +542,8 @@ const checkRecordedList = (
check: ListBodies,
): CheckOutcome => {
const { collection } = check;
const idOf = (item: JsonObject) => scalarText(item[check.idField]) ?? "";
const idOf = (item: JsonObject) =>
scalarText(ownMember(item, check.idField)) ?? "";
const members = check.baseItems.flatMap((item) => {
const entity = state.entity(view.type, collection, idOf(item));
return entity === undefined ? [] : [entity];
@@ -1,5 +1,10 @@
import type { JsonValue } from "../jsonValue.js";
import { same, scalarText, type JsonObject } from "./environmentStateRules.js";
import {
ownMember,
same,
scalarText,
type JsonObject,
} from "./environmentStateRules.js";
import { COPY_MIN_LENGTH } from "./environmentStateWriteCopies.js";
import { GENERATED } from "./environmentStateWriteGenerated.js";
import type { ComposedPart, FieldRule } from "./environmentStateWriteRules.js";
@@ -39,7 +44,7 @@ export const composedPartText = (
fields: JsonObject,
): string | undefined => {
if ("text" in part) return part.text;
const value = fields[part.from];
const value = ownMember(fields, part.from);
if (part.as === "clock") return clockDigits(value, part.pattern);
if (part.as === "value") return scalarText(value);
return typeof value === "string" ? slugOf(value) : undefined;
@@ -208,14 +213,14 @@ export const createdComposer = (
zones.get(left.from) === zones.get(right.from));
let index: CandidateIndex | undefined;
return (field) => {
const text = entity[field];
const text = ownMember(entity, field);
if (typeof text !== "string") return undefined;
index ??= indexed(
rules.flatMap((rule) =>
SOURCES.has(rule.rule)
? candidatesOf(
rule.field,
entity[rule.field],
ownMember(entity, rule.field),
// A generated value is short and still the call's own, as a template's is.
GENERATED.has(rule.rule) ? 1 : COPY_MIN_LENGTH,
)
@@ -246,8 +251,8 @@ export const composedChange = (
},
): FieldRule | undefined => {
const { before, after, sent, idField } = entity;
const text = after[field];
if (typeof text !== "string" || typeof before[field] !== "string")
const text = ownMember(after, field);
if (typeof text !== "string" || typeof ownMember(before, field) !== "string")
return undefined;
const known = Object.keys(after).filter(
(other) =>
@@ -260,14 +265,14 @@ export const composedChange = (
known.flatMap((other) =>
candidatesOf(
other,
after[other],
ownMember(after, other),
other === idField ? 1 : COPY_MIN_LENGTH,
),
),
);
const parts = segmented(text, index, samePart);
return parts === undefined ||
composedValue(parts, before) !== before[field] ||
composedValue(parts, before) !== ownMember(before, field) ||
heldSpellings(parts, field, entity.others) === undefined
? undefined
: { field, rule: "composed", parts };
@@ -1,5 +1,6 @@
import type { JsonValue } from "../jsonValue.js";
import {
ownMember,
pointerTokens,
scalarText,
type JsonObject,
@@ -18,10 +19,10 @@ export const templateRule = (
field: string,
sources: readonly FieldRule[],
): FieldRule | undefined => {
const value = entity[field];
const value = ownMember(entity, field);
if (typeof value !== "string") return undefined;
for (const rule of sources) {
const token = scalarText(entity[rule.field]);
const token = scalarText(ownMember(entity, rule.field));
const generated = GENERATED.has(rule.rule);
if (
rule.field !== field &&
@@ -95,7 +96,7 @@ export const strandedCopies = (
),
);
const sources = rules.flatMap((rule) => {
const token = scalarText(entity[rule.field]);
const token = scalarText(ownMember(entity, rule.field));
return (rule.rule === "copy" || rule.rule === "echo") &&
token !== undefined &&
token.length >= COPY_MIN_LENGTH
@@ -105,7 +106,7 @@ export const strandedCopies = (
const holders = holdersOf(
new Map(
texts.flatMap((field) => {
const value = entity[field];
const value = ownMember(entity, field);
return typeof value === "string" ? [[field, value] as const] : [];
}),
),
@@ -187,7 +188,7 @@ export const referencedMembers = (
if (referenced.length === 0) continue;
// A reference repeated under another reference's name is the same entity named twice, not its text.
const repeats = (field: string): boolean => {
const value = entity[field];
const value = ownMember(entity, field);
return (
typeof value === "string" &&
value.length >= COPY_MIN_LENGTH &&
@@ -226,7 +227,7 @@ export const instantMembers = (
constants: readonly string[],
): Set<string> => {
const instants = constants.flatMap((field) => {
const at = instantOf(entity[field]);
const at = instantOf(ownMember(entity, field));
return at === undefined ? [] : [{ field, at }];
});
return new Set(
@@ -1,6 +1,7 @@
import { jsonTextCodeUnits } from "../jsonSerialization.js";
import type { JsonValue } from "../jsonValue.js";
import {
ownMember,
collectionPathOf,
isObject,
isParam,
@@ -223,7 +224,7 @@ const strandedInteger = (
rules.some((rule) => {
const digits =
rule.rule === "unique"
? generatedIntegerDigits(entity[rule.field])
? generatedIntegerDigits(ownMember(entity, rule.field))
: undefined;
return (
digits !== undefined &&
@@ -346,7 +347,7 @@ const buildCreate = (
// Identity and clock orders first, then any other scalar field the collection may be sorted by.
const scalars = Object.keys(members[0] ?? {}).filter((field) =>
members.every((member) =>
["string", "number"].includes(typeof member[field]),
["string", "number"].includes(typeof ownMember(member, field)),
),
);
const generated = explained.rules.some(
@@ -437,12 +438,16 @@ const followsSequences = (
rule.scope === "type" || entity.collection === created.collection,
);
const next = nextSequence(
nextInteger(fieldsOf(scope), rule.field, draft.observed?.[rule.field]),
nextInteger(
fieldsOf(scope),
rule.field,
ownMember(draft.observed, rule.field),
),
draft.sequences.get(key),
);
return (
created.fields[rule.field] ===
spelledLike(draft.observed?.[rule.field] ?? "", next)
ownMember(created.fields, rule.field) ===
spelledLike(ownMember(draft.observed, rule.field) ?? "", next)
);
});
@@ -467,7 +472,7 @@ const contradictsDefault = (
rule.rule === "echo" &&
!Object.hasOwn(call.body, rule.field) &&
Object.hasOwn(call.observed, rule.field) &&
!same(call.observed[rule.field], written[rule.field]),
!same(call.observed[rule.field], ownMember(written, rule.field)),
);
/** Whether a later create preserves generation shapes and its derived fields reproduce the captured observation. */
@@ -479,13 +484,14 @@ const followsCreateRules = (
},
): boolean =>
draft.plan.rules.every((rule) => {
const value = created.fields[rule.field];
const exemplar = draft.observed?.[rule.field];
const value = ownMember(created.fields, rule.field);
const exemplar = ownMember(draft.observed, rule.field);
if (rule.rule === "composed" || rule.rule === "reference")
return (
!Object.hasOwn(created.observed, rule.field) ||
value === created.observed[rule.field] ||
(rule.rule === "reference" && same(value, created.observed[rule.field]))
value === ownMember(created.observed, rule.field) ||
(rule.rule === "reference" &&
same(value, ownMember(created.observed, rule.field)))
);
if (rule.rule === "clock")
return (
@@ -504,7 +510,7 @@ const followsCreateRules = (
: isOpaqueToken(value, exemplar)) &&
!context.state
.ofType(created.type)
.some((held) => held.fields[rule.field] === value)
.some((held) => ownMember(held.fields, rule.field) === value)
);
return (
rule.rule !== "identity" || rule.generator !== "uuid" || isUuid(value)
@@ -517,7 +523,7 @@ const fitClocks = (draft: WriteDraft, fields: JsonObject): void => {
if (rule.rule !== "clock") return rule;
const format = fittedClockFormat(
rule.format,
fields[rule.field],
ownMember(fields, rule.field),
clockEvidence(draft, rule.field),
);
return format === undefined || format === rule.format
@@ -540,8 +546,8 @@ const takeSequences = (
): void => {
for (const { rule, key } of sequencesOf(draft, created.collection)) {
const value = sequenceNumber(
created.fields[rule.field],
draft.observed?.[rule.field],
ownMember(created.fields, rule.field),
ownMember(draft.observed, rule.field),
);
if (value !== undefined) draft.sequences.set(key, value);
}
@@ -736,8 +742,8 @@ export const applyCreate = (
(rule) =>
recordedGeneratedValue(
rule,
draft.observed![rule.field],
observed.object[rule.field] ?? null,
ownMember(draft.observed!, rule.field),
ownMember(observed.object, rule.field) ?? null,
),
referencedIn(context),
);
@@ -751,7 +757,9 @@ export const applyCreate = (
envelope: request?.envelope,
});
const fields = fieldsOfCall();
const id = fields.ok ? scalarText(fields.value[idField]) : undefined;
const id = fields.ok
? scalarText(ownMember(fields.value, idField))
: undefined;
if (
!fields.ok ||
id === undefined ||
@@ -1,5 +1,5 @@
import type { JsonValue } from "../jsonValue.js";
import { same, type JsonObject } from "./environmentStateRules.js";
import { ownMember, same, type JsonObject } from "./environmentStateRules.js";
import { storedKinds } from "./environmentStateWriteApply.js";
import { fittedClockFormat } from "./environmentStateWriteClockFormats.js";
import {
@@ -127,9 +127,12 @@ const mergedField = (
// This call echoes a member the plan's calls left out: what they stored there is its default.
if (after !== undefined)
return after.rule === "echo" && !calls.required.includes(field)
? { rule: after, default: { member: field, value: exemplar[field]! } }
? {
rule: after,
default: { member: field, value: ownMember(exemplar, field)! },
}
: undefined;
return same(exemplar[field], later.entity[field]) ? {} : undefined;
return same(ownMember(exemplar, field), later.entity[field]) ? {} : undefined;
};
/**
@@ -151,7 +154,7 @@ export const mergedCreatePlan = (
},
earlier: EarlierEvidence = {
kept: new Set(),
clockOf: (field) => exemplar[field],
clockOf: (field) => ownMember(exemplar, field),
},
): { readonly rules: FieldRule[]; readonly body: WriteBody } | undefined => {
const held = new Map(plan.rules.map((rule) => [rule.field, rule]));
@@ -1,6 +1,7 @@
import { jsonTextCodeUnits } from "../jsonSerialization.js";
import type { JsonValue } from "../jsonValue.js";
import {
ownMember,
isObject,
locateItem,
at,
@@ -103,7 +104,7 @@ export const clockEvidence = (
draft: WriteDraft,
field: string,
): JsonValue | undefined =>
draft.fixedWidth.get(field) ?? draft.observed?.[field];
draft.fixedWidth.get(field) ?? ownMember(draft.observed, field);
/** A write observation plus the fields its selected read view actually covers, if any. */
type UpdateObservation = Observed & {
@@ -599,8 +600,8 @@ export const updateEntity = (
(rule) =>
recordedGeneratedValue(
rule,
entity.fields[rule.field],
after.object[rule.field],
ownMember(entity.fields, rule.field),
ownMember(after.object, rule.field),
),
referencedIn(context),
);
@@ -663,7 +664,7 @@ const fitUpdateClocks = (
return rule;
const format = fittedClockFormat(
rule.format,
after[rule.field],
ownMember(after, rule.field),
clockEvidence(draft, rule.field),
);
return format === undefined ? rule : { ...rule, format };
@@ -682,7 +683,10 @@ const fitUpdateClocks = (
const held = planned.get(rule.field);
return rule.rule === "clock" &&
held?.rule === "clock" &&
matchesClockFormat(held.format, readClock(after[rule.field]))
matchesClockFormat(
held.format,
readClock(ownMember(after, rule.field)),
)
? { ...rule, format: held.format }
: rule;
}),
@@ -710,7 +714,7 @@ const contradictsGeneratedClock = (
)
)
return false;
const previous = readClock(before[rule.field]);
const previous = readClock(ownMember(before, rule.field));
if (previous === undefined) return false;
const next = readClock(after[rule.field]);
return (
@@ -1,6 +1,7 @@
import type { JsonValue } from "../jsonValue.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import {
ownMember,
isObject,
locateItem,
pointerTo,
@@ -125,7 +126,7 @@ export const nextInteger = (
): number | undefined => {
let largest: number | undefined;
for (const member of members) {
const value = sequenceNumber(member[field], exemplar);
const value = sequenceNumber(ownMember(member, field), exemplar);
if (value !== undefined && (largest === undefined || value > largest))
largest = value;
}
@@ -179,10 +180,12 @@ const learnedClockFormat = (
): ClockFormat => {
let learned = format;
for (const member of type) {
const fitted = fittedClockFormat(learned, member[field], value);
const fitted = fittedClockFormat(learned, ownMember(member, field), value);
if (fitted?.kind === "iso" && fitted.trimmed === true) learned = fitted;
}
return type.some((member) => showsFixedWidth(format, member[field]))
return type.some((member) =>
showsFixedWidth(format, ownMember(member, field)),
)
? format
: learned;
};
@@ -193,7 +196,7 @@ export const createdInteger = (
entity: JsonObject,
type: readonly JsonObject[],
): "independent" | "linked" | undefined => {
const value = entity[field];
const value = ownMember(entity, field);
const digits = generatedIntegerDigits(value);
if (
digits === undefined ||
@@ -203,10 +206,10 @@ export const createdInteger = (
return undefined;
const held = new Set([digits]);
const distinct = type.every((member) => {
const other = generatedIntegerDigits(member[field]);
const other = generatedIntegerDigits(ownMember(member, field));
if (
other === undefined ||
typeof member[field] !== typeof value ||
typeof ownMember(member, field) !== typeof value ||
other.length !== digits.length ||
held.has(other)
)
@@ -252,7 +255,7 @@ const generatedRule = (
if (
(isUuid(value) &&
evidence.type.length > 0 &&
evidence.type.every((member) => member[field] !== value)) ||
evidence.type.every((member) => ownMember(member, field) !== value)) ||
createdInteger(field, evidence.entity, evidence.type) === "independent"
)
return { field, rule: "unique" };
@@ -311,7 +314,9 @@ export const explainCreate = (
// Generated values first, then copies: an echoed value may recur in unrelated text, a copied one is the call's own unless also echoed.
const echoed = new Set(
rules.flatMap((rule) =>
rule.rule === "echo" ? [scalarText(evidence.entity[rule.field])] : [],
rule.rule === "echo"
? [scalarText(ownMember(evidence.entity, rule.field))]
: [],
),
);
const sources = [
@@ -319,7 +324,7 @@ export const explainCreate = (
...rules.filter(
(rule) =>
rule.rule === "copy" &&
!echoed.has(scalarText(evidence.entity[rule.field])),
!echoed.has(scalarText(ownMember(evidence.entity, rule.field))),
),
];
const constants: string[] = [];
@@ -375,7 +380,9 @@ export const explainCreate = (
),
// A template rebuilds only its generated value, so other text it keeps ties it like a constant.
tied: [
...[...constants, ...templates].map((field) => evidence.entity[field]!),
...[...constants, ...templates].map((field) =>
ownMember(evidence.entity, field)!,
),
...literals,
].some((text) => tokens.some((token) => containsDelimited(text, token))),
};
@@ -388,7 +395,7 @@ const echoedSlugs = (
): Map<string, string> => {
const slugs = new Map<string, string>();
for (const member of echoed) {
const sent = body[member];
const sent = ownMember(body, member);
if (typeof sent === "string" && !slugs.has(slugOf(sent)))
slugs.set(slugOf(sent), member);
}
@@ -404,7 +411,7 @@ const keptReference = (
if (known === undefined || evidence.named === undefined) return undefined;
const shown = referenceRule(
field,
evidence.after[field],
ownMember(evidence.after, field),
evidence.call.body,
evidence.named,
);
@@ -439,7 +446,12 @@ export const explainUpdate = (
// The identity is the entity's key: the path's id repeated is the identity member; another value is one the application ignored, or used to re-key the entity, which no rule models.
if (member === evidence.idField) {
if (scalarText(sent) === evidence.id) identity = member;
else if (!same(evidence.after[member], evidence.before[member]))
else if (
!same(
ownMember(evidence.after, member),
ownMember(evidence.before, member),
)
)
unexplained.push(member);
continue;
}
@@ -465,7 +477,9 @@ export const explainUpdate = (
!(evidence.shown ?? []).includes(field))
)
continue;
if (same(evidence.before[field], evidence.after[field])) {
if (
same(ownMember(evidence.before, field), ownMember(evidence.after, field))
) {
const kept = keptReference(evidence, field);
if (kept !== undefined) rules.push(kept);
continue;
@@ -553,7 +567,10 @@ const orderedBy = (
): boolean =>
members.every((member, index) => {
if (index === 0) return true;
const order = compareValues(members[index - 1]![field], member[field]);
const order = compareValues(
ownMember(members[index - 1]!, field),
ownMember(member, field),
);
return (
order !== undefined && (direction === "ascending" ? order < 0 : order > 0)
);
@@ -572,17 +589,23 @@ export const randomIdentities = (
idField: string,
clocks: readonly string[],
): boolean => {
const ids = members.map((member) => scalarText(member[idField]) ?? "");
const ids = members.map(
(member) => scalarText(ownMember(member, idField)) ?? "",
);
if (ids.length > 0 && ids.every((id) => UUID_V4.test(id))) return true;
if (members.length < 2 || clocks.length === 0) return false;
const byId = members.toSorted(
(left, right) => compareValues(left[idField], right[idField]) ?? 0,
(left, right) =>
compareValues(ownMember(left, idField), ownMember(right, idField)) ?? 0,
);
return clocks.every((clock) =>
byId.some(
(member, index) =>
index > 0 &&
(compareValues(byId[index - 1]![clock], member[clock]) ?? 1) > 0,
(compareValues(
ownMember(byId[index - 1]!, clock),
ownMember(member, clock),
) ?? 1) > 0,
),
);
};
@@ -628,7 +651,10 @@ export const insertIndex = (
): number => {
if (rule.rule !== "sorted") return rule.rule === "first" ? 0 : members.length;
const index = members.findIndex((member) => {
const order = compareValues(fields[rule.field], member[rule.field]);
const order = compareValues(
ownMember(fields, rule.field),
ownMember(member, rule.field),
);
return (
order !== undefined &&
(rule.direction === "ascending" ? order < 0 : order > 0)
@@ -1,5 +1,6 @@
import type { JsonValue } from "../jsonValue.js";
import {
ownMember,
isObject,
pointerTo,
type JsonObject,
@@ -27,8 +28,8 @@ export const freshUuid = (
evidence: GeneratedUpdateEvidence,
field: string,
): boolean => {
const before = evidence.before[field];
const after = evidence.after[field];
const before = ownMember(evidence.before, field);
const after = ownMember(evidence.after, field);
if (!isUuid(before) || !isUuid(after)) return false;
const fresh = after.toLowerCase();
// Scalar generation and nested renewal have separate owners. A shared output UUID
@@ -97,7 +98,11 @@ const pairUuids = (before: JsonValue, after: JsonValue): Paired | undefined => {
if (keys.length !== Object.keys(now).length) return undefined;
for (const key of keys) {
if (!Object.hasOwn(now, key)) return undefined;
pending.push([was[key]!, now[key]!, childPointer(pointer, key)]);
pending.push([
ownMember(was, key)!,
now[key]!,
childPointer(pointer, key),
]);
}
} else if (was !== now) return undefined;
}
@@ -146,7 +151,7 @@ export const renewedRule = (
others: readonly JsonObject[],
): FieldRule | undefined => {
const renewal = renewedUuids(change.before, change.after);
const held = uuidsIn(others.map((other) => other[field] ?? null));
const held = uuidsIn(others.map((other) => ownMember(other, field) ?? null));
return renewal !== undefined &&
[...renewal.uuids.values()].every((uuid) => !held.has(uuid.toLowerCase()))
? { field, rule: "renewed", pointers: renewal.pointers }
@@ -178,7 +183,10 @@ export const unexplainedRenewals = (
const fields = new Set<string>();
for (const rule of rules) {
if (rule.rule !== "renewed") continue;
const renewal = renewedUuids(before[rule.field]!, after[rule.field]!);
const renewal = renewedUuids(
ownMember(before, rule.field)!,
ownMember(after, rule.field)!,
);
for (const [was, now] of renewal?.uuids ?? []) {
if (
held.has(now) ||
@@ -1,5 +1,6 @@
import type { JsonValue } from "../jsonValue.js";
import {
ownMember,
collectionPathOf,
IDENTITY_NAMES,
identityCandidates,
@@ -37,7 +38,7 @@ const succeeded = (record: StateRecordFacts): boolean =>
const identityValues = (object: JsonValue | undefined): string[] =>
isObject(object)
? IDENTITY_NAMES.flatMap((name) => {
const value = scalarText(object[name]);
const value = scalarText(ownMember(object, name));
return value === undefined ? [] : [value];
})
: [];
@@ -101,7 +102,7 @@ const listedIds = (body: JsonValue | undefined): Set<string> => {
...items.flatMap(identityValues),
...(suffixed === undefined
? []
: items.flatMap((item) => scalarText(item[suffixed]) ?? [])),
: items.flatMap((item) => scalarText(ownMember(item, suffixed)) ?? [])),
]);
};
@@ -1,6 +1,7 @@
import type { JsonValue } from "../jsonValue.js";
import { overlapsWrite } from "./environmentStateWriteAttribution.js";
import {
ownMember,
at,
collectionPathOf,
isObject,
@@ -86,7 +87,9 @@ export const newEntityIn = (
if (body === undefined) return undefined;
for (const tier of entitySlots(body)) {
const found = tier.flatMap(([pointer, object]) => {
const id = isObject(object) ? scalarText(object[idField]) : undefined;
const id = isObject(object)
? scalarText(ownMember(object, idField))
: undefined;
// An object holding only the identity names the entity without showing it.
return isObject(object) &&
id !== undefined &&
@@ -229,7 +232,8 @@ const itemObservation = (
: entitySlots(body)
.flat()
.find(([at]) => at === pointer)?.[1];
return isObject(object) && scalarText(object[target.idField]) === id
return isObject(object) &&
scalarText(ownMember(object, target.idField)) === id
? { ordinal: record.ordinal, pointer, object, id }
: undefined;
});
@@ -248,7 +252,7 @@ const listObservation = (
.map((object, index) => ({
object,
index,
id: scalarText(object[target.idField]),
id: scalarText(ownMember(object, target.idField)),
}))
.filter(
(entry): entry is { object: JsonObject; index: number; id: string } =>
@@ -1,4 +1,5 @@
import {
ownMember,
at,
isObject,
listItems,
@@ -120,11 +121,11 @@ const heldEntities = (
? (listItems(read.body!, view.pointer) ?? [])
: isObject(object) &&
read.itemId !== undefined &&
scalarText(object[read.idField]) === read.itemId
scalarText(ownMember(object, read.idField)) === read.itemId
? [object]
: [];
return shown.filter((entity) => {
const id = scalarText(entity[read.idField]);
const id = scalarText(ownMember(entity, read.idField));
return (
id !== undefined &&
state.entity(view.type, read.collection, id) !== undefined
@@ -1,5 +1,6 @@
import type { JsonValue } from "../jsonValue.js";
import {
ownMember,
decodedSegment,
isParam,
normalizedPath,
@@ -148,10 +149,10 @@ export const siblingTrimmedClocks = (
const peer = trimmed.get(`${rule.format.separator}${rule.format.zone}`);
if (peer === undefined) return rule;
const format = later.some((fields) =>
showsFixedWidth(rule.format, fields[rule.field]),
showsFixedWidth(rule.format, ownMember(fields, rule.field)),
)
? rule.format
: trimmedLike(rule.format, peer, values?.[rule.field]);
: trimmedLike(rule.format, peer, ownMember(values, rule.field));
return format === rule.format ? rule : { ...rule, format };
});
};
@@ -178,7 +179,7 @@ export const keptOrders = (body: JsonObject, stored: JsonObject): string[] =>
Object.keys(body).filter(
(member) =>
sortedStrings(body[member]) !== undefined &&
same(stored[member], body[member]),
same(ownMember(stored, member), body[member]),
);
/** One part of a composed string: literal text, or another written field as written, slugged, or as a clock's calendar digits. */
@@ -1,6 +1,8 @@
import type { JsonValue } from "../jsonValue.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import {
ownMember,
setOwnMember,
at,
isObject,
isStringArray,
@@ -35,7 +37,7 @@ const ordered = (value: JsonValue | undefined): value is string[] =>
/** Whether no object a read shows lists the field as a string array out of order. */
const listedSorted = (shown: readonly JsonObject[], field: string): boolean =>
shown.every((object) => {
const value = object[field];
const value = ownMember(object, field);
return !ordered(value) || inOrder(value);
});
@@ -50,7 +52,7 @@ const shownBy = (
)?.fields;
return own !== undefined && Object.hasOwn(own, field)
? own[field]
: entity.fields[field];
: ownMember(entity.fields, field);
};
/** Fields every initial entity shows, as the view renders it, as a sorted string array or null, one with two distinct strings. */
@@ -231,9 +233,9 @@ export class SortedArrays {
if (!isObject(value)) return value;
const object = { ...value };
for (const field of fields) {
const array = object[field];
const array = ownMember(object, field);
if (array !== undefined && isStringArray(array))
object[field] = array.toSorted(compare);
setOwnMember(object, field, array.toSorted(compare));
}
return object;
};
@@ -290,11 +292,11 @@ export class SortedArrays {
const idField = this.facts.idField(view.type) ?? "";
const existing = this.#existing.get(`${view.type}\u0000${field}`)!;
return shown.some((object) => {
const value = object[field];
const id = scalarText(object[idField]);
const value = ownMember(object, field);
const id = scalarText(ownMember(object, idField));
if (!ordered(value) || id === undefined) return false;
return this.facts.state.named(id).some((entity) => {
const written = entity.fields[field];
const written = ownMember(entity.fields, field);
return (
targets.has(
entityTarget(entity.type, entity.collection, entity.id),
@@ -332,8 +334,8 @@ export class SortedArrays {
if (values === undefined) this.#existing.set(key, (values = new Set()));
const held = entities.flatMap((entity) =>
[
entity.fields[field],
...entity.view_fields.map((view) => view.fields[field]),
ownMember(entity.fields, field),
...entity.view_fields.map((view) => ownMember(view.fields, field)),
].flatMap((value) =>
value !== undefined && value !== null && isStringArray(value)
? value
@@ -630,3 +630,20 @@ it("does not explain nested opaque-token references as independent generation",
expect(update.unexplained).toContain("version");
}
});
it.each(["__proto__", "constructor", "toString"])(
"treats an absent %s stamp field as missing JSON data",
(field) => {
const rule: FieldRule = {
rule: "derived",
field,
member: "change",
from: null,
value: 1,
};
expect(stampProblem([rule], {}, { change: true })).toBeUndefined();
expect(
stampProblem([rule], Object.fromEntries([[field, 7]]), { change: true }),
).toContain("accepted only");
},
);
@@ -1,6 +1,8 @@
import type { JsonValue } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import {
ownMember,
setOwnMember,
IDENTITY_NAMES,
isObject,
same,
@@ -81,7 +83,8 @@ export const envelopeOf = (
): { readonly member: string; readonly object: JsonObject } | undefined => {
if (!isObject(body)) return undefined;
const members = Object.keys(body);
const wrapped = members.length === 1 ? body[members[0]!] : undefined;
const wrapped =
members.length === 1 ? ownMember(body, members[0]!) : undefined;
return Array.isArray(wrapped) && wrapped.length === 1 && isObject(wrapped[0]!)
? { member: members[0]!, object: wrapped[0] }
: undefined;
@@ -154,7 +157,7 @@ const sharedBesides = (
others: readonly JsonObject[],
): boolean => {
const held = others.flatMap((other) => {
const value = other[field];
const value = ownMember(other, field);
return value !== undefined && isObject(value) ? [value] : [];
});
return (
@@ -163,9 +166,9 @@ const sharedBesides = (
[...new Set([...Object.keys(other), ...Object.keys(object)])].every(
(key) =>
members.includes(key)
? readClock(other[key]) !== undefined &&
!same(other[key], object[key])
: same(other[key], object[key]),
? readClock(ownMember(other, key)) !== undefined &&
!same(ownMember(other, key), ownMember(object, key))
: same(ownMember(other, key), ownMember(object, key)),
),
)
);
@@ -179,7 +182,9 @@ const nestedRule = (
others: readonly JsonObject[],
): FieldRule | undefined => {
const formats = new Set(
members.map((key) => JSON.stringify(readClock(object[key])?.format)),
members.map((key) =>
JSON.stringify(readClock(ownMember(object, key))?.format),
),
);
return members.length > 0 &&
formats.size === 1 &&
@@ -188,7 +193,7 @@ const nestedRule = (
field,
rule: "nested-clock",
members: [...members],
format: readClock(object[members[0]!])!.format,
format: readClock(ownMember(object, members[0]!))!.format,
}
: undefined;
};
@@ -207,7 +212,7 @@ export const nestedClockRule = (
return undefined;
const changed = [
...new Set([...Object.keys(before), ...Object.keys(after)]),
].filter((key) => !same(before[key], after[key]));
].filter((key) => !same(ownMember(before, key), ownMember(after, key)));
return changed.every(
(key) =>
Object.hasOwn(after, key) && isClockNear(after[key]!, evidence.startMs),
@@ -232,7 +237,7 @@ export const createdNestedClock = (
);
// A clock read before the request, past a whole second's truncation, belongs to an entity made earlier.
const stamped = members.every((key) => {
const ms = readClock(value[key])!.ms;
const ms = readClock(ownMember(value, key))!.ms;
return ms >= startMs - TRUNCATION_MS && ms <= startMs + NESTED_WINDOW_MS;
});
return stamped ? nestedRule(field, value, members, evidence.type) : undefined;
@@ -257,13 +262,14 @@ export const createdToken = (
entity: JsonObject,
type: readonly JsonObject[],
): "independent" | "linked" | undefined => {
const value = entity[field];
const value = ownMember(entity, field);
if (
!isOpaqueToken(value) ||
type.length === 0 ||
!type.every(
(member) =>
member[field] !== value && isOpaqueToken(member[field], value),
ownMember(member, field) !== value &&
isOpaqueToken(ownMember(member, field), value),
)
)
return undefined;
@@ -294,8 +300,8 @@ export const freshToken = (
evidence: ChangeEvidence,
field: string,
): boolean => {
const before = evidence.before[field];
const after = evidence.after[field];
const before = ownMember(evidence.before, field);
const after = ownMember(evidence.after, field);
return (
isOpaqueToken(after, before) &&
isOpaqueToken(before) &&
@@ -337,7 +343,7 @@ export const stampRule = (
evidence: ChangeEvidence,
field: string,
): FieldRule | undefined => {
const after = evidence.after[field];
const after = ownMember(evidence.after, field);
const creator = creatorOf(field);
if (typeof after !== "string" || after.length === 0 || creator === undefined)
return undefined;
@@ -395,7 +401,7 @@ export const derivedRule = (
evidence: ChangeEvidence,
field: string,
): FieldRule | undefined => {
const after = evidence.after[field];
const after = ownMember(evidence.after, field);
if (after === undefined || readClock(after) !== undefined) return undefined;
const { body } = evidence.call;
const unstored = Object.keys(body).filter(
@@ -403,7 +409,8 @@ export const derivedRule = (
!Object.hasOwn(evidence.after, member) &&
!Object.hasOwn(evidence.before, member),
);
const text = unstored.length === 1 ? textOf(body[unstored[0]!]) : undefined;
const text =
unstored.length === 1 ? textOf(ownMember(body, unstored[0]!)) : undefined;
const shown = typeof after === "string" ? after : JSON.stringify(after);
return text !== undefined &&
text.length >= DERIVED_TEXT_MIN &&
@@ -412,7 +419,7 @@ export const derivedRule = (
field,
rule: "derived",
value: after,
from: evidence.before[field] ?? null,
from: ownMember(evidence.before, field) ?? null,
member: unstored[0]!,
}
: undefined;
@@ -428,14 +435,14 @@ export const stampProblem = (
if (
rule.rule === "derived" &&
Object.hasOwn(sent, rule.member) &&
!same(before[rule.field] ?? null, rule.from)
!same(ownMember(before, rule.field) ?? null, rule.from)
)
return `Member ${rule.member} is accepted only on an entity whose ${rule.field} is as the recorded call found it, since the application derives ${rule.field} from it.`;
if (rule.rule === "held-clock" && !Object.hasOwn(sent, rule.field))
return `Member ${rule.field} must be sent with the value the entity holds, as the recorded call did.`;
if (
rule.rule === "held-clock" &&
!same(sent[rule.field], before[rule.field])
!same(ownMember(sent, rule.field), ownMember(before, rule.field))
)
return `Member ${rule.field} must repeat the value the entity holds, as the recorded call did.`;
}
@@ -448,7 +455,7 @@ const nestedIdentities = (entity: JsonObject): Map<string, Set<string>> => {
for (const [field, nested] of Object.entries(entity)) {
if (!isObject(nested)) continue;
const texts = IDENTITY_NAMES.flatMap((name) => {
const text = scalarText(nested[name]);
const text = scalarText(ownMember(nested, name));
return text === undefined ? [] : [text];
});
if (texts.length === 0) continue;
@@ -480,7 +487,10 @@ const stateOf = (
Object.keys(entity).some(
(field) =>
field !== member &&
others.every((other) => unset(other[field]) !== unset(entity[field])),
others.every(
(other) =>
unset(ownMember(other, field)) !== unset(ownMember(entity, field)),
),
)
);
};
@@ -527,11 +537,11 @@ export const restampedFields = (
const fields = { ...shown };
for (const rule of nested) {
const read = storedRestamp(
shown[rule.field],
later?.object[rule.field],
ownMember(shown, rule.field),
ownMember(later?.object, rule.field),
rule.members,
);
if (read !== undefined) fields[rule.field] = read;
if (read !== undefined) setOwnMember(fields, rule.field, read);
}
return fields;
};
@@ -546,10 +556,13 @@ export const storedRestamp = (
if (!isObject(own) || !isObject(read)) return undefined;
for (const key of new Set([...Object.keys(own), ...Object.keys(read)])) {
if (!members.includes(key)) {
if (!same(own[key], read[key])) return undefined;
if (!same(ownMember(own, key), ownMember(read, key))) return undefined;
continue;
}
const [written, stored] = [readClock(own[key]), readClock(read[key])];
const [written, stored] = [
readClock(ownMember(own, key)),
readClock(ownMember(read, key)),
];
if (
written === undefined ||
stored === undefined ||
@@ -729,7 +729,6 @@ describe("unserved write plans", () => {
stateOf(sending()),
stateOf(sending([{ name: "Third Book" }])),
recorded("application/x-www-form-urlencoded", "name=Third+Book"),
recorded("application/json", '{"__proto__":{},"name":"Third Book"}'),
recorded(
"application/json",
'{"name":"Third Book","copies":12345678901234567890}',
@@ -2602,3 +2601,26 @@ describe("recorded calls serving would refuse", () => {
}
});
});
it.each(["__proto__", "constructor"])(
"learns a confirmed update of an own %s field",
(field) => {
const initial = {
id: 1,
name: "One",
...Object.fromEntries([[field, { label: "old" }]]),
};
const body = Object.fromEntries([[field, { label: "stored" }]]);
const updated = { ...initial, ...body };
const state = stateOf([
["GET", "/api/items", [initial, { id: 2, name: "Two" }]],
["GET", "/api/items/1", initial],
["PATCH", "/api/items/1", updated, 200, body],
["GET", "/api/items/1", updated],
]);
expect(planOf(state, "patch_items_by_id")).toMatchObject({
served: true,
rules: expect.arrayContaining([{ rule: "echo", field }]),
});
},
);
+18
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import {
jsonObjectSchema,
ownedJsonObjectSchema,
jsonValueSchema,
jsonValueValidationIssue,
jsonValueValidationSteps,
@@ -42,6 +43,23 @@ it("still parses only JSON values at runtime", () => {
expect(jsonValueSchema.safeParse(value).success).toBe(false);
});
it("retains owned object identity while caller objects remain isolated", () => {
const source = JSON.parse('{"__proto__":{"name":"recorded"},"items":[1]}');
const owned = ownedJsonObjectSchema.parse(source);
const copied = jsonObjectSchema.parse(source);
expect(owned).toBe(source);
expect(copied).not.toBe(source);
expect(copied["__proto__"]).not.toBe(source["__proto__"]);
expect(Object.hasOwn(owned, "__proto__")).toBe(true);
expect(JSON.stringify(owned)).toBe(JSON.stringify(copied));
for (const input of [null, [], { value: undefined }, { value: Infinity }])
expect(ownedJsonObjectSchema.safeParse(input).success).toBe(false);
for (const io of ["input", "output"] as const)
expect(z.toJSONSchema(ownedJsonObjectSchema, { io })).toEqual(
z.toJSONSchema(jsonObjectSchema, { io }),
);
});
it("rejects sparse arrays and enumerable symbol-keyed object members", () => {
const sparse = new Array(1);
const symbolKeyed = { [Symbol("caller data")]: "retain or reject" };
+5
View File
@@ -204,3 +204,8 @@ objectValidation._zod.toJSONSchema = objectProjection;
/** JSON object boundary for caller-visible parameter maps. */
export const jsonObjectSchema = jsonValueSchema.pipe(objectValidation);
jsonObjectSchema._zod.toJSONSchema = objectProjection;
/** Validate an exclusively owned JSON object while retaining its existing tree. */
export const ownedJsonObjectSchema =
ownedJsonValueSchema.pipe(objectValidation);
ownedJsonObjectSchema._zod.toJSONSchema = objectProjection;
@@ -245,7 +245,7 @@ describe("state data plane", () => {
},
});
expect(loginOf(plane.store)).toBe("probe");
// Values parsing would alter are refused where they sit, never rounded or turned into a prototype.
// Numbers parsing or serialization would alter are refused where they sit.
const body = JSON.stringify({
jsonrpc: "2.0",
id: 2,
@@ -256,7 +256,6 @@ describe("state data plane", () => {
'"login":9007199254740993',
// Exact in this spelling, but data/get would write it as digits it could not load again.
'"login":1e20',
'"__proto__":{"polluted":true},"login":"renamed"',
]) {
const reply = await plane.post(
body.replace('"login":"renamed"', altered),
@@ -267,12 +266,45 @@ describe("state data plane", () => {
data: { path: expect.stringMatching(/\/fields$/u) },
},
});
expect(JSON.stringify(reply.json)).not.toContain("polluted");
}
expect(loginOf(plane.store)).toBe("probe");
});
});
it("preserves arbitrary own data keys in inline and encoded file parts", async () => {
const plane = dataPlane();
const initial = renamedDocument();
const original = initial.rea_state.entities[0]!;
const members = JSON.parse(
'{"__proto__":{"constructor":0},"toString":false}',
);
const entity = { ...original, fields: { ...original.fields, ...members } };
const document = {
rea_state: {
...initial.rea_state,
entities: [entity, ...initial.rea_state.entities.slice(1)],
},
};
for (const part of [
{ kind: "data", data: document },
{
kind: "file",
file: {
bytes: Buffer.from(JSON.stringify(document)).toString("base64"),
},
},
]) {
await expect(plane.add(part)).resolves.toMatchObject({ result: {} });
const retained = plane.store.entity(
entity.type,
entity.collection,
entity.id,
)!;
expect(Object.hasOwn(retained.fields, "__proto__")).toBe(true);
expect(retained.fields).toEqual(entity.fields);
}
});
describe("state data plane intake", () => {
it("loads file parts from bytes and from a file uri", async () => {
const plane = dataPlane();
+3 -4
View File
@@ -73,17 +73,16 @@ export const DEFAULT_STATE_INTAKE: StateIntake = {
timeoutMs: STATE_FETCH_TIMEOUT_MS,
};
/** Stands in for a value that JSON parsing would change: a number without an exact native value, or a `__proto__` member. */
/** Stands in for a number that native JSON parsing or serialization would change. */
const NOT_EXACT_JSON = Symbol("not exact JSON");
/** Mark values native parsing would change, so validation refuses them rather than storing an altered value. */
export const exactJsonReviver = (
key: string,
_key: string,
value: unknown,
context?: { readonly source?: string },
): unknown =>
key === "__proto__" ||
(typeof value === "number" && !isExactJsonNumber(value, context?.source))
typeof value === "number" && !isExactJsonNumber(value, context?.source)
? NOT_EXACT_JSON
: value;
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { timedCaptureEvidence } from "../../fixtures/analysisView.js";
import { jsonObjectSchema } from "../../../src/domain/jsonValue.js";
import {
Client,
@@ -57,6 +58,36 @@ const connect = async (url: URL) => {
return client;
};
processTest(
"replays recorded own JSON keys without interpreting inherited members as fields",
async ({ processes }) => {
const body = JSON.parse(
'{"id":1,"__proto__":{"source":"recorded"},"nested":{"constructor":null,"__proto__":[false,0,""]}}',
);
const fixture = environmentStateServeFixture(
timedCaptureEvidence([["GET", "/api/books/1", body]]),
);
const started = await serveReplayOnly(processes, {}, fixture);
const client = await connect(started.url);
try {
const response = await client.callTool({
name: "get_books_by_id",
arguments: { id: "1" },
});
expect(response.isError).toBeFalsy();
expect(response.structuredContent).toMatchObject({ body });
const replayed = jsonObjectSchema.parse(
jsonObjectSchema.parse(response.structuredContent).body,
);
expect(Object.hasOwn(replayed, "__proto__")).toBe(true);
expect(Object.hasOwn(replayed, "constructor")).toBe(false);
} finally {
await client.close();
}
},
HTTP_BOUNDARY_TIMEOUT_MS,
);
processTest(
"validates repeated query arrays through the advertised MCP contract",
async ({ processes }) => {
@@ -32,12 +32,137 @@ import { unsupportedInputLiteralSchemaError } from "../../fixtures/mcpClientSche
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { startListeningProcess } from "../../support/mcp/streamableHttpProcess.js";
import { processTest } from "../../support/process/processFixture.js";
import {
jsonObjectSchema,
type JsonValue,
} from "../../../src/domain/jsonValue.js";
const HTTP_BOUNDARY_TIMEOUT_MS = 90_000;
/** The book the first served create of "Boundary Book" makes: one second past the served clock's start. */
const BOUNDARY_BOOK = book(3, "Boundary Book", bookClock(13));
processTest(
"preserves own JSON keys through capture, state reload and computed writes",
async ({ processes }) => {
const nested = JSON.parse(
'{"__proto__":{"recorded":true},"constructor":null,"values":[false,0,""]}',
) as JsonValue;
const captured = (id: number, name: string, updated = bookClock(id)) => {
const { slug, ...fields } = book(id, name, bookClock(id), updated);
if (typeof slug !== "string") throw new Error("Fixture book has no slug");
return { ...fields, ["__proto__"]: slug, nested };
};
const first = captured(1, "First Book");
const second = captured(2, "Second Book");
const third = captured(3, "Third Book", bookClock(3));
const renamed = captured(1, "Renamed Book", bookClock(5));
const rows: readonly TimedCaptureRow[] = [
["GET", "/api/books", [first, second]],
["GET", "/api/books/1", first],
["GET", "/api/books/2", second],
["POST", "/api/books", third, 201, { name: "Third Book" }],
["GET", "/api/books/3", third],
["PATCH", "/api/books/1", renamed, 200, { name: "Renamed Book" }],
["GET", "/api/books/1", renamed],
];
const fixture = environmentStateServeFixture(timedCaptureEvidence(rows));
expect(fixture.state.normalized_result).toMatchObject({
entities: expect.arrayContaining([
expect.objectContaining({ id: "1", fields: { ...first } }),
]),
});
const root = await createTestTempDirectory("rea-own-keys-state-mcp-");
const files = await writeEnvironmentServeFiles(root, fixture);
const started = await startListeningProcess(processes, [
"environment-serve",
files.manifestPath,
"--evidence",
files.capturePath,
"--port",
"0",
]);
type StateDocument = {
rea_state: {
entities: Array<{ id: string; fields: Record<string, JsonValue> }>;
[key: string]: unknown;
};
};
const rpc = async (method: string, params?: unknown) =>
(
await fetch(new URL("/agentenv", started.url.origin), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
})
).json() as Promise<{
result?: { parts?: Array<{ data: StateDocument }> };
error?: { code: number };
}>;
const document = (await rpc("data/get")).result?.parts?.[0]?.data;
if (document === undefined)
throw new Error("Missing served state document");
const held = document.rea_state.entities.find(({ id }) => id === "1");
if (held === undefined) throw new Error("Missing initial book");
expect(Object.hasOwn(held.fields, "__proto__")).toBe(true);
expect(held.fields.nested).toEqual(nested);
expect(Object.hasOwn(held.fields, "constructor")).toBe(false);
await expect(
rpc("data/add", { parts: [{ kind: "data", data: document }] }),
).resolves.toMatchObject({ result: {} });
const reloaded = (await rpc("data/get")).result?.parts?.[0]?.data;
expect(reloaded).toEqual(document);
const invalid = {
rea_state: {
...document.rea_state,
["__proto__"]: "unexpected structural field",
},
};
await expect(
rpc("data/add", { parts: [{ kind: "data", data: invalid }] }),
).resolves.toMatchObject({ error: { code: -32602 } });
delete held.fields["__proto__"];
await expect(
rpc("data/add", { parts: [{ kind: "data", data: document }] }),
).resolves.toMatchObject({ result: {} });
const client = new Client({ name: "own-keys-writes", version: "1" });
await client.connect(new StreamableHTTPClientTransport(started.url));
try {
for (const [tool, args, name, id] of [
[
"patch_books_by_id",
{ id: "1", body: { name: "Updated Book" } },
"Updated Book",
"1",
],
["post_books", { body: { name: "Created Book" } }, "Created Book", "3"],
] as const) {
const written = await client.callTool({ name: tool, arguments: args });
expect(written.isError).toBeFalsy();
const expected = {
name,
["__proto__"]: name.toLowerCase().replaceAll(" ", "-"),
nested,
};
expect(written.structuredContent).toMatchObject({ body: expected });
const read = await client.callTool({
name: "get_books_by_id",
arguments: { id },
});
expect(read.structuredContent).toMatchObject({ body: expected });
const body = jsonObjectSchema.parse(
jsonObjectSchema.parse(read.structuredContent).body,
);
expect(Object.hasOwn(body, "__proto__")).toBe(true);
expect(Object.hasOwn(body, "constructor")).toBe(false);
}
} finally {
await client.close();
}
},
HTTP_BOUNDARY_TIMEOUT_MS,
);
processTest(
"keeps create response clocks separate from enriched item reads through the SDK",
async ({ processes }) => {