Skip to content

Repository files navigation

id758

Prefixed, sortable public ids. A UUIDv7 in the database, a fixed-width base58 string with a type prefix everywhere else. The two forms encode the same 16 bytes and convert in either direction without loss.

0192f8a1-7c3e-7b9a-8c1d-4e5f6a7b8c9d   ⟷   org_1CGtMb233ezidDvSwDLNBn

No dependencies. Runs wherever crypto.getRandomValues exists: Node 20+, Bun, Deno, Cloudflare Workers, browsers.

npm install id758

Usage

import { createIdRegistry } from "id758";

export const ids = createIdRegistry({
	org: "org",
	invoice: "inv",
	user: "usr",
});

const uuid = ids.invoice.decode("inv_1CGtMb233ezidDvSwDLNBn"); // → "0192f8a1-…"
const id = ids.invoice.encode(uuid); // → "inv_1CGtMb233ezidDvSwDLNBn"
ids.invoice.mint(); // → "inv_…" with a fresh UUIDv7 inside

ids.invoice.is("org_…"); // false
ids.invoice.decodeOrNull("org_…"); // null
ids.invoice.decode("org_…"); // throws "not a inv_ id"

Each helper's ids are typed Id<"invoice">, a branded string, so TypeScript refuses to pass an invoice id where a user id is expected. Decoded values are typed Uuid, a different brand, so a still-encoded id can't reach a uuid slot either. Both are plain strings at runtime.

Without a registry

import { uuidv7, encodeId, decodeId, mintId } from "id758";

uuidv7(); // "0192f8a1-7c3e-7b9a-8c1d-4e5f6a7b8c9d"
encodeId(uuid, "org"); // "org_1CGtMb233ezidDvSwDLNBn"
decodeId("org_1CGtMb233ezidDvSwDLNBn"); // "0192f8a1-…"  (any prefix accepted)
mintId("org"); // encodeId(uuidv7(), "org")

Ids of unknown entity

A prefixed id names its own entity, so it can be resolved without context, for example a polymorphic foreign key or an event payload:

import { entityOf, decodeAnyId } from "id758";

entityOf(ids, "inv_1CGtMb233ezidDvSwDLNBn"); // "invoice"
entityOf(ids, "0192f8a1-…"); // null — a bare uuid names nothing

decodeAnyId(ids, "inv_…"); // the uuid
decodeAnyId(ids, "0192f8a1-…"); // passed through unchanged

decodeAnyId passes unrecognised values through unchanged, so it can sit in front of a query that may receive either form. When you know the entity, use that helper's decode, which rejects the wrong prefix.

Store the uuid, encode at the boundary

Keep the column a native uuid. Database defaults and ::uuid casts keep working, and UUIDv7's timestamp prefix keeps inserts clustered. Encode when sending an id to a client; decode when one comes in, at the request boundary. Everything below that works with raw uuids.

Storing the prefix in every row would duplicate what the table already says.

In Postgres

The codec is also shipped as plain plpgsql — id758.sql in the package, or import { ID758_SQL } from "id758/sql" — so the database speaks both forms and nothing has to convert in application code before touching it directly:

select * from invoices where id = id758_decode('inv_1CGtMb233ezidDvSwDLNBn');
select id758_encode('inv', id) as id, total from invoices;
Function Description
id758_encode(prefix text, id uuid) → text encodeId.
id758_decode(id text) → uuid decodeId: validates the shape, any prefix.
id758_decode(id text, prefix text) → uuid Also requires that prefix, like a registry helper's decode.
id758_prefix(id text) → text prefixOf: the prefix, or NULL.

The functions are IMMUTABLE STRICT PARALLEL SAFE: NULL in gives NULL out, a literal argument is folded at plan time so the ordinary primary-key index serves where id = id758_decode($1), and they can back an expression index or a generated column. No extensions; Postgres 14 or later. The script is create or replace and safe to re-run, so apply it once in a migration (ID758_SQL_STATEMENTS has it one statement per entry for runners that want that) or on the fly:

npx id758 sql | psql "$DATABASE_URL"

On the command line

npx id758 mint inv                                   # inv_1CGtMb233ezidDvSwDLNBn
npx id758 decode inv_1CGtMb233ezidDvSwDLNBn         # 0192f8a1-7c3e-7b9a-8c1d-4e5f6a7b8c9d
npx id758 encode inv 0192f8a1-7c3e-7b9a-8c1d-4e5f6a7b8c9d

Why this shape

  • inv_… tells a reader what the id is for (compare Stripe's cus_… / sub_…).
  • The body is left-padded to 22 characters and the alphabet is in ascending order, so lexical order equals numeric order, which for UUIDv7 equals creation order. Ids minted in the same millisecond are in random order relative to each other.
  • Base58 uses the Bitcoin alphabet: no 0/O, no I/l, no punctuation or padding. Ids are URL-safe and double-click selectable.
  • The database only ever sees standard UUIDs. Removing the library means deleting the encode/decode calls at the boundary; no data migration.

API

Export Description
uuidv7() Mint an RFC 9562 UUIDv7 (48-bit ms timestamp, random tail). Returns Uuid.
encodeId(uuid, prefix) Hyphenated uuid → prefix_<22 base58 chars>. A no-op on a prefix_ id; throws on an invalid prefix.
decodeId(id) Inverse of encodeId. Validates the shape, not the prefix. Throws.
mintId(prefix) encodeId(uuidv7(), prefix).
prefixOf(id) The prefix of a well-formed id, else null.
createIdRegistry({ entity: prefix, … }) One IdHelper per entity: mint, encode, decode, decodeOrNull, is, prefix.
entityOf(registry, value) Which registry entity a value belongs to, else null.
decodeAnyId(registry, value) Decode any registered entity's id; pass unknown values through.
isUuid(value) / asUuid(value) Narrow / assert a string to Uuid (any RFC 9562 version, any case).
encodeBase58(bytes) / decodeBase58(body) The raw 16-byte ⟷ 22-char codec.
uuidToBytes(uuid) / bytesToUuid(bytes) Hyphenated uuid ⟷ 16 bytes.
ALPHABET 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz
Types Id<E>, Uuid, IdHelper<E>, IdRegistry<K>
ID758_SQL / ID758_SQL_STATEMENTS (from id758/sql) The Postgres functions, as one script / one statement per entry.

Prefixes must match [A-Za-z0-9]+; the underscore is the separator, so it cannot appear in a prefix.

decodeBase58 rejects bodies above 2¹²⁸ − 1 (base58 has a little more room than 128 bits) rather than truncating, so no two distinct strings decode to the same uuid.

Porting to another language

The encoding is: take the 16 bytes of the UUID, interpret them as one big-endian unsigned integer, write it in base 58 with the alphabet above, and left-pad with 1 (the zero digit) to 22 characters. An implementation is correct when it reproduces these vectors:

Bytes (hex) Body
00000000000000000000000000000000 1111111111111111111111
00000000000000000000000000000001 1111111111111111111112
000000000000000000000000000000ff 111111111111111111115Q
0192f8a17c3e7b9a8c1d4e5f6a7b8c9d 1CGtMb233ezidDvSwDLNBn
ffffffffffffffffffffffffffffffff YcVfxkQb6JRzqk5kF2tNLv

The same table lives in src/index.test.ts. The Postgres port in id758.sql is checked against it in src/sql.test.ts.

License

MIT © Ingram Technologies

About

Prefixed, sortable public ids: UUIDv7 at rest, fixed-width base58 on the wire. Zero dependencies.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages