ADR-001: Use NanoID / UUIDv7 over Auto-increment
Date: 2024-01-15 Status: Accepted
Context
LumiBase is a multi-tenant SaaS platform where:
-
Multiple sites share the same database — if collections across different sites both use auto-increment IDs, a
UNIONor cross-site query could expose data from another tenant through ID enumeration attacks (attacker guessesid=123to find records from other sites). -
Edge deployment with distributed workers — Cloudflare Workers runs in 200+ data centers globally. Auto-increment requires a single authoritative sequence source; with connection pooling via Hyperdrive, sequence generation creates contention and latency.
-
Config-as-Code export/import — when exporting a collection's config (schema, sample data, permissions) to YAML/JSON for GitOps, records need stable, portable IDs that don't conflict when imported into another environment.
-
Directus parity — Directus uses UUIDs for system tables. LumiBase aims to be migration-compatible.
Decision
All primary keys in LumiBase use NanoID (21-character URL-safe strings, ~2.1M IDs/second with collision probability < 1% after 1 billion IDs) for domain records, and UUIDv7 for system/audit tables where time-ordering matters.
Rules:
- NanoID — user content (items, collections, fields, relations, users, files, flows, extensions)
- UUIDv7 — system tables (activity, revisions, ai_approvals, ai_messages) where chronological ordering by primary key is useful
- No serial/auto-increment — anywhere in domain or system tables
IDs are generated at the application layer (not the database), using:
nanoid()from thenanoidpackage (Node.js) orcrypto.getRandomValuesshim (CF Workers)uuidv7()from theuuidv7package
Consequences
Positive:
- Eliminates ID enumeration security risk across tenants
- Works without a central sequence generator — compatible with distributed edge deployment
- IDs are portable across environments (dev → staging → prod)
- Human-readable in URLs and logs (NanoID:
art_Mk3qp7vs UUID:550e8400-e29b-41d4-a716-446655440000)
Negative:
- Slightly more storage than integer PKs (21 chars vs 4-8 bytes)
- No guaranteed ordering by ID (mitigated: use
created_atfor ordering) - Application must generate IDs (cannot use DB DEFAULT for inserts without explicit ID)
Neutral:
- Foreign keys are
texttype in Drizzle schema, notinteger— slightly larger join overhead (acceptable at expected data volumes)