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-js integration + 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.tsresolveIdScheme / generateId(scheme, tableName) / deriveTypeIdPrefix for TypeID / ULID / Numeric / Snowflake / Custom
  • voltro/packages/database/src/snowflake.ts — Twitter-spec Snowflake implementation
  • voltro/packages/database/src/columns.tsid() column constructor + IdOptions
  • voltro/packages/runtime/src/schemaRegistry.ts — tracks scheme + prefix per table for branded TS types
  • voltro/packages/runtime/src/storeMiddleware.tsstampedForInsert auto-injects from the registered scheme
  • voltro/packages/database/src/queryBuilder.tspaginateById cursor pagination helper