fix(environment): release batch replies as they are encoded; judge exact numbers by value

answerBatch kept every settled reply in its pending array until the
batch ended, so a batch alternating data/add and data/get held each
loaded state alive; each reply now leaves the queue as it is encoded.

The exact-number policy judged a number by its recorded spelling, so a
state loaded with 1e20 came back from data/get as
100000000000000000000, which data/add refused, breaking the promise
that data/get output loads back unchanged. A number is exact only when
both its recorded spelling and the one serialization writes are safe,
so integers from 2^53 below 1e21 are refused in any spelling; replay
keeps such bodies as recorded text. A domain test checks that every
accepted number is accepted again once written back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-20 08:55:51 -07:00
1 parent 5956e00dd6
commit 5a61853a69
6 files changed
+50 -4

No files matched your search

+3 -1
View File
@@ -478,7 +478,9 @@ calls find entities under the checked line's recorded path. Each document
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 a `__proto__` member is refused rather than altered.
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 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
@@ -18,6 +18,7 @@ describe("replay JSON value fidelity", () => {
'{"id":9007199254740993}',
'{"id":9007199254740993.0}',
'{"id":9.007199254740993e15}',
'{"id":1e20}',
'[{"id":18446744073709551615}]',
'{"value":0.100000000000000005}',
'{"value":1e-1000}',
+33
View File
@@ -0,0 +1,33 @@
import { expect, it } from "vitest";
import { isExactJsonNumber } from "./exactJsonNumber.js";
const spellings = [
"0",
"42",
"-7",
"1.5",
"0.1",
"1e3",
"1e-7",
"9007199254740991",
"9007199254740992",
"9.007199254740992e15",
"1e20",
"100000000000000000000",
"1e21",
"1.7976931348623157e308",
];
it("judges a number alike in every spelling, so an accepted number is accepted again once written back", () => {
for (const spelling of spellings) {
const value = Number(spelling);
if (isExactJsonNumber(value, spelling))
expect(isExactJsonNumber(value, String(value)), spelling).toBe(true);
}
expect(isExactJsonNumber(1e20, "1e20")).toBe(false);
expect(isExactJsonNumber(1e20, "100000000000000000000")).toBe(false);
expect(isExactJsonNumber(1e21, "1e21")).toBe(true);
expect(isExactJsonNumber(9007199254740991, "9007199254740991")).toBe(true);
expect(isExactJsonNumber(-0, "-0")).toBe(false);
});
+9 -2
View File
@@ -2,12 +2,19 @@ import { isSafeNumber } from "lossless-json";
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
import { safeParseJson } from "./safeJson.js";
/** Whether native JSON conversion and serialization preserve the recorded number. */
/**
* Whether native JSON conversion and serialization preserve the recorded number. Both the recorded
* spelling and the one serialization writes must be safe, so `1e20` and `100000000000000000000`
* are judged alike and anything accepted is accepted again once written back.
*/
export const isExactJsonNumber = (
value: number,
source: string | undefined,
): boolean =>
source !== undefined && isSafeNumber(source) && !Object.is(value, -0);
source !== undefined &&
isSafeNumber(source) &&
isSafeNumber(String(value)) &&
!Object.is(value, -0);
/** Parse JSON text whose every number survives native conversion exactly; undefined otherwise. */
export const parseExactJson = (text: string): JsonValue | undefined => {
+2 -1
View File
@@ -248,7 +248,8 @@ const answerBatch = async (
for (const reply of pending) void reply.catch(() => undefined);
const encode = replyEncoder();
const texts: string[] = [];
for (const next of pending) {
// Taking each settled reply off the queue releases it, and any state it holds, once encoded.
for (let next = pending.shift(); next !== undefined; next = pending.shift()) {
const reply = await next;
if (reply !== undefined) texts.push(encode(reply));
}
@@ -202,6 +202,8 @@ describe("state data plane", () => {
});
for (const altered of [
'"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(