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

@ -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,