* feat(environment): explain values an application derives from other fields
Prompt-built environments withheld creates whose responses carry values the
application derives deterministically, which the write model kept as
constants or could not match across calls:
- A composed string is literal text plus parts read from the fields other
rules write: a field's value, its slug, or a clock's calendar digits. A
create composes a string only when another entity of the type spells it
the same way and none spells it otherwise, and never from two sources
serving could tell apart; an update composes an unsent change from members
stored as sent and unchanged fields that also spell its value before the
call. A composed field no longer pins the members it reads.
- A trimmed clock format drops trailing fraction zeros, as Go spells
nanoseconds. It is learned from the type's values, a later create or a
later update, and spreads to the plan's other clocks unless a value shows
a fixed width.
- A large integer no entity of the type holds, among distinct ones of the
same length and kind, is a generated unique value, served as a seeded
integer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the derived-values verification
Gates, tests, write plans over 69 recorded captures and served write tasks on the baseline and on this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): learn a create plan from every call
A later create the plan does not take is explained on its own and merged when both calls show the same fields, every field both rule they rule alike, and a field neither rules holds one value: members either call sent are accepted, only those every call sent are required, and an echoed member a call left out takes the value that call stored, kept in the body's defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): sorted arrays, referenced entities and nested view keys
- An echo is `sorted` when the application stored the sent strings in
code-point order; serving stores them sorted, and a plain echo and a
sorted one agree in update and create plans.
- A `reference` is an object field holding fields of the entity a sent
member names, as the state holds it. The member is accepted with any
value; replay and serving look the entity up, and serving refuses a call
naming one the environment does not hold.
- A view names in `nested_fields` the keys it always shows of objects it
shows with fewer keys than an entity stores; a stored object it never
recorded renders with them. The view spelling learner moves to its own
module.
- Every later create, not only one the plan refuses, is merged into its
plan when it agrees, keeping the plan's rule order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep clock spellings, linked ids and word boundaries honest
- A later update's clocks take the plan's spelling, sibling-trimmed
clocks included, wherever the call's values fit it, so an update whose
later call trims only one of two clocks is served.
- A held value that ends its fraction in a zero keeps a create's clock
at a fixed width, whatever order the held spellings come in.
- A large integer that another field of the created entity also holds
is linked, as a repeated token is: it is never unique, and the create
is withheld as unexplained-change.
- Composed parts are delimited by whole characters through the shared
joinsBefore/joinsAfter, so an astral letter joins a match to its word.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): keep a same-width fixed clock out of trimming
A held .123456780 beside a held .12345678 keeps the create's clock at
nine digits in either order; the cases fail on 8ce1127b5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the create-calls verification
Gates, tests, write plans over 69 recorded captures and served write tasks on the derived-values baseline and on this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): note the create-calls census on current main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): check composed parts and clock fits before they shape a plan
- A composed string whose part has nothing to spell refuses the create
or update through the apply Result instead of keeping a stale value.
- A composition learned on a later call is rebuilt from each earlier
call's fields, so an earlier call that left the value otherwise
contradicts it.
- An update adopts its fitted clock spellings only after the
unknown-entity and contradiction checks, so a call on an entity an
unmodelled write left unknown no longer trims a served plan's clock.
- Sibling trimming on a later create also checks that call's fields,
so a sibling clock it shows with a trailing zero stays fixed-width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): split the derived-values view tests by rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep fixed-width, zone and generated-integer evidence through a plan
- A plan keeps the first value that showed each clock at a fixed width,
from held entities and every accepted create or update. No later fit
or sibling trim widens that field, and a later call that shows it
shorter is withheld.
- Two stamped clocks are one composed source only when they share a
zone offset. A held entity that holds the target field as anything
the parts do not spell, a non-string included, vetoes the
composition. The token index is built once, and only when a string is
left to compose.
- A generated integer that another field repeats as a number at any
depth is linked. Compare treats two integers of one JSON kind and
length under a unique rule as generated values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep optional references across create calls and in answers
- A reference a later create added stays while further creates leave its
member out, and agrees with a later call that shows it again.
- A create's answer shows optional reference and echo fields only when the
call wrote them, whichever recorded call the answer came from.
- An update that re-sends the id of the entity a field already repeats keeps
the plan's reference instead of pinning the member.
- An id naming entities of one type in several collections teaches no
reference, and serving or replay refuses a call that names one.
- An echo is not promoted to sorted once a call stored its strings out of
code-point order as sent.
- Nested view keys compare as sets and keep the first recording's order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(setup): share registration prewarm host preparation
* fix(environment): retain write evidence and match trimmed response clocks
* fix(environment): withhold creates whose generated integer another string repeats
A unique integer whose digits show in another top-level string that no
template or composed rule rebuilds from it (an echoed serialText, or a
token like folder-<id>) withholds the create as unexplained-change,
since serving would seed the integer and keep the string. A templated
copy is still served.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): respect earlier clock widths and left-out members in later creates
- The create merge widens a clock to trimmed only against the value that
showed its width: a held entity or accepted call at a fixed width keeps it
untrimmed.
- A later create that left out a member the plan fills, with a default or a
reference's absence, while its observation shows that field otherwise is
unexplained-change, so the plan cannot serve the stale value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): hold later creates and compare to the composed and integer forms
- A later create whose response spells a composed field otherwise than
the plan rebuilds it is withheld as unexplained-change.
- A unique field learned from a generated integer accepts only an
integer of the same length and kind from a later create, as serving
seeds one.
- Compare counts a composed field as equal when each side's response
spells it from its own fields, which compare under their own rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): preserve composed parts and unique generator families
* fix(environment): reject contradictory create references
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
480 lines
18 KiB
TypeScript
480 lines
18 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import type { JsonValue } from "../jsonValue.js";
|
|
import { readRecordedDateTime } from "../offsetDateTime.js";
|
|
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.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;
|
|
/** Fraction digits end at the last non-zero one, and the point goes with them, as Go spells nanoseconds; `fraction` is the most shown. */
|
|
readonly trimmed?: true;
|
|
}
|
|
| { readonly kind: "epoch"; readonly unit: "s" | "ms" };
|
|
|
|
export interface ClockReading {
|
|
readonly ms: number;
|
|
/** Nanoseconds after the floored millisecond, so distinct recorded instants stay distinct. */
|
|
readonly subMillisecond: number;
|
|
readonly format: ClockFormat;
|
|
/** The fraction ends in a zero, which a trimmed spelling never shows, so its width is fixed. */
|
|
readonly fixedWidth: boolean;
|
|
}
|
|
|
|
const MINUTE_MS = 60_000;
|
|
|
|
/** Minutes east of UTC that a zone suffix names; none and `Z` are UTC. */
|
|
const zoneMinutes = (zone: string): number | undefined => {
|
|
if (zone === "" || zone === "Z") return 0;
|
|
if (!/^[+-]\d{2}:?\d{2}$/u.test(zone)) return undefined;
|
|
const digits = zone.slice(1).replace(":", "");
|
|
const hours = Number(digits.slice(0, 2));
|
|
const remainder = Number(digits.slice(2, 4));
|
|
if (hours > 23 || remainder > 59) return undefined;
|
|
const minutes = hours * 60 + remainder;
|
|
return zone.startsWith("-") ? -minutes : minutes;
|
|
};
|
|
|
|
/** Whether a stored ISO clock format has a supported RFC 3339 offset suffix. */
|
|
export const isClockZone = (zone: string): boolean =>
|
|
zoneMinutes(zone) !== undefined;
|
|
|
|
/** Minutes east of UTC a stored format's zone names. */
|
|
export const zoneMinutesForFormat = (zone: string): number => {
|
|
const minutes = zoneMinutes(zone);
|
|
if (minutes === undefined)
|
|
throw new TypeError(`Invalid clock zone in a derived format: ${zone}`);
|
|
return 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): ClockReading | undefined => {
|
|
const reading = readRecordedDateTime(value);
|
|
if (
|
|
reading === undefined ||
|
|
!reading.spelling.secondsPresent ||
|
|
reading.spelling.fractionDigits > 9
|
|
)
|
|
return undefined;
|
|
const { instant, spelling } = reading;
|
|
return {
|
|
ms:
|
|
instant.epochSecond * 1000 + Number(`${instant.fraction}000`.slice(0, 3)),
|
|
subMillisecond: Number(instant.fraction.padEnd(9, "0").slice(3)),
|
|
format: {
|
|
kind: "iso",
|
|
separator: spelling.separator,
|
|
fraction: spelling.fractionDigits,
|
|
zone: spelling.zone,
|
|
},
|
|
// The instant's fraction drops trailing zeros, so a longer spelling ended in one.
|
|
fixedWidth: spelling.fractionDigits > instant.fraction.length,
|
|
};
|
|
};
|
|
|
|
/** 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,
|
|
): ClockReading | 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,
|
|
subMillisecond: 0,
|
|
format: { kind: "epoch", unit: "s" },
|
|
fixedWidth: true,
|
|
};
|
|
if (value >= 1e12 && value < 1e13)
|
|
return {
|
|
ms: value,
|
|
subMillisecond: 0,
|
|
format: { kind: "epoch", unit: "ms" },
|
|
fixedWidth: true,
|
|
};
|
|
return undefined;
|
|
};
|
|
|
|
const pad = (value: number, width: number): string =>
|
|
String(value).padStart(width, "0");
|
|
|
|
/** An ISO-8601 date-time from the UTC fields of `at`, with fraction digits and a zone suffix. */
|
|
const isoText = (
|
|
at: Date,
|
|
separator: string,
|
|
digits: string,
|
|
zone: string,
|
|
): string | undefined => {
|
|
const year = at.getUTCFullYear();
|
|
if (!Number.isInteger(year) || year < 0 || year > 9999) return undefined;
|
|
return `${pad(year, 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${digits === "" ? "" : `.${digits}`}${zone}`;
|
|
};
|
|
|
|
/** An instant in a recorded format; undefined outside its four-digit ISO year range. Fraction digits past milliseconds are zeros. */
|
|
export const formatClock = (
|
|
ms: number,
|
|
format: ClockFormat,
|
|
): JsonValue | undefined => {
|
|
if (format.kind === "epoch")
|
|
return format.unit === "s" ? Math.floor(ms / 1000) : ms;
|
|
const at = new Date(ms + zoneMinutesForFormat(format.zone) * MINUTE_MS);
|
|
const digits = `${pad(at.getUTCMilliseconds(), 3)}${"0".repeat(9)}`.slice(
|
|
0,
|
|
format.fraction,
|
|
);
|
|
return isoText(
|
|
at,
|
|
format.separator,
|
|
format.trimmed === true ? digits.replace(/0+$/u, "") : digits,
|
|
format.zone,
|
|
);
|
|
};
|
|
|
|
/**
|
|
* A clock value spelled in another ISO format: its wall clock in the format's zone, under the format's separator, with
|
|
* the value's own fraction digits padded to the format's width, so no digit is lost; an epoch format is computed from
|
|
* the instant.
|
|
*/
|
|
export const respellClock = (
|
|
value: JsonValue,
|
|
format: ClockFormat,
|
|
): JsonValue => {
|
|
const clock = readClock(value);
|
|
if (clock === undefined) return value;
|
|
if (format.kind === "epoch") return formatClock(clock.ms, format) ?? value;
|
|
const millis = ((clock.ms % 1000) + 1000) % 1000;
|
|
// An epoch value's fraction is its milliseconds, without trailing zeros.
|
|
const digits =
|
|
clock.format.kind === "epoch"
|
|
? pad(millis, 3).replace(/0+$/u, "")
|
|
: `${pad(millis, 3)}${pad(clock.subMillisecond, 6)}`.slice(
|
|
0,
|
|
clock.format.fraction,
|
|
);
|
|
const at = new Date(
|
|
clock.ms - millis + zoneMinutesForFormat(format.zone) * MINUTE_MS,
|
|
);
|
|
return (
|
|
isoText(
|
|
at,
|
|
format.separator,
|
|
format.trimmed === true
|
|
? digits.replace(/0+$/u, "")
|
|
: digits.padEnd(format.fraction, "0"),
|
|
format.zone,
|
|
) ?? value
|
|
);
|
|
};
|
|
|
|
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);
|
|
|
|
/** Digits a generated integer shows at least, as a time-based id does; counts and codes are shorter. */
|
|
const GENERATED_INTEGER_DIGITS = 10;
|
|
|
|
/** A large integer's digits, from a safe JSON number or a string of digits without a leading zero. */
|
|
export const generatedIntegerDigits = (
|
|
value: JsonValue | undefined,
|
|
): string | undefined => {
|
|
const digits =
|
|
typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
? String(value)
|
|
: typeof value === "string" && /^[1-9]\d*$/u.test(value)
|
|
? value
|
|
: undefined;
|
|
return digits !== undefined &&
|
|
digits.length >= GENERATED_INTEGER_DIGITS &&
|
|
digits.length <= 20
|
|
? digits
|
|
: undefined;
|
|
};
|
|
|
|
/** A sent array of strings as an application stores it sorted: in code-point order, when that differs from the sent order and shows two distinct strings. */
|
|
export const sortedStrings = (
|
|
sent: JsonValue | undefined,
|
|
): string[] | undefined => {
|
|
if (
|
|
!Array.isArray(sent) ||
|
|
!sent.every((item) => typeof item === "string") ||
|
|
new Set(sent).size < 2
|
|
)
|
|
return undefined;
|
|
const sorted = (sent as string[]).toSorted(compareUnicodeCodePoints);
|
|
return sorted.some((item, index) => item !== sent[index])
|
|
? sorted
|
|
: undefined;
|
|
};
|
|
|
|
/** The value an echo rule stores for a sent value: as sent, or its strings sorted. */
|
|
export const echoed = (
|
|
rule: { readonly sorted?: true },
|
|
sent: JsonValue,
|
|
): JsonValue => (rule.sorted === true ? (sortedStrings(sent) ?? sent) : sent);
|
|
|
|
/** Whether a generated integer retains the exemplar's digit count and JSON kind. */
|
|
export const likeGeneratedInteger = (
|
|
value: JsonValue | undefined,
|
|
exemplar: JsonValue | undefined,
|
|
): boolean => {
|
|
const digits = generatedIntegerDigits(value);
|
|
return (
|
|
digits !== undefined &&
|
|
typeof value === typeof exemplar &&
|
|
digits.length === generatedIntegerDigits(exemplar)?.length
|
|
);
|
|
};
|
|
|
|
/** 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 =>
|
|
maskedCount(exemplar) !== undefined
|
|
? fillMask(exemplar as string, value)
|
|
: typeof exemplar === "string"
|
|
? String(value)
|
|
: value;
|
|
|
|
const MASKED_DIGITS = /^\d{2,}(?:-\d{2,})+$/u;
|
|
|
|
/** The count `-`-separated digit groups of one width spell, such as an asset number `000-005`; dates, versions and addresses are not counts. */
|
|
const maskedCount = (value: JsonValue | undefined): number | undefined => {
|
|
if (typeof value !== "string" || !MASKED_DIGITS.test(value)) return undefined;
|
|
const groups = value.split("-");
|
|
if (groups.some((group) => group.length !== groups[0]!.length))
|
|
return undefined;
|
|
const digits = groups.join("");
|
|
return digits.length <= 15 ? Number(digits) : undefined;
|
|
};
|
|
|
|
/** A count that outgrew the exemplar's mask, read only when it is spelled as serving spells it, so dates never match. */
|
|
const outgrownMask = (
|
|
value: JsonValue | undefined,
|
|
exemplar: JsonValue | undefined,
|
|
): number | undefined => {
|
|
if (typeof value !== "string" || maskedCount(exemplar) === undefined)
|
|
return undefined;
|
|
const digits = value.replaceAll("-", "");
|
|
if (!/^\d{1,15}$/u.test(digits)) return undefined;
|
|
const count = Number(digits);
|
|
return fillMask(exemplar as string, count) === value ? count : undefined;
|
|
};
|
|
|
|
/** A sequence's number: an integer, digit groups that count, or with the sequence's exemplar a count that outgrew its mask. */
|
|
export const sequenceNumber = (
|
|
value: JsonValue | undefined,
|
|
exemplar?: JsonValue,
|
|
): number | undefined =>
|
|
integerOf(value) ?? maskedCount(value) ?? outgrownMask(value, exemplar);
|
|
|
|
/** A number written into a mask's digit positions, zero-padded; digits that outgrow the mask lead it. */
|
|
const fillMask = (mask: string, value: number): string => {
|
|
const padded = String(value).padStart(mask.replace(/\D/gu, "").length, "0");
|
|
let next = padded.length - 1;
|
|
let out = "";
|
|
for (let at = mask.length - 1; at >= 0; at -= 1) {
|
|
const character = mask[at]!;
|
|
if (/\d/u.test(character)) {
|
|
out = padded[next]! + out;
|
|
next -= 1;
|
|
} else out = character + out;
|
|
}
|
|
return padded.slice(0, next + 1) + out;
|
|
};
|
|
|
|
/** 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);
|
|
|
|
/** Source offsets stay UTF-16, while delimiter classification reads complete code points. */
|
|
const previousCharacter = (text: string, at: number): string | undefined => {
|
|
if (at === 0) return undefined;
|
|
const last = text.charCodeAt(at - 1);
|
|
if (last >= 0xdc00 && last <= 0xdfff && at > 1) {
|
|
const first = text.charCodeAt(at - 2);
|
|
if (first >= 0xd800 && first <= 0xdbff) return text.slice(at - 2, at);
|
|
}
|
|
return text[at - 1];
|
|
};
|
|
|
|
const nextCharacter = (text: string, at: number): string | undefined => {
|
|
const point = text.codePointAt(at);
|
|
return point === undefined ? undefined : String.fromCodePoint(point);
|
|
};
|
|
|
|
/** Whether the character before UTF-16 offset `at` is a letter or digit, joining text that starts there to its left. */
|
|
export const joinsBefore = (text: string, at: number): boolean =>
|
|
isAlphanumeric(previousCharacter(text, at));
|
|
|
|
/** Whether the character at UTF-16 offset `at` is a letter or digit, joining text that ends there to its right. */
|
|
export const joinsAfter = (text: string, at: number): boolean =>
|
|
isAlphanumeric(nextCharacter(text, at));
|
|
|
|
/** 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 =
|
|
!joinsBefore(text, at) && !joinsAfter(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;
|
|
};
|
|
|
|
/** Whether any number inside a value spells `digits`. */
|
|
export const containsNumber = (value: JsonValue, digits: string): boolean => {
|
|
const pending: JsonValue[] = [value];
|
|
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
|
if (typeof next === "number") {
|
|
if (String(next) === digits) return true;
|
|
} else if (typeof next === "object" && next !== null)
|
|
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 seeded value spelled like the recorded one: a UUID for a UUID or when asked for one, else a token. */
|
|
export const seededLike = (
|
|
seed: string,
|
|
exemplar: JsonValue,
|
|
uuid = false,
|
|
): string =>
|
|
uuid || isUuid(exemplar)
|
|
? seededUuid(seed, String(exemplar))
|
|
: seededToken(seed, String(exemplar));
|
|
|
|
/** A seeded integer with as many digits as the recorded one and none leading zero, as a number when it is one and stays safe. */
|
|
export const seededInteger = (seed: string, exemplar: JsonValue): JsonValue => {
|
|
const length = String(exemplar).length;
|
|
const bytes = seededBytes(seed, length);
|
|
// A number keeps under the largest safe integer, which a sixteen-digit one starting with 9 can pass.
|
|
const leading = typeof exemplar === "number" && length >= 16 ? 8 : 9;
|
|
const digits = [...bytes]
|
|
.map((byte, index) =>
|
|
String(index === 0 ? 1 + (byte % leading) : byte % 10),
|
|
)
|
|
.join("");
|
|
return typeof exemplar === "number" ? Number(digits) : digits;
|
|
};
|
|
|
|
/** 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("");
|
|
};
|