fix(8.0): accept application-supplied entity ids, not just UUIDs

Sharding required a 32-hex UUID and threw "Invalid UUID format" on every
non-UUID id, even though add()'s own docs show `id: "user-12345"` and the u64
id-mapper happily assigns ints to any string. So custom ids mapped fine in
memory then threw on save — breaking documented usage and any 7.x consumer
using friendly ids (`'user-123'`, slugs, emails) on upgrade.

getShardId() (renamed from getShardIdFromUuid) now buckets UUID-format ids by
their first byte (on-disk layout unchanged, so existing data never moves) and
hashes any other id via FNV-1a into the same 256-bucket space. The hash is part
of the on-disk contract and must not change. Verbs stay Brainy-generated UUIDs
by contract; only custom noun ids take the hash path.

Adds getShardId unit coverage: dual scheme, determinism, even distribution
across buckets, and the empty-id guard.
This commit is contained in:
David Snelling 2026-06-16 10:40:40 -07:00
parent 5096f90fbc
commit 36b7216929
3 changed files with 111 additions and 37 deletions

View file

@ -0,0 +1,52 @@
/**
* Shard-bucket resolution for entity ids.
*
* `getShardId` buckets every entity id into one of 256 dirs (00-ff). It must
* accept BOTH UUID-format ids (bucketed by first byte the original on-disk
* layout) AND arbitrary application ids like `'user-123'` (bucketed by a stable
* hash). A refactor once required UUID format and threw on everything else,
* which broke the documented `add({ id: '…' })` contract. These tests pin the
* dual scheme, determinism, and distribution.
*/
import { describe, it, expect } from 'vitest'
import { getShardId, getAllShardIds } from '../../../src/storage/sharding.js'
describe('getShardId', () => {
it('buckets a UUID by its first byte (with or without hyphens)', () => {
expect(getShardId('ab123456-1234-5678-9abc-def012345678')).toBe('ab')
expect(getShardId('ab123456123456789abcdef012345678')).toBe('ab')
expect(getShardId('CD987654-4321-8765-cba9-fed543210987')).toBe('cd') // case-insensitive
expect(getShardId('00000000-0000-0000-0000-000000000000')).toBe('00')
})
it('buckets an arbitrary application id without throwing (the regression)', () => {
const shard = getShardId('user-12345')
expect(shard).toMatch(/^[0-9a-f]{2}$/)
})
it('is deterministic for application ids', () => {
expect(getShardId('order-42')).toBe(getShardId('order-42'))
expect(getShardId('alice@example.com')).toBe(getShardId('alice@example.com'))
})
it('distributes application ids across many buckets (not one hot bucket)', () => {
const buckets = new Set<string>()
for (let i = 0; i < 1000; i++) {
buckets.add(getShardId(`entity-${i}`))
}
// FNV-1a over 1000 sequential keys should touch a large fraction of the
// 256-bucket space; a degenerate hash would collapse to a handful.
expect(buckets.size).toBeGreaterThan(200)
})
it('every produced shard is a valid bucket id', () => {
const valid = new Set(getAllShardIds())
for (const sample of ['user-1', 'café', '🚀', 'a', 'A_VERY_LONG_APPLICATION_KEY_0001']) {
expect(valid.has(getShardId(sample))).toBe(true)
}
})
it('throws on an empty id', () => {
expect(() => getShardId('')).toThrow(/id is required/)
})
})