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
| Member | Description |
|---|---|
name | Identifies the adapter (errors, logs, introspection). |
handle | Receives 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). |
setup | Optional. One-time initialization (connections, clients). |
flush | Optional. Drain internal buffers on demand — called at end of invocation in serverless deployments. |
shutdown | Optional. Graceful teardown. |
identify | Optional. 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 aboutcurrent; 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. identifyis deduplicated. The dispatcher keeps a dedupe cache (backed by itsstore) of already-signaledanonymousIdtouserIdlinks; your hook is only called on a new link or a changed trait.identifyis not a catalog event — it never flows through the wiring matchers, only through this dedicated channel.
posthog({ apiKey }) vs. injection), and the default handling of unknown-schema durable events are still open.