Skip to content

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.

Use three separate concepts:

ConceptMeaning
Internal IDThe database primary key, named ID in current Drizzle schemas. Internal joins and server-side context use it.
publicIdImmutable opaque identifier used at API, URL, DTO, and frontend state boundaries.
slugMutable 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 request
Look up the entity by public_id
Authorize using the resolved internal id and entity reference
Read or mutate using internal ids
Return public-facing DTOs with public ids, not internal ids

Authenticated 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 IDs are random 12-character base62 tokens using:

0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz

This 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~4T8bQa9Lm2Zx

over:

/artists/marie-davidson~artist_4T8bQa9Lm2Zx

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~:publicId

API reads and mutations should prefer public-ID-only route params once the browser route has parsed the canonical segment:

GET /api/v1/artists/:artistPublicId
PATCH /api/v1/artists/:artistPublicId

The 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.

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~publicId segment 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.

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.

Artists are the reference implementation:

  • Artist rows have internal ID, immutable publicId, and mutable slug.
  • Artist create generates publicId and slug server-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~publicId path.
  • Public artist DTOs expose publicId and slug instead 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.

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:

  • publicId for stable frontend keys, route params, and API mutations.
  • slug for 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.

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 ID for imported legacy row identity.
  • Server-owned publicId and slug.
  • createdAt and updatedAt timestamps.
  • createdByUserID and updatedByUserID.
  • 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.

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 canonicalPath in addition to publicId and slug.
  • A global cross-entity public-ID resolver.