mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
7de20bc9ea
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>
237 lines
8.8 KiB
TypeScript
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("");
|
|
};
|