Dotyc
Concepts

Delivery guarantees

Two delivery classes declared per event — best-effort for the noise, durable at-least-once for business facts — plus a trusted source rule for anything with monetary consequence.

Reliability in Dotyc is part of the product, not a documented limitation. It is what makes the core pitch tenable: if feature_used feeds your product analytics and your billing counter, "a lost event is acceptable" cannot be one blanket answer. So the answer is per event.

Two classes of traffic, declared on the event

defineEvent('feature_used', {
  tags: ['feature'],
  delivery: 'durable',   // 'best-effort' (default) | 'durable'
  properties: ...,
})
  • best-effort (default) — logs, marketing, traces. The unchanged pipeline: fire-and-forget, isolated fan-out. Losing one is acceptable, and that contract buys you zero overhead: a catalog with no durable events pays nothing for this feature existing.
  • durable — business facts: usage, billing, quotas. At-least-once delivery: persisted before dispatch, acknowledged to the transport, retried per consumer until success.

Heavy processing applies only to the events that deserve it. You do not pay durability costs for a log line.

Every event carries an eventId

Every event — both classes — gets a stable ULID generated at emission:

  • Consumer idempotency. At-least-once means the same event can arrive twice; consumers dedupe on eventId.
  • Replay protection. The ingest dedupes on a TTL window.
  • Temporal ordering. ULIDs sort by time.

The trusted source: source: 'trusted'

defineEvent('subscription_upgraded', {
  delivery: 'durable',
  source: 'trusted',   // 'anonymous' (default) | 'trusted'
})

A source: 'trusted' event can only be emitted via direct(dispatcher) — in-process, on the server. Two enforcement layers:

  • It is excluded from the client emitter's track() type: the client cannot even name it. The prohibition is a compile error, not a runtime error.
  • If one somehow arrives over the HTTP transport, it is rejected at ingest.

There is no client↔server healthcheck pattern in Dotyc, and that is deliberate. Instead of verifying a client's claim after the fact, a critical fact is emitted where it happens: the API route that performs the action emits the event. The server has nothing to check — it is the emitter. Transport reliability is handled by acks, not by pinging the client.

The rule, everywhere in these docs: any event with monetary consequence is delivery: 'durable', source: 'trusted'.

The durable pipeline

persist (outbox via store) → ack transport → fan-out → mark done per consumer
                                              ↓ failure
                                     retry with backoff (from the store)
                                              ↓ exhausted
                                     dead-letter status + onError

A durable event is persisted to an outbox before anything else, then acknowledged to the emitter. Fan-out proceeds as usual, but each matching consumer is tracked individually: a consumer that fails is retried with backoff from the store; when retries are exhausted, the event is marked dead-letter for that consumer and surfaced via onError.

The store: a new brick family

The outbox needs persistence, so durability introduces Dotyc's fourth family of pluggable bricks — after adapters, identity providers, and transports — the store:

const dispatcher = createDispatcher({
  catalog,
  wiring,
  store: redisStore(env.URL),   // memoryStore() in dev; official Redis / Postgres / KV drivers
})

The same store also backs the identify dedupe cache and replay dedupe — which is what solves serverless: shared state across invocations instead of per-instance memory. memoryStore() exists for dev; in production, state never lives in instance memory.

Acks and emitter feedback

The ingest response reports the fate of each event: accepted | rejected | legacy. The emitter reacts per class:

  • Durable: kept in a queue until acked; retried otherwise.
  • Best-effort: fire-and-forget, unchanged (sendBeacon on unload).
  • Dev: rejections are surfaced loudly in the console — no more debugging blind.

Serverless

Vercel/Lambda are an assumed target, not an afterthought:

  • State (outbox, dedupe) lives in the pluggable store, never in instance memory in prod.
  • toHandler(dispatcher, { waitUntil }) lets fan-out finish after the response is sent, without being killed at instance freeze.
  • Buffering adapters get their flush() called at the end of the invocation.

QoS: the important never travels with the noise

A single pipe for traffic of unequal importance risks head-of-line blocking (best-effort volume delaying business facts) and a shared blast radius. Dotyc partitions at every layer:

  1. Already partitioned: trusted events go through direct(), in-process — the most critical facts never take the public HTTP route at all.
  2. Two lanes in the emitter transport: durable events are flushed immediately in their own envelopes; best-effort events are batched lazily. An envelope is never mixed.
  3. Priority and load shedding at ingest: under pressure, durables are processed first; best-effort events are shed — that is their contract — rather than degrading everyone.
  4. Double mount (a documented pattern, not an API): the same dispatcher mounted on two routes — say /ingest and /ingest/critical — with different rate limits, scaling, or WAF rules. Free, since toHandler is just a function.

Draft: still being decided

The following details are draft and may change: the default retry policy (attempts, backoff curve) and the exact dead-letter shape; load-shedding thresholds and how shedding is surfaced observably; the flush policy of the two emitter lanes; whether the client-side durable queue is persisted (localStorage) or memory-only; the exact store interface; and whether direct() durable events are persisted before dispatch too (probably yes, to survive a crash between emission and dispatch).

On this page