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
94
tests/integration/aggregation-delete.test.ts
Normal file
94
tests/integration/aggregation-delete.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* @module tests/integration/aggregation-delete
|
||||
* @description Regression for BR-AGG-DELETE-HANG (consumer-reported on 7.x): once a
|
||||
* brain had ever deleted an entity, raw `queryAggregate` never resolved (7.x recomputed
|
||||
* MIN/MAX after a delete by SCANNING entities, which fed materialized-aggregate entities
|
||||
* back into aggregation → infinite loop). 8.0 must (a) RESOLVE after deletes — never hang —
|
||||
* and (b) return the CORRECT new min/max after deleting the current extreme (recomputed
|
||||
* from the in-memory value multiset, no entity scan).
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import { NounType } from '../../src/types/graphTypes.js'
|
||||
|
||||
/** Deterministic 384-dim vectors so the embedder is never invoked. */
|
||||
function vec(seed: number): number[] {
|
||||
return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 7) % 100) / 100)
|
||||
}
|
||||
|
||||
/** Resolve a query or fail loudly if it hangs (the 7.x symptom). */
|
||||
function noHang<R>(p: Promise<R>, label: string, ms = 4000): Promise<R> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise<R>((_, rej) =>
|
||||
setTimeout(() => rej(new Error(`${label} HUNG (did not resolve in ${ms}ms)`)), ms)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
describe('queryAggregate across deletes — no hang + correct min/max', () => {
|
||||
const brains: Brainy[] = []
|
||||
afterEach(async () => {
|
||||
for (const b of brains.splice(0)) await b.close().catch(() => {})
|
||||
})
|
||||
|
||||
it('resolves after deletes and recomputes min/max from the multiset', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
|
||||
brains.push(brain)
|
||||
await brain.init()
|
||||
|
||||
brain.defineAggregate({
|
||||
name: 'stats_by_cat',
|
||||
source: { type: NounType.Thing },
|
||||
groupBy: ['category'],
|
||||
metrics: {
|
||||
total: { op: 'sum', field: 'amount' },
|
||||
lo: { op: 'min', field: 'amount' },
|
||||
hi: { op: 'max', field: 'amount' },
|
||||
n: { op: 'count' }
|
||||
}
|
||||
})
|
||||
|
||||
const ids: Record<string, string> = {}
|
||||
for (const [label, amount] of [
|
||||
['a', 10],
|
||||
['b', 20],
|
||||
['c', 30],
|
||||
['d', 40]
|
||||
] as const) {
|
||||
ids[label] = await brain.add({
|
||||
data: `e-${label}`,
|
||||
type: NounType.Thing,
|
||||
vector: vec(amount),
|
||||
metadata: { category: 'x', amount }
|
||||
})
|
||||
}
|
||||
|
||||
const pick = (rows: any[]) => rows.find((r) => r.groupKey.category === 'x')
|
||||
|
||||
let g = pick(await noHang(brain.queryAggregate('stats_by_cat'), 'queryAggregate (initial)'))
|
||||
expect(g).toBeDefined()
|
||||
expect(g.metrics.total).toBe(100)
|
||||
expect(g.metrics.lo).toBe(10)
|
||||
expect(g.metrics.hi).toBe(40)
|
||||
expect(g.metrics.n).toBe(4)
|
||||
|
||||
// Delete the CURRENT min (10) AND the CURRENT max (40) — the case that hung in 7.x.
|
||||
await brain.remove(ids.a)
|
||||
await brain.remove(ids.d)
|
||||
|
||||
g = pick(await noHang(brain.queryAggregate('stats_by_cat'), 'queryAggregate (after delete)'))
|
||||
expect(g).toBeDefined()
|
||||
expect(g.metrics.n).toBe(2)
|
||||
expect(g.metrics.total).toBe(50) // 20 + 30
|
||||
expect(g.metrics.lo).toBe(20) // new min after deleting 10
|
||||
expect(g.metrics.hi).toBe(30) // new max after deleting 40
|
||||
|
||||
// A further delete still resolves and stays correct.
|
||||
await brain.remove(ids.b)
|
||||
g = pick(await noHang(brain.queryAggregate('stats_by_cat'), 'queryAggregate (after 2nd delete)'))
|
||||
expect(g.metrics.n).toBe(1)
|
||||
expect(g.metrics.lo).toBe(30)
|
||||
expect(g.metrics.hi).toBe(30)
|
||||
})
|
||||
})
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy'
|
||||
import { NounType, VerbType } from '../../src/types/graphTypes'
|
||||
// 8.0 id normalization: the test factory seeds natural-key ids ('alice', …).
|
||||
// The engine maps each to a stable UUID (v5) and preserves the original under
|
||||
// _originalId, so entity.id is the canonical UUID and lookups round-trip by key.
|
||||
import { v5 } from '../../src/universal/uuid'
|
||||
import {
|
||||
createTestEntity,
|
||||
createTestRelation,
|
||||
|
|
@ -110,8 +114,9 @@ describe('Unified Find() Integration Tests', () => {
|
|||
})
|
||||
|
||||
expect(results).toHaveLength(3)
|
||||
// First result should be Alice herself with high similarity
|
||||
expect(results[0].entity.id).toBe('alice')
|
||||
// First result should be Alice herself with high similarity (her id is
|
||||
// the canonical v5 mapping of the natural key 'alice').
|
||||
expect(results[0].entity.id).toBe(v5('alice'))
|
||||
expect(results[0].score).toBeGreaterThan(0.95)
|
||||
})
|
||||
|
||||
|
|
@ -138,8 +143,8 @@ describe('Unified Find() Integration Tests', () => {
|
|||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
// Should find Bob and Charlie (Alice's friends)
|
||||
const bobResult = results.find((r: any) => r.entity.id === 'bob')
|
||||
const charlieResult = results.find((r: any) => r.entity.id === 'charlie')
|
||||
const bobResult = results.find((r: any) => r.entity.id === v5('bob'))
|
||||
const charlieResult = results.find((r: any) => r.entity.id === v5('charlie'))
|
||||
expect(bobResult || charlieResult).toBeDefined()
|
||||
})
|
||||
|
||||
|
|
@ -154,7 +159,7 @@ describe('Unified Find() Integration Tests', () => {
|
|||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
// Should find Alice (who is friends with Bob)
|
||||
const aliceResult = results.find((r: any) => r.entity.id === 'alice')
|
||||
const aliceResult = results.find((r: any) => r.entity.id === v5('alice'))
|
||||
expect(aliceResult).toBeDefined()
|
||||
})
|
||||
|
||||
|
|
@ -169,8 +174,8 @@ describe('Unified Find() Integration Tests', () => {
|
|||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
// Should find both Bob and Charlie
|
||||
const bobResult = results.find((r: any) => r.entity.id === 'bob')
|
||||
const charlieResult = results.find((r: any) => r.entity.id === 'charlie')
|
||||
const bobResult = results.find((r: any) => r.entity.id === v5('bob'))
|
||||
const charlieResult = results.find((r: any) => r.entity.id === v5('charlie'))
|
||||
expect(bobResult).toBeDefined()
|
||||
expect(charlieResult).toBeDefined()
|
||||
})
|
||||
|
|
@ -204,9 +209,10 @@ describe('Unified Find() Integration Tests', () => {
|
|||
})
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
// Should find direct connections (Bob, Charlie) and second-degree (Diana)
|
||||
const directConnections = ['bob', 'charlie']
|
||||
const secondDegreeConnections = ['diana']
|
||||
// Should find direct connections (Bob, Charlie) and second-degree (Diana).
|
||||
// Compare against the canonical v5 ids the engine stored for each key.
|
||||
const directConnections = [v5('bob'), v5('charlie')]
|
||||
const secondDegreeConnections = [v5('diana')]
|
||||
|
||||
const hasDirect = results.some((r: any) => directConnections.includes(r.entity.id))
|
||||
const hasSecondDegree = results.some((r: any) => secondDegreeConnections.includes(r.entity.id))
|
||||
|
|
@ -326,7 +332,7 @@ describe('Unified Find() Integration Tests', () => {
|
|||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
// Charlie should be highly ranked due to direct connection
|
||||
const charlieResult = results.find((r: any) => r.entity.id === 'charlie')
|
||||
const charlieResult = results.find((r: any) => r.entity.id === v5('charlie'))
|
||||
if (charlieResult) {
|
||||
expect(charlieResult.score).toBeGreaterThan(0.5)
|
||||
}
|
||||
|
|
|
|||
225
tests/integration/id-normalization.test.ts
Normal file
225
tests/integration/id-normalization.test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* @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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue