HAID (Hybrid Adaptive Identifier) — npm package modeid. Source repo: sagarchive/modeid.
Hybrid Adaptive Identifier — 128-bit IDs with explicit profiles for row-store DBs, hash-partitioned stores, and public-facing opaque tokens.
- Profile-based —
db,shard,opaque; pick by storage engine and threat model, not one global format - 128-bit — Same size as UUID/ULID; 26-character Crockford base32 strings (URL-safe, no hyphens on output)
- Documented wire format —
SPEC.mdfrozen at v0.1.0; conformance tests in-repo - Sortable —
dbandopaque(with key) approximate creation-time lex order;shardspreads partitions - Monotonic semantics — Shared generator core: same-ms counter, clock-rollback handling, drift guard (
SPEC.md§6) - Zero runtime dependencies — TypeScript compiled to ESM; tree-shakable subpath exports
- Cross-runtime — Node 18+, browsers, Workers, Deno, Bun (Web Crypto for
opaque)
Status: v0.1.0 — wire format and encoding are frozen. Public API may change until v1.0. Not a drop-in replacement for RFC UUID or ULID bytes on the wire.
1. Install
npm install modeidLocal development (before publish):
npm install file:../modeid2. Generate an ID
import { db } from 'modeid';
const id = db.generate();
id.toString(); // ⇨ '400sw0shksqbv0pgr1f4m0y31w' (26 chars)
id.timestamp(); // ⇨ 1715000000000
id.profile(); // ⇨ 'db'3. Parse and validate
import { parse, validate } from 'modeid';
const back = parse(id.toString());
back.equals(id); // ⇨ true
validate(id.toString()); // ⇨ true
validate('not-a-modeid'); // ⇨ false| Profile | Use instead of | Storage / exposure |
|---|---|---|
db |
UUIDv7, ULID (B-tree PK) | Postgres/MySQL uuid / binary(16) PK |
shard |
UUIDv7 on Cassandra/Dynamo | Hash-partitioned writes; prefix spread |
opaque |
UUIDv4 in public URLs | External IDs; hide creation time without key |
There is no single profile that replaces all UUID and ULID uses. See Migration.
| Export | Description |
|---|---|
db.generate() |
Default row-store ID (sync) |
DbGenerator |
Configurable db generator |
shard.generate() |
Default sharded-store ID (sync) |
ShardGenerator |
Keyed / generatorId shard generator |
OpaqueGenerator |
Public opaque IDs (async) |
unwrapOpaque() |
Recover time + counter with key |
parse(str) |
Parse string → HaidId |
validate(str) |
true if valid HAID string |
compare(a, b) |
Lexicographic byte compare |
HaidId |
Opaque token type |
import { generate } from 'modeid/db';
import { ShardGenerator } from 'modeid/shard';
import { OpaqueGenerator, unwrapOpaque } from 'modeid/opaque';
import { parse, validate } from 'modeid';Returned by all generators and parse().
| Method | Returns | Notes |
|---|---|---|
toString() |
string |
26-char lowercase Crockford; canonical form |
toBytes() |
Uint8Array |
16 bytes; store in uuid / bytea columns |
profile() |
'db' | 'shard' | 'opaque' |
From header byte |
timestamp() |
number |
Unix ms; throws on opaque (use unwrapOpaque) |
equals(other) |
boolean |
Constant-time-ish byte compare |
debug() |
object |
Unstable; debugging only |
str |
26-char HAID string (hyphens allowed, stripped) |
| returns | HaidId |
| throws | Invalid length, charset, version, profile, or trailing bits |
import { parse } from 'modeid';
parse('400sw0shksqbv0pgr1f4m0y31w');str |
Candidate string |
| returns | boolean — never throws |
import { validate } from 'modeid';
validate('400sw0shksqbv0pgr1f4m0y31w'); // true| returns | -1 | 0 | 1 — lexicographic on 16-byte form |
For db IDs, byte order ≈ creation-time order. For shard, use .timestamp() for time ordering.
import { compare, db } from 'modeid';
const a = db.generate();
const b = db.generate();
compare(a, b); // -1 | 0 | 1Row-store primary keys. Layout: 48-bit unix_ms + 16-bit monotonic counter + 56-bit entropy. Header 0x20.
Module convenience
import { db } from 'modeid';
const id = db.generate();DbGenerator
import { DbGenerator } from 'modeid';
const gen = new DbGenerator({
timeSource: () => Date.now(),
maxDriftMs: 3_600_000, // throw if clock drifts >1h (default)
});
const id = gen.generate();| Option | Default | Description |
|---|---|---|
timeSource |
Date.now |
Millisecond clock |
maxDriftMs |
3600000 |
Max lastMs - now before throw |
Hash-partitioned stores. SipHash-2-4 prefix (16 bits) + 48-bit time + 56-bit entropy. Header 0x21. No on-wire counter (entropy only within a ms).
import { shard, ShardGenerator } from 'modeid';
shard.generate();
const gen = new ShardGenerator({
key: undefined, // default: zero key (public prefix derivation)
generatorId: myPodId, // 8 bytes; default random per process
});
const id = gen.generate();
const prefix = gen.prefixFor(Date.now()); // range-scan helper| Option | Default | Description |
|---|---|---|
key |
16 zero bytes | SipHash key; set for keyed mode |
generatorId |
random 8 bytes | Per-instance; spreads same-ms writes across shards |
Public-facing IDs. AES-128 single-block keystream XOR over encrypted time+counter; 56-bit nonce in plaintext. Header 0x22. Requires 16-byte key.
import { OpaqueGenerator, unwrapOpaque } from 'modeid';
const key = crypto.getRandomValues(new Uint8Array(16));
const gen = new OpaqueGenerator({ key });
const id = await gen.generate();
id.toString(); // looks random to outsiders
const { timestamp, counter } = await unwrapOpaque(id, key);generate() |
Promise<HaidId> — async (Web Crypto) |
unwrapOpaque(id, key) |
Promise<{ timestamp, counter }> |
Not provided: MAC/authenticity; treat IDs as opaque tokens and validate existence in your DB.
- Alphabet: Crockford base32 lowercase (
0123456789abcdefghjkmnpqrstvwxyz) - Length: 26 characters for 128 bits
- Parse: case-insensitive;
I/L→1,O→0; hyphens ignored - Output: never emits hyphens
Store id.toBytes() (16 bytes) in Postgres uuid, MySQL binary(16), or text via id.toString().
-- Postgres example
CREATE TABLE items (
id uuid PRIMARY KEY DEFAULT NULL,
...
);
-- insert: pass 16-byte buffer from id.toBytes()| From | To | Action |
|---|---|---|
uuid.v7() / ULID for new PKs |
db.generate() |
New column or new tables only; re-encode is required |
uuid.v4() in URLs |
opaque + key |
Add key management; async generate |
| Hot partitions with time-sorted UUIDs | shard |
Use prefixFor() for range scans |
| Existing UUID/ULID columns | — | No in-place wire upgrade; migrate with backfill |
Not supported: RFC v1/v3/v4/v5/v6, namespace UUIDs, hyphenated UUID canonical form, ULID byte compatibility.
Rough equivalence
- ULID: 48-bit time + 80-bit random, monotonic via random increment → HAID
dbuses explicit 16-bit counter + 56-bit entropy and a profile header. - uuid v7: RFC layout and 36-char hex → HAID
dbis 26-char Crockford with different bit layout (SPEC.md).
| Runtime | Support |
|---|---|
| Node.js | 18+ (CI: 20, 22 on Ubuntu + Windows) |
| TypeScript | Types included; strict compatible |
| Browsers | ESM + Web Crypto (opaque needs crypto.subtle) |
| Deno / Bun / Workers | Expected to work; not all matrix-tested in CI |
| React Native | Polyfill crypto.getRandomValues / subtle before import (same as uuid) |
Branch naming: Conventional Branch 1.0.0 (main, feature/…, fix/…, release/v0.1.0, etc.).
npm ci
npm run typecheck
npm run build
npm test # 54 tests
npm run test:smoke # consumer smoke against dist/Web Crypto AES has no sync API in portable code. Use await or .then().
Strings and bytes differ. Do not feed HAID strings into uuid.parse() or ULID decoders.
If this package lives in a monorepo, point the CI badge at your repo’s workflow path.
- Wire format:
SPEC.md - Changelog:
CHANGELOG.md - License: MIT