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" }; interface ClockReading { readonly ms: number; /** Nanoseconds after the floored millisecond, so distinct recorded instants stay distinct. */ readonly subMillisecond: number; readonly format: ClockFormat; } /** Compare recorded instants without rounding away their submillisecond digits. */ export const compareClockInstants = ( left: Pick, right: Pick, ): number => left.ms - right.ms || left.subMillisecond - right.subMillisecond; 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 | 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; 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 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 offsetMinutes = zoneMinutes(zone); if (offsetMinutes === undefined) return undefined; // setUTCFullYear avoids Date.UTC's special 1900 adjustment for years 0–99. const rolled = new Date(0); rolled.setUTCFullYear(year as number, (month as number) - 1, day as number); rolled.setUTCHours( hour as number, minute as number, second as number, Number(`${fraction}000`.slice(0, 3)), ); const local = rolled.getTime(); // Date setters roll invalid fields over; the round trip refuses them. if ( rolled.getUTCFullYear() !== year || rolled.getUTCMonth() !== (month as number) - 1 || rolled.getUTCDate() !== day || rolled.getUTCHours() !== hour || rolled.getUTCMinutes() !== minute || rolled.getUTCSeconds() !== second ) return undefined; return { ms: local - offsetMinutes * MINUTE_MS, subMillisecond: Number(fraction.padEnd(9, "0").slice(3)), 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, ): 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" }, }; if (value >= 1e12 && value < 1e13) return { ms: value, subMillisecond: 0, format: { kind: "epoch", unit: "ms" }, }; 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 => `${pad(at.getUTCFullYear(), 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 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 + zoneMinutesForFormat(format.zone) * MINUTE_MS); const digits = `${pad(at.getUTCMilliseconds(), 3)}${"0".repeat(9)}`.slice( 0, format.fraction, ); return isoText(at, format.separator, 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); 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, digits.padEnd(format.fraction, "0"), 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 => 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); return last >= 0xdc00 && last <= 0xdfff && at > 1 ? text.slice(at - 2, at) : text[at - 1]; }; const nextCharacter = (text: string, at: number): string | undefined => { const point = text.codePointAt(at); return point === undefined ? undefined : String.fromCodePoint(point); }; /** 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(previousCharacter(text, at)) && !isAlphanumeric(nextCharacter(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(""); };