feat(8.0): id-normalization (#18) + aggregation min/max delete-safety + RC-safe release

- 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.
This commit is contained in:
David Snelling 2026-06-20 14:40:57 -07:00
parent 606445cd61
commit d02e522a3e
14 changed files with 943 additions and 100 deletions

View file

@ -1,24 +1,222 @@
/**
* Universal UUID implementation
* Framework-friendly: Works in all environments
* @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 idint 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 {
// Use crypto.randomUUID if available (Node.js 19+, modern browsers)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
// Fallback implementation for older environments
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
// Named export to match uuid package API
export { v4 as uuidv4 }
/**
* @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)
}
// Default export for convenience
export default { v4 }
/** 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 stringUUID 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 }