Custom — bring your own generator

Escape hatch for IDs that don't fit any of the built-in schemes. Pure, side-effect-free generator function returns a string per insert.

When the four built-in schemes (TypeID, ULID, numeric, Snowflake) don't fit, declare a custom generator. The framework calls it before every insert; the returned string becomes the row's ID.

import { id, table } from '@voltro/database'
import { customAlphabet } from 'nanoid'

const nano = customAlphabet('abcdefghjkmnpqrstuvwxyz23456789', 12)

export const shortlinks = table('shortlinks', {
  id: id({
    scheme:   'custom',
    generate: (_tableName) => `sl_${nano()}`,
  }),
  targetUrl: text(),
  createdAt: timestamp().default('now'),
})

Examples of valid use cases:

  • NanoID-style human-readable short codes. URL shorteners, OTPs, room codes.
  • Time-bucketed IDs like 2026-05-30/abc12345 for date-partitioned tables.
  • IDs sourced from an external system — a Redis counter, AWS-generated reference, hash of input.
  • Hash-prefixed IDs like sha256:abc123… for content-addressable storage.
  • Voltro Cloud's per-customer numeric IDs that need to start at a customer-specific offset.

If you're reaching for custom IDs, double-check none of the built-in schemes fit — they handle 99% of cases. Custom is the escape hatch, not the default.

Generator contract

The id() argument is FLAT — { scheme: 'custom', generate }. The { kind: 'custom', generate } object below is the RESOLVED internal scheme the framework stores on the column after table() walks the fields; you never write it yourself.

// What you pass to id():
id({ scheme: 'custom', generate: (tableName: string) => string })

// Resolved internal scheme (framework-internal, FYI only):
interface CustomScheme {
  readonly kind: 'custom'
  readonly generate: (tableName: string) => string
}

The contract:

  1. Synchronous. The framework calls generate(tableName) during the mutation middleware's pre-insert phase. No async work — the mutation pipeline doesn't await Promises here.
  2. Pure. Same inputs should produce equivalent outputs IF the generator is deterministic; if it's random (typical for nanoID-style), entropy comes from crypto.randomBytes or equivalent. Don't make a network call.
  3. Side-effect-free. Don't write to a counter / cache / log inside generate. Side effects make the insert non-idempotent on retry.
  4. Unique per call. Two calls to generate for the same tableName MUST produce different IDs (unless your application logic intentionally produces deterministic ones, in which case collisions are your responsibility).
  5. Returns a string. The framework stores it as a text() / VARCHAR(64) column; arbitrary length is allowed but keep it reasonable (under 100 chars for URL-safety).

What if I need async ID generation?

You can't have it inside the auto-injection path. Two alternatives:

// Option 1: Pass id: explicitly, generated upstream.
const id = await fetchExternalId()
await ctx.store.insert('shortlinks', { id, targetUrl })

// Option 2: Generate a placeholder client-side, replace via UPDATE
// after the external call resolves. Requires the table to support
// UPDATE of the id column (which is unusual + fragile).

Most "async" cases turn out to be solvable with option 1 — fetch the ID, then insert. The framework's auto-injection is for cases where the ID generator is cheap + synchronous.

Collision handling

generate MUST produce unique IDs for the framework's auto-injection contract to hold. If a duplicate slips through and the DB rejects the insert with a PK conflict, the framework surfaces it as a PrimaryKeyConflictError typed error — your handler can catch and retry:

import { PrimaryKeyConflictError } from '@voltro/database'

try {
  await ctx.store.insert('shortlinks', { targetUrl: input.url })
} catch (e) {
  if (e instanceof PrimaryKeyConflictError) {
    // Generator collision — retry with a fresh ID.
    return retryWithBackoff()
  }
  throw e
}

Built-in schemes (TypeID, ULID, Snowflake) collision-rate is astronomically low; you'll never hit PrimaryKeyConflictError from them. Custom generators with short alphabets / short bodies (e.g. 4-character codes) WILL collide and need explicit retry.

