{"_id":"@anizoptera/uuid-slug","_rev":"2-167efae69985e0968c16c89581082739","name":"@anizoptera/uuid-slug","dist-tags":{"latest":"0.3.0"},"versions":{"0.2.0":{"name":"@anizoptera/uuid-slug","version":"0.2.0","keywords":["base64url","browser","bun","deno","node","slug","uuid","uuidv7","zero-dependencies"],"author":{"name":"Anizoptera"},"license":"Apache-2.0","_id":"@anizoptera/uuid-slug@0.2.0","maintainers":[{"name":"art-shen","email":"amal.samally@gmail.com"}],"contributors":[{"name":"Art Shendrik"}],"homepage":"https://github.com/Anizoptera/uuid-slug#readme","bugs":{"url":"https://github.com/Anizoptera/uuid-slug/issues"},"dist":{"shasum":"dc1de71b17f7a33b6afd9fb68b74800fbb4c8741","tarball":"https://registry.npmjs.org/@anizoptera/uuid-slug/-/uuid-slug-0.2.0.tgz","fileCount":22,"integrity":"sha512-7XgcVUPanEiu+sY6EJDLASGc5H8WjfOMOYsgsySRdZybgY+5TukwtZD4nGP0SyufWUQpnXX/2ziTb9cws9P16Q==","signatures":[{"sig":"MEQCICv0r41rIxdp1rmmkg3xfmwOKrMrF4SNhInN1Y/kq2yqAiAfVPtcxKj8tulGU6V+tjveGDV6NX4y9wOELg8/pwnXfw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@anizoptera%2fuuid-slug@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":174217},"main":"./dist/index.js","type":"module","_from":"file:/tmp/publish-clean-dIsAzn/npm-pack/anizoptera-uuid-slug-0.2.0.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"bun":"./src/index.ts","deno":"./dist/web/index.js","node":"./dist/node/index.js","types":"./dist/index.d.ts","browser":"./dist/web/index.js","default":"./dist/web/index.js"},"./package.json":"./package.json"},"imports":{"#runtime":{"bun":"./src/runtime/bun.ts","deno":"./src/runtime/web.ts","node":"./src/runtime/node.ts","browser":"./src/runtime/web.ts","default":"./src/runtime/web.ts"}},"_npmUser":{"name":"art-shen","email":"amal.samally@gmail.com"},"_resolved":"/tmp/publish-clean-dIsAzn/npm-pack/anizoptera-uuid-slug-0.2.0.tgz","_integrity":"sha512-7XgcVUPanEiu+sY6EJDLASGc5H8WjfOMOYsgsySRdZybgY+5TukwtZD4nGP0SyufWUQpnXX/2ziTb9cws9P16Q==","repository":{"url":"git+https://github.com/Anizoptera/uuid-slug.git","type":"git"},"_npmVersion":"11.16.0","description":"Zero-dependency UUID v4/v7 generation, direct base64url slugs, and configurable masked UUID slugs.","directories":{},"sideEffects":false,"_nodeVersion":"24.18.0","dependencies":{},"publishConfig":{"access":"public","provenance":true},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/uuid-slug_0.2.0_1786438488508_0.8044229476551041","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@anizoptera/uuid-slug","version":"0.3.0","description":"Short URL-safe UUIDs: 22-character base64url slugs, plus reversible masking that hides the UUIDv7 timestamp. Zero dependencies.","keywords":["base64url","browser","bun","deno","identifier","node","short-uuid","slug","url-safe","uuid","uuidv7","zero-dependencies"],"homepage":"https://github.com/Anizoptera/uuid-slug#readme","bugs":{"url":"https://github.com/Anizoptera/uuid-slug/issues"},"license":"Apache-2.0","author":{"name":"Anizoptera"},"contributors":[{"name":"Art Shendrik"}],"repository":{"type":"git","url":"git+https://github.com/Anizoptera/uuid-slug.git"},"type":"module","sideEffects":false,"main":"./dist/index.js","module":"./dist/index.js","types":"./dist/index.d.ts","imports":{"#runtime":{"bun":"./src/runtime/bun.ts","deno":"./src/runtime/web.ts","browser":"./src/runtime/web.ts","node":"./src/runtime/node.ts","default":"./src/runtime/web.ts"}},"exports":{".":{"types":"./dist/index.d.ts","bun":"./src/index.ts","deno":"./dist/index.js","browser":"./dist/index.js","node":"./dist/node/index.js","default":"./dist/index.js"},"./package.json":"./package.json"},"publishConfig":{"access":"public","provenance":true},"engines":{"node":">=20"},"_id":"@anizoptera/uuid-slug@0.3.0","_integrity":"sha512-GkUCL9jeug9IuW7/ilAps+ePvgTlWB89TqLX3AQ54ME6bwlLo6LW1fsgeLHmgv5BBWlwCK2d5sQtWSd5VVcY7A==","_resolved":"/tmp/publish-clean-cj9xPt/anizoptera-uuid-slug-0.3.0.tgz","_from":"file:/tmp/publish-clean-cj9xPt/anizoptera-uuid-slug-0.3.0.tgz","_nodeVersion":"24.20.0","_npmVersion":"11.19.0","dist":{"integrity":"sha512-GkUCL9jeug9IuW7/ilAps+ePvgTlWB89TqLX3AQ54ME6bwlLo6LW1fsgeLHmgv5BBWlwCK2d5sQtWSd5VVcY7A==","shasum":"154567e139fed866416c3d1a7b1657dc1dbed492","tarball":"https://registry.npmjs.org/@anizoptera/uuid-slug/-/uuid-slug-0.3.0.tgz","fileCount":93,"unpackedSize":490545,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@anizoptera%2fuuid-slug@0.3.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIB5q/TxNRRcS/C68ITxM0KxkmcKPvqbFJ7etJY8zNGewAiEAp385Nl9aDABh0/CNEHI2r2Crb/iXJnKwWijNjfapvIw="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:b1f48d92-c851-4f55-aa3a-9cb9a0b51da1"}},"directories":{},"maintainers":[{"name":"art-shen","email":"amal.samally@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/uuid-slug_0.3.0_1788845145994_0.4133852285598967"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-11T08:54:48.351Z","modified":"2026-09-08T05:25:46.442Z","0.2.0":"2026-08-11T08:54:48.635Z","0.3.0":"2026-09-08T05:25:46.141Z"},"bugs":{"url":"https://github.com/Anizoptera/uuid-slug/issues"},"author":{"name":"Anizoptera"},"license":"Apache-2.0","homepage":"https://github.com/Anizoptera/uuid-slug#readme","keywords":["base64url","browser","bun","deno","identifier","node","short-uuid","slug","url-safe","uuid","uuidv7","zero-dependencies"],"repository":{"type":"git","url":"git+https://github.com/Anizoptera/uuid-slug.git"},"description":"Short URL-safe UUIDs: 22-character base64url slugs, plus reversible masking that hides the UUIDv7 timestamp. Zero dependencies.","contributors":[{"name":"Art Shendrik"}],"maintainers":[{"name":"art-shen","email":"amal.samally@gmail.com"}],"readme":"# @anizoptera/uuid-slug\n\nUse shorter UUIDs in URLs, with optional masking of UUIDv7 timestamps.\n\n[![npm version](https://img.shields.io/npm/v/@anizoptera/uuid-slug?label=npm)](https://www.npmjs.com/package/@anizoptera/uuid-slug)\n[![CI](https://github.com/Anizoptera/uuid-slug/actions/workflows/check.yml/badge.svg?branch=main)](https://github.com/Anizoptera/uuid-slug/actions/workflows/check.yml)\n[![Node >=20](https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js&logoColor=white)](package.json)\n[![Runtime deps](https://img.shields.io/badge/runtime_deps-0-2ea44f)](package.json)\n[![License](https://img.shields.io/github/license/Anizoptera/uuid-slug)](LICENSE)\n\nA UUID in a URL is 36 characters of hex and dashes. The same 16 bytes written as base64url\nare 22 characters, and nothing is lost: it is the same identifier in a shorter alphabet.\n\nUUIDv7 adds a second problem. It begins with a millisecond timestamp, which is exactly\nwhat you want in a database index and rarely what you want in public. A v7 id tells anyone\nwho reads it when the record was created, and two of them tell them how fast you are\ncreating records. Masking removes that directly readable structure from public identifiers,\nthen recovers the UUID for a lookup. It is obfuscation, not an authorization boundary.\n\nIt defines no new identifier scheme. Everything here is a UUID underneath.\n\n## Install\n\n```bash\npnpm add @anizoptera/uuid-slug\n```\n\nRequires TypeScript 5.7 or newer for `Uint8Array<ArrayBuffer>` declarations.\nCustom `fillRandom` callbacks receive an ordinary `ArrayBuffer` view, which Web Crypto accepts.\n\n## Quick start\n\n```ts\nimport { createUuidSlugCodec } from \"@anizoptera/uuid-slug\";\n\nconst seed = process.env.ID_MASK_SEED;\nif (!seed) throw new Error(\"Set ID_MASK_SEED to a stable, randomly generated secret.\");\n\n// Reuse the codec: the seed is expanded once, at construction.\nexport const ids = createUuidSlugCodec({ mask: { seed } });\n\nconst publicId = ids.uuidV7MaskedSlug();\nconst uuid = ids.maskedSlugToUuidV7(publicId);\nif (uuid instanceof Error) throw uuid;\n// Look up uuid in your store and apply the caller's authorization policy.\n```\n\n**Generate the seed; do not think one up.** For example, run `openssl rand -base64 32` and\nstore the result outside your source repository. Seeds shorter than 16 bytes are rejected, but\nlength alone does not make a seed unpredictable: a known UUID/slug pair allows offline guesses.\n\nKeep the seed and `keyIterations` stable for as long as issued slugs must remain readable.\nChanging either derives different keys; retain the old settings in `previousMasks` during\nrotation. `keyIterations` defaults to `1`; increasing it adds construction and guessing cost,\nbut does not turn a human-chosen seed into a suitable secret.\n\n## Two kinds of slug\n\n**Direct slug.** The 16 UUID bytes as unpadded base64url, 22 characters. Pure re-encoding:\n`uuidToSlug` and `slugToUuid` preserve the UUID value, and anyone can convert either way. Use it\nwhen you only want the identifier shorter.\n\nPass `\"base32hex\"` to get 26 characters instead, and gain one thing: **base32hex slugs of UUIDv7s\nsort by creation time as plain strings.** A v7 starts with its timestamp, and base32hex writes\nits digits in the same order as their values, so comparing two slugs compares two instants. That\nmakes them usable directly as an ordered database key. base64url cannot do it — its alphabet runs\n`A-Za-z0-9-_`, so `0` stands for a larger value than `A` but sorts before it.\n\nbase32hex is also the safer of the two for a **filename**: it is lowercase throughout, so it\nsurvives a case-insensitive filesystem unchanged, and a directory listing comes back in creation\norder for free. `uuidV7Slug(\"base32hex\")` generates one directly; the alphabet is an argument on\nevery keyless entry point that produces a slug, so nothing about choosing it requires a codec.\n\nYou never say which alphabet you are reading. The two lengths are different, 22 and 26, so\n`slugToUuid` works out the encoding from the slug itself — which means switching alphabet leaves\nevery slug you have already handed out working.\n\n**Masked slug.** A UUIDv7 payload and check passed through a small block cipher keyed by your\nseed, then base64url-encoded. Also 22 characters. Use it when the identifier is public and you\nwould rather not publish creation times. The exact steps are in\n[how masking works](https://github.com/Anizoptera/uuid-slug/blob/main/docs/design.md).\n\nA supported UUIDv7 carries 116 bits that the masked format must preserve — the version and\nvariant are fixed, and the top six bits of the timestamp stay zero until the year 2109. 22 base64url characters hold 132\nbits, so the spare 16 carry a check for incorrect decoding. Two things follow, and both are\npart of the contract rather than implementation detail:\n\n- **A decoded slug is a candidate, not a proof.** Roughly one random 22-character string in\n  65,536 passes the check and decodes to a well-formed, possibly unissued UUID. Look the result\n  up in your own store; a wrong candidate is then a miss rather than a wrong answer. Every extra\n  distinct schema you try adds another opportunity for false acceptance; the combined rate is\n  at most the sum of the individual rates. Keep the rotation list short.\n- **Masking refuses a timestamp at or past 2^42 ms** — the year 2109 — with `invalid_timestamp`,\n  because those six bits are spent on the check. Truncating instead would let two UUIDs share one\n  slug, which nothing could recover from. Direct slugs have no such limit.\n\n**Sentinels** are reserved UUIDs that skip masking on purpose, so they stay recognisable in\nlogs and support tickets. The defaults always encode to these fixed slugs:\n\n| Name            | UUID                                   | Slug                     |\n| --------------- | -------------------------------------- | ------------------------ |\n| `nil`           | `00000000-0000-7000-8000-000000000000` | `AAAAAAAAcACAAAAAAAAAAA` |\n| `anonymousUser` | `00000000-0001-7000-8000-000000000000` | `AAAAAAABcACAAAAAAAAAAA` |\n| `unknownUser`   | `00000000-0002-7000-8000-000000000000` | `AAAAAAACcACAAAAAAAAAAA` |\n\nRead the names, UUIDs and slugs from `codec.sentinels`. Pass your own list as `sentinels` to replace the defaults:\n\n```ts\nconst ids = createUuidSlugCodec({\n  mask: { seed },\n  sentinels: [{ name: \"unknownUser\", uuid: \"00000000-0002-7000-8000-000000000000\" }],\n});\nconsole.table(ids.sentinels);\n```\n\nKeep sentinel definitions stable while their slugs are in use. Decoding checks sentinels first, so they\nare reserved even if a mask could produce the same text. Encoding rejects a non-sentinel UUID\nwhose masked output collides with a reserved slug, using `invalid_mask_schema`; it never returns\na slug that would decode to the wrong sentinel.\n\n## What masking is not\n\nMasking uses a four-round Feistel network with HalfSipHash-2-4 round functions and a\nseed-derived key schedule. It removes a directly readable timestamp; this package does not\nestablish a confidentiality strength for that construction or resistance to known-pair attacks.\nA reference-vector test for the round function does not establish those properties for the\nwhole construction. See [the mechanism and its limits](docs/design.md).\n\nMasked slugs are not authentication tokens or capabilities. The check detects many incorrect\ndecodes; it is not a message authentication code. Confirm decoded candidates against your store\nand enforce authorization separately. Supplying a custom `mix` still leaves the same short check\nand does not turn the API into an authenticated-token system.\n\n**Supply your own secret seed for real identifiers.** Omitting `mask` uses the published default\nseed, so anyone can decode the result. Reserve that default for tests and demos.\nMasked operations are codec methods; `defaultCodec` uses the same public seed.\n\n## Handling errors\n\nParsing, decoding, formatting, conversion, byte generation, and explicit-timestamp generation\nreturn `UuidSlugError | T`. Check `instanceof Error` before using the value. String-only\ngenerators return a value directly and throw on failure; constructing an invalid codec throws too.\n\nUse the error's `code` for handling and its `message` for diagnostics. `UuidSlugErrorCode` and\n`UUID_SLUG_ERROR_CODES` are the exported type and runtime list of codes. Errors wrapping failed\nentropy draws, clock readings, and custom `mix` calls preserve the original exception as `cause`.\n\n## Types\n\nBranded types distinguish the values returned by this library. Their marker is optional:\nassigning a string does not validate it. Use guards to check format and your store to check issuance.\n\n- Strings: `UuidString`, `UuidV4String`, `UuidV7String`, `Base64UrlString`.\n- Slugs: `UuidSlug` (either alphabet), `UuidV4Slug`, `UuidV7Slug`; masked ones are base64url only\n  — `MaskedUuidSlug`, `MaskedUuidV7Slug`, `SentinelUuidV7Slug`, and `PublicUuidV7Slug` for\n  \"masked or sentinel\", which is what a masking call returns. `SlugAlphabet` names the two\n  alphabets, and `UuidBytes` brands 16 raw bytes — for annotating storage of your own, which is why\n  it appears in no signature here: a byte writer returns the caller's output array, which can be\n  longer than the UUID range it writes.\n- Shapes: `UuidSlugCodec` and `UuidSlugCodecOptions`, `MaskSchema`, `MaskSchemaOptions` and `AsyncMaskSchemaOptions`,\n  `UuidV7Sentinel` and `UuidV7SentinelDefinition`, `UuidV7Generator` and\n  `UuidV7GeneratorOptions`, `FreshValidationOptions`, `UuidSlugErrorCode`.\n\n**Some masked slugs also pass direct-slug guards.** Canonical trailing bits, version, and variant\ncan all match by chance. A guard answers \"is this well formed\", never \"is this mine\". Decode with\nthe matching API and look up the candidate; a brand or guard cannot establish issuance.\n\n## API\n\nThe **Where** column tells you whether to import a function or call a codec method.\n\n- **codec + top level.** Keyless generation is available directly, for example `import { uuidV7 }`.\n  A codec can use your supplied v7 generator; top-level generation uses the platform adapter.\n- **codec only.** Uses your configured keys or sentinels.\n- **module only.** Import these functions directly. Codec settings do not affect them.\n\n| Method                                                  | Where             | Returns                       | What it does                             |\n| ------------------------------------------------------- | ----------------- | ----------------------------- | ---------------------------------------- |\n| `uuidV4()` / `uuidV7()`                                 | codec + top level | UUID string                   | Generate.                                |\n| `uuidV4Bytes(out?, offset?)` / `uuidV7Bytes(...)`       | codec + top level | `UuidSlugError \\| Uint8Array` | Generate into a buffer you own.          |\n| `uuidV7At(ms)` / `uuidV7BytesAt(ms, out?, offset?)`     | codec + top level | `UuidSlugError \\| ...`        | Generate at an instant you name.         |\n| `uuidV4Slug(alphabet?)` / `uuidV7Slug(alphabet?)`       | top level         | direct slug                   | Generate and encode in one step.         |\n| `codec.uuidV4Slug()` / `codec.uuidV7Slug()`             | codec             | direct slug                   | Same, in the codec's `slugAlphabet`.     |\n| `uuidV7MaskedSlug()`                                    | codec only        | `PublicUuidV7Slug`             | Generate a masked or sentinel slug.       |\n| `uuidV7ToMaskedSlug(uuid)` / `maskedSlugToUuidV7(slug)` | codec only        | `UuidSlugError \\| ...`        | Masked slug, both ways; v7 only.         |\n| `maskedSlugToUuid(slug)`                                | codec only        | `UuidSlugError \\| ...`        | Unmask, accepting any UUID version.      |\n| `validateMaskedSlugFresh(slug, opts?)`                  | codec only        | `UuidSlugError \\| ...`        | Unmask, then check the freshness window. |\n| `sentinels`                                             | codec only        | array                         | The reserved values this codec knows.    |\n| `migration(options?)`                                 | codec only        | migration reader              | Read selected old formats without changing new output. |\n| `uuidToSlug(uuid, alphabet?)` / `slugToUuid(slug)`      | module only       | `UuidSlugError \\| ...`        | Direct slug, both ways.                  |\n| `parseUuid` / `formatUuid`                              | module only       | `UuidSlugError \\| ...`        | UUID string to bytes and back.           |\n| `parseUuidSlug` / `formatUuidSlug`                      | module only       | `UuidSlugError \\| ...`        | Slug to bytes and back.                  |\n\nThere is no `uuidToMaskedSlug`: masking is defined for UUIDv7 only, because a v7 is the version\nthat leaks a creation time and 22 characters have no room for a version tag as well as the check.\n\nThe `At` pair supports backfilling with a known timestamp. It takes the instant first and returns\nan error unless it is a whole number of Unix milliseconds in 0..2^48-1. Its interaction with\ncurrent-time generation is runtime-specific; within-millisecond ordering is not promised.\n\nBoth masking entry points return a raw sentinel slug when the UUID is registered as a sentinel.\nTheir result type is `PublicUuidV7Slug`, the union of masked and sentinel slugs.\n\nAlso exported, all module-level:\n\n- Type guards: `isUuidString`, `isUuidV4String`, `isUuidV7String`, `isUuidSlug`, `isUuidV4Slug`,\n  `isUuidV7Slug`, and `isUuidV7Bytes` for 16 bytes you already hold.\n- base64url helpers: `encodeBase64Url`, `decodeBase64Url`, `isBase64Url`. `decodeBase64Url` returns\n  a view over exactly the bytes it decoded — with or without an `out` buffer — so `.length` is the\n  decoded length, never the buffer's. Given `out`, that view shares your buffer's memory.\n- `extractUuidV7Timestamp` — the instant a v7 was minted at, and an error for anything that is\n  not a v7, because a v4's first six bytes are random and would otherwise read back as a\n  plausible date. `hasUuidV7Entropy` answers whether the 74 random bits are anything but\n  all-zero or all-one. This rejects obvious degeneracy; it does not measure unpredictability.\n- `createMaskSchema` builds a schema from a seed; `createMaskSchemaAsync` adds cooperative cancellation; `DEFAULT_MASK_SCHEMA` is the one a codec uses\n  when you pass no `mask`, exported so `mask: DEFAULT_MASK_SCHEMA` can be a visible choice rather\n  than an omission. `UUID_V7_SENTINEL_DEFINITIONS` is the default sentinel list.\n- `createUuidV7Generator(options)` builds a generator from your own `clock` and `fillRandom`, for\n  a `generator:` codec option. Its options type is `UuidV7GeneratorOptions`.\n- `UuidSlugError` and its subclass `UuidRuntimeError`, which is what the generators throw when the\n  runtime has no usable randomness. `UuidSlugErrorCode` types the `code` property.\n\n### Codec options\n\n```ts\ncreateUuidSlugCodec({\n  mask: { seed: \"…\", keyIterations: 1 }, // or your own { id, mix } schema\n  sentinels: [{ name: \"nil\", uuid: \"…\" }],\n  previousMasks: [{ seed: \"…previous seed…\" }],\n  acceptUnmaskedSlugs: false,\n  slugAlphabet: \"base32hex\", // plain slugs only; masked slugs and sentinels stay base64url\n  generator: myUuidV7Generator,\n});\n```\n\nThe current masked format is fixed: 22 base64url characters, including a 16-bit check.\nChanging `slugAlphabet` affects plain slugs only.\n\nTo supply a different mixing implementation, pass a `MaskSchema`: an `id` and\n`mix(input, output, forward)`, where `forward: false` must reverse `forward: true` for every\n16-byte input. Construction runs round-trip smoke tests and throws if they fail. Passing them\nis not a proof of invertibility; the caller remains responsible for the complete domain.\n\n`createMaskSchema({ seed, keyIterations })` returns an error for invalid seed options.\n`createUuidSlugCodec` throws for invalid options, including a supplied schema that fails its\nsmoke tests. Build a seed schema first when you want to inspect its returned error.\n\nFor expensive derivation, await `createMaskSchemaAsync` and pass the completed schema to the codec:\n\n```ts\nimport { createMaskSchemaAsync, createUuidSlugCodec } from \"@anizoptera/uuid-slug\";\n\nconst controller = new AbortController();\nconst schema = await createMaskSchemaAsync({\n  seed, // the same stored secret used by the synchronous constructor\n  keyIterations, // preserve the setting used for issued slugs\n  signal: controller.signal, // optional; call controller.abort(reason) to stop\n});\nif (schema instanceof Error) throw schema;\nconst codec = createUuidSlugCodec({ mask: schema });\n```\n\nThe async form derives the same keys and accepts the same seed settings. It splits encoding,\npacking and hashing into cooperative work slices, allowing host tasks to run during long\nconstruction. Cancellation stops derivation and returns `UuidSlugError` with code `aborted` and\n`signal.reason` as its cause. Invalid options return `invalid_mask_schema`; unexpected construction\nfailures retain their cause. No partially derived schema is returned.\n\nOptions are captured when called. Byte seeds are copied before the first suspension, so later\ncaller mutation cannot change the key; synchronize concurrent writes to shared backing memory.\nThe initial copy of a very large byte seed, allocation, GC and host scheduling can exceed the\nresponsiveness target. Small derivations need no host yield. Construct once and reuse the schema;\npassing seed options to each new codec would repeat derivation.\n\n`previousMasks` is how you rotate a seed. Decoding tries `mask` first, then each entry in the\norder you list them, and the first whose check passes wins — so put the newest first, and keep\nthe list short. Nothing else is ever tried: a codec decodes with the schemas you gave it and no\nothers.\n\n### Changing an existing ID format\n\nPreserve the UUID; change only how it is encoded. Do not generate a replacement UUID when\nconverting an existing ID: references and stored records must still identify the same object.\n\nDeploy readers that accept both formats before any writer issues the new format. Include\nbackground jobs, cached clients and extensions in that rollout. After switching writers, keep\ncompatible readers available for rollback: an old-only reader cannot read newly issued IDs.\n\nUpdating the database does not update bookmarks, shared links, cookies or client storage.\nRemove compatibility only when the old IDs in those places no longer need to work.\nNo recent legacy traffic does not prove that old links are gone. To keep old URLs after removing\ntheir decoder, retain an application-owned mapping from each supported old slug to its UUID.\n\nKeep keys and sentinel definitions stable throughout the rollout. Non-v7 UUIDs and UUIDv7s\ntimestamped at or beyond 2^42 ms cannot use the current masked format; preserve them as UUIDs\nor direct slugs. Never truncate the timestamp or substitute a new identity to make masking pass.\n\n### Reading original slugs during migration\n\nCreate a migration reader from your codec. Enable only the old formats your application issued:\n\n```ts\nconst reader = ids.migration({ original: true, plain: true });\nconst uuid = reader.decode(incomingSlug);\nif (uuid instanceof Error) throw uuid;\n// Look up uuid and check the caller's access to the record.\n```\n\n`original` accepts the original fixed permutation/XOR format with its 4-bit checksum.\nIt does **not** accept the different masked format from package version 0.2.0.\n`plain` accepts direct UUID slugs of any version, including old base64url spellings whose\nlast four bits were ignored. Ordinary `slugToUuid` remains strict about those trailing bits.\nNeither option changes generation, ordinary codec methods, keys or sentinels.\n\n`decode` returns one UUID or `UuidSlugError`: sentinels first, then current keys, then enabled\noriginal and plain formats. A successful decode still needs a record lookup. The same slug can\ndecode to different UUIDs in different formats; the original check has only four bits.\n\nWhen you know the input format, name it to avoid trying unrelated formats:\n\n```ts\nconst uuid = reader.decode(oldSlug, \"original\"); // \"plain\" or \"current\" also works\nif (uuid instanceof Error) throw uuid;\nconst replacement = ids.uuidV7ToMaskedSlug(uuid);\nif (replacement instanceof Error) throw replacement;\n// Keep uuid in the database; use replacement in new links.\n```\n\nEnable an old format before selecting it. Recognized sentinels always return their reserved UUID,\nincluding old trailing-bit aliases when reading original or plain slugs.\n\nFor unknown formats, use `candidates` to find the matching record without guessing:\n\n```ts\nconst candidates = reader.candidates(incomingSlug);\nif (candidates instanceof Error) throw candidates;\nconst matches = candidates.length ? await findRecordsByUuids(candidates) : [];\nif (matches.length === 0) throw new Error(\"ID not found\");\nif (matches.length !== 1) throw new Error(\"ID matches multiple records; supply its original format\");\nconst record = matches[0];\n// Check access to record before returning it.\n```\n\n`findRecordsByUuids` is your database query. Return distinct records by UUID. `candidates`\nreturns distinct UUIDs in decoding order, or an empty array if none match. Malformed input and\nfailed mask callbacks return an error. Callback errors keep their cause; they never produce a\nsuccessful but incomplete list.\nAn old sentinel alias can also be a current masked ID. `candidates` retains both UUIDs;\n`decode` keeps sentinel priority.\n\nKeep this reader while old inputs must work. Replace it with `ids.migration({ plain: true })`\nto stop trying the original mask, or `ids.migration({ original: true })` to stop plain decoding.\n`ids.migration()` tries only current keys and sentinels. Removing the migration reader entirely\nleaves ordinary codec decoding unchanged. Neither method can identify the issuing format from\nthe slug alone, so successful decoding is not a reliable count of legacy traffic.\n\n### Rejecting stale ids\n\n```ts\nimport { validateUuidV7Fresh } from \"@anizoptera/uuid-slug\";\n\nconst ok = validateUuidV7Fresh(uuid, { windowMs: 60_000 });\n```\n\nThe timestamp must be inside the inclusive interval `now() - windowMs` through\n`now() + windowMs`; the default radius is 24 hours. `windowMs` must be finite and nonnegative;\nzero and finite fractions are valid. `now` defaults to `Date.now`, must return finite Unix\nmilliseconds, and is read once per validation. Invalid settings return an error instead of\ndisabling the check. Degenerate all-zero or all-one random bits are also rejected.\n\n`validateUuidV7SlugFresh` starts from a plain slug; `codec.validateMaskedSlugFresh` starts from\na slug decoded with that codec. This is a symmetric timestamp sanity check, not past-only expiry,\nauthentication, or replay prevention. Use ordinary decoding for permanent identifiers. For links\nwith expiration or one-time use, check the stored expiry and consumption state as well.\n\n## Guarantees\n\nThese are API contracts. Runtime measurements and their exercised conditions belong in\n[`bench/README.md`](bench/README.md); a benchmark pass is not proof of every guarantee.\n\n- **Conformance.** Generated ids have RFC 9562 version and variant bits. A custom generator is\n  responsible for honoring its declared contract.\n- **Randomness.** Built-in portable generation draws from Web Crypto or `node:crypto`, never\n  `Math.random`. Native generation delegates to the platform; a caller-supplied `fillRandom`\n  controls its own entropy. Collisions are possible: enforce uniqueness in storage when required.\n- **Time ordering, to the millisecond.** A v7 carries a 48-bit millisecond timestamp in its\n  leading bits, so UUID strings sort by their encoded timestamps across different milliseconds.\n  Backfilled timestamps or a clock adjustment need not reflect generation order.\n- **NOT ordered within a millisecond.** Two ids created in the same millisecond may sort in\n  either order. RFC 9562 §6.2 permits different counter and random-bit strategies. Do not use a v7\n  to recover the order of events inside a millisecond; use a sequence column.\n- **Sortable as a slug only in base32hex.** A 26-character base32hex slug compares as a plain\n  string in creation order, to the same millisecond granularity as the guarantee above: its digits\n  are in ASCII order and a v7 leads with its timestamp. That is the whole reason the alphabet is\n  offered.\n- **NOT sortable as a base64url slug.** The default 22-character form is the same 16 bytes in a\n  shorter alphabet, and the value round-trips exactly — but that alphabet runs `A-Za-z0-9-_`, so\n  its character order and its value order disagree. Sort by the UUID, or by a timestamp column. A\n  masked slug has no chronological ordering; masking always uses base64url.\n- **One type for owned bytes.** Without an output buffer, byte generators return a plain\n  `Uint8Array`, never a platform-specific subclass. With an output buffer, they return that same\n  caller-owned array and write only the requested UUID range.\n- **You pay for masking only if you mask.** The keyless operations — generating an id, converting\n  between a UUID and a plain slug — reach no codec, so a bundle that imports only those carries\n  none of the cipher, key derivation, or sentinel table. Use named imports with a tree-shaking\n  bundler; loading the complete module namespace does not promise that removal.\n\n## Runtime\n\nNo runtime dependencies, on any platform.\n\n- **Bun** uses `Bun.randomUUIDv7`.\n- **Node** uses native `crypto.randomUUID`, and native `crypto.randomUUIDv7` where the\n  version provides it, otherwise the bundled v7 generator.\n- **Browsers and Deno** use Web Crypto for randomness plus the bundled v7 generator.\n\n## License\n\nApache-2.0. Copyright 2026 Anizoptera and Art Shendrik.\n","readmeFilename":"README.md"}