Public Entity Identifiers
Wavemap public entities need stable API and URL identifiers without exposing internal database IDs or making display names globally unique. The current convention separates internal identity, public identity, and readable route labels.
This page is the durable reference for the public identifier pattern that started with artists and now forms the baseline for events, venues, event series, and users.
Identity Layers
Section titled “Identity Layers”Use three separate concepts:
| Concept | Meaning |
|---|---|
| Internal ID | The database primary key, named ID in current Drizzle schemas. Internal joins and server-side context use it. |
publicId | Immutable opaque identifier used at API, URL, DTO, and frontend state boundaries. |
slug | Mutable readable label derived from the entity name or title. Slugs improve links but are not authoritative. |
External clients should speak in publicId. Backend handlers should translate that public ID to the internal database
ID before doing joins, authorization checks, relationship work, or mutations.
The expected backend boundary flow is:
Parse public id from route or requestLook up the entity by public_idAuthorize using the resolved internal id and entity referenceRead or mutate using internal idsReturn public-facing DTOs with public ids, not internal idsAuthenticated session DTOs follow the same rule. Clients receive currentUser.publicId, public account/settings fields,
and role codes, but not internal user, settings, metadata, or role IDs.
Public ID Format
Section titled “Public ID Format”Public IDs are random 12-character base62 tokens using:
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzThis keeps identifiers compact, URL-safe, punctuation-free, and opaque. Public IDs are generated server-side and enforced with per-table database uniqueness. The route already carries the entity type, so global cross-entity uniqueness is deferred until a real global resolver or cross-entity reference surface needs it.
Do not add entity-type prefixes to public IDs. Prefer:
/artists/marie-davidson~4T8bQa9Lm2Zxover:
/artists/marie-davidson~artist_4T8bQa9Lm2ZxRoute Shape
Section titled “Route Shape”Canonical public detail URLs should combine the mutable slug and immutable public ID:
/artists/:slug~:publicId/venues/:slug~:publicId/events/:slug~:publicId/event-series/:slug~:publicIdAPI reads and mutations should prefer public-ID-only route params once the browser route has parsed the canonical segment:
GET /api/v1/artists/:artistPublicIdPATCH /api/v1/artists/:artistPublicIdThe slug is not authoritative. If a public page receives a valid public ID with a stale slug, it should resolve by
publicId and redirect to the current canonical slug/public-ID path.
Users are a special case. User rows receive immutable publicId, but usernames are not treated as public handles in this
baseline. A future handle can be introduced separately if product-facing user profile URLs need it.
Helper Surface
Section titled “Helper Surface”Shared public identifier helpers live in @wavemap/shared-utils. They own format mechanics such as:
- Public ID alphabet and length constants.
- Public ID generation and validation.
- Display-name/title slug generation and validation.
slug~publicIdsegment building and parsing.- Canonical public entity path building.
Backend app helpers own server behavior that depends on database constraints:
buildPublicArtistIdentifierFields(...)buildPublicEventIdentifierFields(...)buildPublicEventSeriesIdentifierFields(...)buildPublicVenueIdentifierFields(...)buildPublicUserIdentifierFields(...)withPublicEntityIdCollisionRetry(...)
Keep uniqueness out of shared utilities. Each database table owns its public_id uniqueness constraint, and backend
mutation code handles collisions as a rare database-enforced retry path.
Collision Retry
Section titled “Collision Retry”Creation handlers that generate public IDs should wrap the smallest safely rerunnable operation in
withPublicEntityIdCollisionRetry(...).
The helper retries only when Postgres reports a 23505 unique violation for one of the named public-ID constraints. It
does not retry arbitrary validation, transaction, network, storage, or unknown database failures.
Use it like this:
await withPublicEntityIdCollisionRetry({ constraintNames: ["artistsPublicIDIndex"], operationName: "create artist", run: () => db.transaction(async (tx) => { // Generate identifiers inside the rerunnable operation. // Insert the entity and dependent rows. }),})Default retry count is intentionally small. Public ID collisions should be rare; exhausting retries is treated as an internal failure rather than a user-correctable input error.
Current Coverage
Section titled “Current Coverage”Artists are the reference implementation:
- Artist rows have internal
ID, immutablepublicId, and mutableslug. - Artist create generates
publicIdandslugserver-side. - Artist name updates refresh the stored slug.
- Artist detail, list, typeahead, edit, delete, media, and artist-event relationship surfaces use public IDs at the client/API boundary.
- Artist detail pages redirect stale slugs and legacy singular routes to the canonical plural
slug~publicIdpath. - Public artist DTOs expose
publicIdandsluginstead of internal artist IDs. - Artist creation uses
withPublicEntityIdCollisionRetry(...).
Events, venues, and event series have the schema, seed, and route-helper baseline in place. Their modern detail/edit routes should continue the same public-ID and canonical-route pattern when those surfaces are built.
Users have publicId and public-session DTO usage in place. Future user profile handles remain a separate product
decision.
DTO Rules
Section titled “DTO Rules”Public DTOs should not expose internal IDs unless an endpoint is intentionally internal, operational, or admin-only and the consumer truly needs those IDs.
Prefer:
publicIdfor stable frontend keys, route params, and API mutations.slugfor canonical public links where the entity has a public display name or title.- Full replacement arrays or public child identifiers instead of leaking internal child-row IDs to public forms.
Internal IDs still belong inside joins, relationship inserts, authorization checks, storage ownership records, audit targets, and server-side request context after the public boundary lookup has happened.
Seed And Migration Notes
Section titled “Seed And Migration Notes”Legacy MongoDB JSON seed data may still use old id, createdOn, createdByID, lastModifiedOn, and
lastModifiedByID fields. Seed adapters should translate those into the current database convention:
- Internal
IDfor imported legacy row identity. - Server-owned
publicIdandslug. createdAtandupdatedAttimestamps.createdByUserIDandupdatedByUserID.revision: 1.entityStatus: active.
The adapter boundary is the right place for compatibility with old JSON backups. New application handlers and DTOs should use the current public-identifier and provenance vocabulary directly.
Open Follow-Ups
Section titled “Open Follow-Ups”Keep these out of the baseline until their product surfaces exist:
- Event detail/edit canonical redirects and public-ID mutation paths beyond the current query/typeahead baseline.
- Venue and event-series detail/edit canonical redirects.
- Public user profile handles.
- Whether some API DTOs should include prebuilt
canonicalPathin addition topublicIdandslug. - A global cross-entity public-ID resolver.
Related Pages
Section titled “Related Pages”- Content Entity Governance for lifecycle, provenance, deletion, ownership, and audit conventions.
- API Contracts for route, DTO, and permission contract ownership.
- Authentication And Authorization for using resolved entity references in backend authorization helpers.