TypeScript brand

The schema registry brands every custom-scheme table the same way it brands TypeID tables — ShortlinkIdUserId at compile time. The brand survives across hooks, query handlers, RPC boundaries via descriptor serialization.

Storage

TEXT on postgres / sqlite / turso, VARCHAR(64) on mysql / mariadb / mssql. The framework picks 64 because typical custom-scheme outputs are short; if you need longer (e.g. SHA-256 hex is 64 chars + prefix), set the column type explicitly via text() instead of id() — but you lose the auto-injection at that point.

The PK index is a B-tree on every dialect. Performance depends on your generator's distribution: clustered timestamp-leading IDs (like TypeID/ULID/Snowflake) are B-tree friendly; random scattered IDs (like UUIDv4 or nanoID) cause leaf-page splits and write amplification at high insert rates.

Examples

Nano-ID short codes

import { customAlphabet } from 'nanoid'
const nano = customAlphabet('abcdefghjkmnpqrstuvwxyz23456789', 12)

id({
  scheme: 'custom',
  generate: () => `sl_${nano()}`,
})

12-char body, 32-char alphabet → 12 × log2(32) = 60 bits of entropy. Plenty for short-link uniqueness up to ~10^9 IDs (~5% collision at that scale). At higher scale, increase the body length.

Hash-prefixed (content-addressable)

import { createHash } from 'node:crypto'

id({
  scheme: 'custom',
  generate: (_tableName) => {
    // This pattern only works when the row's content is known
    // before the insert is built — e.g. a content blob the
    // caller already has in hand. Generator can't access the
    // row, so you'd actually want to pre-compute outside.
    throw new Error('use ctx.store.insert with explicit id for content-addressed')
  },
})

In practice, content-addressed IDs are pre-computed by the caller + passed via explicit id: rather than auto-injected.

Time-bucketed

import { ulid } from 'ulidx'

id({
  scheme: 'custom',
  generate: () => {
    const now = new Date().toISOString().slice(0, 10)  // YYYY-MM-DD
    return `${now}/${ulid().toLowerCase()}`
  },
})
// → '2026-05-30/01j5xkqyz8x3n4m9pvabcd'

Useful when you want PK ordering to reflect the date partition + want to query a single day's rows via WHERE id BETWEEN '2026-05-30/' AND '2026-05-31/'. Caveat: doesn't sort cleanly across years (2026/ < 2027/ is fine, but you can't compare two IDs from different calendars).

Migration to / from custom

Going from custom to a built-in scheme: same approach as ULID ↔ TypeID — a one-off UPDATE that rewrites IDs across all FK references. Get a lock, run inside a transaction.

Going from a built-in to custom: usually means generating new IDs for every row (since the built-in body isn't compatible with your custom format). At scale this is expensive; consider whether the custom benefit outweighs the migration cost.

Caveats

  • Generator throughput is the framework's ceiling. If generate takes 5ms, every insert takes 5ms minimum. Profile + optimize before adopting in a hot path.
  • No type-level branding distinction between custom schemes. The framework brands by table name; two custom-scheme tables with overlapping ID formats are still distinguishable by brand, but the BRAND comes from the table, not the scheme. Don't confuse runtime format with compile-time identity.
  • Custom generators that produce non-comparable strings break paginateById. The cursor-pagination helper assumes IDs sort lexically. Hash-based IDs (UUIDv4, sha256) don't; use a separate sortable column (createdAt) as the cursor key.

Where it lives

  • voltro/packages/database/src/idGenerator.tsresolveIdScheme resolves { scheme: 'custom', generate } to the internal { kind: 'custom', generate }; generateId(scheme, tableName) calls your generate
  • voltro/packages/database/src/columns.tsid({ scheme: 'custom', generate }) column constructor
  • voltro/packages/runtime/src/schemaRegistry.ts — registers the scheme + brand at table-declaration time