ID schemes
Five generation schemes — TypeID (default), ULID, numeric, Snowflake, custom. Decision matrix, auto-injection lifecycle, cursor pagination, branded TypeScript types.
Every id() column in a Voltro schema has a generation scheme. The framework auto-injects the ID before each insert based on the column's declared scheme; application code never has to generate one by hand for the common case.
The default is TypeID — a Stripe-style <prefix>_<26-char-ulid> shape that's sortable, brandable, URL-friendly. Other schemes exist for cases where TypeID doesn't fit:
- TypeID — default. Sortable, branded by table, doubleclick-selectable.
- ULID — 26-char Crockford-base32 without prefix. Drops the type tag for privacy-sensitive IDs.
- Numeric — dialect-native auto-increment. Internal tables only.
- Snowflake — Twitter 64-bit. Extreme-throughput distributed inserts.
- Custom — bring your own generator.
Decision matrix
| Scheme | Shape | Pick when |
|---|---|---|
typeid (default) |
user_01j5xkqyz8x3n4m9pvabcd |
Every user-facing entity. Sortable, brandable, URL-safe. |
ulid |
01j5xkqyz8x3n4m9pvabcd |
Public-facing IDs where the table type should NOT leak. Invite tokens, share links, magic links. |
numeric |
1, 2, 3, … |
Internal tables that are never URL-exposed AND benefit from compact FK joins. CDC log, audit log, migration log. |
snowflake |
1879382109193580544 |
Extreme-throughput distributed inserts. Set SNOWFLAKE_MACHINE_ID (0–1023) per process. |
custom |
user-supplied generator | Escape hatch — any string format you control. |
Default to typeid. The other schemes exist for specific reasons; don't pick one without a reason.
Declaration
import { id, table, text } from '@voltro/database'
// Default — implicit typeid, prefix auto-derived from table name.
export const users = table('users', {
id: id(), // → 'user_01j5xkqyz8x3n4m9pvabcd'
email: text(),
})
// Explicit prefix (for tables whose singularize-prefix would be ugly):
export const orgs = table('organizations', {
id: id({ prefix: 'org' }),
})
// Explicit non-typeid scheme:
export const cdcLog = table('_voltro_cdc_log', {
id: id({ scheme: 'numeric' }),
})
export const inviteTokens = table('invite_tokens', {
id: id({ scheme: 'ulid' }),
})
export const events = table('events', {
id: id({ scheme: 'snowflake' }),
})
export const shortlinks = table('shortlinks', {
id: id({
scheme: 'custom',
generate: (_tableName) => `sl_${nanoid(8)}`,
}),
})The schema registry records the scheme + (for typeid) the prefix at registration time. The framework's mutation middleware reads it before every insert to inject the ID.
Auto-injection lifecycle
The mutation middleware (in runtime/storeMiddleware.ts) wraps every ctx.store.insert(...) call:
// You write:
await ctx.store.insert('todos', { title: 'hello', done: false })
// The middleware reads the schema's id scheme + prefix:
// schemaRegistry.idScheme('todos') → 'typeid' | 'ulid' | 'numeric' | 'snowflake' | custom
// schemaRegistry.idPrefix('todos') → 'todo' (auto-derived)
// For typeid/ulid/snowflake/custom: generate + insert.
// For numeric: omit `id` from the insert; let the dialect's
// AUTO_INCREMENT / SERIAL / IDENTITY fire + return via RETURNING.Explicit id: values pass through unchanged — useful for tests with deterministic IDs and for signup flows where the actor is the row being created.
// Production code — id auto-injected.
const row = await ctx.store.insert('todos', { title, done: false })
// row.id === 'todo_01j5xkqyz8x3n4m9pvabcd'
// Test — deterministic id.
await ctx.store.insert('todos', { id: 'todo_pinned_for_test', title, done: false })
// Signup self-stamping — the actor IS the row. Generate the id up front
// so createdBy can point at the row being created, in the same insert.
import { typeid } from 'typeid-js'
const userId = typeid('user').toString()
await ctx.store.insert('users', { id: userId, email, createdBy: userId })Branded TypeScript types
The framework's schema registry brands each table's ID type — UserId, OrgId, ProjectId — at the TypeScript level. They're strings at runtime, opaque-branded types at compile time:
declare const tagOfTable: unique symbol
type UserId = string & { [tagOfTable]: 'users' }
type OrgId = string & { [tagOfTable]: 'organizations' }
function findUser(id: UserId): Promise<User> { ... }
const user = await store.query(database.users.findFirst())
findUser(user.id) // ✓ typechecks — user.id is UserId
findUser('plain-string') // ✗ "Type 'string' is not assignable to type 'UserId'"
findUser(org.id) // ✗ "Type 'OrgId' is not assignable to 'UserId'"The brand flows through reference() columns too — posts.authorId is typed as UserId, not just string, so passing the wrong ID kind into a FK lookup fails compilation.
Brand-skipping escape hatch: cast through as unknown as UserId when you genuinely need to construct an ID from a free string (e.g. reading from a URL param after validation). The cast is loud + greppable.
Cursor pagination
Sortable IDs (TypeID, ULID, Snowflake) enable cheap cursor pagination — WHERE id > $cursor ORDER BY id LIMIT N — without OFFSET's O(n) scan.
import { paginateById } from '@voltro/database'
// `paginateById(descriptor, cursor, limit)` RETURNS a QueryDescriptor
// — pass it to `store.query()`. The cursor is the last row's id.
// First page.
const rows = await ctx.store.query(
paginateById(database.posts.where(eq('userId', subject.id)).descriptor, undefined, 20),
)
const nextCursor = rows.at(-1)?.id // undefined on the last page
// Subsequent pages: pass nextCursor back in.
const page2 = await ctx.store.query(
paginateById(database.posts.where(eq('userId', subject.id)).descriptor, nextCursor, 20),
)Numeric IDs work too. Custom IDs work IFF the generator output is comparable in lexical order (Snowflake's left-padded 19-digit form is; an arbitrary generate() may not be).
Per-scheme details
See the detail pages for the runtime behaviour:
- TypeID covers prefix-resolution rules + the
typeid-jsintegration + sort-order properties. - ULID covers when to drop the prefix tag.
- Numeric covers per-dialect AUTO_INCREMENT idioms + enumeration-attack risks.
- Snowflake covers the Twitter spec +
SNOWFLAKE_MACHINE_ID+ the 70-year epoch caveat + JS Number precision. - Custom covers the generator API + deterministic-output requirements.
Where it lives
voltro/packages/database/src/idGenerator.ts—resolveIdScheme/generateId(scheme, tableName)/deriveTypeIdPrefixfor TypeID / ULID / Numeric / Snowflake / Customvoltro/packages/database/src/snowflake.ts— Twitter-spec Snowflake implementationvoltro/packages/database/src/columns.ts—id()column constructor +IdOptionsvoltro/packages/runtime/src/schemaRegistry.ts— tracks scheme + prefix per table for branded TS typesvoltro/packages/runtime/src/storeMiddleware.ts—stampedForInsertauto-injects from the registered schemevoltro/packages/database/src/queryBuilder.ts—paginateByIdcursor pagination helper