— lowercase ASCII that STARTS with a letter (a leading digit or underscore is rejected by `typeid-js`). Up to 63 chars (practical limit: keep it short).\n- **Separator**: single underscore `_`.\n- **Body**: 26 characters in Crockford-base32 (no `i`, `l`, `o`, `u` to avoid visual ambiguity). Represents a 128-bit ULID.\n- **Body composition**: 48-bit Unix millisecond timestamp followed by 80 random bits.\n\nTotal length: prefix + 1 + 26 chars. For a typical 3–5 char prefix, that's 30–32 characters.\n\n## Why default\n\n### Sortable by creation time\n\nThe 48-bit timestamp prefix means IDs generated in order are lexically ordered:\n\n```typescript\nimport { typeid } from 'typeid-js'\nconst a = typeid('user').toString() // user_01j5xkqyz8x3n4m9pvabcd\nawait new Promise(r => setTimeout(r, 1))\nconst b = typeid('user').toString() // user_01j5xkqyz8x3n4n0pcdefgh\na \u003c b // true\n```\n\n`ORDER BY id` works as a cheap \"ORDER BY createdAt\" with no separate column. Cursor pagination via `paginateById` exploits this.\n\n### Doubleclick-selectable\n\nModern browsers treat `_` as a word-boundary character. Doubleclicking on `user_01j5xkqyz8x3n4m9pvabcd` in a log line, terminal, or page selects the WHOLE id. UUIDv4's dashes break this — `01234567-89ab-...` selects only one of the hyphen-separated chunks.\n\nThis is the difference between \"I can paste this from a Slack log into a SQL query\" and \"I have to manually fix the substring on every paste\".\n\n### URL-friendly\n\nOnly `[a-z0-9_]` — no percent-encoding needed. `/posts/post_01j5xkqyz8x3n4m9pvabcd` is exactly 31 characters in the URL with no escaping overhead. Compare to UUID's dashes (which RFC-3986 allows but tooling sometimes mangles) or base64-encoded random bytes (which need URL-safe base64 + are rarely the right call anyway).\n\n### Branded at the TypeScript level\n\nThe framework's schema registry brands each table's ID type. `UserId` ≠ `OrgId` ≠ `ProjectId` at compile time, even though they're all `string` at runtime:\n\n```typescript\nfunction getUser(id: UserId): Promise\u003cUser> { ... }\n\nconst user: User = ...\nconst org: Org = ...\n\ngetUser(user.id) // ✓ typechecks\ngetUser(org.id) // ✗ Type 'OrgId' not assignable to 'UserId'\ngetUser('plain-string') // ✗ Type 'string' not assignable to 'UserId'\n```\n\nThe brand flows through `reference()` columns — `posts.authorId` is `UserId` not `string`, so FK lookups can't accept the wrong table's ID by accident.\n\n### Compact\n\n~30 chars vs UUIDv4's 36 (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). Multiplies across foreign keys + log lines + URL params. Snowflake (8 bytes) is denser still but loses the type tag.\n\n## Prefix resolution\n\nTwo paths:\n\n### Explicit prefix\n\n```typescript\nid({ prefix: 'foo' }) // → 'foo_01j5xkqyz8x3n4m9pvabcd'\n```\n\nUse when the implicit derivation would produce something ugly or non-unique.\n\n### Implicit (derived from table name)\n\n```typescript\ntable('users', { id: id() }) // → 'user_01j5...'\ntable('todos', { id: id() }) // → 'todo_01j5...'\ntable('apps', { id: id() }) // → 'app_01j5...'\ntable('posts', { id: id() }) // → 'post_01j5...'\n```\n\nThe derivation runs at schema-registration time via a simple-singularize:\n\n- Strip trailing `s` from plural nouns.\n- Special-case nothing else.\n\nFor unconventional plurals (`feet`, `children`, `status`, `data`, `series`), set an explicit prefix to avoid the resolver guessing wrong:\n\n```typescript\ntable('feet', { id: id({ prefix: 'foot' }) })\ntable('children', { id: id({ prefix: 'child' }) })\ntable('status', { id: id({ prefix: 'status' }) }) // identical singular/plural\ntable('data', { id: id({ prefix: 'datum' }) })\n```\n\nThe schema registry validates prefix at registration — a prefix collision across two tables throws at boot with both call sites pointed at. You can't accidentally use `user_` for both `users` and `system_users`.\n\n## Lifecycle\n\n```typescript\n// Production handler — id auto-injected by the mutation middleware.\nconst row = await ctx.store.insert('todos', { title: 'hello', done: false })\n// row.id === 'todo_01j5xkqyz8x3n4m9pvabcd'\n\n// Test — deterministic id for assertion.\nawait ctx.store.insert('todos', { id: 'todo_pinned_for_test', title, done: false })\n\n// Signup self-stamping — actor is the row being created.\nimport { typeid } from 'typeid-js'\nconst userId = typeid('user').toString() // pre-compute the id\nawait ctx.store.insert('users', { id: userId, email, createdBy: userId })\n```\n\n## Storage\n\nTypeID values are strings. On every dialect:\n\n- `VARCHAR(64)` on mysql / mariadb / mssql (length set explicitly because MySQL's `TEXT` can't be a PK).\n- `TEXT` on postgres / sqlite (typed strings of arbitrary length).\n\nThe PK index is a B-tree. Lookup is O(log n) on every dialect; the framework's eager-load join compiler relies on indexed PK lookup for child rows.\n\n## Performance\n\nTypeID's body is monotonically increasing in time, so:\n\n- **Index hot spots are predictable**: new inserts cluster at the high end of the PK B-tree. Postgres / mysql leaf-page splits happen at the right edge — no random insertion penalty.\n- **Range scans by id approximate range scans by createdAt**: useful for cursor pagination, batch deletes by age, and audit queries.\n\n## Caveats\n\n- **Renaming a table breaks branded type compatibility**. If you rename `users` → `customers`, every `UserId`-typed function signature now wants `CustomerId`. The framework's typecheck surfaces this at the call site, which is the right place — fix the migrations + propagate the rename through code.\n- **Prefix changes are silent**. Changing `id({ prefix: 'usr' })` → `id({ prefix: 'user' })` on an existing table makes NEW rows prefixed differently from OLD rows. The framework doesn't migrate existing IDs. Pick the prefix carefully + change it only with a data migration.\n- **The 26-char body is canonical lowercase**. Don't uppercase typeids for display — it breaks the spec and Crockford-base32's case-insensitivity gets confusing.\n\n## Where it lives\n\n- `voltro/packages/database/src/idGenerator.ts` — `generateId(scheme, tableName)` wires up `typeid-js`; `deriveTypeIdPrefix(tableName)` does the singularize\n- `voltro/packages/runtime/src/schemaRegistry.ts` — branded type registration\n- `voltro/packages/runtime/src/storeMiddleware.ts` — auto-injection on insert\n"},"segments":{"0":{"toc":[{"id":"spec","text":"Spec","level":2},{"id":"why-default","text":"Why default","level":2},{"id":"sortable-by-creation-time","text":"Sortable by creation time","level":3},{"id":"doubleclick-selectable","text":"Doubleclick-selectable","level":3},{"id":"url-friendly","text":"URL-friendly","level":3},{"id":"branded-at-the-typescript-level","text":"Branded at the TypeScript level","level":3},{"id":"compact","text":"Compact","level":3},{"id":"prefix-resolution","text":"Prefix resolution","level":2},{"id":"explicit-prefix","text":"Explicit prefix","level":3},{"id":"implicit-derived-from-table-name","text":"Implicit (derived from table name)","level":3},{"id":"lifecycle","text":"Lifecycle","level":2},{"id":"storage","text":"Storage","level":2},{"id":"performance","text":"Performance","level":2},{"id":"caveats","text":"Caveats","level":2},{"id":"where-it-lives","text":"Where it lives","level":2}]}},"ran":{"page":true,"segments":[0]}}

TypeID — the default

Stripe-style prefix_ulid IDs. Sortable, branded, URL-friendly, doubleclick-selectable, ~30 char. Default for every user-facing entity.

TypeID is the framework's default ID scheme — the format Stripe popularized: a short table-tag prefix, an underscore, then a 26-character Crockford-base32 ULID body.

user_01j5xkqyz8x3n4m9pvabcd
└─┘ └──────────────────────┘
type    ulid body (48-bit ms timestamp + 80-bit random)

Every id() call without an explicit scheme: argument produces TypeID.

Spec

TypeID is a community spec maintained at typeid.io. The framework implements it via the typeid-js library — Jetify's official TypeScript implementation. Shape:

  • Prefix: must match ^[a-z][a-z0-9_]*$ — lowercase ASCII that STARTS with a letter (a leading digit or underscore is rejected by typeid-js). Up to 63 chars (practical limit: keep it short).
  • Separator: single underscore _.
  • Body: 26 characters in Crockford-base32 (no i, l, o, u to avoid visual ambiguity). Represents a 128-bit ULID.
  • Body composition: 48-bit Unix millisecond timestamp followed by 80 random bits.

Total length: prefix + 1 + 26 chars. For a typical 3–5 char prefix, that's 30–32 characters.

Why default

Sortable by creation time

The 48-bit timestamp prefix means IDs generated in order are lexically ordered:

import { typeid } from 'typeid-js'
const a = typeid('user').toString()  // user_01j5xkqyz8x3n4m9pvabcd
await new Promise(r => setTimeout(r, 1))
const b = typeid('user').toString()  // user_01j5xkqyz8x3n4n0pcdefgh
a < b   // true

ORDER BY id works as a cheap "ORDER BY createdAt" with no separate column. Cursor pagination via paginateById exploits this.

Doubleclick-selectable

Modern browsers treat _ as a word-boundary character. Doubleclicking on user_01j5xkqyz8x3n4m9pvabcd in a log line, terminal, or page selects the WHOLE id. UUIDv4's dashes break this — 01234567-89ab-... selects only one of the hyphen-separated chunks.

This is the difference between "I can paste this from a Slack log into a SQL query" and "I have to manually fix the substring on every paste".

URL-friendly

Only [a-z0-9_] — no percent-encoding needed. /posts/post_01j5xkqyz8x3n4m9pvabcd is exactly 31 characters in the URL with no escaping overhead. Compare to UUID's dashes (which RFC-3986 allows but tooling sometimes mangles) or base64-encoded random bytes (which need URL-safe base64 + are rarely the right call anyway).

Branded at the TypeScript level

The framework's schema registry brands each table's ID type. UserId ≠ OrgId ≠ ProjectId at compile time, even though they're all string at runtime:

function getUser(id: UserId): Promise<User> { ... }

const user: User = ...
const org:  Org  = ...

getUser(user.id)              // ✓ typechecks
getUser(org.id)               // ✗ Type 'OrgId' not assignable to 'UserId'
getUser('plain-string')       // ✗ Type 'string' not assignable to 'UserId'

The brand flows through reference() columns — posts.authorId is UserId not string, so FK lookups can't accept the wrong table's ID by accident.

Compact

~30 chars vs UUIDv4's 36 (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). Multiplies across foreign keys + log lines + URL params. Snowflake (8 bytes) is denser still but loses the type tag.

Prefix resolution

Two paths:

Explicit prefix

id({ prefix: 'foo' })  // → 'foo_01j5xkqyz8x3n4m9pvabcd'

Use when the implicit derivation would produce something ugly or non-unique.

Implicit (derived from table name)

table('users', { id: id() })            // → 'user_01j5...'
table('todos', { id: id() })            // → 'todo_01j5...'
table('apps',  { id: id() })            // → 'app_01j5...'
table('posts', { id: id() })            // → 'post_01j5...'

The derivation runs at schema-registration time via a simple-singularize:

  • Strip trailing s from plural nouns.
  • Special-case nothing else.

For unconventional plurals (feet, children, status, data, series), set an explicit prefix to avoid the resolver guessing wrong:

table('feet',     { id: id({ prefix: 'foot' }) })
table('children', { id: id({ prefix: 'child' }) })
table('status',   { id: id({ prefix: 'status' }) })  // identical singular/plural
table('data',     { id: id({ prefix: 'datum' }) })

The schema registry validates prefix at registration — a prefix collision across two tables throws at boot with both call sites pointed at. You can't accidentally use user_ for both users and system_users.

Lifecycle

// Production handler — id auto-injected by the mutation middleware.
const row = await ctx.store.insert('todos', { title: 'hello', done: false })
// row.id === 'todo_01j5xkqyz8x3n4m9pvabcd'

// Test — deterministic id for assertion.
await ctx.store.insert('todos', { id: 'todo_pinned_for_test', title, done: false })

// Signup self-stamping — actor is the row being created.
import { typeid } from 'typeid-js'
const userId = typeid('user').toString()  // pre-compute the id
await ctx.store.insert('users', { id: userId, email, createdBy: userId })

Storage

TypeID values are strings. On every dialect:

  • VARCHAR(64) on mysql / mariadb / mssql (length set explicitly because MySQL's TEXT can't be a PK).
  • TEXT on postgres / sqlite (typed strings of arbitrary length).

The PK index is a B-tree. Lookup is O(log n) on every dialect; the framework's eager-load join compiler relies on indexed PK lookup for child rows.

Performance

TypeID's body is monotonically increasing in time, so:

  • Index hot spots are predictable: new inserts cluster at the high end of the PK B-tree. Postgres / mysql leaf-page splits happen at the right edge — no random insertion penalty.
  • Range scans by id approximate range scans by createdAt: useful for cursor pagination, batch deletes by age, and audit queries.

Caveats

  • Renaming a table breaks branded type compatibility. If you rename users → customers, every UserId-typed function signature now wants CustomerId. The framework's typecheck surfaces this at the call site, which is the right place — fix the migrations + propagate the rename through code.
  • Prefix changes are silent. Changing id({ prefix: 'usr' }) → id({ prefix: 'user' }) on an existing table makes NEW rows prefixed differently from OLD rows. The framework doesn't migrate existing IDs. Pick the prefix carefully + change it only with a data migration.
  • The 26-char body is canonical lowercase. Don't uppercase typeids for display — it breaks the spec and Crockford-base32's case-insensitivity gets confusing.

Where it lives

  • voltro/packages/database/src/idGenerator.ts — generateId(scheme, tableName) wires up typeid-js; deriveTypeIdPrefix(tableName) does the singularize
  • voltro/packages/runtime/src/schemaRegistry.ts — branded type registration
  • voltro/packages/runtime/src/storeMiddleware.ts — auto-injection on insert