deterministic-trilabel
v2.1.0
Published
Deterministic, human-readable URL generator for multi-tenant applications. Produces memorable adjective-verb-noun labels from any client/route pair — same input always yields the same output. Zero dependencies.
Maintainers
Readme
Deterministic Trilabel
A deterministic URL generation system that creates human-readable labels using combinations of adjectives, verbs, and nouns. Designed for multi-tenant applications that need consistent, memorable subdomains or URL identifiers — the same input always produces the same output, on any machine, with no shared state.
Features
- Deterministic — same client + route input always yields the same label
- Human-readable — produces memorable labels like
vivid-gather-tiger - 753M+ unique combinations — 910 adjectives × 910 verbs × 910 nouns
- Bias-free distribution — uses rejection sampling for perfectly uniform word selection
- Trilabel and bilabel modes —
adjective-verb-nounoradjective-noun - Exception keys — bypass generation for specific clients
- Composite URL mode — optional
client-routeformat - Collision detection — built-in utility to verify uniqueness across your input set
- TypeScript-first — full type definitions included
- Zero dependencies
Installation
npm install deterministic-trilabel
# or
yarn add deterministic-trilabelUsage
Basic Usage (Trilabel)
import { createLabels } from 'deterministic-trilabel';
const generateUrl = createLabels({});
generateUrl('acme-corp', 'app'); // -> 'vivid-gather-tiger'
generateUrl('acme-corp', 'api'); // -> 'tired-direct-lance'
generateUrl('acme-corp', 'app'); // -> 'vivid-gather-tiger' (always the same)Bilabel Mode
// Set at generator level
const bilabel = createLabels({ generatesTrilabel: false });
bilabel('acme-corp', 'app'); // -> 'vivid-tiger'
// Or override per-call on any generator
const trilabel = createLabels({});
trilabel('acme-corp', 'app', false); // -> 'vivid-tiger' (bilabel override)Exception Keys
Bypass label generation for a specific client — useful when one tenant should use the route name directly.
const generateUrl = createLabels({ exceptionKey: 'sportbuddy' });
generateUrl('acme-corp', 'app'); // -> 'vivid-gather-tiger'
generateUrl('sportbuddy', 'app'); // -> 'app' (bypassed)
generateUrl('sportbuddy', 'api'); // -> 'api' (bypassed)Composite URL Mode
Return client-route format instead of word combinations.
const generateUrl = createLabels({
exceptionKey: 'sportbuddy',
useCompositeUrls: true,
});
generateUrl('manchester-city', 'app'); // -> 'manchester-city-app'
generateUrl('sportbuddy', 'app'); // -> 'app' (exception still applies)GUID Mode
Use a UUID/GUID as the input instead of client + route names. The GUID's raw bytes are used directly for word selection — no hashing step needed since GUIDs already contain sufficient entropy.
When useGuids is enabled, only appendNumbers may be used alongside it — all other options must be omitted.
const generateUrl = createLabels({ useGuids: true });
generateUrl('550e8400-e29b-41d4-a716-446655440000'); // -> 'yummy-divide-ticket'
generateUrl('550e8400e29b41d4a716446655440000'); // -> 'yummy-divide-ticket' (same without hyphens)
generateUrl('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); // -> 'hostile-urge-jazz'Append Numbers
Appends a decimal numeric suffix derived from unused hash/GUID bytes, effectively eliminating collisions. Works in both GUID mode and standard mode.
// GUID mode with numbers — uses remaining 10 GUID bytes (80 bits) as suffix
const guidUrl = createLabels({ useGuids: true, appendNumbers: true });
guidUrl('550e8400-e29b-41d4-a716-446655440000'); // -> 'yummy-divide-ticket-310876571016013493305344'
guidUrl('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); // -> 'hostile-urge-jazz-84144873758547900641480'
// Standard mode with numbers — uses 12 bytes (96 bits) of SHA-256 hash as suffix
const standardUrl = createLabels({ appendNumbers: true });
standardUrl('acme-corp', 'app'); // -> 'vivid-gather-tiger-27163738176725723472341469084'
standardUrl('acme-corp', 'api'); // -> 'tired-direct-lance-69232944429967161165327811215'The word prefix remains the same whether appendNumbers is enabled or not — the suffix is purely additive.
Use shortSuffix: true alongside appendNumbers for a compact 10-digit suffix (4 bytes / 32 bits) instead of the full-length suffix:
// Short suffix — GUID mode (10 digits max)
const guidShort = createLabels({ useGuids: true, appendNumbers: true, shortSuffix: true });
guidShort('550e8400-e29b-41d4-a716-446655440000'); // -> 'yummy-divide-ticket-1430519808'
// Short suffix — standard mode (10 digits max)
const standardShort = createLabels({ appendNumbers: true, shortSuffix: true });
standardShort('acme-corp', 'app'); // -> 'vivid-gather-tiger-1472549197'shortSuffix requires appendNumbers to be enabled.
Collision Detection
Verify that your set of inputs produces unique labels.
import { checkCollisions } from 'deterministic-trilabel';
const pairs = [
{ clientName: 'client-a', routeName: 'app' },
{ clientName: 'client-b', routeName: 'app' },
{ clientName: 'client-c', routeName: 'api' },
];
const collisions = checkCollisions(pairs);
// Returns [] if all labels are unique
// Returns [{ label: 'word-word-word', inputs: [...] }] if any collideAPI
createLabels(options)
Creates a URL generator function.
Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| exceptionKey | string | — | Client name that bypasses generation (returns route name as-is) |
| useCompositeUrls | boolean | false | Use client-route format instead of word combinations |
| generatesTrilabel | boolean | true | true for adjective-verb-noun, false for adjective-noun |
| useGuids | boolean | false | Use UUID/GUID as input. When true, only appendNumbers may be used alongside it |
| appendNumbers | boolean | false | Append a decimal numeric suffix from unused hash/GUID bytes for near-100% collision elimination |
| shortSuffix | boolean | false | Use a compact 10-digit suffix (4 bytes) instead of the full-length suffix. Requires appendNumbers |
Returns: (clientName: string, routeName?: string, trilabel?: boolean) => string
- In standard mode, pass
clientNameandrouteName. The optional third parameter (trilabel) overrides the generator's default mode for that call. - In GUID mode (
useGuids: true), pass the UUID as the first argument. Accepts with or without hyphens (32 hex characters). The second and third parameters are ignored.
checkCollisions(pairs, options?)
Checks an array of { clientName, routeName } pairs for label collisions.
Returns: CollisionResult[] — array of { label: string, inputs: Array<{ clientName, routeName }> } for any labels that appear more than once. Empty array means no collisions.
calculatePotentialVariations()
Returns system capacity statistics.
interface SystemCapacity {
nouns: number; // Total noun count (910)
verbs: number; // Total verb count (910)
adjectives: number; // Total adjective count (910)
totalCombinations: number; // 753,571,000
bitsOfEntropy: number; // ~29.5
maxUrlLength: number; // Longest possible label
}generateUrl(clientName, routeName)
Legacy standalone function that always generates a trilabel. Prefer createLabels() for new code.
Exported Validation Rules
import { CLIENT_NAME_RULES, ROUTE_NAME_RULES } from 'deterministic-trilabel';Client names must:
- Be 3–63 characters (RFC 1035 DNS label limit)
- Contain only letters, numbers, and hyphens
- Start and end with a letter or number
- Not contain consecutive hyphens
Route names must:
- Be 2–16 characters
- Contain only letters, numbers, and hyphens
All inputs are lowercased and trimmed before processing.
Capacity & Collision Properties
The system maps arbitrary string inputs to a fixed output space using SHA-256 hashing with rejection sampling for uniform distribution.
Output space
| Mode | Combinations | Entropy | |------|-------------|---------| | Trilabel (adj-verb-noun) | 753,571,000 | ~29.5 bits | | Bilabel (adj-noun) | 828,100 | ~19.7 bits |
Why collisions can occur
The library generates labels from a fixed vocabulary — 910 adjectives, 910 verbs, and 910 nouns — producing 753 million possible trilabel combinations. The valid input space (all possible client + route pairs, or all possible GUIDs) is far larger than 753 million. By the pigeonhole principle, multiple different inputs must eventually map to the same label.
In practice, collisions don't require anywhere near 753 million inputs. The birthday paradox means the probability of any two inputs sharing a label grows quadratically with the number of inputs:
| Input pairs | Collision probability | |------------|---------------------| | 100 | 0.0007% | | 500 | 0.017% | | 1,000 | 0.066% | | 5,000 | 1.6% | | 10,000 | 6.4% | | ~32,000 | 50% (birthday bound) |
These probabilities apply to standard mode, GUID mode, and bilabel mode (without appendNumbers). The bottleneck is the output space (753M combinations), not the input entropy.
Using appendNumbers effectively eliminates collisions. The numeric suffix adds additional entropy from unused hash/GUID bytes. Use shortSuffix for a compact 10-digit number that's still more than sufficient for any practical deployment:
| Mode | Suffix bytes | Max digits | Combined bits | P(collision) at 500K |
|------|-------------|-----------|---------------|---------------------|
| Words only | — | — | ~29.5 | 1 in 6 |
| appendNumbers | 10–12 | 25–29 | ~109.5 | ~1 in 10^22 |
| appendNumbers + shortSuffix | 4 | 10 | ~62.5 | ~1 in 20 million |
When to use checkCollisions()
The checkCollisions() utility lets you verify that your specific set of inputs produces unique labels — without shared state or a database. Run it:
- At onboarding/provisioning — when adding a new tenant or route, check it against your existing set
- In CI/CD — validate your tenant registry hasn't grown into collision territory
- As a pre-deployment check — catch collisions before they reach production
import { checkCollisions } from 'deterministic-trilabel';
const allPairs = getAllTenantRoutePairs(); // your data source
const collisions = checkCollisions(allPairs);
if (collisions.length > 0) {
console.error('Label collisions detected:', collisions);
// Handle: reassign, use composite mode for conflicts, etc.
}For most deployments with fewer than 1,000 active label pairs, collision risk is negligible (<0.07%).
When to consider a database solution
This library is stateless by design — it generates labels deterministically but cannot track what has been assigned. If your deployment exceeds the comfort threshold for purely probabilistic uniqueness, consider adding a database layer on your end:
- > 5,000 active label pairs — collision probability exceeds 1.6%, making collisions a realistic operational concern
- Uniqueness is a hard requirement — any collision would break your system (e.g., DNS routing, tenant isolation)
- Labels must survive reassignment — you need to revoke, reserve, or redirect specific labels
A common pattern is to use the library to generate a candidate label, then check-and-insert into a uniqueness-constrained table:
const generateUrl = createLabels({});
const candidate = generateUrl(clientName, routeName);
// INSERT INTO labels (label, client, route)
// VALUES ($candidate, $client, $route)
// ON CONFLICT (label) → handle collision (retry with salt, use composite, alert)The library handles the deterministic generation; your database handles the uniqueness guarantee.
Scaling the vocabulary
If you need lower collision probabilities without a database, the lever is vocabulary size. The table below shows what the word lists would need to look like:
| Words/letter | Per collection | Trilabel combos | <1% collision threshold | |---|---|---|---| | 35 (current) | 910 | 753M | ~3,900 inputs | | 100 | 2,600 | 17.6B | ~18,700 inputs | | 250 | 6,500 | 274B | ~74,100 inputs | | 500 | 13,000 | 2.2T | ~209,000 inputs | | 891 | 23,174 | 12.4T | ~500,000 inputs |
Word Collections
Each collection contains 910 words organized as 35 words per letter (A–Z):
- 910 adjectives
- 910 verbs
- 910 nouns
License
MIT
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
