diff --git a/src/internals.ts b/src/internals.ts index 90ae3d3a..bcd067cf 100644 --- a/src/internals.ts +++ b/src/internals.ts @@ -7,7 +7,11 @@ export type { UnifiedCacheConfig, CacheItem } from './utils/unifiedCache.js' export { prodLog, createModuleLogger } from './utils/logger.js' export { FieldTypeInference, FieldType } from './utils/fieldTypeInference.js' export type { FieldTypeInfo } from './utils/fieldTypeInference.js' -export { EntityIdMapper } from './utils/entityIdMapper.js' +export { + EntityIdMapper, + EntityIdSpaceExceeded, + U32_ENTITY_ID_MAX, +} from './utils/entityIdMapper.js' export type { EntityIdMapperOptions, EntityIdMapperData } from './utils/entityIdMapper.js' export { getRecommendedCacheConfig, formatBytes, checkMemoryPressure } from './utils/memoryDetection.js' export type { MemoryInfo, CacheAllocationStrategy } from './utils/memoryDetection.js' diff --git a/src/utils/entityIdMapper.ts b/src/utils/entityIdMapper.ts index 5333c025..f2e25778 100644 --- a/src/utils/entityIdMapper.ts +++ b/src/utils/entityIdMapper.ts @@ -36,6 +36,51 @@ import type { StorageAdapter } from '../coreTypes.js' import type { EntityIdMapperProvider } from '../plugin.js' +/** + * The largest entity int the JS fallback `EntityIdMapper` will allocate. + * The metadata index's roaring bitmaps are 32-bit-keyed (`Roaring32`), so + * allowing the JS counter past this point would silently corrupt every + * downstream bitmap. The cortex 3.0 binary mapper supports a U64 IdSpace + * for brains above this ceiling — see the + * [`EntityIdSpaceExceeded`](#EntityIdSpaceExceeded) message for the + * migration pointer. + */ +export const U32_ENTITY_ID_MAX = 0xffff_ffff + +/** + * Thrown by the JS fallback `EntityIdMapper` when `nextId` would exceed + * [`U32_ENTITY_ID_MAX`](#U32_ENTITY_ID_MAX). The JS path is the + * cortex-free fallback; once a brain has more than ~4.29 B entities, + * callers MUST install the cortex 3.0 `NativeBinaryEntityIdMapper` with + * `idSpace: 'u64'` (mmap-backed extendible-hash KV; persists the full + * u64 range losslessly). + * + * Brainy 8.0 surfaces this loudly rather than silently widening past + * u32::MAX, because the metadata index's roaring bitmaps would silently + * truncate entity ids and zero-out query results. + */ +export class EntityIdSpaceExceeded extends Error { + /** The u32 entity-id ceiling. */ + readonly ceiling: number = U32_ENTITY_ID_MAX + /** + * The would-be `nextId` value the mapper was about to assign — always + * `U32_ENTITY_ID_MAX + 1`. + */ + readonly attempted: number + + constructor(attempted: number) { + super( + `EntityIdMapper: nextId ${attempted} would exceed u32::MAX ` + + `(${U32_ENTITY_ID_MAX}). The JS fallback mapper caps at u32 to ` + + `match the metadata index's Roaring32 bitmap width. For >4.29 B ` + + `entities, install @soulcraft/cortex and configure the binary ` + + `mapper with idSpace: 'u64' (mmap-backed extendible-hash KV).`, + ) + this.name = 'EntityIdSpaceExceeded' + this.attempted = attempted + } +} + export interface EntityIdMapperOptions { storage: StorageAdapter storageKey?: string @@ -109,7 +154,13 @@ export class EntityIdMapper implements EntityIdMapperProvider { } /** - * Get integer ID for UUID, assigning a new ID if not exists + * Get integer ID for UUID, assigning a new ID if not exists. + * + * The JS fallback mapper caps at [`U32_ENTITY_ID_MAX`](#U32_ENTITY_ID_MAX) + * to match the metadata index's Roaring32 bitmap width — once `nextId` + * would exceed that, throws {@link EntityIdSpaceExceeded} so the caller + * loudly migrates to cortex's binary mapper with `idSpace: 'u64'` + * rather than silently truncating entity ids. */ getOrAssign(uuid: string): number { const existing = this.uuidToInt.get(uuid) @@ -118,6 +169,9 @@ export class EntityIdMapper implements EntityIdMapperProvider { } // Assign new ID + if (this.nextId > U32_ENTITY_ID_MAX) { + throw new EntityIdSpaceExceeded(this.nextId) + } const newId = this.nextId++ this.uuidToInt.set(uuid, newId) this.intToUuid.set(newId, uuid) diff --git a/tests/unit/utils/entity-id-mapper-u32-ceiling.test.ts b/tests/unit/utils/entity-id-mapper-u32-ceiling.test.ts new file mode 100644 index 00000000..bbca5dbc --- /dev/null +++ b/tests/unit/utils/entity-id-mapper-u32-ceiling.test.ts @@ -0,0 +1,98 @@ +/** + * @description Brainy 8.0 IdSpace contract: the JS fallback + * `EntityIdMapper` caps at `u32::MAX` to match the metadata index's + * Roaring32 bitmap width. When `nextId` would exceed the ceiling, + * `getOrAssign` throws `EntityIdSpaceExceeded` with a message pointing + * at the cortex 3.0 binary mapper's `idSpace: 'u64'` mode as the + * migration path. + * + * This is the lockstep counterpart to cortex 3.0's Piece 10 / Step 15 + * (TS wrapper + napi BigInt siblings + Roaring32-or-Treemap + * `PostingList` enum). Without this guard, a JS-only brainy install + * past 4.29 B entities would silently truncate entity ids into the + * low-32-bit range, zeroing query results and corrupting any + * persisted int-keyed structure that consumed the mapper. + */ + +import { describe, it, expect } from 'vitest' +import { + EntityIdMapper, + EntityIdSpaceExceeded, + U32_ENTITY_ID_MAX, +} from '../../../src/utils/entityIdMapper.js' + +/** + * Construct a fresh in-memory mapper without going through brainy's + * full storage adapter — we only exercise the in-RAM ceiling guard + * here, so a minimal `getMetadata`-returning shim is enough. + */ +function makeMapper(): EntityIdMapper { + const storage = { + getMetadata: async () => null, + setMetadata: async () => {}, + getNouns: async () => ({ items: [], totalCount: 0 }), + } as any + return new EntityIdMapper({ storage }) +} + +describe('EntityIdMapper U32 IdSpace ceiling (Brainy 8.0)', () => { + it('U32_ENTITY_ID_MAX equals 0xFFFF_FFFF (u32 ceiling)', () => { + expect(U32_ENTITY_ID_MAX).toBe(0xffff_ffff) + }) + + it('EntityIdSpaceExceeded is an Error subclass with attempted + ceiling', () => { + const err = new EntityIdSpaceExceeded(0xffff_ffff + 1) + expect(err).toBeInstanceOf(Error) + expect(err.name).toBe('EntityIdSpaceExceeded') + expect(err.ceiling).toBe(0xffff_ffff) + expect(err.attempted).toBe(0x1_0000_0000) + expect(err.message).toMatch(/u32::MAX/) + expect(err.message).toMatch(/cortex/) + expect(err.message).toMatch(/idSpace: 'u64'/) + }) + + it('normal allocations under the ceiling succeed', async () => { + const m = makeMapper() + await m.init() + const a = m.getOrAssign('uuid-a') + const b = m.getOrAssign('uuid-b') + expect(a).toBe(1) + expect(b).toBe(2) + expect(a).not.toBe(b) + }) + + it('throws EntityIdSpaceExceeded when nextId would exceed u32::MAX', async () => { + const m = makeMapper() + await m.init() + // Reach into the mapper to bump `nextId` past the ceiling without + // actually allocating 4.29 B entities at test time. This mirrors + // the runtime invariant the guard protects. + ;(m as any).nextId = U32_ENTITY_ID_MAX + 1 + expect(() => m.getOrAssign('uuid-overflow')).toThrow(EntityIdSpaceExceeded) + }) + + it('the last representable u32 int IS allocatable (boundary case)', async () => { + const m = makeMapper() + await m.init() + ;(m as any).nextId = U32_ENTITY_ID_MAX + // `nextId === U32_ENTITY_ID_MAX` is still within range — the + // guard checks `> U32_ENTITY_ID_MAX`, so this last allocation + // succeeds. + const id = m.getOrAssign('uuid-at-ceiling') + expect(id).toBe(U32_ENTITY_ID_MAX) + // The NEXT allocation overflows. + expect(() => m.getOrAssign('uuid-after-ceiling')).toThrow( + EntityIdSpaceExceeded, + ) + }) + + it('existing uuid lookup never overflows even when nextId is past ceiling', async () => { + const m = makeMapper() + await m.init() + const assigned = m.getOrAssign('uuid-existing') + ;(m as any).nextId = U32_ENTITY_ID_MAX + 1 + // Looking up an already-assigned UUID does NOT allocate; the + // guard is on the assign path only. + expect(m.getOrAssign('uuid-existing')).toBe(assigned) + }) +})