Files
rea/src/domain/analysisView/environmentStateWriteValues.ts
T
Cursor Agent 7de20bc9ea fix(environment): credit sent fields, negative sequences, and encoded targets
A later overwrite copied surviving derived fields into the sent-member
claim, so a members-only check rejected an update whose sent field still
matched. Serving floored sequences at zero and stored missing history as
zero, so a collection sequence learned from negative ranks skipped the
next value. An absence after a delete forgot the resolved collection, and
an encoded read could not observe an update spelled with another
collection encoding.

Regression tests cover each case.

Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 22:49:11 +00:00

237 lines
8.8 KiB
TypeScript

import { createHash } from "node:crypto";
import type { JsonValue } from "../jsonValue.js";
/** How a recorded clock value is spelled: ISO-8601 with its separator, fraction digits and zone, or an epoch number. */
export type ClockFormat =
| {
readonly kind: "iso";
readonly separator: "T" | " ";
readonly fraction: number;
readonly zone: string;
}
| { readonly kind: "epoch"; readonly unit: "s" | "ms" };
const ISO_INSTANT =
/^(\d{4})-(\d{2})-(\d{2})([T ])(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/u;
const MINUTE_MS = 60_000;
/** Minutes east of UTC that a zone suffix names; none and `Z` are UTC. */
const zoneMinutes = (zone: string): number => {
if (zone === "" || zone === "Z") return 0;
const digits = zone.slice(1).replace(":", "");
const minutes =
Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2, 4) || "0");
return zone.startsWith("-") ? -minutes : minutes;
};
/** The instant of an ISO-8601 date-time and how it is spelled, or undefined when it is not one. */
const readIsoClock = (
value: string,
): { readonly ms: number; readonly format: ClockFormat } | undefined => {
const match = ISO_INSTANT.exec(value);
if (match === null) return undefined;
const [year, month, day, separator, hour, minute, second] = match
.slice(1, 8)
.map((part) => (part === "T" || part === " " ? part : Number(part)));
const fraction = match[8] ?? "";
const zone = match[9] ?? "";
const local = Date.UTC(
year as number,
(month as number) - 1,
day as number,
hour as number,
minute as number,
second as number,
Number(`${fraction}000`.slice(0, 3)),
);
// Date.UTC rolls invalid fields over; the round trip refuses them.
const rolled = new Date(local);
if (
rolled.getUTCFullYear() !== year ||
rolled.getUTCMonth() !== (month as number) - 1 ||
rolled.getUTCDate() !== day ||
rolled.getUTCHours() !== hour ||
rolled.getUTCMinutes() !== minute
)
return undefined;
return {
ms: local - zoneMinutes(zone) * MINUTE_MS,
format: {
kind: "iso",
separator: separator as "T" | " ",
fraction: fraction.length,
zone,
},
};
};
/** The instant a clock value names and its format: an ISO-8601 date-time, or epoch seconds or milliseconds. */
export const readClock = (
value: JsonValue | undefined,
): { readonly ms: number; readonly format: ClockFormat } | undefined => {
if (typeof value === "string") return readIsoClock(value);
if (typeof value !== "number" || !Number.isSafeInteger(value))
return undefined;
if (value >= 1e9 && value < 1e10)
return { ms: value * 1000, format: { kind: "epoch", unit: "s" } };
if (value >= 1e12 && value < 1e13)
return { ms: value, format: { kind: "epoch", unit: "ms" } };
return undefined;
};
const pad = (value: number, width: number): string =>
String(value).padStart(width, "0");
/** An instant spelled in a recorded clock format; fraction digits past milliseconds are zeros. */
export const formatClock = (ms: number, format: ClockFormat): JsonValue => {
if (format.kind === "epoch")
return format.unit === "s" ? Math.floor(ms / 1000) : ms;
const at = new Date(ms + zoneMinutes(format.zone) * MINUTE_MS);
const fraction =
format.fraction === 0
? ""
: `.${`${pad(at.getUTCMilliseconds(), 3)}${"0".repeat(9)}`.slice(0, format.fraction)}`;
return `${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${format.separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${fraction}${format.zone}`;
};
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
export const isUuid = (value: JsonValue | undefined): value is string =>
typeof value === "string" && UUID.test(value);
/** An integer value as a number, from a JSON number or a string of digits. */
export const integerOf = (value: JsonValue | undefined): number | undefined =>
typeof value === "number" && Number.isSafeInteger(value)
? value
: typeof value === "string" && /^\d{1,15}$/u.test(value)
? Number(value)
: undefined;
/**
* The next value of a sequence. With nothing taken yet, that is the next held integer, or 1 when
* the scope holds none. Otherwise it is one past the later of that and the last value taken.
* A missing side leaves a negative value negative.
*/
export const nextSequence = (
held: number | undefined,
taken: number | undefined,
): number =>
taken === undefined ? (held ?? 1) : Math.max(held ?? taken + 1, taken + 1);
/** An integer spelled as the recorded value spells integers: a number, or a string of digits. */
export const spelledLike = (exemplar: JsonValue, value: number): JsonValue =>
typeof exemplar === "string" ? String(value) : value;
/** Lower case, with every run of other characters joined by one `-` and none at either end. */
export const slugOf = (text: string): string =>
text
.normalize("NFKD")
.replace(/[̀-ͯ]/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "");
const isAlphanumeric = (character: string | undefined): boolean =>
character !== undefined && /[\p{L}\p{N}]/u.test(character);
/** Start offsets of `token` in `text` where neither neighbour is a letter or digit. */
export const delimitedOffsets = (text: string, token: string): number[] => {
const offsets: number[] = [];
if (token.length === 0) return offsets;
let at = text.indexOf(token);
while (at >= 0) {
const delimited =
!isAlphanumeric(text[at - 1]) && !isAlphanumeric(text[at + token.length]);
if (delimited) offsets.push(at);
// A rejected match may overlap a delimited one, so the search moves on by one character.
at = text.indexOf(token, at + (delimited ? token.length : 1));
}
return offsets;
};
/** `text` with every delimited occurrence of `token` replaced. */
export const replaceDelimited = (
text: string,
token: string,
replacement: string,
): string => {
let result = "";
let from = 0;
for (const at of delimitedOffsets(text, token)) {
result += text.slice(from, at) + replacement;
from = at + token.length;
}
return result + text.slice(from);
};
/** Whether any string inside a value holds `token` delimited. */
export const containsDelimited = (value: JsonValue, token: string): boolean => {
const pending: JsonValue[] = [value];
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === "string") {
if (delimitedOffsets(next, token).length > 0) return true;
} else if (typeof next === "object" && next !== null)
// One at a time: spreading a wide array into push overflows the call stack.
for (const child of Array.isArray(next) ? next : Object.values(next))
pending.push(child);
}
return false;
};
/** SHA-256 bytes of a seed, extended by counter blocks to the requested length. */
const seededBytes = (seed: string, length: number): Uint8Array => {
const bytes = new Uint8Array(length);
for (let block = 0; block * 32 < length; block += 1) {
const digest = createHash("sha256")
.update(`${seed}#${String(block)}`)
.digest();
bytes.set(
digest.subarray(0, Math.min(32, length - block * 32)),
block * 32,
);
}
return bytes;
};
/** A version 4 UUID derived from a seed, in the recorded value's letter case. */
export const seededUuid = (seed: string, exemplar: string): string => {
const bytes = seededBytes(seed, 16);
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Buffer.from(bytes).toString("hex");
const uuid = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
return exemplar === exemplar.toUpperCase() && /[A-F]/u.test(exemplar)
? uuid.toUpperCase()
: uuid;
};
const DIGITS = "0123456789";
const LOWER = "abcdefghijklmnopqrstuvwxyz";
const UPPER = LOWER.toUpperCase();
/** The characters a token of this spelling draws from: hexadecimal, or the digit and letter cases it uses. */
const tokenAlphabet = (exemplar: string): string => {
if (/^[0-9a-f]+$/u.test(exemplar) && /[0-9]/u.test(exemplar))
return `${DIGITS}abcdef`;
if (/^[0-9A-F]+$/u.test(exemplar) && /[0-9]/u.test(exemplar))
return `${DIGITS}ABCDEF`;
const alphabet =
(/[0-9]/u.test(exemplar) ? DIGITS : "") +
(/[a-z]/u.test(exemplar) ? LOWER : "") +
(/[A-Z]/u.test(exemplar) ? UPPER : "");
return alphabet === "" ? LOWER : alphabet;
};
/** A token as long as the recorded one, from its alphabet, keeping its other characters in place. */
export const seededToken = (seed: string, exemplar: string): string => {
const alphabet = tokenAlphabet(exemplar);
const bytes = seededBytes(seed, exemplar.length);
return [...exemplar]
.map((character, index) =>
/[0-9A-Za-z]/u.test(character)
? alphabet[bytes[index]! % alphabet.length]!
: character,
)
.join("");
};