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:
parent
5096f90fbc
commit
36b7216929
3 changed files with 111 additions and 37 deletions
52
tests/unit/storage/sharding.test.ts
Normal file
52
tests/unit/storage/sharding.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue