svid (Stateless Verifiable IDs)
64-bit domain-typed, stateless, verifiable IDs for native and WASM.
svid generates i64 IDs that are chronologically sortable, carry a 7-bit entity tag, and need zero coordination between processes.
Why SVID?
- Compact PK — 64-bit
i64fits in 8 bytes per row; half the on-disk footprint of a UUID and the natural width forBIGINTcolumns and JSbigint. - B-tree friendly — high bits are a monotonic timestamp, so recent inserts cluster at the right edge of the index instead of scattering across the tree the way UUIDv4 does. Reduces page splits and index bloat.
- Type info travels with the ID — the 7-bit entity tag lets any service, log line, queue payload, or DB row dispatch on entity kind without a side-table lookup or external schema.
- Compile-time type safety —
#[derive(Svid)]mints distinct newtypes (UserId,GroupId, …); the Rust compiler refuses to swap them. Parse-time tag mismatches return typed errors instead of corrupting data silently. - Chronologically sortable —
ORDER BY idisORDER BY creation_timeto one-second resolution, often eliminating the need for a separatecreated_atcolumn. - Stateless and coordination-free — server and WASM clients mint IDs locally; the 1-bit source field disambiguates origin without a central allocator.
- Format-stable — positive
i64round-trips losslessly through PostgreSQLBIGINT, JSON strings, JSbigint, and the base58 wire forms; same bytes, all the way down.
Bit Layout
Default profile (bits-balanced):
63 62 34 33 8 7 6 0
┌──┬──────────────────────┬───────────────────────────────┬──┬────────┐
│S0│ TIMESTAMP (T) │ RANDOM (M) │W │ TAG │
│1b│ 29 bits │ 26 bits │1b│ 7 bits│
└──┴──────────────────────┴───────────────────────────────┴──┴────────┘
T + M = 55 (the timestamp/random tradeoff is set by a Cargo feature)
- Sign (63): always
0(keepsi64positive). - Timestamp: seconds since
2026-01-01 UTC(SVID_EPOCH). Sits at the top soORDER BY idmatches chronological order — the property that makes the format B-tree friendly, same as ULID / Snowflake / UUIDv7. - Random: CSPRNG output (
rand::thread_rng()= ChaCha12). - Source (7):
0= server,1= client/WASM. - Tag (0–6): 7-bit entity tag (0–127). Anchored at the LSB across every profile, so raw bit-ops like
id & 0x7Fstay stable when the bit budget is reallocated. Downstream SQL / JS code that extracts the tag never has to change when you switch profiles.
Bit-layout profiles (compile-time)
The timestamp/random trade-off is selected at compile time via Cargo features. Exactly one must be enabled.
| Feature | Timestamp | Range from 2026 | Random | 50% collision at | When to use |
|---|---|---|---|---|---|
bits-long-life |
31 bits | ~68 years (until 2094) | 24 | ~4.8K IDs/sec | Archival / long-lived data, low ID rate |
bits-balanced (default) |
29 bits | ~17 years (until 2043) | 26 | ~9.6K IDs/sec | Recommended for most apps |
bits-high-rand |
28 bits | ~8.5 years (until 2034) | 27 | ~13.6K IDs/sec | Short-lived data, high generation rate |
Override the default:
[]
= { = "0.3", = false, = ["bits-high-rand"] }
The field order is fixed across profiles: only the timestamp and random bit-widths trade against each other. Other field positions (sign, source, tag) are stable — see the diagram above.
Picking a profile
The collision rate is purely a function of the random bits — RNG quality isn't the bottleneck, the bit budget is. Approximate 50%-collision threshold is √(2 · 2^M · ln 2):
| M (random bits) | 50% collision threshold | At 100K IDs/sec, expected collisions |
|---|---|---|
| 24 | ~4.8K | ~287 |
| 26 | ~9.6K | ~75 |
| 27 | ~13.6K | ~37 |
If you need fully collision-free generation above ~10K IDs/sec, 64-bit isn't enough — you'd need a 128-bit format (ULID / UUIDv7). For most apps, bits-balanced is the right point on the curve.
Install
[]
= "0.3"
# optional: features = ["serde", "diesel", "ts"]
# pick a different bit-layout profile (default is "bits-balanced"):
# svid = { version = "0.3", default-features = false, features = ["bits-high-rand"] }
Quick Start
use FromStr;
let reg = new;
// Type-inferred — variant picked from the binding type.
let u: UserId = reg.generate_id;
let g: GroupId = reg.generate_id;
// Or address the typed generator directly:
let u2: UserId = reg.user_id.generate_id;
// Tag is checked on parse.
assert_eq!;
assert!;
#[derive(svid::Svid)] emits one newtype (UserId, GroupId, …) and one marker type per variant alongside the enum. Variants must be unit variants with explicit = N discriminants in the 0–127 range — those values get persisted inside every ID and must not be reused or renumbered later. The #[svid(registry = ...)] helper is optional; omit it to skip generating the registry struct.
Encoding
let u: UserId = reg.user_id.generate_id;
let b: String = u.to_base58; // variable-length base58
let h: String = u.to_str; // fixed 11-char base58
let n: i64 = u.to_i64; // raw — no tag check
from_base58?; // tag-checked
from_str_id?; // tag-checked
let _: UserId = from; // unchecked
// Display / FromStr auto-dispatch by length (11 → str_id, else base58).
let _: UserId = u.to_string.parse?;
Domain Enums
Group related IDs into one type:
let f: FolderId = reg.generate_id;
let any: FolderEnum = f.into;
let _: FolderEnum = from_i64?;
let _: FolderId = any.try_into?; // recover inner newtype
Variants must be single-field tuple variants whose inner type is a bare ident matching a SvidTag variant (e.g. Folder(FolderId) pairs with SvidTag::FolderId). error_label is interpolated into the error message returned by from_i64 when the tag doesn't match any variant. If your tag enum isn't named SvidTag, add #[svid(tag = MyTag)] to point the derive at it.
Bridging to a Wider Enum
bridge!;
let f: FolderId = reg.generate_id;
let any: AnyId = Folder.into;
Inspecting Raw IDs
use SvidExt;
let id: i64 = u.to_i64;
id.tag; // u8
id.unix_timestamp; // i64
id.is_client; // bool
id.random_bits; // u32
let dec = from_i64;
Features
| Feature | Adds |
|---|---|
serde |
string-based Serialize / Deserialize |
diesel |
ToSql / FromSql for BigInt on Postgres |
ts |
#[derive(TS)] for ts-rs TypeScript export |
The derives emit #[cfg(feature = "…")] impls that resolve against your crate's features — mirror them in your Cargo.toml:
[]
= { = "0.3", = ["diesel"] }
= { = "2", = ["postgres"] }
[]
= ["svid/diesel"]
JavaScript / TypeScript
The crate ships JS/TS bindings built with wasm-bindgen. IDs cross the FFI as native bigint (no precision loss).
Build
Usage
import {
generateSvid,
decodeSvid,
encodeHumanReadable,
decodeHumanReadableExpecting,
encodeBase58,
decodeBase58,
extractTag,
svidEpoch,
} from "svid";
const USER_ID_TAG = 1;
const id: bigint = generateSvid(USER_ID_TAG);
const s: string = encodeHumanReadable(id); // fixed 11-char base58
const b: string = encodeBase58(id); // variable-length base58
const back: bigint = decodeHumanReadableExpecting(s, USER_ID_TAG); // throws on tag mismatch
console.assert(back === id);
const d = decodeSvid(id);
// { timestamp: number, isClient: true, idType: 1, random: number, unixTimestamp: bigint }
extractTag(id); // 1
extractIsClient(id); // true
extractUnixTimestamp(id); // bigint, seconds since unix epoch
extractTimestampBits(id); // number, seconds since SVID_EPOCH
extractRandomBits(id); // number, profile-dependent random width
svidEpoch(); // 1767225600n
decodeBase58(b) === id; // true
Notes
- Time uses
js_sys::Date::now(); randomness usesgetrandom'sjsbackend. generateSvidalways setsisClient = true(the WASM build runs in the client). To mint server-source IDs from JS, use the low-levelencodeSvidpacker.IdRegistryand the strongly-typed newtype derives are Rust-only — JS works with rawbigints plus theextract*helpers for tag-based dispatch.
Limitations
- Epoch: depends on the selected profile —
bits-long-lifewraps in 2094,bits-balanced(default) in 2043,bits-high-randin 2034. - Tags: 7 bits ⇒ max 128 entity types.
- Collisions: random width is profile-dependent (24–27 bits). At the default 26 bits, 50% birthday-bound is ~9,600 IDs/sec/tag; plan retries above that. For higher rates, switch to
bits-high-randor move to a 128-bit format. - Source bit: 1 bit only (server vs client).
- No reserved bits — format changes are breaking. Switching profiles is also a wire-format change; pick once per deployment.
Citation
If you use svid in academic or technical work, please cite it:
GitHub renders a Cite this repository button from CITATION.cff.