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/abc12345for 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:
- 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. - Pure. Same inputs should produce equivalent outputs IF the generator is deterministic; if it's random (typical for nanoID-style), entropy comes from
crypto.randomBytesor equivalent. Don't make a network call. - Side-effect-free. Don't write to a counter / cache / log inside
generate. Side effects make the insert non-idempotent on retry. - Unique per call. Two calls to
generatefor the sametableNameMUST produce different IDs (unless your application logic intentionally produces deterministic ones, in which case collisions are your responsibility). - 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 — ShortlinkId ≠ UserId 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
generatetakes 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.ts—resolveIdSchemeresolves{ scheme: 'custom', generate }to the internal{ kind: 'custom', generate };generateId(scheme, tableName)calls yourgeneratevoltro/packages/database/src/columns.ts—id({ scheme: 'custom', generate })column constructorvoltro/packages/runtime/src/schemaRegistry.ts— registers the scheme + brand at table-declaration time