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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue