- TypeScript 77.4%
- JavaScript 22.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
CI / node 20 · typecheck · lint · format · test · build (push) Failing after 0s
CI / node 22 · typecheck · lint · format · test · build (push) Failing after 0s
CI / node 24 · typecheck · lint · format · test · build (push) Failing after 0s
CI / node 26 · typecheck · lint · format · test · build (push) Failing after 0s
Implements the expanded UEID Specification v1 (full bit layout
+ encoding + parsing, not just the model field). The spec
repo's references in the README were unqualified, and a future
reader would reasonably expect ueid-spec to define the whole
format, not just the model field algorithm.
Spec conformance:
- conformance/roundtrip-vectors.json: 8 vectors pinning every
field of a UEID (unix_ts_ms, ver, model, count, var, random,
hex, dashed, undashed). Mirrors the canonical set in ueid-spec.
- scripts/generate-roundtrip-vectors.js: regenerate the file
when the bit layout or encoding changes.
- scripts/check-conformance.mjs: now verifies BOTH the model-
hash vectors and the round-trip vectors.
- test/conformance.test.ts: 10 vitest cases covering round-trip,
layout invariants, fixed-bit validation, dashed/undashed
forms.
Bug fix from the spec work:
- The hardcoded test-vector string used by the Ruby gem and
inherited here ('019c2c83-8104-83fa-8417-5fb7e28a5464')
encoded model=65, count=7 in its bits, not the documented
model=63 (String), count=10. Corrected everywhere to
'019c2c83-8104-83fa-8000-1fb7e28a5464'.
- test/ueid.test.ts updated with the matching byte constant
and the non-byte-aligned round-trip case now exercises a
value with valid ver/var bits.
Spec compliance:
- UEID.fromString now rejects strings whose ver field is not
0b1000 or whose var field is not 0b10, per SPECIFICATION.md
§2.2 and §2.5. Previous behaviour silently accepted any
32-hex-char string regardless of reserved bits. Throws
UEIDFormatError on mismatch.
Docs:
- README: retitle 'Model ID Specification' section to 'UEID
Specification' to match the spec repo's scope.
- CHANGELOG: document the additions.
86/86 tests pass; all 66 model-hash and 8 round-trip conformance
vectors verify against the canonical spec.
|
||
| .github/workflows | ||
| .husky | ||
| conformance | ||
| scripts | ||
| src | ||
| test | ||
| .editorconfig | ||
| .gitignore | ||
| .lintstagedrc.json | ||
| .prettierignore | ||
| .prettierrc.json | ||
| CHANGELOG.md | ||
| eslint.config.mjs | ||
| LICENSE | ||
| package.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
ueid
TypeScript implementation of Universal Entity IDs (UEIDs) — universal, machine-identifiable record identifiers. Zero runtime dependencies. ESM. Node 20+.
This is a port of ueid-ruby to TypeScript, with the ActiveRecord integration removed. Models are registered explicitly through the Registry API.
The 8-bit model field follows the UEID Specification, Version 1, which both implementations verify against the same shared test-vector set.
What is a UEID?
UEIDs follow the same design principle as UXIDs (human-friendly, type-tagged IDs) but optimise for machine processing and storage efficiency. They are an implementation of UUID Version 8, combining:
- the timestamp ordering of UUIDv7,
- a model field identifying the entity type (8 bits),
- a sequential counter for intra-millisecond uniqueness (4 bits), and
- a random component for global uniqueness (62 bits).
They can be stored using the native UUID data types of modern databases — PostgreSQL's uuid, for example — which makes them more compact and faster to index than string-based identifiers.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms | ver | model | count |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var| random |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| random |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Fields
unix_ts_ms (48 bits)
A 48-bit big-endian unsigned integer of the Unix epoch timestamp in milliseconds, per the UUID Timestamp Considerations. Identical to the timestamp field of UUIDv7.
ver (4 bits)
The 4-bit UUIDv8 version per the UUID Version Field. Always 0b1000 (= 8).
model (8 bits)
An 8-bit unsigned integer identifying the entity type. The hash is computed by FNV-1a (32-bit) over the UTF-8 bytes of the class name, then folded to 8 bits.
count (4 bits)
A 4-bit per-process counter that increments with each UEID generated within the same millisecond and model. The counter wraps at 16; after that, the 62 random bits are still strong enough to keep two UEIDs distinct with overwhelming probability.
var (2 bits)
The 2-bit UUID variant per the UUID Variant Field. Always 0b10 (= RFC 4122bis).
random (62 bits)
62 bits of cryptographically random data sourced from crypto.randomBytes in Node.
Limitations
- Model identifiers are only unique within a single application. Different applications may use the same model identifier for different entity types.
- The 8-bit
modelfield is a 256-bucket hash. Two different identifiers have a 50% chance of colliding once you register roughly 20 of them (birthday paradox).Registry.hashForClassthrows on collision by default; rename one of the conflicting identifiers or callRegistry.hashForClassAllowingCollisionto take the Ruby gem's silent-overwrite behaviour. - Up to 256 unique entity types are theoretically possible per application, but in practice you should expect collisions well before that ceiling.
- UEIDs can guarantee a maximum of 16 unique IDs per model per millisecond. Applications requiring higher throughput should implement additional mechanisms.
- Only 62 bits of randomness are provided, compared to 74 in UUIDv7 and 122 in UUIDv4. This is generally sufficient for most applications.
Installation
pnpm add ueid
# or
npm install ueid
Usage
import { generate, toClass, Registry, UEID } from "ueid";
// Register your classes explicitly (one-time, at boot).
// The registry remembers the hash → class mapping so that
// `toClass(ueid)` can recover the original class.
Registry.hashForClass(User);
Registry.hashForClass(Order);
// Generate a UEID for a class
const id: string = generate(User);
// → "019f85ab-9608-8681-8100-2dfe0f56b1ac"
// ...or for a string identifier
const orderId: string = generate("Order");
// Round-trip back to the class
const klass = toClass(id); // → User
// Parse manually if you want the bytes
const parsed = UEID.fromString(id);
console.log(parsed.bytes); // bigint, 128 bits
The Registry accepts either a string or anything with a .name property:
Registry.hashForClass("User"); // by name
Registry.hashForClass(User); // by class
Registry.hashForClass({ name: "Order" }); // by tagged object
Database storage
UEIDs are 128 bits and fit in a standard UUID column. For example, with Drizzle:
import { uuid } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id")
.primaryKey()
.$defaultFn(() => generate(users)),
// ...
});
You can assign UEIDs from a beforeInsert-style hook in your ORM of choice, or generate them application-side and pass the string in.
API
generate(klass: ClassLike): string
Mint a UEID for the given class identifier and return its canonical 36-character UUID form. The class is registered with the Registry if it is not already.
generate(User); // string
generate("User"); // string
toClass(ueid: string): ClassLike
Parse a UEID string and return the class identifier that minted it. Throws UEIDLookupError if no class is registered for the embedded model hash.
toClass("019f85ab-9608-8681-8100-2dfe0f56b1ac"); // → User
Registry.hashForClass(klass: ClassLike): number
Compute (or return the cached) 8-bit hash for the class identifier. Idempotent — re-registering the same identifier returns the cached hash without touching any state.
Collisions. The 8-bit model field is a 256-bucket hash; the birthday-paradox threshold is around 20–25 identifiers. If a different identifier hashes to a slot already occupied by another registration, hashForClass throws UEIDLookupError naming both colliding identifiers and the shared hash. Re-registering the same identifier is a no-op and never triggers the collision check.
If you want the Ruby gem's silent-overwrite behaviour (the later registration wins, displacing whatever was there), call hashForClassAllowingCollision instead.
Registry.hashForClassAllowingCollision(klass: ClassLike): number
Same as hashForClass but silently overwrites any prior binding for the computed hash. This is the Ruby gem's default behaviour, preserved here for callers who have measured a collision and want to keep it. Most callers should prefer the strict hashForClass and rename the colliding identifier instead.
Registry.computeModelHash(name: string): number
Pure function: compute the 8-bit hash for a string identifier without consulting or mutating the registry. Useful for probing collisions before registering, or for tests that need to assert hash values without side effects.
Registry.classForHash(hash: number): ClassLike
Look up the class identifier associated with the given hash. Throws if no class is registered for that hash.
Registry.clear(): void
Forget every registered mapping. Intended for tests.
Registry.size(): number
Number of distinct identifiers currently registered.
UEID.fromString(input: string): UEID
Parse a UEID from either the dashed 8-4-4-4-12 form or the un-dashed 32-hex-char form. Throws UEIDFormatError on malformed input.
new UEID(bytes: bigint | number)
Construct a UEID from its raw 128-bit value.
ueid.bytes: bigint
Raw 128-bit value, bigint.
ueid.toString(): string
Canonical 8-4-4-4-12 lowercase hex form with dashes (36 chars).
ueid.toClass(): ClassLike
Same as Registry.classForHash(extractModelBits(ueid)).
ueid.equals(other: unknown): boolean
Structural equality on the underlying bigint.
Errors
UEIDError— base class for every UEID-related failure.UEIDFormatError— malformed input toUEID.fromStringornew UEID.UEIDLookupError— model hash is not registered, or invalid class identifier.
Bit-layout constants
For callers that want to inspect or manipulate UEID bytes directly:
import {
COUNTER_MASK,
COUNTER_SHIFT,
MODEL_SHIFT,
RANDOM_MASK,
TIMESTAMP_MASK,
TIMESTAMP_SHIFT,
UUID_VARIANT,
UUID_VERSION,
VARIANT_SHIFT,
VERSION_SHIFT,
} from "ueid";
Each constant is exported as a bigint (masks / shifts) or number (mask nibbles). The bit positions match the layout diagram above and the Ruby reference gem.
Differences from the Ruby gem
- The ActiveRecord auto-registration path is removed. You call
Registry.hashForClass(klass)explicitly. - The byte value is held as a JavaScript
bigintinstead of a RubyInteger. All bitwise operations onbigintare sign-aware, but every UEID is non-negative so the semantics are identical. - Randomness comes from
crypto.randomBytes; the timestamp comes fromDate.now(). - A
ClassLikeaccepts either a string identifier or any value with a non-emptyname: stringproperty, making it convenient to test without instantiating real domain classes. - The
to_bytesRuby alias is not provided — the publicbytesfield is sufficient.
Development
Requirements
- Node 20 or later (CI matrix: 20, 22, 24, 26 — Iron, Jod, Krypton, current)
- pnpm 11 or later
Setup
git clone https://git.anteater-wall.ts.net/kalmiya/ueid-ts.git
cd ueid-ts
pnpm install
Scripts
| Script | What it does |
|---|---|
pnpm typecheck |
tsc -p tsconfig.json (typecheck src + test, no emit) |
pnpm test |
vitest run — one-shot test execution |
pnpm test:watch |
vitest — watch mode |
pnpm build |
tsc -p tsconfig.build.json — emit dist/ |
pnpm lint |
eslint over src/ and test/ |
pnpm lint:fix |
eslint --fix |
pnpm format |
prettier --write over the source, tests, and config |
pnpm format:check |
prettier --check (used in CI) |
pnpm prepare |
husky — installs the git hooks (runs on pnpm install) |
Verifying everything locally
pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build
This is the same gate the GitHub Actions CI workflow runs on every push and PR.
Pre-commit hook
Husky runs lint-staged on every commit, which formats and lints staged files. Hooks live in .husky/ and the staged-file globs are in package.json under lint-staged.
CI
The GitHub Actions workflow at .github/workflows/ci.yml runs on push and PR to trunk. It tests against a matrix of Node versions — Iron (20), Jod (22), Krypton (24), and the current release line (26) — so every supported Node release line is exercised on every change. Each matrix entry installs dependencies with the frozen lockfile, then runs format check, lint, typecheck, test, conformance check (UEID Model ID Spec v1), build, and a smoke import of the built artifact.
UEID Specification
This library implements the UEID Specification, Version 1. The full algorithm contract — bit layout, encoding, parsing rules, the FNV-1a-32 + iterative xor-shift fold that produces the model field, the conformance checklist, and the round-trip vectors that pin every field of a UEID — is in SPECIFICATION.md in the spec repository. Test vectors live at conformance/vectors.json (model-hash) and conformance/roundtrip-vectors.json (full-bit-layout), both mirrored in this repository.
The conformance check (scripts/check-conformance.mjs) is the canonical reference implementation in TypeScript; the same vectors are checked by the Ruby gem (scripts/check-conformance.rb in the ueid-ruby repo). Both run in their respective CI pipelines.
License
MIT — see LICENSE.