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
72
src/db/db.ts
72
src/db/db.ts
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue