ULID — bare, no prefix

26-char Crockford-base32 ULID without a prefix tag. For public-facing IDs where the table type should not leak from the ID itself.

ULID drops TypeID's prefix and keeps just the 26-character body. Use when the mere existence of a row in a specific table is sensitive — leaking the table tag in the ID leaks the row's category.

import { id } from '@voltro/database'

export const inviteTokens = table('invite_tokens', {
  id: id({ scheme: 'ulid' }),       // → '01j5xkqyz8x3n4m9pvabcd' (no prefix)
  invitedBy: reference(() => users),
  email: text(),
  expiresAt: timestamp(),
})

Spec

ULID is the Universally Unique Lexicographically Sortable Identifier. The framework implements it via the ulidx library — a Crockford-base32 encoder/decoder with monotonic guarantees.

  • Length: 26 characters.
  • Alphabet: Crockford-base32 (no I, L, O, U).
  • Body: 48-bit Unix millisecond timestamp + 80 random bits — same as TypeID's body component.
  • Case: lowercase canonical (01j5xkqyz8x3n4m9pvabcd). Crockford-base32 is case-insensitive but the framework + ulidx emit lowercase.

The 48-bit timestamp prefix means ULIDs sort by creation time — same property as TypeID, just without the type tag in front.

When to pick ULID over TypeID

Three cases, in order of how often they apply:

1. Existence of the row is sensitive

For invite tokens, share links, magic-link references, password-reset tokens — the URL containing the ID is the secret. If the prefix invite_ is in the URL, an attacker who sees https://app/i/invite_01j5xkqyz8x3n4m9pvabcd knows the row is an invite_tokens entry, not a public document. That information is itself useful for targeted attacks.

// TypeID — leaks "this is an invite" in the URL.
export const inviteTokens = table('invite_tokens', {
  id: id({ prefix: 'invite' }),
})
// URL: https://app/accept?token=invite_01j5xkqyz8x3n4m9pvabcd
//                              └──┘ — leaks the category

// ULID — opaque from the URL alone.
export const inviteTokens = table('invite_tokens', {
  id: id({ scheme: 'ulid' }),
})
// URL: https://app/accept?token=01j5xkqyz8x3n4m9pvabcd

The framework's schema-registry brand still flows — InviteTokenIdUserId at compile time even though both are 26-char strings at runtime.

2. Interop with a system that expects bare ULIDs

If you're integrating with software that already standardized on bare ULIDs and you want the framework's IDs to be drop-in compatible, ULID gives you that without a translation step.

3. You really hate the prefix aesthetic

Subjective, but valid. Some teams prefer the cleaner look of 01j5xkqyz8x3n4m9pvabcd over user_01j5xkqyz8x3n4m9pvabcd. Decide at the schema level — picking ULID for the whole table is the right place; trying to strip prefixes in display code is a mess.

What you lose vs TypeID

  • Doubleclick selection. ULIDs are pure alphanumeric so this works fine — same as TypeID.
  • Type-tag at-a-glance in logs. Greppping for user_ in voltro logs won't catch ULID-typed entities. The compile-time brand is still there but you lose the runtime affordance.
  • Disambiguation in mixed-table dumps. If you SELECT id FROM users UNION SELECT id FROM orgs and grep the result, ULIDs are visually identical across tables. TypeID makes the source self-evident.

What you DON'T lose

  • Sortable by creation time: ULID's timestamp prefix is identical to TypeID's body. ORDER BY id works the same.
  • Branded at the TypeScript level: the framework's schema registry brands InviteTokenId regardless of the wire format.
  • Cursor pagination via paginateById: works on ULID columns the same as TypeID.

Storage

VARCHAR(64) on mysql / mariadb / mssql, TEXT on postgres / sqlite / turso. Same column type as TypeID — the framework doesn't tune the column shape per scheme. The PK B-tree behaves identically (timestamp-leading lexical order).

Migration between schemes

ULID and TypeID share the same body format. Going from TypeID user_01j5xkqyz8x3n4m9pvabcd to ULID 01j5xkqyz8x3n4m9pvabcd is a string-prefix strip. Going the other way is a string-prefix prepend. The framework doesn't ship a one-shot migrator; if you flip the scheme on an existing table, write a one-off UPDATE.

-- TypeID → ULID
UPDATE invite_tokens SET id = SUBSTRING(id, 8) WHERE id LIKE 'invite_%';

-- ULID → TypeID
UPDATE invite_tokens SET id = 'invite_' || id WHERE id NOT LIKE 'invite_%';

Run inside a transaction. Both directions invalidate any URL or external reference that embedded the old form — coordinate with the consumers.

Caveats

  • Monotonicity within the same millisecond is not guaranteed. ULID spec says two IDs generated in the same ms can be sorted in any order. For most uses this is fine; for nano-precision audit logs, prefer Snowflake (which uses a sequence counter within each ms).
  • No type discrimination in error messages. A handler that logs "couldn't find id 01j5xkqyz8x3n4m9pvabcd" gives the user no hint which table to check. TypeID's prefix is the better choice for any ID a user might encounter in an error message.
  • Don't try to derive type from the ID at runtime. The framework's brand exists at compile time only; once a string crosses an API boundary as unknown, you can't tell what table it belongs to without looking it up. If runtime discrimination matters, use TypeID.

Where it lives

  • voltro/packages/database/src/idGenerator.tsgenerateId(scheme, tableName) wires up ulidx for the ulid scheme
  • voltro/packages/runtime/src/schemaRegistry.ts — ULID-scheme registration (no prefix to resolve)