brainy/tests/integration/id-normalization.test.ts
David Snelling d02e522a3e 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.
2026-06-20 14:46:40 -07:00

225 lines
8.5 KiB
TypeScript

/**
* @module tests/integration/id-normalization
* @description Correctness gate for the 8.0 transparent entity-id normalization
* layer (#18). A caller may use a natural string key (e.g. `'user-1'`) anywhere
* an id is accepted; it is mapped to a STABLE UUID (`v5('user-1')`) on creation
* AND on every lookup, so it round-trips transparently while the engine only
* ever sees a UUID. The caller's original string is preserved under
* {@link ORIGINAL_ID_KEY} in the stored entity metadata. A real UUID passes
* through untouched (no `_originalId`).
*
* The layer is ALL-OR-NOTHING: every creation AND lookup path must normalize
* identically, or round-trips break. These tests prove the contract holds
* end-to-end across `add` / `get` / `update` / `remove` / `relate` / `related`
* / `find({ connected })` / `transact` / `addMany` / `relateMany`, plus
* determinism (same key → same UUID, upsert not duplicate), UUID passthrough,
* and the v7 defaults for no-id `add()` and `newId()`.
*
* All entities carry explicit 384-dim vectors so no test invokes the embedder.
*/
import { describe, it, expect } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { NounType, VerbType } from '../../src/types/graphTypes.js'
import { v5, v7, isUUID } from '../../src/universal/uuid.js'
import { ORIGINAL_ID_KEY } from '../../src/utils/idNormalization.js'
/** Deterministic 384-dim vector so no test ever invokes the embedder. */
function vec(seed: number): number[] {
return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 7) % 100) / 100)
}
/** Fresh in-memory brain (no embedder, no subtype enforcement). */
async function makeBrain(): Promise<Brainy> {
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
await brain.init()
return brain
}
describe('id normalization — transparent string-key round-trips', () => {
it('1. add() returns v5(key); get(key) and get(returnedId) both resolve; _originalId preserved', async () => {
const brain = await makeBrain()
const returnedId = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
// The returned id is the stable v5 mapping, a real UUID.
expect(returnedId).toBe(v5('user-1'))
expect(isUUID(returnedId)).toBe(true)
// get() by the natural key AND by the canonical id both resolve.
const byKey = await brain.get('user-1')
const byId = await brain.get(returnedId)
expect(byKey).not.toBeNull()
expect(byId).not.toBeNull()
expect(byKey!.id).toBe(returnedId)
expect(byId!.id).toBe(returnedId)
// The caller's original string is surfaced under ORIGINAL_ID_KEY.
expect(byKey!.metadata[ORIGINAL_ID_KEY]).toBe('user-1')
})
it('2. relate() by string keys; related(key) and related({from:key}) return the edge to v5(toKey)', async () => {
const brain = await makeBrain()
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
await brain.add({ id: 'doc-1', vector: vec(2), type: NounType.Document })
await brain.relate({ from: 'user-1', to: 'doc-1', type: VerbType.Creates })
const expectedTo = v5('doc-1')
const viaString = await brain.related('user-1')
expect(viaString.length).toBe(1)
expect(viaString[0].from).toBe(v5('user-1'))
expect(viaString[0].to).toBe(expectedTo)
const viaFrom = await brain.related({ from: 'user-1' })
expect(viaFrom.length).toBe(1)
expect(viaFrom[0].to).toBe(expectedTo)
// And the inbound anchor resolves too.
const viaTo = await brain.related({ to: 'doc-1' })
expect(viaTo.length).toBe(1)
expect(viaTo[0].from).toBe(v5('user-1'))
})
it('3. update() by string key reflects on get(key)', async () => {
const brain = await makeBrain()
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { role: 'admin' } })
await brain.update({ id: 'user-1', metadata: { role: 'owner' } })
const e = await brain.get('user-1')
expect(e).not.toBeNull()
expect((e!.metadata as any).role).toBe('owner')
// Original id still surfaced after update.
expect(e!.metadata[ORIGINAL_ID_KEY]).toBe('user-1')
})
it('4. remove() by string key deletes; get(key) is null', async () => {
const brain = await makeBrain()
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
expect(await brain.get('user-1')).not.toBeNull()
await brain.remove('user-1')
expect(await brain.get('user-1')).toBeNull()
// The canonical id is gone too.
expect(await brain.get(v5('user-1'))).toBeNull()
})
it('5. find({ connected: { from: key } }) resolves the anchor key', async () => {
const brain = await makeBrain()
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
await brain.add({ id: 'doc-1', vector: vec(2), type: NounType.Document })
await brain.relate({ from: 'user-1', to: 'doc-1', type: VerbType.Creates })
const results = await brain.find({ connected: { from: 'user-1' } })
const ids = results.map((r) => r.id)
expect(ids).toContain(v5('doc-1'))
})
it('6. transact() add+relate by string keys round-trips with consistent canonical ids', async () => {
const brain = await makeBrain()
// Seed user-1 so the relate op has a target to point at.
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
const db = await brain.transact([
{ op: 'add', id: 'u-2', vector: vec(3), type: NounType.Person },
{ op: 'relate', from: 'u-2', to: 'user-1', type: VerbType.RelatedTo }
])
// Receipt ids resolve to the canonical mappings.
expect(db.receipt.ids[0]).toBe(v5('u-2'))
// get('u-2') works and surfaces the original id.
const u2 = await brain.get('u-2')
expect(u2).not.toBeNull()
expect(u2!.id).toBe(v5('u-2'))
expect(u2!.metadata[ORIGINAL_ID_KEY]).toBe('u-2')
// The relation links the right canonical ids.
const rels = await brain.related('u-2')
expect(rels.length).toBe(1)
expect(rels[0].from).toBe(v5('u-2'))
expect(rels[0].to).toBe(v5('user-1'))
})
it('7. addMany() + relateMany() with string ids round-trip', async () => {
const brain = await makeBrain()
const added = await brain.addMany({
items: [
{ id: 'a-1', vector: vec(10), type: NounType.Person },
{ id: 'b-1', vector: vec(11), type: NounType.Document }
]
})
expect(added.successful).toContain(v5('a-1'))
expect(added.successful).toContain(v5('b-1'))
const relIds = await brain.relateMany({
items: [{ from: 'a-1', to: 'b-1', type: VerbType.Creates }]
})
expect(relIds.length).toBe(1)
const a1 = await brain.get('a-1')
expect(a1).not.toBeNull()
expect(a1!.metadata[ORIGINAL_ID_KEY]).toBe('a-1')
const rels = await brain.related('a-1')
expect(rels.length).toBe(1)
expect(rels[0].to).toBe(v5('b-1'))
})
it('8. determinism: same key maps to same UUID — two adds upsert ONE entity, not two', async () => {
const brain = await makeBrain()
const id1 = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { n: 1 } })
const id2 = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { n: 2 } })
// Stable mapping across calls.
expect(id1).toBe(id2)
expect(id1).toBe(v5('user-1'))
// Only ONE entity exists (the second add overwrote the first).
const count = await brain.getNounCount()
expect(count).toBe(1)
const e = await brain.get('user-1')
expect((e!.metadata as any).n).toBe(2)
})
it('9. valid-UUID passthrough: a real UUID is kept verbatim with NO _originalId', async () => {
const brain = await makeBrain()
const realUuid = v7()
const returnedId = await brain.add({ id: realUuid, vector: vec(5), type: NounType.Thing })
expect(returnedId).toBe(realUuid)
const e = await brain.get(realUuid)
expect(e).not.toBeNull()
expect(e!.id).toBe(realUuid)
expect(e!.metadata[ORIGINAL_ID_KEY]).toBeUndefined()
})
it('10. no-id add() mints a v7; newId() mints a v7', async () => {
const brain = await makeBrain()
const autoId = await brain.add({ vector: vec(6), type: NounType.Thing })
expect(isUUID(autoId)).toBe(true)
// v7 carries version nibble '7' at the canonical position.
expect(autoId[14]).toBe('7')
const minted = brain.newId()
expect(isUUID(minted)).toBe(true)
expect(minted[14]).toBe('7')
// The auto-id entity round-trips by its returned canonical id.
const e = await brain.get(autoId)
expect(e).not.toBeNull()
expect(e!.metadata[ORIGINAL_ID_KEY]).toBeUndefined()
})
})