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
139
src/brainy.ts
139
src/brainy.ts
|
|
@ -5,7 +5,8 @@
|
|||
* NO STUBS, NO MOCKS, REAL IMPLEMENTATION
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from './universal/uuid.js'
|
||||
import { v4 as uuidv4, v7 as uuidv7 } from './universal/uuid.js'
|
||||
import { coerceNewEntityId, resolveEntityId, ORIGINAL_ID_KEY } from './utils/idNormalization.js'
|
||||
import { JsHnswVectorIndex } from './hnsw/hnswIndex.js'
|
||||
// TypeAwareHNSWIndex removed from default path — single unified JsHnswVectorIndex is faster
|
||||
// for the 99% of queries that don't filter by type (avoids searching 42 separate graphs)
|
||||
|
|
@ -1206,6 +1207,20 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* })
|
||||
* ```
|
||||
*/
|
||||
/**
|
||||
* @description Mint a fresh, time-ordered entity id (UUID v7) WITHOUT a write.
|
||||
* Assign ids client-side to reference an entity before it exists — forward
|
||||
* references in `transact()`, deterministic `relate()`, no write→get→relate
|
||||
* round-trip. Time-ordered, so ids sort by creation time.
|
||||
* @returns A new UUID v7 string.
|
||||
* @example
|
||||
* const id = brain.newId()
|
||||
* await brain.add({ id, type: NounType.Document, data: 'draft' })
|
||||
*/
|
||||
newId(): string {
|
||||
return uuidv7()
|
||||
}
|
||||
|
||||
async add(params: AddParams<T>): Promise<string> {
|
||||
this.assertWritable('add')
|
||||
await this.ensureInitialized()
|
||||
|
|
@ -1235,17 +1250,22 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// missing-subtype check via the `isVFSEntity` marker.
|
||||
this.enforceSubtypeOnAdd('add', params.type, params.subtype, params.metadata)
|
||||
|
||||
// Id normalization (8.0): a natural string key (e.g. 'user-123') is coerced
|
||||
// to a STABLE UUID (v5) so the engine only ever sees a UUID, while the
|
||||
// caller's original string is preserved under ORIGINAL_ID_KEY for reads.
|
||||
// A real UUID passes through; no id mints a fresh v7. Done BEFORE the
|
||||
// ifAbsent check so a natural-key upsert is idempotent against the same key.
|
||||
const { id, originalId } = coerceNewEntityId(params.id)
|
||||
|
||||
// ifAbsent (7.31.0) — by-ID idempotent insert. Only meaningful when a custom id
|
||||
// is supplied; a freshly generated UUID can never collide. Returns the existing
|
||||
// id without writing if the entity is already present (no throw, no overwrite).
|
||||
// (canonical) id without writing if the entity is already present (no throw,
|
||||
// no overwrite).
|
||||
if (params.id && params.ifAbsent) {
|
||||
const existing = await this.storage.getNounMetadata(params.id)
|
||||
if (existing) return params.id
|
||||
const existing = await this.storage.getNounMetadata(id)
|
||||
if (existing) return id
|
||||
}
|
||||
|
||||
// Generate ID if not provided
|
||||
const id = params.id || uuidv4()
|
||||
|
||||
// Get or compute vector
|
||||
const vector = params.vector || (await this.embed(params.data))
|
||||
|
||||
|
|
@ -1263,6 +1283,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// Only metadata fields are queryable via find({ where }).
|
||||
const storageMetadata = {
|
||||
...params.metadata,
|
||||
// Preserve the caller's original (non-UUID) id when normalized, so reads
|
||||
// can surface it. A real UUID passes through with no _originalId.
|
||||
...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId }),
|
||||
data: params.data,
|
||||
noun: params.type,
|
||||
...(params.subtype !== undefined && { subtype: params.subtype }),
|
||||
|
|
@ -1299,8 +1322,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
service: params.service,
|
||||
data: params.data,
|
||||
...(params.createdBy && { createdBy: params.createdBy }),
|
||||
// Only custom fields in metadata
|
||||
metadata: params.metadata || {}
|
||||
// Only custom fields in metadata (plus the preserved original id, which
|
||||
// is a custom field — surfaced on read under ORIGINAL_ID_KEY).
|
||||
metadata: {
|
||||
...(params.metadata || {}),
|
||||
...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId })
|
||||
}
|
||||
}
|
||||
|
||||
// Execute atomically with transaction system
|
||||
|
|
@ -1477,6 +1504,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
async get(id: string, options?: GetOptions): Promise<Entity<T> | null> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
// Id normalization (8.0): a caller may read by their natural key — resolve
|
||||
// it to the same canonical UUID add() stored. A real UUID passes through.
|
||||
// Only a non-empty string is normalized; empty/null/undefined fall through
|
||||
// to the storage layer's structural-validation throw (preserved contract).
|
||||
if (typeof id === 'string' && id !== '') {
|
||||
id = resolveEntityId(id)
|
||||
}
|
||||
|
||||
// Route to metadata-only or full entity based on options
|
||||
const includeVectors = options?.includeVectors ?? false // Default: metadata-only (fast)
|
||||
|
||||
|
|
@ -1527,6 +1562,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
async batchGet(ids: string[], options?: GetOptions): Promise<Map<string, Entity<T>>> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
// Id normalization (8.0): resolve each id to its canonical UUID so callers
|
||||
// can batch-read by natural key. resolveEntityId is idempotent on real
|
||||
// UUIDs, so internal callers passing canonical ids are unaffected. The
|
||||
// returned map is keyed by the canonical (stored) id.
|
||||
ids = ids.map((id) => resolveEntityId(id))
|
||||
|
||||
const results = new Map<string, Entity<T>>()
|
||||
if (ids.length === 0) return results
|
||||
|
||||
|
|
@ -2003,6 +2044,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// Zero-config validation (static import for performance)
|
||||
validateUpdateParams(params)
|
||||
|
||||
// Id normalization (8.0): resolve a natural key to the canonical UUID add()
|
||||
// stored, so update() targets the same entity. A real UUID passes through.
|
||||
params = { ...params, id: resolveEntityId(params.id) }
|
||||
|
||||
// Reserved fields arriving via the metadata patch are remapped to their
|
||||
// canonical top-level location, mirroring add()'s lift. Without this the
|
||||
// patch value survived the merge but was then clobbered by the
|
||||
|
|
@ -2221,6 +2266,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
|
||||
await this.ensureInitialized()
|
||||
|
||||
// Id normalization (8.0): resolve a natural key to the canonical UUID add()
|
||||
// stored, so remove() deletes the same entity. A real UUID passes through.
|
||||
id = resolveEntityId(id)
|
||||
|
||||
// Get entity metadata and related verbs before deletion
|
||||
const metadata = await this.storage.getNounMetadata(id)
|
||||
const noun = await this.storage.getNoun(id)
|
||||
|
|
@ -2536,6 +2585,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// Zero-config validation (static import for performance)
|
||||
validateRelateParams(params)
|
||||
|
||||
// Id normalization (8.0): resolve BOTH endpoints so a caller may relate by
|
||||
// natural key on either side. Each maps to the same canonical UUID add()
|
||||
// stored; real UUIDs pass through. (The relationship's own id is an
|
||||
// engine-minted UUID — relation ids are never caller-supplied here.)
|
||||
params = { ...params, from: resolveEntityId(params.from), to: resolveEntityId(params.to) }
|
||||
|
||||
// Reserved fields arriving via the metadata bag are normalized to their
|
||||
// canonical top-level params before enforcement — mirror of add()'s lift.
|
||||
params = this.remapReservedRelateMetadata(params)
|
||||
|
|
@ -2924,10 +2979,19 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
await this.ensureInitialized()
|
||||
|
||||
// Handle string ID shorthand: related(id) -> related({ from: id })
|
||||
const params = typeof paramsOrId === 'string'
|
||||
const rawParams = typeof paramsOrId === 'string'
|
||||
? { from: paramsOrId }
|
||||
: (paramsOrId || {})
|
||||
|
||||
// Id normalization (8.0): resolve the anchor id(s) so a caller may traverse
|
||||
// by natural key. Each maps to the canonical UUID add() stored; real UUIDs
|
||||
// pass through. Mutates a copy so the caller's params object is untouched.
|
||||
const params: RelatedParams = {
|
||||
...rawParams,
|
||||
...(rawParams.from !== undefined && { from: resolveEntityId(rawParams.from) }),
|
||||
...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) })
|
||||
}
|
||||
|
||||
const limit = params.limit || 100
|
||||
const offset = params.offset || 0
|
||||
|
||||
|
|
@ -3668,9 +3732,24 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
await this.ensureIndexesLoaded()
|
||||
|
||||
// Parse natural language queries
|
||||
const params: FindParams<T> =
|
||||
let params: FindParams<T> =
|
||||
typeof query === 'string' ? await this.parseNaturalQuery(query) : query
|
||||
|
||||
// Id normalization (8.0): resolve the graph-traversal anchor id(s) so a
|
||||
// caller may constrain by natural key. Each maps to the canonical UUID
|
||||
// add() stored; real UUIDs pass through. Done once here so every downstream
|
||||
// consumer of params.connected sees canonical ids.
|
||||
if (params.connected && (params.connected.from !== undefined || params.connected.to !== undefined)) {
|
||||
params = {
|
||||
...params,
|
||||
connected: {
|
||||
...params.connected,
|
||||
...(params.connected.from !== undefined && { from: resolveEntityId(params.connected.from) }),
|
||||
...(params.connected.to !== undefined && { to: resolveEntityId(params.connected.to) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-config validation (static import for performance)
|
||||
validateFindParams(params)
|
||||
|
||||
|
|
@ -4503,7 +4582,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
let idsToDelete: string[] = []
|
||||
|
||||
if (params.ids) {
|
||||
idsToDelete = params.ids
|
||||
// Id normalization (8.0): resolve each supplied id to the canonical UUID
|
||||
// add() stored, so callers may delete by natural key. The find()-derived
|
||||
// path below already yields canonical ids.
|
||||
idsToDelete = params.ids.map((id) => resolveEntityId(id))
|
||||
} else if (params.type || params.where) {
|
||||
// Find entities to delete
|
||||
const entities = await this.find({
|
||||
|
|
@ -6206,15 +6288,20 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
this.enforceTrackedFieldValues({ subtype: params.subtype } as Record<string, unknown>, 'top-level')
|
||||
this.enforceSubtypeOnAdd('add', params.type, params.subtype, params.metadata)
|
||||
|
||||
// Id normalization (8.0) — mirror of add(): 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 v7. Done BEFORE ifAbsent so a
|
||||
// natural-key upsert is idempotent against the same key.
|
||||
const { id, originalId } = coerceNewEntityId(params.id)
|
||||
|
||||
// ifAbsent — idempotent by-id insert, resolved against batch + store.
|
||||
if (params.id && params.ifAbsent) {
|
||||
const existing = await this.planGetEntity(state, params.id)
|
||||
const existing = await this.planGetEntity(state, id)
|
||||
if (existing) {
|
||||
return params.id
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
const id = params.id || uuidv4()
|
||||
const vector = params.vector || (await this.embed(params.data))
|
||||
if (!this.dimensions) {
|
||||
this.dimensions = vector.length
|
||||
|
|
@ -6234,6 +6321,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const now = Date.now()
|
||||
const storageMetadata = {
|
||||
...params.metadata,
|
||||
// Preserve the caller's original (non-UUID) id when normalized — mirror
|
||||
// of add(). A real UUID passes through with no _originalId.
|
||||
...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId }),
|
||||
data: params.data,
|
||||
noun: params.type,
|
||||
...(params.subtype !== undefined && { subtype: params.subtype }),
|
||||
|
|
@ -6264,7 +6354,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
service: params.service,
|
||||
data: params.data,
|
||||
...(params.createdBy && { createdBy: params.createdBy }),
|
||||
metadata: params.metadata || {}
|
||||
metadata: {
|
||||
...(params.metadata || {}),
|
||||
...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId })
|
||||
}
|
||||
}
|
||||
|
||||
plan.operations.push(
|
||||
|
|
@ -6297,6 +6390,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// remap to their dedicated param (top-level wins), system-managed fields
|
||||
// drop with a one-shot warning.
|
||||
const params = this.remapReservedUpdateMetadata(rawParams as UpdateParams<T>)
|
||||
// Id normalization (8.0) — mirror of update(): a natural key resolves to the
|
||||
// canonical UUID add() stored. A real UUID passes through.
|
||||
params.id = resolveEntityId(params.id)
|
||||
this.enforceTrackedFieldValues(params.metadata as Record<string, unknown> | undefined, 'metadata')
|
||||
if (params.subtype !== undefined) {
|
||||
this.enforceTrackedFieldValues({ subtype: params.subtype } as Record<string, unknown>, 'top-level')
|
||||
|
|
@ -6447,7 +6543,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
if (!op.id || typeof op.id !== 'string') {
|
||||
throw new Error(`transact(): remove operation requires an entity id (got ${JSON.stringify(op.id)})`)
|
||||
}
|
||||
const id = op.id
|
||||
// Id normalization (8.0) — mirror of remove(): a natural key resolves to the
|
||||
// canonical UUID add() stored. A real UUID passes through.
|
||||
const id = resolveEntityId(op.id)
|
||||
|
||||
const pending = state.nouns.get(id)
|
||||
const metadata = pending ? pending.metadata : await this.storage.getNounMetadata(id)
|
||||
|
|
@ -6531,6 +6629,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
validateRelateParams(rawParams as RelateParams<T>)
|
||||
// Same reserved-field normalization as relate().
|
||||
const params = this.remapReservedRelateMetadata(rawParams as RelateParams<T>)
|
||||
// Id normalization (8.0) — mirror of relate(): resolve BOTH endpoints to the
|
||||
// canonical UUID add() stored, so a relate op may reference either side by
|
||||
// natural key. Real UUIDs pass through. (Relationship ids are engine-minted.)
|
||||
params.from = resolveEntityId(params.from)
|
||||
params.to = resolveEntityId(params.to)
|
||||
this.enforceSubtypeOnRelate('relate', params.type, params.subtype, params.metadata)
|
||||
|
||||
const fromEntity = await this.planGetEntity(state, params.from)
|
||||
|
|
@ -11451,7 +11554,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// undefined (default) → no auto-detection (safe default)
|
||||
// false → no auto-detection
|
||||
// [] → no auto-detection
|
||||
// ['@soulcraft/cortex'] → load only these explicitly listed packages
|
||||
// ['@soulcraft/cor'] → load only these explicitly listed packages
|
||||
// Note: plugins registered via brain.use() are always activated regardless of config
|
||||
const pluginConfig = this.config.plugins
|
||||
if (Array.isArray(pluginConfig) && pluginConfig.length > 0) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue