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

@ -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) {