Dotyc
API Reference

DotycAdapter

The adapter interface: batch-aware handle(), optional lifecycle hooks, the identify channel, and idempotent consumption.

The interface every consumer registered in the wiring implements. The full lifecycle is available, the minimum is sufficient: only name and handle are required.

Interface

interface DotycAdapter {
  name: string
  handle(events: ValidatedEvent[], ctx: IngestContext): Promise<void> // batch-aware
  setup?(): Promise<void>
  flush?(): Promise<void>
  shutdown?(): Promise<void>
  identify?(identity: Identity): Promise<void> // identity transitions (native alias/merge)
}

Members

MemberDescription
nameIdentifies the adapter (errors, logs, introspection).
handleReceives a batch of validated events. Current-schema events are strictly validated — check event.schema.status before trusting the shape of legacy or unknown events (see below).
setupOptional. One-time initialization (connections, clients).
flushOptional. Drain internal buffers on demand — called at end of invocation in serverless deployments.
shutdownOptional. Graceful teardown.
identifyOptional. Called on identity transitions — when a verified userId is linked to lightweight ids. Translate it natively: PostHog into identify/alias, a CRM adapter into an upsert.

The events you receive

interface ValidatedEvent<Name = string, Props = unknown> {
  eventId: string     // ULID generated at emission — your idempotency key
  name: Name
  properties: Props   // validated + computed fields applied
  tags: readonly string[]
  identity: Identity
  timestamp: number
  schema: { hash: string; status: 'current' | 'legacy' | 'unknown' }
  meta: { source: 'client' | 'server' }
}
  • eventId — a stable ULID stamped at emission. Unique per fact, sortable by time, and the key you deduplicate on.
  • schema — which schema version the event was validated against. current: strictly validated against the live catalog. legacy: emitted by an out-of-date client against a hash found in the committed registry (.dotyc/schema-history.json), validated against its own version and dispatched rather than blocked. unknown: hash not in the registry — properties are passed through raw. Most adapters only care about current; consumers with strong shape assumptions should check the flag and decide.

Handle events twice — idempotency

Events defined with delivery: 'durable' are delivered at-least-once: after a crash, a timeout, or a partial batch failure, your handle may see the same event again. Retries are per consumer with backoff, and exhausted retries dead-letter through the dispatcher's onError.

Write durable consumers idempotently, keyed on eventId:

const usageCounter: DotycAdapter = {
  name: 'usage-counter',
  async handle(events) {
    for (const event of events) {
      const { accountId } = event.identity
      if (!accountId) continue
      // INSERT ... ON CONFLICT (event_id) DO NOTHING → the increment runs once per fact
      await db.recordUsage({ eventId: event.eventId, accountId })
    }
  },
}

wiring.use(usageCounter, { events: ['feature_used'] })

Best-effort events keep fire-and-forget semantics — no retry, so no duplicate pressure — but storing eventId costs nothing and keeps every consumer replay-safe.

Adapter roles — a convention, not an API

A convention for reading a wiring at a glance: adapters play roles. domain (business facts into projections/counters in your DB — usage, quotas), analytics (PostHog, Matomo, Plausible), observability (Sentry, Datadog, logs), engagement (Resend, Loops, CRM), archive (everything, raw — S3, warehouse), audit (trusted + durable events into an append-only log), plus debug in dev. One event can feed several roles; a whole app's wiring stays a few readable use() lines. See Adapters concepts.

Guardrail for the domain role: adapters build projections — counters, derived state — not event sourcing. If losing one event would break a transactional invariant of your app (not just a counter), that job belongs to your app's database or queue, not to an adapter. Dotyc is not Kafka.

Custom adapters and anonymous activity

Retro-merging anonymous events into a user's history is a destination capability — PostHog can, a naive custom adapter cannot. The pattern for custom adapters: store anonymousId (and sessionId) in your rows from the first event, and resolve anonymousId to userId at read time — the link arrives through your identify hook once the user authenticates. Rows never need rewriting.

Notes

  • Isolation is guaranteed by the dispatcher. An adapter that throws affects neither the other adapters nor the response to the emitter; the error is reported through the dispatcher's onError. Durable events are then retried for the failing consumer alone.
  • identify is deduplicated. The dispatcher keeps a dedupe cache (backed by its store) of already-signaled anonymousId to userId links; your hook is only called on a new link or a changed trait.
  • identify is not a catalog event — it never flows through the wiring matchers, only through this dedicated channel.
Draft — subject to change: whether an adapter can statically restrict the event types it accepts, the convention for adapter configuration (factory such as posthog({ apiKey }) vs. injection), and the default handling of unknown-schema durable events are still open.

See also

On this page