Skip to content

sagarchive/modeid

Repository files navigation

modeid

HAID (Hybrid Adaptive Identifier) — npm package modeid. Source repo: sagarchive/modeid.

CI

Hybrid Adaptive Identifier — 128-bit IDs with explicit profiles for row-store DBs, hash-partitioned stores, and public-facing opaque tokens.

  • Profile-baseddb, 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 formatSPEC.md frozen at v0.1.0; conformance tests in-repo
  • Sortabledb and opaque (with key) approximate creation-time lex order; shard spreads 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.

Quickstart

1. Install

npm install modeid

Local development (before publish):

npm install file:../modeid

2. 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

When to use which profile

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.


API summary

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

Subpath imports (tree-shaking)

import { generate } from 'modeid/db';
import { ShardGenerator } from 'modeid/shard';
import { OpaqueGenerator, unwrapOpaque } from 'modeid/opaque';
import { parse, validate } from 'modeid';

API

HaidId

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

parse(str)

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');

validate(str)

str Candidate string
returns boolean — never throws
import { validate } from 'modeid';

validate('400sw0shksqbv0pgr1f4m0y31w'); // true

compare(a, b)

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 | 1

db profile

Row-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

shard profile

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

opaque profile

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.


Encoding

  • Alphabet: Crockford base32 lowercase (0123456789abcdefghjkmnpqrstvwxyz)
  • Length: 26 characters for 128 bits
  • Parse: case-insensitive; I/L1, O0; hyphens ignored
  • Output: never emits hyphens

Database storage

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()

Migration from UUID / ULID

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 db uses explicit 16-bit counter + 56-bit entropy and a profile header.
  • uuid v7: RFC layout and 36-char hex → HAID db is 26-char Crockford with different bit layout (SPEC.md).

Support

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)

Development

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/

Known issues

opaque generate() is async

Web Crypto AES has no sync API in portable code. Use await or .then().

Not interchangeable with UUID or ULID parsers

Strings and bytes differ. Do not feed HAID strings into uuid.parse() or ULID decoders.

package.json / monorepo CI

If this package lives in a monorepo, point the CI badge at your repo’s workflow path.


Specification & license

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors