- id-normalization (#18): `brain.newId()` (UUID v7) + v7 default ids; non-UUID string ids are transparently normalized to a stable UUID v5 on creation AND every lookup — get/update/remove, relate(from,to), related, find({connected}), getMany, removeMany, the transact op-handler, and db.with() overlays — with the caller's original key preserved under `_originalId`. The engine only ever sees a UUID; real UUIDs pass through untouched. universal/uuid.ts gains v5/v7/isUUID + BRAINY_ID_NAMESPACE; new utils/idNormalization.ts (coerceNewEntityId / resolveEntityId). - aggregation min/max delete-safety: `queryAggregate` no longer leaves a stale min/max after deleting the current extreme — min/max now track a value multiset and recompute in-memory (no entity scan; that scan was the prior delete-then-hang a consumer reported). Other metrics were already exact across deletes. Regression test proves resolve-after-delete + correct new min/max. - release.sh RC-safe: explicit version arg, prerelease detection (npm `--tag rc` + GitHub `--prerelease`), full `test:ci` gate (unit + integration), current-branch push, npm-access verification after publish. - native-engine references point at `@soulcraft/cor` (8.0's partner) in user-facing strings/docs. Unit 1461/0 · integration 599/0 · tsc clean.
222 lines
7.1 KiB
TypeScript
222 lines
7.1 KiB
TypeScript
/**
|
|
* @module universal/uuid
|
|
* @description Framework-friendly UUID utilities used across Brainy — works in
|
|
* Node, Bun, Deno, and browsers (relies only on the global Web Crypto API, with
|
|
* pure-JS fallbacks).
|
|
*
|
|
* Three generators + helpers:
|
|
* - {@link v4} — random UUID (legacy default).
|
|
* - {@link v7} — time-ordered UUID (RFC 9562). The 8.0 default for new ids:
|
|
* lexicographically sortable by creation time, which keeps the id↔int mapper
|
|
* and any range scan locality-friendly.
|
|
* - {@link v5} — deterministic namespaced UUID (RFC 4122, SHA-1). Used by the
|
|
* 8.0 id-normalization layer to map a caller's non-UUID string key to a STABLE
|
|
* UUID, so a native engine (which requires UUID ids for its int mapper) always
|
|
* sees a valid UUID while the caller can keep using their natural key.
|
|
*/
|
|
|
|
/** Render 16 bytes as a canonical hyphenated UUID string. */
|
|
function bytesToUuid(b: Uint8Array): string {
|
|
const h: string[] = []
|
|
for (let i = 0; i < 16; i++) h.push(b[i].toString(16).padStart(2, '0'))
|
|
return (
|
|
`${h[0]}${h[1]}${h[2]}${h[3]}-${h[4]}${h[5]}-${h[6]}${h[7]}-` +
|
|
`${h[8]}${h[9]}-${h[10]}${h[11]}${h[12]}${h[13]}${h[14]}${h[15]}`
|
|
)
|
|
}
|
|
|
|
/** Parse a hyphenated UUID string into its 16 bytes. */
|
|
function uuidToBytes(uuid: string): Uint8Array {
|
|
const hex = uuid.replace(/-/g, '')
|
|
const b = new Uint8Array(16)
|
|
for (let i = 0; i < 16; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
|
return b
|
|
}
|
|
|
|
/** Fill a byte array with cryptographically-strong (or Math.random fallback) randomness. */
|
|
function randomBytes(n: number): Uint8Array {
|
|
const out = new Uint8Array(n)
|
|
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
crypto.getRandomValues(out)
|
|
} else {
|
|
for (let i = 0; i < n; i++) out[i] = Math.floor(Math.random() * 256)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* @description Canonical UUID-format check (any version/variant). NOT a version
|
|
* assertion — just "is this string shaped like a UUID".
|
|
* @param value - The string to test.
|
|
* @returns `true` if `value` matches the 8-4-4-4-12 hex UUID format.
|
|
*/
|
|
export function isUUID(value: string): boolean {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
}
|
|
|
|
/**
|
|
* @description Random (version 4) UUID.
|
|
* @returns A random UUID string.
|
|
*/
|
|
export function v4(): string {
|
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
return crypto.randomUUID()
|
|
}
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
const r = (Math.random() * 16) | 0
|
|
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
|
return v.toString(16)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @description Time-ordered (version 7) UUID — 48-bit Unix-ms timestamp prefix
|
|
* + random tail. Two ids minted in order sort in order as strings.
|
|
* @returns A version-7 UUID string.
|
|
*/
|
|
export function v7(): string {
|
|
const ms = Date.now()
|
|
const b = new Uint8Array(16)
|
|
// 48-bit big-endian millisecond timestamp.
|
|
b[0] = Math.floor(ms / 0x10000000000) & 0xff
|
|
b[1] = Math.floor(ms / 0x100000000) & 0xff
|
|
b[2] = Math.floor(ms / 0x1000000) & 0xff
|
|
b[3] = Math.floor(ms / 0x10000) & 0xff
|
|
b[4] = Math.floor(ms / 0x100) & 0xff
|
|
b[5] = ms & 0xff
|
|
const rand = randomBytes(10)
|
|
b[6] = (rand[0] & 0x0f) | 0x70 // version 7
|
|
b[7] = rand[1]
|
|
b[8] = (rand[2] & 0x3f) | 0x80 // variant 10xx
|
|
for (let i = 9; i < 16; i++) b[i] = rand[i - 6] // rand[3..9]
|
|
return bytesToUuid(b)
|
|
}
|
|
|
|
/** Left-rotate a 32-bit word. */
|
|
function rotl(n: number, s: number): number {
|
|
return ((n << s) | (n >>> (32 - s))) >>> 0
|
|
}
|
|
|
|
/**
|
|
* @description SHA-1 of a byte array → 20 bytes. Pure JS so it runs synchronously
|
|
* everywhere (Web Crypto's digest is async-only). Used solely by {@link v5}.
|
|
* @param msg - Input bytes.
|
|
* @returns The 20-byte SHA-1 digest.
|
|
*/
|
|
function sha1(msg: Uint8Array): Uint8Array {
|
|
const ml = msg.length * 8
|
|
const withByte = msg.length + 1
|
|
const padLen = withByte % 64 <= 56 ? 56 - (withByte % 64) : 120 - (withByte % 64)
|
|
const total = msg.length + 1 + padLen + 8
|
|
const buf = new Uint8Array(total)
|
|
buf.set(msg, 0)
|
|
buf[msg.length] = 0x80
|
|
const hi = Math.floor(ml / 0x100000000)
|
|
const lo = ml >>> 0
|
|
buf[total - 8] = (hi >>> 24) & 0xff
|
|
buf[total - 7] = (hi >>> 16) & 0xff
|
|
buf[total - 6] = (hi >>> 8) & 0xff
|
|
buf[total - 5] = hi & 0xff
|
|
buf[total - 4] = (lo >>> 24) & 0xff
|
|
buf[total - 3] = (lo >>> 16) & 0xff
|
|
buf[total - 2] = (lo >>> 8) & 0xff
|
|
buf[total - 1] = lo & 0xff
|
|
|
|
let h0 = 0x67452301
|
|
let h1 = 0xefcdab89
|
|
let h2 = 0x98badcfe
|
|
let h3 = 0x10325476
|
|
let h4 = 0xc3d2e1f0
|
|
|
|
const w = new Array<number>(80)
|
|
for (let i = 0; i < total; i += 64) {
|
|
for (let t = 0; t < 16; t++) {
|
|
w[t] =
|
|
((buf[i + t * 4] << 24) |
|
|
(buf[i + t * 4 + 1] << 16) |
|
|
(buf[i + t * 4 + 2] << 8) |
|
|
buf[i + t * 4 + 3]) >>>
|
|
0
|
|
}
|
|
for (let t = 16; t < 80; t++) {
|
|
w[t] = rotl(w[t - 3] ^ w[t - 8] ^ w[t - 14] ^ w[t - 16], 1)
|
|
}
|
|
let a = h0
|
|
let b = h1
|
|
let c = h2
|
|
let d = h3
|
|
let e = h4
|
|
for (let t = 0; t < 80; t++) {
|
|
let f: number
|
|
let k: number
|
|
if (t < 20) {
|
|
f = (b & c) | (~b & d)
|
|
k = 0x5a827999
|
|
} else if (t < 40) {
|
|
f = b ^ c ^ d
|
|
k = 0x6ed9eba1
|
|
} else if (t < 60) {
|
|
f = (b & c) | (b & d) | (c & d)
|
|
k = 0x8f1bbcdc
|
|
} else {
|
|
f = b ^ c ^ d
|
|
k = 0xca62c1d6
|
|
}
|
|
const tmp = (rotl(a, 5) + (f >>> 0) + e + k + w[t]) >>> 0
|
|
e = d
|
|
d = c
|
|
c = rotl(b, 30)
|
|
b = a
|
|
a = tmp
|
|
}
|
|
h0 = (h0 + a) >>> 0
|
|
h1 = (h1 + b) >>> 0
|
|
h2 = (h2 + c) >>> 0
|
|
h3 = (h3 + d) >>> 0
|
|
h4 = (h4 + e) >>> 0
|
|
}
|
|
|
|
const out = new Uint8Array(20)
|
|
const hs = [h0, h1, h2, h3, h4]
|
|
for (let i = 0; i < 5; i++) {
|
|
out[i * 4] = (hs[i] >>> 24) & 0xff
|
|
out[i * 4 + 1] = (hs[i] >>> 16) & 0xff
|
|
out[i * 4 + 2] = (hs[i] >>> 8) & 0xff
|
|
out[i * 4 + 3] = hs[i] & 0xff
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* @description Deterministic version-5 (SHA-1, namespaced) UUID. The same
|
|
* `(name, namespace)` always yields the same UUID — the property the
|
|
* id-normalization layer relies on to turn a stable string key into a stable
|
|
* UUID id.
|
|
* @param name - The name to hash (e.g. a caller-supplied string id).
|
|
* @param namespace - A UUID-format namespace (defaults to {@link BRAINY_ID_NAMESPACE}).
|
|
* @returns A version-5 UUID string.
|
|
*/
|
|
export function v5(name: string, namespace: string = BRAINY_ID_NAMESPACE): string {
|
|
const ns = uuidToBytes(namespace)
|
|
const nameBytes = new TextEncoder().encode(name)
|
|
const data = new Uint8Array(16 + nameBytes.length)
|
|
data.set(ns, 0)
|
|
data.set(nameBytes, 16)
|
|
const hash = sha1(data)
|
|
const b = hash.slice(0, 16)
|
|
b[6] = (b[6] & 0x0f) | 0x50 // version 5
|
|
b[8] = (b[8] & 0x3f) | 0x80 // variant 10xx
|
|
return bytesToUuid(b)
|
|
}
|
|
|
|
/**
|
|
* @description The fixed namespace for Brainy's string→UUID normalization. A
|
|
* stable constant (spells "brainy" in the first bytes) so the same caller string
|
|
* maps to the same UUID across processes and machines.
|
|
*/
|
|
export const BRAINY_ID_NAMESPACE = '62726169-6e79-5000-8000-000000000000'
|
|
|
|
// Named export to match the `uuid` package API.
|
|
export { v4 as uuidv4, v7 as uuidv7, v5 as uuidv5 }
|
|
|
|
export default { v4, v7, v5, isUUID, BRAINY_ID_NAMESPACE }
|