mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(environment): own JSON fields across capture, state and replay
This commit is contained in:
1 parent
cc5a96933a
commit
e54bf1006e
57 files changed
+1019
-389
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 }]),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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" };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user