Dotyc
Concepts

Identity

Lightweight client ids reconciled to a verified userId as early as possible — with identity providers that plug into the auth you already have.

Identity answers who is behind the event. It is Dotyc's third family of bricks, distinct from adapters: adapters consume events, identity providers resolve who sent them.

The model

An identity has three layers, only the first mandatory:

interface Identity {
  anonymousId: string   // lightweight id per DEVICE — cookie (localStorage fallback)
  sessionId: string     // lightweight id per SESSION — volatile, rotates on inactivity
  userId?: string       // the authenticated human — the reconciliation pivot
  accountId?: string    // the organization / account (B2B)
  traits?: Record<string, unknown>   // plan, role, …
  verified: boolean     // false = emitter claim, true = resolved at ingest
}

Trust model: what the client declares is a claim; what the ingest resolves from a session or token is the truth. When both exist, the verified version wins.

Lightweight ids, early reconciliation

The client only ever generates cheap, PII-free ids: an anonymousId per device (persisted in a cookie — SSR-friendly, shared across subdomains, readable by same-origin ingest — with a localStorage fallback) and a volatile sessionId. Both are attached to every event, before any login.

The verified userId is the pivot that reconciles them — and reconciliation happens as early as possible. The moment an ingested event carries both lightweight ids and a verified identity, the anonymousId/sessionId ↔ userId link is signaled to adapters — without waiting for an explicit identify() call. Each device attaches itself to the pivot at its first authenticated event, so every session and device of a user becomes addressable in your destinations.

The merge itself belongs to the destinations: PostHog aliases, your CRM upserts. Dotyc stores nothing.

Emitter side

Manual control is always available:

dotyc.identify({ userId, accountId, traits })   // deduped — no-op if unchanged
dotyc.reset()                                   // logout: new sessionId, identity purged

But the intended path is zero identity code: plug an identity provider into the auth you already run —

import { createEmitter, http } from 'dotyc'
import { clerkIdentity } from '@dotyc/clerk/client'

export const dotyc = createEmitter({
  catalog,
  transport: http('/api/ingest'),
  identity: clerkIdentity(),   // reads the current user from Clerk, subscribes to changes
})

Official providers are planned for Better Auth, Clerk, Supabase Auth, and Neon Auth, plus generic JWT and custom-function fallbacks. Each provider ships both facets — emitter and ingest — in one package.

Ingest side

The same mechanism, verification facet: the provider resolves the session or token from the incoming Request and overrides the claim.

import { createDispatcher } from 'dotyc/ingest'
import { clerkIdentity } from '@dotyc/clerk/server'

const dispatcher = createDispatcher({
  catalog,
  wiring,
  identity: clerkIdentity(),   // session → verified identity, overrides the claim
})

Full-client apps are covered. No backend session? The provider verifies the JWT your auth issues (Clerk, Supabase, Neon Auth, and Better Auth all issue one) — verified identity without a server of your own. Dotyc never forces you server-side.

Signaling to adapters, without hammering them

Identity transitions reach adapters through the dedicated optional identify?() hook of the adapter interface — PostHog translates it to a native identify/alias, a CRM adapter to an upsert. Two dedupe caches keep the hook quiet:

  • Emitter: a hash of the last identity is persisted alongside the anonymousId, so a transition is only emitted when the identity actually changes (auth SDKs notify on every render/refresh; Dotyc absorbs it).
  • Dispatcher: a TTL cache of already-signaled anonymousId ↔ userId links, so identify?() fires only on a new link or a changed trait.

An honest limitation: retro-merge depends on destinations

The merge belongs to the destinations — and not all of them can retroactively re-attach anonymous events already written. PostHog can (its alias mechanism merges the anonymous history); Matomo is weak at it; a naive custom adapter cannot at all: rows written under an anonymousId before login do not rewrite themselves when the userId arrives.

The pattern for custom adapters is to plan for it instead of fighting it:

  • Store anonymousId (and sessionId) in your rows from the very first event, even for pre-login traffic.
  • Resolve anonymousId → userId at read time. The link arrives through the identify hook — persist it once (a small mapping table), and your queries join through it.

That way pre-login history becomes attributable the moment the link exists, without ever rewriting rows.

identify() is not a catalog event

A deliberate decision: identity transitions do not flow through the wiring. The real-world needs — provider aliasing, CRM upserts — are covered by the dedicated identify?() channel, and routing identity through the catalog would create magic recursion.

If you want business logic on login, declare an explicit signed_in event in your catalog and track it. Explicit beats magic — consistent with everything else in Dotyc.

On this page