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:
parent
606445cd61
commit
d02e522a3e
14 changed files with 943 additions and 100 deletions
|
|
@ -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 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 {
|
||||
// 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 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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue