Skip to content
AnizopteraPublic

About

UUID v4/v7 generation, direct base64url slugs, and configurable masked UUID slugs

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Latest commit

 

History

642 Commits

Folders and files

Repository files navigation

@anizoptera/uuid-slug

Generate and validate UUIDv4 and UUIDv7. Encode any UUID as a reversible 22-character base64url or 26-character base32hex slug. UUIDv7 also supports keyed masking that removes its recognizable layout. The package has no runtime dependencies.

npm version CI Node >=20 Runtime deps License

A UUID is a 128-bit identifier. Its usual text form is 36 characters of hex and dashes. A slug here is the same identifier encoded as short URL-safe text. A direct base64url slug is 22 characters and loses no information: it converts back to the original UUID.

Every UUID version exposes a standardized bit layout in UUID text, bytes, and direct slugs. UUIDv7 also exposes its millisecond timestamp. This package masks UUIDv7: it transforms the compact payload into keyed, unstructured-looking text and recovers the UUID for lookup. Masking is obfuscation, not an authorization boundary.

This package defines no new identity scheme. UUID text, bytes, and direct slugs are reversible representations of the same value. Masked slugs also recover the same UUID through their codec. Direct conversion accepts every UUID version; built-in generation covers UUIDv4 and UUIDv7. This package masks only UUIDv7.

When to use it

Use this package when you need more than UUID generation:

Need Capability
Short UUIDs in URLs Lossless 22-character base64url slugs
Text keys ordered by UUIDv7 time 26-character base32hex direct slugs that sort by encoded millisecond
Binary database keys Atomic conversion to and from 16-byte caller-owned storage
Public UUIDv7 IDs without recognizable UUID structure Reversible keyed masking with a 16-bit wrong-key check
Key rotation or old stored links Ordered previous masks and opt-in migration readers
Reserved IDs such as anonymous Named sentinels that remain recognizable
Strong TypeScript boundaries Branded representations, version-aware results, and typed errors

Use a smaller UUID library when you only need to generate ordinary UUID text. This package earns its larger API when conversion, compact text, masking, migration, or cross-runtime behavior must share one checked contract.

Install

pnpm add @anizoptera/uuid-slug

Import from the package root:

import { generateUuidV7Slug, slugToUuid } from "@anizoptera/uuid-slug";

The native Rust crate implements the same wire contract and lives in rust/uuid-slug. The npm package remains TypeScript/JavaScript-only.

Quick start

import {
  generateUuidV7Slug,
  isUuidSlugError,
  slugToUuid,
} from "@anizoptera/uuid-slug";

function createShortId() {
  const slug = generateUuidV7Slug();
  if (isUuidSlugError(slug)) return slug;

  const uuid = slugToUuid(slug);
  if (isUuidSlugError(uuid)) return uuid;
  return { slug, uuid };
}

Use a codec only when IDs must hide their UUIDv7 layout:

import {
  createUuidV7SlugCodec,
  isUuidSlugError,
} from "@anizoptera/uuid-slug";

function createPublicId(seed: string) {
  // Reuse the codec in real code: the seed is expanded once, at construction.
  const ids = createUuidV7SlugCodec({ mask: { seed } });
  if (isUuidSlugError(ids)) return ids;

  const publicId = ids.generateSlug();
  if (isUuidSlugError(publicId)) return publicId;
  return { ids, publicId };
}

Keep binary database keys binary across the same codec seam:

import { generateUuidV7Bytes, isUuidSlugError } from "@anizoptera/uuid-slug";
import type { UuidV7SlugCodec } from "@anizoptera/uuid-slug";

function roundTripBytes(ids: UuidV7SlugCodec) {
  const inputBytes = generateUuidV7Bytes();
  if (isUuidSlugError(inputBytes)) return inputBytes;
  const publicId = ids.encodeUuid(inputBytes);
  if (isUuidSlugError(publicId)) return publicId;
  return ids.decodeSlugToBytes(publicId);
}

Convert canonical UUID text and binary storage with names that state the direction:

import { bytesToUuid, isUuidSlugError, uuidToBytes } from "@anizoptera/uuid-slug";

function convertUuid() {
  const bytes = uuidToBytes("01990d4d-e600-7000-8000-000000000000");
  if (isUuidSlugError(bytes)) return bytes;
  const uuid = bytesToUuid(bytes);
  if (isUuidSlugError(uuid)) return uuid;

  // Convert atomically into storage you already own.
  const record = new Uint8Array(32);
  return uuidToBytes(uuid, record, 8);
}

uuidToBytes accepts one canonical lowercase UUID and either allocates a plain Uint8Array or writes 16 bytes at the requested offset. bytesToUuid reads 16 bytes at an optional offset. Buffer works because it is a Uint8Array; ArrayBuffer, DataView, and array-like values do not. Use isUuidBytes, isUuidV4Bytes, or isUuidV7Bytes when unknown binary input must be narrowed before storage or version-specific work.

Generate the seed; do not think one up. For example, run openssl rand -base64 32 and store the result outside your source repository. Seeds shorter than 16 bytes are rejected, but length alone does not make a seed unpredictable: a known UUID/slug pair allows offline guesses.

Keep the seed and keyDerivationIterations stable for as long as issued slugs must remain readable. Changing either derives different keys; retain the old settings in previousMasks during rotation. keyDerivationIterations defaults to 1; increasing it adds construction and guessing cost, but does not turn a human-chosen seed into a suitable secret.

Representations

Representation Size Use
UUID text 36 characters Standard interchange and logs
UUID bytes 16 bytes Compact database or protocol storage
Direct base64url slug 22 characters Shortest lossless URL-safe text
Direct base32hex slug 26 characters Lowercase filenames and sortable UUIDv7 text
Masked base64url UUIDv7 slug 22 characters Short public ID without recognizable UUID structure
Masked base32hex UUIDv7 slug 27 characters Lowercase masked text for case-insensitive filesystems

Direct slugs need no key. Masked slugs require the codec configured with the matching mask. A sentinel is a reserved UUIDv7 that its codec writes as a direct slug instead of masking.

Two kinds of slug

Direct slug. The 16 UUID bytes as unpadded base64url, 22 characters. Pure re-encoding: uuidToSlug and slugToUuid preserve the UUID value, and anyone can convert either way. Use it when you only want the identifier shorter.

Pass "base32hex" to get 26 characters instead, and gain one thing: base32hex slugs of UUIDv7s sort by millisecond timestamp as plain strings. A v7 starts with its timestamp, and base32hex writes its digits in the same order as their values, so comparing two slugs compares two instants. That makes them usable directly as an ordered database key. base64url cannot do it — its alphabet runs A-Za-z0-9-_, so 0 stands for a larger value than A but sorts before it.

base32hex is also the safer of the two for a filename: it is lowercase throughout, so it survives a case-insensitive filesystem unchanged, and a directory listing comes back in timestamp order across different milliseconds. generateUuidV7Slug("base32hex") generates one directly; the alphabet is an argument on every keyless entry point that produces a slug, so nothing about choosing it requires a codec.

You never say which alphabet you are reading. The two lengths are different, 22 and 26, so slugToUuid works out the encoding from the slug itself — which means switching alphabet leaves every slug you have already handed out working.

Masked slug. Masking supports UUIDv7 only. Its compact payload and check pass through a small block cipher keyed by your seed, then encoded as 22 base64url characters by default, or 27 lowercase base32hex characters with outputAlphabet: "base32hex". Both preserve the same payload and check. Use it when the identifier is public and you want an opaque, hash-like spelling instead of recognizable UUID fields. Masked slugs are reversible and therefore are not hashes. The exact steps are in how masking works.

The application creates one codec from a stable secret seed. Encoding compresses the UUIDv7's fixed fields, adds a 16-bit check, applies the keyed reversible transform, then writes the selected slug alphabet. Decoding reverses those steps and rejects a value when its check does not match. Create the codec once and reuse it; changing its seed changes the mapping.

A supported UUIDv7 carries 116 bits that the masked format must preserve — the version and variant are fixed, and the top six bits of the timestamp stay zero until the year 2109. 22 base64url characters hold 132 bits, so the spare 16 carry a check for incorrect decoding. Two things follow, and both are part of the contract rather than implementation detail:

  • A decoded slug is a candidate, not a proof. Roughly one random 22-character string in 65,536 passes the check and decodes to a well-formed, possibly unissued UUID. Look the result up in your own store; a wrong candidate is then a miss rather than a wrong answer. Every extra distinct schema you try adds another opportunity for false acceptance; the combined rate is at most the sum of the individual rates. Keep the rotation list short.
  • Masking refuses a timestamp at or past 2^42 ms — the year 2109 — with invalid_timestamp, because those six bits are spent on the check. Truncating instead would let two UUIDs share one slug, which nothing could recover from. Direct slugs have no such limit.

Sentinels are application-selected UUIDv7 values that skip masking on purpose, so they stay recognisable in logs and support tickets. Codecs default to no sentinels; configure only identities your application has reserved:

Name UUID Slug
nil 00000000-0000-7000-8000-000000000000 AAAAAAAAcACAAAAAAAAAAA
anonymous 00000000-0001-7000-8000-000000000000 AAAAAAABcACAAAAAAAAAAA
unknown 00000000-0002-7000-8000-000000000000 AAAAAAACcACAAAAAAAAAAA

Read the validated names, UUIDs, and slugs from codec.sentinels:

import { createUuidV7SlugCodec, isUuidSlugError } from "@anizoptera/uuid-slug";

function createIds(seed: string) {
  const ids = createUuidV7SlugCodec({
    mask: { seed },
    sentinels: [{ name: "unknownUser", uuid: "00000000-0002-7000-8000-000000000000" }],
  });
  return isUuidSlugError(ids) ? ids : ids.sentinels;
}

Each sentinel name must be nonempty and unique within the codec. Each sentinel UUID must also be unique, canonical UUIDv7 text.

Keep sentinel definitions stable while their slugs are in use. Decoding checks sentinels first, so they are reserved even if a mask could produce the same text. Encoding rejects a non-sentinel UUID whose masked output collides with a reserved slug, using invalid_mask_schema; it never returns a slug that would decode to the wrong sentinel.

What masking is not

Masking uses a four-round Feistel network with HalfSipHash-2-4 round functions and a seed-derived key schedule. It removes the directly recognizable UUIDv7 field layout; this package does not establish a confidentiality strength for that construction or resistance to known-pair attacks. A reference-vector test for the round function does not establish those properties for the whole construction. See the mechanism and its limits.

Masked slugs are not authentication tokens or capabilities. The check detects many incorrect decodes; it is not a message authentication code. Confirm decoded candidates against your store and enforce authorization separately. Supplying a custom transform still leaves the same short check and does not turn the API into an authenticated-token system.

import { isUuidSlugError } from "@anizoptera/uuid-slug";
import type { UuidV7SlugCodec, UuidV7String } from "@anizoptera/uuid-slug";

async function findAuthorizedRecord<Record>(
  ids: UuidV7SlugCodec,
  slug: string,
  findByUuid: (uuid: UuidV7String) => Promise<Record | undefined>,
  canRead: (record: Record) => boolean,
) {
  const uuid = ids.decodeSlug(slug);
  if (isUuidSlugError(uuid)) return uuid;
  const record = await findByUuid(uuid);
  return record !== undefined && canRead(record) ? record : undefined;
}

Supply your own secret seed for real identifiers. Codec construction fails with invalid_mask_schema when mask is missing. For tests and demos that need stable public bytes, select UUID_V7_TEST_MASK explicitly; anyone can decode identifiers produced with it.

Handling errors

Parsing, decoding, formatting, conversion, byte generation, and explicit-timestamp generation return T | UuidSlugError. Use isUuidSlugError to narrow the union reliably across realms and duplicate package copies. The guard recognizes the package's error name and code vocabulary; it classifies data and does not authenticate untrusted input. Codec construction returns the same union and requires an explicit mask.

Use the error's code for handling and its message for diagnostics. UuidSlugErrorCode and UUID_SLUG_ERROR_CODES are the exported type and runtime list of codes. Errors wrapping failed entropy draws, clock readings, and custom transform calls preserve the original exception as cause.

Replace encodeBase64Url with bytesToSlug, decodeBase64Url with slugToBytes, and isBase64Url with isUuidSlug. These operations enforce the UUID-sized 16-byte contract:

import { bytesToSlug } from "@anizoptera/uuid-slug";

function encodeStoredUuid(bytes: Uint8Array) {
  return bytesToSlug(bytes, "base32hex");
}

Types

Branded types distinguish values this library has validated or produced. Plain strings and byte arrays are not assignable to them; pass raw input directly to package operations or use a guard when you need to retain the narrowed type. A brand proves format, not issuance: check your store for that. JavaScript consumers do not need TypeScript. The shipped declarations compile with TypeScript 5.0 and newer.

  • Strings: UuidString, UuidV4String, UuidV7String.

  • Direct slugs: UuidSlug (either alphabet), UuidV4Slug, and UuidV7Slug. Masked types stay separate because their check bits and lengths are not direct UUID encodings. They cover both spellings — MaskedUuidV7Slug, SentinelUuidV7Slug, and UuidV7CodecSlug for "masked or sentinel", which is what a masking call returns. SlugAlphabet names the two alphabets. UuidBytes labels one validated 16-byte UUID payload; UuidV4Bytes and UuidV7Bytes retain the generated version. Byte operations return those exact types when they allocate the payload, or preserve the caller's exact array subtype when writing into supplied storage.

    A value typed as masked, or as possibly masked, is rejected by direct-slug decoding and timestamp operations; use the codec that owns its key. Raw strings remain accepted because direct and masked base64url spellings overlap, so only the application knows which protocol produced external text. Values already typed as UUID text are rejected by slug parsers, codecs, and migration readers; supplying caller-owned output storage does not weaken either representation check. V7-only timestamp validation and masking operations likewise reject values already typed as v4 while continuing to validate raw or version-unknown input at runtime. Parsing and direct string/slug conversions preserve a known v4 or v7. Formatting mutable bytes returns a version-agnostic value because their contents may have changed since validation. Codec byte masking rejects a known v4 brand, while byte decoding returns owned UuidV7Bytes or the exact caller-supplied array subtype.

  • Shapes: UuidV7SlugCodec and UuidV7SlugCodecOptions, UuidV7Mask, UuidV7MaskOptions, AsyncUuidV7MaskOptions, and CustomUuidV7MaskOptions, UuidV7Sentinel and UuidV7SentinelConfig, UuidV7Generator and UuidV7GeneratorOptions, UuidV7TimestampWindow, UuidSlugMigrationFormat, UuidSlugMigrationDecoder, UuidSlugMigrationOptions, and UuidSlugErrorCode.

Some masked slugs also pass direct-slug guards. Canonical trailing bits, version, and variant can all match by chance. A guard answers "is this well formed", never "is this mine". Decode with the matching API and look up the candidate; a brand or guard cannot establish issuance.

API

The Where column tells you whether to import a function or call a codec method.

  • codec only. Uses your configured keys or sentinels.
  • module only. Import these functions directly. Codec settings do not affect them.

The state split keeps each caller responsible for one thing. A generator owns its clock and mutable UUIDv7 sequence; the package runtime owns cryptographic entropy. A codec owns immutable masking keys, sentinels, rotation, and writer settings. Generator, codec, and migration methods do not depend on this; they can be passed as callbacks without binding them first.

Method Where Returns What it does
generateUuidV4() / generateUuidV7() module only UuidSlugError or UUID string Generate.
generateUuidV4Bytes() / generateUuidV7Bytes() module only UuidSlugError or typed 16 bytes Generate owned UUID bytes.
generateUuidV4Bytes(out, offset?) / generateUuidV7Bytes(...) module only UuidSlugError or the exact out type Generate into caller storage.
generateUuidV7At(ms) / generateUuidV7BytesAt(ms, out?, offset?) module only UuidSlugError | ... Generate at an explicit instant.
generateUuidV4Slug(alphabet?) / generateUuidV7Slug(alphabet?) module only UuidSlugError or direct slug Generate and encode.
generateSlug() codec only UuidSlugError or UuidV7CodecSlug Generate a masked or sentinel slug.
encodeUuid(uuidOrBytes) / decodeSlug(slug) codec only UuidSlugError | ... Mask UUIDv7 text or bytes; decode to text.
decodeSlugToBytes(slug, out?, offset?) codec only UuidSlugError | ... Decode without producing UUID text.
extractSlugTimestamp(slug) codec only UuidSlugError | number Read time from a masked slug or sentinel.
validateSlugTimestamp(slug, window) codec only UuidSlugError | ... Decode, then check the timestamp window.
sentinels codec only array The reserved values this codec knows.
createMigrationDecoder(options?) codec only migration reader Read selected old formats without changing new output.
uuidToSlug(uuid, alphabet?) / slugToUuid(slug) module only UuidSlugError | ... Direct slug, both ways.
uuidToBytes(uuid, out?, offset?) / bytesToUuid(bytes, offset?) module only UuidSlugError | ... Convert canonical UUID text and bytes.
slugToBytes / bytesToSlug module only UuidSlugError | ... Slug to bytes and back.
extractUuidV7Timestamp(value, offset?) module only UuidSlugError | number Read time from UUIDv7 text, direct slug, or bytes.

A byte offset is the zero-based index where one 16-byte UUID starts inside a larger Uint8Array. Omit it for a standalone 16-byte array. Output offsets select where a method writes; input offsets select where it reads. Bytes outside that 16-byte range stay unchanged.

There is no version-neutral uuidToMaskedSlug. Every UUID version has recognizable fields, but this package defines one masked wire format for UUIDv7. Its fixed version, variant, and timestamp range leave room for the format's 16-bit check without lengthening the default 22-character slug.

The At pair supports backfilling with a known timestamp. It takes the instant first and returns an error unless it is a whole number of Unix milliseconds in 0..2^48-1. Its interaction with current-time generation is runtime-specific; within-millisecond ordering is not promised.

Use an independent generator when the application owns the clock:

import { createUuidV7Generator } from "@anizoptera/uuid-slug";

function createEventGenerator(readUnixMs: () => number) {
  return createUuidV7Generator({ clock: readUnixMs });
}

Both masking entry points return a raw sentinel slug when the UUID is registered as a sentinel. Their result type is UuidV7CodecSlug, the union of masked and sentinel slugs.

The byte decoder returns a new plain Uint8Array branded as UuidV7Bytes when storage is omitted. When storage is supplied, it validates and decodes before writing, then returns that exact array; an error leaves the array unchanged.

Each decoder accepts every value that a supported matching encoder can produce. Masked decoding always accepts both alphabets, sentinels, and current or previous mask schemas. Migration adds only the old formats you enable. Forms no supported encoder produces are invalid.

Also exported, all module-level:

  • Type guards: isUuidString, isUuidV4String, isUuidV7String, isUuidSlug, isUuidV4Slug, isUuidV7Slug, isUuidBytes, isUuidV4Bytes, and isUuidV7Bytes.
  • extractUuidV7Timestamp reads Unix milliseconds from UUIDv7 text, either direct-slug alphabet, or bytes. Use codec.extractSlugTimestamp for masked slugs and sentinels because their decoding keys belong to the codec. Both return an error for other UUID versions; a v4's first six bytes are random and must not be presented as a timestamp.
  • createUuidV7Mask builds a schema from a seed; createUuidV7MaskAsync adds cooperative cancellation; createCustomUuidV7Mask adapts separate forward and reverse callbacks. UUID_V7_TEST_MASK is the explicit, published-key choice for deterministic examples.
  • createUuidV7Generator(options) builds a full generator with your clock and the package's platform cryptographic entropy. Its options type is UuidV7GeneratorOptions. Untyped malformed options return invalid_options. Generation returns rng_unavailable when secure platform entropy is unavailable and never substitutes caller-controlled bytes.
  • UuidSlugError, including code rng_unavailable when the runtime has no usable randomness. UuidSlugErrorCode types the code property; isUuidSlugError recognizes package errors across JavaScript realms and duplicate package copies.
  • UUID_SLUG_ALPHABETS and UUID_SLUG_MIGRATION_FORMATS are the runtime values for schema, configuration, and user-interface validation. Their TypeScript unions derive from the same lists.

Codec options

import { createUuidV7SlugCodec } from "@anizoptera/uuid-slug";

function configureCodec(seed: string, previousSeed: string) {
  return createUuidV7SlugCodec({
    mask: { seed, keyDerivationIterations: 1 },
    sentinels: [{ name: "nil", uuid: "00000000-0000-7000-8000-000000000000" }],
    previousMasks: [{ seed: previousSeed }],
    outputAlphabet: "base32hex",
  });
}

The masked payload is fixed at 132 bits, including a 16-bit check. outputAlphabet defaults to "base64url". Readers accept either masked spelling without an alphabet argument. Safe sentinels use 26-character raw base32hex slugs; old 22-character sentinels remain readable.

To supply a different mixing implementation, call createCustomUuidV7Mask({ label, mask, unmask }). Each callback receives distinct 16-byte input bytes and returns either a 16-byte result or UuidSlugError; unmask must invert mask for every input. A callback may mutate and return its isolated input. The factory copies callback results into package storage, captures the callbacks in an opaque frozen handle, and rejects invalid results and failed round-trip probes. Unexpected throws become errors with the thrown value as their cause. Finite probes cannot prove invertibility, so the caller remains responsible for the complete domain.

import {
  createCustomUuidV7Mask,
  createUuidV7SlugCodec,
  isUuidSlugError,
} from "@anizoptera/uuid-slug";

function adaptMask(mix: (input: Uint8Array, output: Uint8Array, forward: boolean) => void) {
  const transform = (direction: "mask" | "unmask") => (input: Uint8Array) => {
    const output = new Uint8Array(16);
    mix(input, output, direction === "mask");
    return output;
  };
  const mask = createCustomUuidV7Mask({
    label: "existing-scheme",
    mask: transform("mask"),
    unmask: transform("unmask"),
  });
  return isUuidSlugError(mask) ? mask : createUuidV7SlugCodec({ mask });
}

createUuidV7Mask({ seed, keyDerivationIterations }) returns an error for invalid seed options. createUuidV7SlugCodec(options) returns an error for invalid options. Create custom handles before passing them; an arbitrary object is not a schema.

For expensive derivation, await createUuidV7MaskAsync and pass the completed schema to the codec:

import {
  createUuidV7MaskAsync,
  createUuidV7SlugCodec,
  isUuidSlugError,
} from "@anizoptera/uuid-slug";

async function createResponsiveCodec(
  seed: string,
  keyDerivationIterations: number,
  signal: AbortSignal,
) {
  const mask = await createUuidV7MaskAsync({ seed, keyDerivationIterations, signal });
  return isUuidSlugError(mask) ? mask : createUuidV7SlugCodec({ mask });
}

The async form derives the same keys and accepts the same seed settings. It splits encoding, packing and hashing into cooperative work slices, allowing host tasks to run during long construction. Cancellation stops derivation and returns UuidSlugError with code aborted and signal.reason as its cause. Invalid options return invalid_mask_schema; unexpected construction failures retain their cause. No partially derived schema is returned.

Options are captured when called. Byte seeds are copied before the first suspension, so later caller mutation cannot change the key; synchronize concurrent writes to shared backing memory. The initial copy of a very large byte seed, allocation, GC and host scheduling can exceed the responsiveness target. Small derivations need no host yield. Construct once and reuse the schema; passing seed options to each new codec would repeat derivation.

previousMasks is how you rotate a seed. Decoding tries mask first, then each entry in the order you list them, and the first whose check passes wins — so put the newest first, and keep the list short. Nothing else is ever tried: a codec decodes with the schemas you gave it and no others.

import { createUuidV7SlugCodec } from "@anizoptera/uuid-slug";

function createRotatedCodec(currentSeed: string, previousSeed: string) {
  return createUuidV7SlugCodec({
    mask: { seed: currentSeed },
    previousMasks: [{ seed: previousSeed }],
  });
}

Writers use currentSeed; readers also accept previousSeed. Remove the old seed only after its IDs no longer need to decode.

Changing an existing ID format

Preserve the UUID; change only how it is encoded. Do not generate a replacement UUID when converting an existing ID: references and stored records must still identify the same object.

Deploy readers that accept both formats before any writer issues the new format. Include background jobs, cached clients and extensions in that rollout. After switching writers, keep compatible readers available for rollback: an old-only reader cannot read newly issued IDs.

Updating the database does not update bookmarks, shared links, cookies or client storage. Remove compatibility only when the old IDs in those places no longer need to work. No recent legacy traffic does not prove that old links are gone. To keep old URLs after removing their decoder, retain an application-owned mapping from each supported old slug to its UUID.

Keep keys and sentinel definitions stable throughout the rollout. Non-v7 UUIDs and UUIDv7s timestamped at or beyond 2^42 ms cannot use the current masked format; preserve them as UUIDs or direct slugs. Never truncate the timestamp or substitute a new identity to make masking pass.

Reading original slugs during migration

Create a migration reader from your codec. Enable only the old formats your application issued:

import type { UuidV7SlugCodec } from "@anizoptera/uuid-slug";

function readLegacySlug(ids: UuidV7SlugCodec, incomingSlug: string) {
  const reader = ids.createMigrationDecoder({ originalMask: true, plainSlug: true });
  return reader.decodeSlug(incomingSlug);
}

originalMask accepts the original fixed permutation/XOR format with its 4-bit checksum. It does not accept the different masked format from package version 0.2.0. plainSlug accepts direct UUID slugs of any version, including old base64url spellings whose last four bits were ignored. Ordinary slugToUuid remains strict about those trailing bits. Both options must be booleans; untyped malformed reader configuration returns invalid_options. Neither option changes generation, ordinary codec methods, keys or sentinels.

decodeSlug returns one UUID or UuidSlugError: sentinels first, then current keys, then enabled original and plain formats. A successful decode still needs a record lookup. The same slug can decode to different UUIDs in different formats; the original check has only four bits.

When you know the input format, name it to avoid trying unrelated formats:

import { isUuidSlugError } from "@anizoptera/uuid-slug";
import type { UuidSlugMigrationDecoder, UuidV7SlugCodec } from "@anizoptera/uuid-slug";

function replaceOriginalSlug(
  ids: UuidV7SlugCodec,
  reader: UuidSlugMigrationDecoder<"original-mask">,
  oldSlug: string,
) {
  const uuid = reader.decodeSlug(oldSlug, "original-mask");
  if (isUuidSlugError(uuid)) return uuid;
  const replacement = ids.encodeUuid(uuid);
  if (isUuidSlugError(replacement)) return replacement;
  return { uuid, replacement }; // Keep uuid in the database; use replacement in new links.
}

Enable an old format before selecting it. Recognized sentinels always return their reserved UUID, including old trailing-bit aliases when reading original or plain slugs. With literal options, TypeScript offers only "current-mask" and the formats enabled with true; options whose values are dynamic remain selectable because they may be enabled at runtime.

For unknown formats, use decodeSlugCandidates to find the matching record without guessing:

import { isUuidSlugError } from "@anizoptera/uuid-slug";
import type { UuidSlugMigrationDecoder, UuidString } from "@anizoptera/uuid-slug";

async function findMigratedRecord(
  reader: UuidSlugMigrationDecoder,
  incomingSlug: string,
  findRecordsByUuids: (uuids: readonly UuidString[]) => Promise<readonly unknown[]>,
) {
  const candidates = reader.decodeSlugCandidates(incomingSlug);
  if (isUuidSlugError(candidates)) return candidates;
  const matches = candidates.length ? await findRecordsByUuids(candidates) : [];
  if (matches.length !== 1)
    return new Error(matches.length ? "ID matches multiple records" : "ID not found");
  return matches[0]; // Check access before returning the record to the caller.
}

findRecordsByUuids is your database query. Return distinct records by UUID. decodeSlugCandidates returns distinct UUIDs in decoding order, or an empty array if none match. Malformed input and failed mask callbacks return an error. Callback errors keep their cause; they never produce a successful but incomplete list. An old sentinel alias can also be a current masked ID. decodeSlugCandidates retains both UUIDs; decodeSlug keeps sentinel priority.

Keep this reader while old inputs must work. Replace it with ids.createMigrationDecoder({ plainSlug: true }) to stop trying the original mask, or ids.createMigrationDecoder({ originalMask: true }) to stop plain decoding. ids.createMigrationDecoder() tries only current keys and sentinels. Removing the migration reader entirely leaves ordinary codec decoding unchanged. Neither method can identify the issuing format from the slug alone, so successful decoding is not a reliable count of legacy traffic.

Validating timestamp windows

import { validateUuidV7Timestamp } from "@anizoptera/uuid-slug";

function validateRecentUuid(uuid: string) {
  return validateUuidV7Timestamp(uuid, { maxTimestampDeltaMs: 60_000 });
}

The timestamp must be inside the inclusive interval referenceUnixMs - maxTimestampDeltaMs through referenceUnixMs + maxTimestampDeltaMs. The maximum delta is required, finite, and nonnegative; zero and finite fractions are valid. referenceUnixMs defaults to one Date.now() reading and must be finite when supplied. Invalid settings return an error instead of disabling the check.

validateUuidV7SlugTimestamp starts from a plain slug; codec.validateSlugTimestamp starts from a slug decoded with that codec. Successful validation returns the same spelling with its validated plain or public masked-slug type. This is a symmetric timestamp sanity check, not past-only expiry, authentication, or replay prevention. Use ordinary decoding for permanent identifiers. For links with expiration or one-time use, check the stored expiry and consumption state as well.

Measured performance

The package selects native UUID generation when it preserves the public contract and otherwise uses its optimized portable path. The performance target is to lead every comparable operation on every supported engine; the table states where current measurements still miss that target.

Evidence Scope Result that changes a consumer decision
Generation Node, Bun, Deno, Chromium; v4/v7 text, owned bytes, caller buffers, direct slugs Byte routes usually lead; v4 text and one Bun caller-buffer comparison still have measured gaps. Native calls remain visible as implementation floors.
Conversion UUID text, bytes, both direct-slug alphabets, timestamp extraction Complete validated calls are measured; v7 text timestamp extraction still trails its fastest comparable package.
Bundling Bun, esbuild, Parcel, Rollup, Rolldown, Rspack, webpack Named root imports retain only the requested domains.

Methods, competitors, results, limitations, and rejected optimizations are kept with the benchmark evidence so this summary cannot become a second result ledger.

Guarantees

These are API contracts. Runtime measurements and their exercised conditions belong in bench/README.md; a benchmark pass is not proof of every guarantee. See security boundaries for entropy failures and masking limits.

  • Conformance. Generated ids have RFC 9562 version and variant bits.
  • Secure entropy by default. Built-in generators use the runtime's cryptographically secure random source. They never fall back to weaker randomness. If secure randomness is unavailable, generation returns rng_unavailable. With a working runtime, collision risk is negligible. Treat any collision as a generator or entropy failure.
  • Native generation degrades safely. Optional native generation is a shortcut, not a second behavior: if it throws or returns a malformed UUID, slug, or byte array, the matching portable generator is tried. If both fail, the returned error retains both causes.
  • Time ordering, to the millisecond. A v7 carries a 48-bit millisecond timestamp in its leading bits, so UUID strings sort by their encoded timestamps across different milliseconds. Backfilled timestamps or a clock adjustment need not reflect generation order.
  • NOT ordered within a millisecond. Two ids created in the same millisecond may sort in either order. RFC 9562 §6.2 permits different counter and random-bit strategies. Do not use a v7 to recover the order of events inside a millisecond; use a sequence column.
  • Sortable as a slug only in base32hex. A 26-character base32hex slug compares as a plain string in creation order, to the same millisecond granularity as the guarantee above: its digits are in ASCII order and a v7 leads with its timestamp. That is the whole reason the alphabet is offered.
  • NOT sortable as a base64url slug. The default 22-character form is the same 16 bytes in a shorter alphabet, and the value round-trips exactly — but that alphabet runs A-Za-z0-9-_, so its character order and its value order disagree. Sort by the UUID, or by a timestamp column. A masked slug has no chronological ordering in either alphabet.
  • One type for owned bytes. Without an output buffer, byte generators return a plain Uint8Array, never a platform-specific subclass. With an output buffer, they return that same caller-owned array and write only the requested UUID range.
  • Atomic identifier parsing. uuidToBytes and slugToBytes validate the complete identifier before writing. Invalid text or destination ranges return an error and leave caller storage unchanged.
  • You pay for masking only if you mask. Named root imports let supported bundlers remove the cipher, key derivation, sentinel table, and unrelated generation code.
  • Masking is explicit. A codec requires private mask options or a package-produced schema handle. The published-key schema is available only as UUID_V7_TEST_MASK; omission never selects it.

Runtime

No runtime dependencies, on any platform.

  • Bun loads the published TypeScript source and uses Bun.randomUUIDv7 for current-time generation. Explicit timestamps use the portable generator to preserve the requested instant. If Bun loads compiled public JavaScript directly, it still selects the Bun TypeScript adapter and native primitives. Tooling that opts into the development condition loads the same source.
  • Node uses native crypto.randomUUID, and native crypto.randomUUIDv7 where the version provides it, otherwise the bundled v7 generator.
  • Deno loads the published TypeScript source and uses the optimized portable runtime adapter. Consumers need no resolver flag or import map.
  • Browsers and other compatible runtimes use optimized portable JavaScript and Web Crypto.

License

Apache-2.0. Copyright 2026 Anizoptera and Art Shendrik.

About

UUID v4/v7 generation, direct base64url slugs, and configurable masked UUID slugs

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages