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

@ -200,9 +200,11 @@ function updateMetricAdd(state: MetricState, val: number, op: AggregationOp): vo
state.m2 = (state.m2 ?? 0) + (val - oldMean) * (val - mean)
}
// Percentile: track the numeric value multiset (matches Cortex's value_counts) for the
// exact-percentile computation. (distinctCount is tracked separately, on raw values.)
if (op === 'percentile') {
// Track the numeric value multiset for percentile AND min/max. min/max need it
// to recompute the extreme after a delete WITHOUT scanning entities — that scan
// was the 7.x infinite-loop / hang (materialized aggregates fed back in).
// (distinctCount is tracked separately, on raw values.)
if (op === 'percentile' || op === 'min' || op === 'max') {
if (!state.valueCounts) state.valueCounts = {}
const key = String(val)
state.valueCounts[key] = (state.valueCounts[key] ?? 0) + 1
@ -214,9 +216,9 @@ function updateMetricAdd(state: MetricState, val: number, op: AggregationOp): vo
* Note: removing from Welford's is the inverse update.
*/
function updateMetricRemove(state: MetricState, val: number, op: AggregationOp): void {
// Percentile: decrement the numeric value multiset (drop the key at zero).
// (distinctCount is decremented separately, on raw values.)
if (op === 'percentile' && state.valueCounts) {
// Decrement the numeric value multiset for percentile AND min/max (drop the key
// at zero). (distinctCount is decremented separately, on raw values.)
if ((op === 'percentile' || op === 'min' || op === 'max') && state.valueCounts) {
const key = String(val)
const c = state.valueCounts[key]
if (c !== undefined) {
@ -241,6 +243,29 @@ function updateMetricRemove(state: MetricState, val: number, op: AggregationOp):
}
}
/**
* Recompute a metric's min/max from its value multiset O(distinct values), fully
* in-memory, NO entity scan. Called lazily on query when a delete of the current
* extreme marked it stale. (Scanning entities to recompute is what hung 7.x.)
*/
function recomputeMinMaxFromCounts(state: MetricState): void {
const keys = state.valueCounts ? Object.keys(state.valueCounts) : []
if (keys.length === 0) {
state.min = Infinity
state.max = -Infinity
return
}
let mn = Infinity
let mx = -Infinity
for (const k of keys) {
const n = Number(k)
if (n < mn) mn = n
if (n > mx) mx = n
}
state.min = mn
state.max = mx
}
/**
* Exact percentile over a value multiset, using linear interpolation between closest ranks
* (numpy 'linear' / type-7): `rank = p·(n1)`, interpolating between the floor and ceil
@ -610,7 +635,7 @@ export class AggregationIndex {
// Collect all groups
let results: AggregateResult[] = []
for (const group of stateMap.values()) {
for (const [serialized, group] of stateMap.entries()) {
// Skip empty groups (all metrics at zero count)
const hasData = Object.values(group.metrics).some(m => m.count > 0)
if (!hasData) continue
@ -639,11 +664,24 @@ export class AggregationIndex {
metrics[metricName] = state.count > 0 ? state.sum / state.count : 0
break
case 'min':
metrics[metricName] = state.min === Infinity ? 0 : state.min
break
case 'max':
metrics[metricName] = state.max === -Infinity ? 0 : state.max
case 'max': {
// Lazily recompute the extreme from the value multiset if a delete of
// the current min/max marked it stale (hang-free; no entity scan).
const staleSet = this.staleMinMax.get(params.name)
if (staleSet && staleSet.has(`${serialized}:${metricName}`)) {
recomputeMinMaxFromCounts(state)
staleSet.delete(`${serialized}:${metricName}`)
}
metrics[metricName] =
metricDef.op === 'min'
? state.min === Infinity
? 0
: state.min
: state.max === -Infinity
? 0
: state.max
break
}
case 'variance':
metrics[metricName] = state.count > 1 ? (state.m2 ?? 0) / (state.count - 1) : 0
break

View file

@ -5,7 +5,8 @@
* NO STUBS, NO MOCKS, REAL IMPLEMENTATION
*/
import { v4 as uuidv4 } from './universal/uuid.js'
import { v4 as uuidv4, v7 as uuidv7 } from './universal/uuid.js'
import { coerceNewEntityId, resolveEntityId, ORIGINAL_ID_KEY } from './utils/idNormalization.js'
import { JsHnswVectorIndex } from './hnsw/hnswIndex.js'
// TypeAwareHNSWIndex removed from default path — single unified JsHnswVectorIndex is faster
// for the 99% of queries that don't filter by type (avoids searching 42 separate graphs)
@ -1206,6 +1207,20 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* })
* ```
*/
/**
* @description Mint a fresh, time-ordered entity id (UUID v7) WITHOUT a write.
* Assign ids client-side to reference an entity before it exists forward
* references in `transact()`, deterministic `relate()`, no writegetrelate
* round-trip. Time-ordered, so ids sort by creation time.
* @returns A new UUID v7 string.
* @example
* const id = brain.newId()
* await brain.add({ id, type: NounType.Document, data: 'draft' })
*/
newId(): string {
return uuidv7()
}
async add(params: AddParams<T>): Promise<string> {
this.assertWritable('add')
await this.ensureInitialized()
@ -1235,17 +1250,22 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// missing-subtype check via the `isVFSEntity` marker.
this.enforceSubtypeOnAdd('add', params.type, params.subtype, params.metadata)
// Id normalization (8.0): a natural string key (e.g. 'user-123') is coerced
// to a STABLE UUID (v5) so the engine only ever sees a UUID, while the
// caller's original string is preserved under ORIGINAL_ID_KEY for reads.
// A real UUID passes through; no id mints a fresh v7. Done BEFORE the
// ifAbsent check so a natural-key upsert is idempotent against the same key.
const { id, originalId } = coerceNewEntityId(params.id)
// ifAbsent (7.31.0) — by-ID idempotent insert. Only meaningful when a custom id
// is supplied; a freshly generated UUID can never collide. Returns the existing
// id without writing if the entity is already present (no throw, no overwrite).
// (canonical) id without writing if the entity is already present (no throw,
// no overwrite).
if (params.id && params.ifAbsent) {
const existing = await this.storage.getNounMetadata(params.id)
if (existing) return params.id
const existing = await this.storage.getNounMetadata(id)
if (existing) return id
}
// Generate ID if not provided
const id = params.id || uuidv4()
// Get or compute vector
const vector = params.vector || (await this.embed(params.data))
@ -1263,6 +1283,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Only metadata fields are queryable via find({ where }).
const storageMetadata = {
...params.metadata,
// Preserve the caller's original (non-UUID) id when normalized, so reads
// can surface it. A real UUID passes through with no _originalId.
...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId }),
data: params.data,
noun: params.type,
...(params.subtype !== undefined && { subtype: params.subtype }),
@ -1299,8 +1322,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
service: params.service,
data: params.data,
...(params.createdBy && { createdBy: params.createdBy }),
// Only custom fields in metadata
metadata: params.metadata || {}
// Only custom fields in metadata (plus the preserved original id, which
// is a custom field — surfaced on read under ORIGINAL_ID_KEY).
metadata: {
...(params.metadata || {}),
...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId })
}
}
// Execute atomically with transaction system
@ -1477,6 +1504,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
async get(id: string, options?: GetOptions): Promise<Entity<T> | null> {
await this.ensureInitialized()
// Id normalization (8.0): a caller may read by their natural key — resolve
// it to the same canonical UUID add() stored. A real UUID passes through.
// Only a non-empty string is normalized; empty/null/undefined fall through
// to the storage layer's structural-validation throw (preserved contract).
if (typeof id === 'string' && id !== '') {
id = resolveEntityId(id)
}
// Route to metadata-only or full entity based on options
const includeVectors = options?.includeVectors ?? false // Default: metadata-only (fast)
@ -1527,6 +1562,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
async batchGet(ids: string[], options?: GetOptions): Promise<Map<string, Entity<T>>> {
await this.ensureInitialized()
// Id normalization (8.0): resolve each id to its canonical UUID so callers
// can batch-read by natural key. resolveEntityId is idempotent on real
// UUIDs, so internal callers passing canonical ids are unaffected. The
// returned map is keyed by the canonical (stored) id.
ids = ids.map((id) => resolveEntityId(id))
const results = new Map<string, Entity<T>>()
if (ids.length === 0) return results
@ -2003,6 +2044,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Zero-config validation (static import for performance)
validateUpdateParams(params)
// Id normalization (8.0): resolve a natural key to the canonical UUID add()
// stored, so update() targets the same entity. A real UUID passes through.
params = { ...params, id: resolveEntityId(params.id) }
// Reserved fields arriving via the metadata patch are remapped to their
// canonical top-level location, mirroring add()'s lift. Without this the
// patch value survived the merge but was then clobbered by the
@ -2221,6 +2266,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
await this.ensureInitialized()
// Id normalization (8.0): resolve a natural key to the canonical UUID add()
// stored, so remove() deletes the same entity. A real UUID passes through.
id = resolveEntityId(id)
// Get entity metadata and related verbs before deletion
const metadata = await this.storage.getNounMetadata(id)
const noun = await this.storage.getNoun(id)
@ -2536,6 +2585,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Zero-config validation (static import for performance)
validateRelateParams(params)
// Id normalization (8.0): resolve BOTH endpoints so a caller may relate by
// natural key on either side. Each maps to the same canonical UUID add()
// stored; real UUIDs pass through. (The relationship's own id is an
// engine-minted UUID — relation ids are never caller-supplied here.)
params = { ...params, from: resolveEntityId(params.from), to: resolveEntityId(params.to) }
// Reserved fields arriving via the metadata bag are normalized to their
// canonical top-level params before enforcement — mirror of add()'s lift.
params = this.remapReservedRelateMetadata(params)
@ -2924,10 +2979,19 @@ export class Brainy<T = any> implements BrainyInterface<T> {
await this.ensureInitialized()
// Handle string ID shorthand: related(id) -> related({ from: id })
const params = typeof paramsOrId === 'string'
const rawParams = typeof paramsOrId === 'string'
? { from: paramsOrId }
: (paramsOrId || {})
// Id normalization (8.0): resolve the anchor id(s) so a caller may traverse
// by natural key. Each maps to the canonical UUID add() stored; real UUIDs
// pass through. Mutates a copy so the caller's params object is untouched.
const params: RelatedParams = {
...rawParams,
...(rawParams.from !== undefined && { from: resolveEntityId(rawParams.from) }),
...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) })
}
const limit = params.limit || 100
const offset = params.offset || 0
@ -3668,9 +3732,24 @@ export class Brainy<T = any> implements BrainyInterface<T> {
await this.ensureIndexesLoaded()
// Parse natural language queries
const params: FindParams<T> =
let params: FindParams<T> =
typeof query === 'string' ? await this.parseNaturalQuery(query) : query
// Id normalization (8.0): resolve the graph-traversal anchor id(s) so a
// caller may constrain by natural key. Each maps to the canonical UUID
// add() stored; real UUIDs pass through. Done once here so every downstream
// consumer of params.connected sees canonical ids.
if (params.connected && (params.connected.from !== undefined || params.connected.to !== undefined)) {
params = {
...params,
connected: {
...params.connected,
...(params.connected.from !== undefined && { from: resolveEntityId(params.connected.from) }),
...(params.connected.to !== undefined && { to: resolveEntityId(params.connected.to) })
}
}
}
// Zero-config validation (static import for performance)
validateFindParams(params)
@ -4503,7 +4582,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
let idsToDelete: string[] = []
if (params.ids) {
idsToDelete = params.ids
// Id normalization (8.0): resolve each supplied id to the canonical UUID
// add() stored, so callers may delete by natural key. The find()-derived
// path below already yields canonical ids.
idsToDelete = params.ids.map((id) => resolveEntityId(id))
} else if (params.type || params.where) {
// Find entities to delete
const entities = await this.find({
@ -6206,15 +6288,20 @@ export class Brainy<T = any> implements BrainyInterface<T> {
this.enforceTrackedFieldValues({ subtype: params.subtype } as Record<string, unknown>, 'top-level')
this.enforceSubtypeOnAdd('add', params.type, params.subtype, params.metadata)
// Id normalization (8.0) — mirror of add(): a natural key coerces to a
// STABLE UUID (v5), preserving the original under ORIGINAL_ID_KEY; a real
// UUID passes through; no id mints a fresh v7. Done BEFORE ifAbsent so a
// natural-key upsert is idempotent against the same key.
const { id, originalId } = coerceNewEntityId(params.id)
// ifAbsent — idempotent by-id insert, resolved against batch + store.
if (params.id && params.ifAbsent) {
const existing = await this.planGetEntity(state, params.id)
const existing = await this.planGetEntity(state, id)
if (existing) {
return params.id
return id
}
}
const id = params.id || uuidv4()
const vector = params.vector || (await this.embed(params.data))
if (!this.dimensions) {
this.dimensions = vector.length
@ -6234,6 +6321,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
const now = Date.now()
const storageMetadata = {
...params.metadata,
// Preserve the caller's original (non-UUID) id when normalized — mirror
// of add(). A real UUID passes through with no _originalId.
...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId }),
data: params.data,
noun: params.type,
...(params.subtype !== undefined && { subtype: params.subtype }),
@ -6264,7 +6354,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
service: params.service,
data: params.data,
...(params.createdBy && { createdBy: params.createdBy }),
metadata: params.metadata || {}
metadata: {
...(params.metadata || {}),
...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId })
}
}
plan.operations.push(
@ -6297,6 +6390,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// remap to their dedicated param (top-level wins), system-managed fields
// drop with a one-shot warning.
const params = this.remapReservedUpdateMetadata(rawParams as UpdateParams<T>)
// Id normalization (8.0) — mirror of update(): a natural key resolves to the
// canonical UUID add() stored. A real UUID passes through.
params.id = resolveEntityId(params.id)
this.enforceTrackedFieldValues(params.metadata as Record<string, unknown> | undefined, 'metadata')
if (params.subtype !== undefined) {
this.enforceTrackedFieldValues({ subtype: params.subtype } as Record<string, unknown>, 'top-level')
@ -6447,7 +6543,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
if (!op.id || typeof op.id !== 'string') {
throw new Error(`transact(): remove operation requires an entity id (got ${JSON.stringify(op.id)})`)
}
const id = op.id
// Id normalization (8.0) — mirror of remove(): a natural key resolves to the
// canonical UUID add() stored. A real UUID passes through.
const id = resolveEntityId(op.id)
const pending = state.nouns.get(id)
const metadata = pending ? pending.metadata : await this.storage.getNounMetadata(id)
@ -6531,6 +6629,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
validateRelateParams(rawParams as RelateParams<T>)
// Same reserved-field normalization as relate().
const params = this.remapReservedRelateMetadata(rawParams as RelateParams<T>)
// Id normalization (8.0) — mirror of relate(): resolve BOTH endpoints to the
// canonical UUID add() stored, so a relate op may reference either side by
// natural key. Real UUIDs pass through. (Relationship ids are engine-minted.)
params.from = resolveEntityId(params.from)
params.to = resolveEntityId(params.to)
this.enforceSubtypeOnRelate('relate', params.type, params.subtype, params.metadata)
const fromEntity = await this.planGetEntity(state, params.from)
@ -11451,7 +11554,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// undefined (default) → no auto-detection (safe default)
// false → no auto-detection
// [] → no auto-detection
// ['@soulcraft/cortex'] → load only these explicitly listed packages
// ['@soulcraft/cor'] → load only these explicitly listed packages
// Note: plugins registered via brain.use() are always activated regardless of config
const pluginConfig = this.config.plugins
if (Array.isArray(pluginConfig) && pluginConfig.length > 0) {

View file

@ -64,6 +64,7 @@ import {
splitVerbMetadataRecord
} from '../types/reservedFields.js'
import { v4 as uuidv4 } from '../universal/uuid.js'
import { coerceNewEntityId, resolveEntityId, ORIGINAL_ID_KEY } from '../utils/idNormalization.js'
import { EntityNotFoundError } from '../errors/notFound.js'
import { SpeculativeOverlayError } from './errors.js'
import type { GenerationStore } from './generationStore.js'
@ -268,6 +269,11 @@ export class Db<T = any> {
async get(id: string, options?: GetOptions): Promise<Entity<T> | null> {
this.assertUsable('get')
// Id normalization (8.0): resolve a natural key to the canonical UUID the
// write path stored, so this view reads by natural key consistently. A real
// UUID passes through. Overlay keys are canonical (see with()).
id = resolveEntityId(id)
if (this.overlay && this.overlay.nouns.has(id)) {
return this.overlay.nouns.get(id) ?? null
}
@ -447,12 +453,19 @@ export class Db<T = any> {
async related(paramsOrId?: string | RelatedParams): Promise<Relation<T>[]> {
this.assertUsable('related')
const params: RelatedParams =
const rawParams: RelatedParams =
typeof paramsOrId === 'string' ? { from: paramsOrId } : (paramsOrId ?? {})
// Id normalization (8.0): resolve the anchor id(s) so this view traverses by
// natural key consistently with the live engine. Real UUIDs pass through.
const params: RelatedParams = {
...rawParams,
...(rawParams.from !== undefined && { from: resolveEntityId(rawParams.from) }),
...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) })
}
const historical = this.isHistorical()
if (!historical && !this.overlay) {
return this.host.related(paramsOrId)
return this.host.related(params)
}
if (params.cursor !== undefined) {
@ -587,7 +600,11 @@ export class Db<T = any> {
const service =
op.service ?? (typeof reserved.service === 'string' ? reserved.service : undefined)
const id = op.id ?? uuidv4()
// Id normalization (8.0) — mirror of the committed transact() add
// path: a natural key coerces to a STABLE UUID (v5), preserving the
// original under ORIGINAL_ID_KEY; a real UUID passes through; no id
// mints a fresh id so the overlay keys match the durable path.
const { id, originalId } = coerceNewEntityId(op.id)
const now = Date.now()
overlay.nouns.set(id, {
id,
@ -595,7 +612,10 @@ export class Db<T = any> {
type: op.type,
...(subtype !== undefined && { subtype }),
data: op.data,
metadata: custom as T,
metadata: {
...(custom as object),
...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId })
} as T,
...(service !== undefined && { service }),
createdAt: now,
updatedAt: now,
@ -606,11 +626,14 @@ export class Db<T = any> {
break
}
case 'update': {
const base = await speculativeGet(op.id)
// Id normalization (8.0): resolve a natural key to the canonical UUID
// the write path stored. A real UUID passes through.
const updateId = resolveEntityId(op.id)
const base = await speculativeGet(updateId)
if (!base) {
throw new EntityNotFoundError(
op.id,
`with(): entity ${op.id} not found at generation ${this.gen}`
updateId,
`with(): entity ${updateId} not found at generation ${this.gen}`
)
}
// Same reserved-field normalization as the committed update path.
@ -627,7 +650,7 @@ export class Db<T = any> {
op.merge !== false
? ({ ...(base.metadata as object), ...custom } as T)
: ((op.metadata !== undefined ? custom : base.metadata) as T)
overlay.nouns.set(op.id, {
overlay.nouns.set(updateId, {
...base,
...(op.type !== undefined && { type: op.type }),
...(subtype !== undefined && { subtype }),
@ -642,38 +665,45 @@ export class Db<T = any> {
break
}
case 'remove': {
overlay.nouns.set(op.id, null)
// Id normalization (8.0): resolve a natural key to the canonical UUID
// the write path stored. A real UUID passes through.
const removeId = resolveEntityId(op.id)
overlay.nouns.set(removeId, null)
// Tombstone overlay relations touching the removed entity; base
// relations are cascade-filtered at read time in related().
for (const [verbId, relation] of overlay.verbs) {
if (relation && (relation.from === op.id || relation.to === op.id)) {
if (relation && (relation.from === removeId || relation.to === removeId)) {
overlay.verbs.set(verbId, null)
}
}
break
}
case 'relate': {
const from = await speculativeGet(op.from)
const to = await speculativeGet(op.to)
// Id normalization (8.0) — mirror of relate(): resolve BOTH endpoints
// to the canonical UUID the write path stored. Real UUIDs pass through.
const relFrom = resolveEntityId(op.from)
const relTo = resolveEntityId(op.to)
const from = await speculativeGet(relFrom)
const to = await speculativeGet(relTo)
if (!from) {
throw new EntityNotFoundError(op.from, `with(): source entity ${op.from} not found`)
throw new EntityNotFoundError(relFrom, `with(): source entity ${relFrom} not found`)
}
if (!to) {
throw new EntityNotFoundError(op.to, `with(): target entity ${op.to} not found`)
throw new EntityNotFoundError(relTo, `with(): target entity ${relTo} not found`)
}
// Dedupe against the view (overlay first, then the edges visible
// at this generation via the record layer) — mirror of relate().
let duplicate: Relation<T> | undefined
for (const relation of overlay.verbs.values()) {
if (relation && relation.from === op.from && relation.to === op.to && relation.type === op.type) {
if (relation && relation.from === relFrom && relation.to === relTo && relation.type === op.type) {
duplicate = relation
break
}
}
if (!duplicate) {
const existing = await this.related({ from: op.from, type: op.type })
duplicate = existing.find((relation) => relation.to === op.to)
const existing = await this.related({ from: relFrom, type: op.type })
duplicate = existing.find((relation) => relation.to === relTo)
}
if (duplicate) break
@ -694,8 +724,8 @@ export class Db<T = any> {
const id = uuidv4()
overlay.verbs.set(id, {
id,
from: op.from,
to: op.to,
from: relFrom,
to: relTo,
type: op.type,
...(subtype !== undefined && { subtype }),
weight: weight ?? 1.0,
@ -709,8 +739,8 @@ export class Db<T = any> {
const reverseId = uuidv4()
overlay.verbs.set(reverseId, {
id: reverseId,
from: op.to,
to: op.from,
from: relTo,
to: relFrom,
type: op.type,
...(subtype !== undefined && { subtype }),
weight: weight ?? 1.0,

View file

@ -1425,10 +1425,10 @@ export interface BrainyConfig {
// Plugin configuration
// Controls which plugins are loaded during init()
// - undefined (default): Auto-detect installed plugins (@soulcraft/cortex, etc.)
// - undefined (default): Auto-detect installed plugins (@soulcraft/cor, etc.)
// - false: No plugins — skip auto-detection entirely
// - []: No plugins — skip auto-detection entirely
// - ['@soulcraft/cortex']: Load only specified plugins, no auto-detection
// - ['@soulcraft/cor']: Load only specified plugins, no auto-detection
plugins?: string[] | false
// Logging configuration

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 }

View file

@ -73,7 +73,7 @@ export class EntityIdSpaceExceeded extends Error {
`EntityIdMapper: nextId ${attempted} would exceed u32::MAX ` +
`(${U32_ENTITY_ID_MAX}). The JS fallback mapper caps at u32 to ` +
`match the metadata index's Roaring32 bitmap width. For >4.29 B ` +
`entities, install @soulcraft/cortex and configure the binary ` +
`entities, install @soulcraft/cor and configure the binary ` +
`mapper with idSpace: 'u64' (mmap-backed extendible-hash KV).`,
)
this.name = 'EntityIdSpaceExceeded'

View file

@ -0,0 +1,65 @@
/**
* @module utils/idNormalization
* @description The 8.0 entity-id normalization layer. Every id that enters
* Brainy is coerced to a valid UUID, because a native engine maps ids to compact
* integers and requires real UUIDs (`encode()` rejects arbitrary strings).
*
* The rules (decided as AR.1 = "normalize"):
* - **No id supplied** a fresh time-ordered {@link v7} UUID (sortable by
* creation time; friendly to the idint mapper and range scans).
* - **A valid UUID** used as-is.
* - **Any other string** (a natural key like `'user-123'`) a STABLE
* {@link v5} UUID derived from it, so the same key always maps to the same id
* and the caller can keep using their natural key on every read/relate while
* the engine only ever sees a UUID. The original string is preserved so reads
* can surface it.
*
* Two entry points: {@link coerceNewEntityId} at creation (add/relate), and
* {@link resolveEntityId} at lookup (get/update/remove/relate endpoints).
*/
import { v5, v7, isUUID } from '../universal/uuid.js'
/** Metadata key under which a caller's original (non-UUID) id is preserved. */
export const ORIGINAL_ID_KEY = '_originalId'
/**
* @description The result of normalizing an id at creation time.
*/
export interface CoercedId {
/** The canonical UUID to store and hand to the engine. */
id: string
/** The caller's original string, present only when it was normalized (non-UUID). */
originalId?: string
}
/**
* @description Normalize an id supplied at CREATION (add/relate). Generates a
* time-ordered UUID when none is given, passes a real UUID through, and maps any
* other string to a stable namespaced UUID (recording the original).
* @param id - The caller-supplied id, or undefined to auto-generate.
* @returns The canonical UUID and, when normalized, the original string.
* @example
* coerceNewEntityId() // → { id: <uuidv7> }
* coerceNewEntityId('user-123') // → { id: <stable uuidv5>, originalId: 'user-123' }
*/
export function coerceNewEntityId(id?: string | null): CoercedId {
if (id === undefined || id === null || id === '') {
return { id: v7() }
}
if (isUUID(id)) {
return { id }
}
return { id: v5(id), originalId: id }
}
/**
* @description Normalize an id used for LOOKUP (get/update/remove/relate
* endpoints) to the same canonical UUID `coerceNewEntityId` produced so a
* caller can read/relate by their natural key transparently. A real UUID passes
* through; any other string maps to its stable namespaced UUID.
* @param id - The id to resolve.
* @returns The canonical UUID.
*/
export function resolveEntityId(id: string): string {
return isUUID(id) ? id : v5(id)
}