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
|
|
@ -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·(n−1)`, 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
|
||||
|
|
|
|||
139
src/brainy.ts
139
src/brainy.ts
|
|
@ -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 write→get→relate
|
||||
* 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) {
|
||||
|
|
|
|||
72
src/db/db.ts
72
src/db/db.ts
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
65
src/utils/idNormalization.ts
Normal file
65
src/utils/idNormalization.ts
Normal 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 id↔int 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue