Help us shape the future of Go! Take the 2026 Go Developer Survey

events

package
v0.3.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: AGPL-3.0 Imports: 43 Imported by: 0

Documentation

Overview

Package events is the internal domain-event system: a domain event is published once, inside the same transaction that commits the state change (transactional outbox), and fanned out to independent subscribers over watermill. See docs/design/domain-events.md.

Two categories of events, never conflated:

  • OUR events are a closed, typed union (ContactCreated, EmailEngagement, …). Each Go type owns its projection via Project().
  • A CUSTOMER's event (page_view, added_to_cart) is the user's domain — opaque to us, stored as-is. Ingesting one is itself OUR typed event, CollectedEvent, whose body carries the user's action + properties verbatim.

On the wire the envelope carries the typed event as Data (json); subscribers decode it back into the concrete type via the name registry and call Project().

Index

Constants

View Source
const (
	ActorUser     = ent.ActorUser
	ActorAPIToken = ent.ActorAPIToken
	ActorStaff    = ent.ActorStaff
	ActorService  = ent.ActorService
	ActorSystem   = ent.ActorSystem
	ActorIngest   = ent.ActorIngest
)

Actor kinds of an Audit entry. Scopes built from a secret rather than a login carry ActorIngest, which the seam never records. The kinds and the Actor type live in the generated scoped client (package ent), which carries the actor of a scope.

View Source
const (
	ActionMembershipUpdate = "membership.update"
	// ActionMembershipDelete: a Membership was removed: by an Owner or Admin, or by the User leaving (ADR 0050).
	ActionMembershipDelete = "membership.delete"
	// ActionMembershipDomainJoin: a User became a member because they proved an address on a Join domain (ADR 0051); the
	// actor is the system and the diff names the domain.
	ActionMembershipDomainJoin = "membership.domain_join"
	ActionInvitationCreate     = "invitation.create"
	ActionInvitationAccept     = "invitation.accept"
	ActionInvitationRevoke     = "invitation.revoke"
	// ActionUserLogin: a successful sign-in; its diff names the method: "password" or a Sign-in provider (ADR 0048).
	ActionUserLogin = "user.login"
	// ActionUserIdentityLink: a Sign-in provider account was linked to, or unlinked from, the User (ADR 0048); the
	// diff names the provider and never the subject.
	ActionUserIdentityLink   = "user.identity_link"
	ActionUserIdentityUnlink = "user.identity_unlink"
	// ActionUserEmailVerify: the User proved its email by the emailed link and set its password (ADR 0030).
	ActionUserEmailVerify    = "user.email_verify"
	ActionUserPasswordChange = "user.password_change"
	// ActionUserSignOutEverywhere: "Sign out everywhere", every session of the User ends (ADR 0020).
	ActionUserSignOutEverywhere = "user.sign_out_everywhere"
	// ActionUserSecondFactorEnroll starts the Second factor group (ADR 0020).
	ActionUserSecondFactorEnroll      = "user.second_factor_enroll"
	ActionUserSecondFactorDisable     = "user.second_factor_disable"
	ActionUserSecondFactorReset       = "user.second_factor_reset"
	ActionUserRecoveryCodesRegenerate = "user.recovery_codes_regenerate"
	ActionUserRecoveryCodeUse         = "user.recovery_code_use"
	ActionWorkspaceCreate             = "workspace.create"
	ActionWorkspaceUpdate             = "workspace.update"
	ActionWorkspaceDelete             = "workspace.delete"
	ActionWorkspaceRestore            = "workspace.restore"
	ActionWorkspaceSuspend            = "workspace.suspend"
	ActionWorkspaceUnsuspend          = "workspace.unsuspend"
	ActionWorkspaceAbuseExempt        = "workspace.abuse_exempt"
	ActionWorkspaceAbuseUnexempt      = "workspace.abuse_unexempt"
	ActionContactImport               = "contact.import"
	ActionAuditLogExport              = "audit_log.export"
	// ActionWebhookEndpointTest: a sample event was sent to a Webhook endpoint (ADR 0039): no row changes.
	ActionWebhookEndpointTest = "webhook_endpoint.test"
)

Actions recorded through the explicit seam (ADR 0022): raw-client packages and actions that are not a row write. A test drives every one of them and compares with ExplicitAuditActions, so adding an explicit path means listing it here.

View Source
const (
	// DiffFrom is the value before the change; absent when the field was not set.
	DiffFrom = "from"
	// DiffTo is the value after the change; absent when the field was removed.
	DiffTo = "to"
)

The keys of one field's entry in an AuditEntry's Diff, the shape the scoped client's own diffs have (ent/template/scoped_audit.tmpl).

View Source
const (
	NameContactCreated     = "contact.created"
	NameContactErased      = "contact.erased"
	NameEmailSent          = "email.sent"
	NameEmailDelivered     = "email.delivered"
	NameEmailDelayed       = "email.delayed"
	NameEmailDropped       = "email.dropped"
	NameEmailOpened        = "email.opened"
	NameEmailClicked       = "email.clicked"
	NameEmailUnsubscribed  = "email.unsubscribed"
	NameEmailBounced       = suppression.EventBounced
	NameEmailComplained    = suppression.EventComplained
	NameMarketingConfirmed = "marketing.confirmed"
	NameCollected          = "event.collected"
)

Bus event names — the stable type discriminators (registry keys). These are OURS and finite. A customer's action name is NOT one of these; it lives inside CollectedEvent.Action.

View Source
const (
	SourceCollect = "collect"
	SourceAPI     = "api"
)

The surfaces a customer's client event id arrives on; each has its own id space.

View Source
const (
	BounceKindPermanent = suppression.BounceKindPermanent
	BounceKindTransient = suppression.BounceKindTransient
)

Bounce kinds carried by EmailDelivery; what each means for Suppression is the suppression module's rule.

View Source
const (
	GroupPersist     = "persist"
	GroupAutomations = "automations"
	GroupRules       = "rules"
	GroupWebhooks    = "webhooks"
	GroupSuppression = "suppression"
	// GroupAudit is the Enterprise audit subscriber's group (ee/audit). Core lists it
	// so the outbox prune never deletes an Audit entry the subscriber has not consumed
	// (ADR 0022); the subscriber is always registered, licensed or not.
	GroupAudit = "audit"
	// GroupExport is the Enterprise Export streams' subscriber group (ee/export), listed
	// for the same reason as GroupAudit (ADR 0045).
	GroupExport = "export"
)

Consumer groups of the domain-events outbox. Each registered subscriber owns one group (its own offset cursor), and the outbox prune reads this registry to know whose cursors bound what it may delete, so a new subscriber is added here and nowhere else.

View Source
const CapturedProperty = "captured"

CapturedProperty is the key of the Event property that marks an email.* Event of a send through a sender that delivers nowhere outside (a Test inbox): captured, not delivered.

View Source
const MachineProperty = "machine"

MachineProperty is the key of the Event property that marks an open or click made by software (ADR 0053, GLOSSARY: Machine engagement).

View Source
const NameAuditEntry = "audit.entry"

NameAuditEntry is the bus type of an Audit entry (ADR 0022). Core only carries the type and the publish seam; the table, the subscriber and the read surfaces live in ee/audit, gated by the license key. Without a license the event is published and nobody consumes it.

View Source
const NameTrackingDomainChanged = "tracking_domain.changed"

NameTrackingDomainChanged is the bus type of a Tracking domain state change (ADR 0038): pending, dns_verified or serving became another of them.

View Source
const RulePropertiesBound = 32 << 10

RulePropertiesBound is the most JSON an Event's properties may weigh to be shown to a Rule. Ingest already refuses larger customer events; a larger one reaching a Rule anyway has no properties, for the Condition and for the Firing's Actions alike, so a Rule never decides on a value its Actions could not template and a job row does not grow without limit.

View Source
const StaffLabel = "Sphericon staff"

StaffLabel is how a platform Staff member appears to the customer on every read surface (ADR 0022): the staff identity is never exposed.

View Source
const TopicDomainEvents = "domain_events"

TopicDomainEvents is the single watermill topic all domain events flow through. watermill-sql maps it to "watermill_domain_events", which is the outbox table.

Variables

View Source
var ErrNoJobQueue = errors.New("events: the bus has no job queue")

ErrNoJobQueue is returned by a transactional enqueue on a Bus that was given no job queue.

ExplicitAuditActions lists every action emitted by an explicit RecordAudit call.

Functions

func Became

func Became(to any) map[string]any

Became is the Diff entry of a field that was set where there was no value before.

func Change

func Change(from, to any) map[string]any

Change is the Diff entry of a field that went from one value to another.

func CheckAction

func CheckAction(action string) error

CheckAction refuses an action that is empty or reserved for the system.

func ConsumerGroups

func ConsumerGroups() []string

ConsumerGroups lists every consumer group registered in code, in registration order: the core groups, then the extra consumers' (RegisterSubscribers).

func DedupKey

func DedupKey(source string, workspaceID int64, clientEventID string) string

DedupKey is the Event's source_id for a client event id: source_id is unique across Workspaces and sources, the client's id only within one of each.

func HumanOnly

func HumanOnly(sel *sql.Selector)

HumanOnly narrows a query of Events (through Modify) to the ones not made by software, for every figure that counts engagement: a machine Event stays in the log and in no count.

func Ingest

func Ingest(s *ent.Scoped) *ent.Scoped

Ingest returns the scope of a secret-built caller (collect key, provider hook, signed link, bootstrap token): its writes are never audited.

func IsBackfill

func IsBackfill(ev DomainEvent) bool

IsBackfill reports whether ev is a Backfill: recorded without triggering automations or webhooks.

func IsEngagement

func IsEngagement(action string) bool

IsEngagement reports whether action is an open or a click, the Events a machine can make.

func IsMachine

func IsMachine(props map[string]any) bool

IsMachine reports whether the properties of a stored Event mark it as made by software.

func IsStaff

func IsStaff(kind string) bool

IsStaff reports whether an actor kind is the platform's own staff identity, a human Staff member or a Service credential: the kinds no customer-facing read may identify.

func IsUnprojected

func IsUnprojected(name string) bool

IsUnprojected reports whether the bus event type opts out of the Event projection.

func Moment

func Moment(sel *sql.Selector) string

Moment is the SQL expression of when an Event happened (ADR 0044): `occurred_at`, and the creation time when it has none, so backfilled history lands on the day it happened. Every report and window reads an Event's time through it.

func MomentBetween

func MomentBetween(sel *sql.Selector, since, until time.Time)

MomentBetween narrows a query of Events (through Modify) to the ones that happened in [since, until): the start is inclusive, the end exclusive, both bound as arguments.

func NewRouter

func NewRouter() (*message.Router, error)

NewRouter builds the watermill router that hosts the domain-event subscribers. The events package owns its full consume-side runtime (router + per-group subscribers); the producer side is Bus. Run the returned router in a goroutine and Close it on shutdown.

func NewSubscriber

func NewSubscriber(db *sql.DB, consumerGroup string, opts ...SubscribeOption) (message.Subscriber, error)

NewSubscriber builds a watermill-sql subscriber for the outbox topic under a dedicated consumer group (its own offset cursor). InitializeSchema is false: the migrations create the message + offsets tables.

func NotCaptured

func NotCaptured(sel *sql.Selector)

NotCaptured narrows a query of Events (through Modify) to the ones not about a captured send, for every figure of real deliverability: mail kept in a Test inbox reached no mailbox, so it moves no rate.

func Persist

func Persist(ctx context.Context, client *ent.Client, env Envelope) error

Persist writes the durable Event-projection row for a domain event. It is the one place the engagement log is written, replacing scattered Event.Create calls. Exported so the projection logic can be unit-tested directly without running the router.

It decodes the typed event and writes the row from its Project(), so any event type projects faithfully without persist knowing the concrete fields.

func PruneEvents

func PruneEvents(ctx context.Context, client *ent.Client, retention time.Duration, batchSize int) (int64, error)

PruneEvents deletes analytical Events created more than retention ago, in batches of batchSize, and returns how many it removed. Age is created_at (the insert time), not occurred_at: created_at is never null and is what the table's growth follows; an Event backfilled with an old occurred_at is kept until its row itself ages out. A retention of zero or less disables the prune. The job is idempotent: a rerun finds nothing left to delete.

func PruneRuleFirings

func PruneRuleFirings(ctx context.Context, client *ent.Client, retention time.Duration, batchSize int) (int64, error)

PruneRuleFirings deletes Rule Firings created more than retention ago, in batches of batchSize, and returns how many it removed: they are analytical history of what a Rule did, kept for the same window as the Events that fired them (ADR 0019, ADR 0054). A retention of zero or less disables the prune.

func RecordAudit

func RecordAudit(ctx context.Context, pub Publisher, entry *AuditEntry) error

RecordAudit publishes an Audit entry through pub, inside the caller's transaction (the seam, ADR 0022). A missing or ingest actor is skipped: ingest traffic never reaches the log. The request id, address and user agent come from ctx.

func RegisterLagGauge

func RegisterLagGauge(client *ent.Client) (func() error, error)

RegisterLagGauge exports outbox.lag (seconds, one sample per consumer group in ConsumerGroups) on the global meter provider. The label is the group name only: a bounded technical dimension. The reads run once per scrape. The returned function unregisters the callback.

func RegisterSubscribers

func RegisterSubscribers(router *message.Router, db *sql.DB, client *ent.Client, enroller Enroller, rules RuleEvaluator, dispatcher WebhookDispatcher, extra []Consumer, opts ...SubscribeOption) error

RegisterSubscribers wires the domain-event consumers onto the shared watermill router. Each consumer gets its OWN subscriber with a distinct consumer group (see ConsumerGroups), so every subscriber receives every event (fan-out) rather than competing for messages. Add new subscribers to the table below and to ConsumerGroups without touching producers.

extra are consumers owned outside core (the Enterprise audit subscriber, ee/): each gets its own consumer group like the built-in ones, and its group is listed in ConsumerGroups so the outbox prune waits for it.

opts tune every subscriber it builds (see SubscribeOption).

func ReservedAction

func ReservedAction(action string) bool

ReservedAction reports whether action belongs to the system and is refused from a customer's Event.

func RuleProperties

func RuleProperties(props map[string]any) map[string]any

RuleProperties is the view of an Event's properties that a Rule evaluates and a Firing keeps: the properties themselves, or nil when they weigh more than RulePropertiesBound. Every path that shows properties to a Rule (the consumer, Evaluate, a dry run) goes through it.

func Suppress

func Suppress(ctx context.Context, client *ent.Client, env Envelope) error

Suppress adds the event's destination to the workspace suppression list when the action implies a global do-not-send (hard bounce, complaint). No-op for other actions or a missing email. Idempotent — a redelivered or repeated event keeps the original entry rather than erroring or overwriting its reason. Exported so the logic can be tested without running the router.

func Unaudited

func Unaudited(s *ent.Scoped) *ent.Scoped

Unaudited returns the scope with its writes left out of the Audit log, for a write that is not a configuration change by the actor: a bulk import records one entry for the whole import (contacts.RecordImport), and applying a Tag to a Contact is a data-plane edge, not a field of the Contact.

Types

type Actor

type Actor = ent.Actor

Actor is who performed a change. Name is a display snapshot (a User's name) so the entry stays readable after the actor is gone.

type AuditEntry

type AuditEntry struct {
	WorkspaceID int64          `json:"workspaceId"`
	Actor       Actor          `json:"actor"`
	Action      string         `json:"action"` // <entity>.<verb>
	TargetType  string         `json:"targetType"`
	TargetID    string         `json:"targetId,omitempty"`
	TargetName  string         `json:"targetName,omitempty"`
	Diff        map[string]any `json:"diff,omitempty"`
	RequestID   string         `json:"requestId,omitempty"`
	IP          string         `json:"ip,omitempty"`
	UserAgent   string         `json:"userAgent,omitempty"`
}

AuditEntry is emitted in the transaction of the change it records; the envelope id is the dedupe key, so at-least-once redelivery writes one row.

func WorkspaceAudit

func WorkspaceAudit(ws *ent.Workspace, actor Actor, action string, diff map[string]any) *AuditEntry

WorkspaceAudit is the Audit entry of an action on a Workspace itself: the Workspace is its own target.

func (*AuditEntry) EventName

func (*AuditEntry) EventName() string

func (*AuditEntry) EventVersion

func (*AuditEntry) EventVersion() int

func (*AuditEntry) MaskStaff

func (e *AuditEntry) MaskStaff()

MaskStaff replaces a staff actor (a Staff member or a Service credential) with the customer-facing label and drops the staff id, for every surface that leaves the platform (ADR 0022). The stored entry keeps the real identity.

func (*AuditEntry) Project

func (e *AuditEntry) Project() Projection

Project is never persisted (AuditEntry is Unprojected); it only names the action.

func (*AuditEntry) Unprojected

func (*AuditEntry) Unprojected()

func (*AuditEntry) Workspace

func (e *AuditEntry) Workspace() int64

type AuditFilter

type AuditFilter struct {
	// From is inclusive and To exclusive; a zero time is unbounded.
	From, To   time.Time
	ActorKind  string
	ActorID    string
	Action     string
	TargetType string
	TargetID   string
	IP         string
	RequestID  string
}

AuditFilter narrows the Audit log. Every field is optional and they combine with AND; the zero value matches every entry. The site list and the CSV export take the same filter, so an export matches what the page shows.

type Bus

type Bus struct {
	// contains filtered or unexported fields
}

Bus is the producer-side entry point. It owns the database pool and opens a transaction per WithinTx call that carries both the ent writes and the outbox insert.

func New

func New(db *sql.DB) *Bus

New builds a Bus over the application's database pool.

func (*Bus) Act

func (b *Bus) Act(s *ent.Scoped, actor Actor) *ent.Scoped

Act returns the scope with the actor of its writes (ADR 0022). Every construction point of a scope sets it; a scope without an actor, or with ActorIngest, is never audited.

func (*Bus) Use

func (b *Bus) Use(hooks ...ent.Hook)

Use registers ent hooks on the transaction client of every later WithinTx, the way Client.Use does for a long-lived client. Register at composition time, before the bus is shared with concurrent callers; tests use it to inject faults into a transaction the bus opens itself.

func (*Bus) UseJobs

func (b *Bus) UseJobs(q JobInserter)

UseJobs gives the Bus the queue its transactional enqueues insert into. Register it at composition time, before the bus is shared with concurrent callers.

func (*Bus) WithinAuditTx

func (b *Bus) WithinAuditTx(ctx context.Context, s *ent.Scoped, fn func(ts *ent.Scoped) error) error

WithinAuditTx makes the Bus the transaction opener of an audited scope (ent.AuditOpener): the wrapper of an audited entity runs its mutation in a transaction opened here and publishes the entry in it.

func (*Bus) WithinHandOverTx

func (b *Bus) WithinHandOverTx(ctx context.Context, s *ent.Scoped, fromID int64, fn func(to, from *ent.Scoped) error) error

WithinHandOverTx is WithinScopedTx for a hand-over between two Workspaces (a Join domain moving to the Workspace that now proves it): fn receives to, the caller's scope bound to the transaction, and from, a scope over the Workspace named by fromID acting as the system, in the same transaction, so both sides commit or neither does. The hand-over is the one place a transaction is scoped to a Workspace other than the caller's.

func (*Bus) WithinScopedEnqueueTx

func (b *Bus) WithinScopedEnqueueTx(ctx context.Context, s *ent.Scoped, fn func(ts *ent.Scoped, q Enqueuer) error) error

WithinScopedEnqueueTx is WithinScopedTx for a caller that also queues a job in the same transaction: a Broadcast's claim and its send job.

func (*Bus) WithinScopedPurgeTx

func (b *Bus) WithinScopedPurgeTx(ctx context.Context, s *ent.Scoped, fn func(ts *ent.Scoped, pub PurgingPublisher) error) error

WithinScopedPurgeTx is WithinScopedTx for the one caller that clears the internal queues in the same transaction: Erasure (ADR 0021). Asking for the purging publisher here, not by type assertion on a Publisher, makes it a compile-time dependency.

func (*Bus) WithinScopedTx

func (b *Bus) WithinScopedTx(ctx context.Context, s *ent.Scoped, fn func(ts *ent.Scoped, pub Publisher) error) error

WithinScopedTx is WithinTx for a caller that holds a Workspace-scoped client: fn receives a scoped client over the same Workspace but bound to the transaction, so a module can write through it without ever naming the Workspace as an int64. The scope keeps its actor, and an audited write through it joins this transaction (its entry is published here) instead of opening another one (ADR 0022).

func (*Bus) WithinTx

func (b *Bus) WithinTx(ctx context.Context, fn func(tx *ent.Client, pub Publisher) error) error

WithinTx runs fn inside a single SQL transaction. The *ent.Client handed to fn is bound to that transaction, and so is the Publisher: any event published via pub is inserted into the outbox table in the same tx. The tx commits iff fn returns nil — guaranteeing state and event commit (or roll back) atomically.

In tests this rides go-txdb's savepoint nesting, so the commit here is a RELEASE SAVEPOINT contained by the per-test rollback.

type CollectedEvent

type CollectedEvent struct {
	WorkspaceID int64          `json:"workspaceId"`
	ContactID   int64          `json:"contactId,omitempty"` // 0 ⇒ anonymous
	VisitorID   string         `json:"visitorId,omitempty"`
	SubjectID   string         `json:"subjectId,omitempty"`
	Action      string         `json:"action"`
	Email       string         `json:"email,omitempty"`
	Phone       string         `json:"phone,omitempty"`
	Properties  map[string]any `json:"properties,omitempty"`
	OccurredAt  time.Time      `json:"occurredAt,omitzero"`
	// DedupID is the persist idempotency key of an event the client gave a clientEventId
	// (DedupKey: the source, the Workspace and that id). Empty for an event without one.
	DedupID string `json:"dedupId,omitempty"`
	// Backfill marks an event loaded from history: it is recorded, but no Automation enrolls
	// on it and no webhook endpoint receives it.
	Backfill bool `json:"backfill,omitempty"`
}

CollectedEvent wraps a customer's own event ingested via the collect or external API. Its body is the user's domain, stored as-is: Action and Properties are opaque to us. Identity is resolved at ingest: ContactID is the Contact this device resolves to (0 ⇒ anonymous, pre-Identify), VisitorID the anonymous device — kept so the event can be stitched onto a Contact when Identify later arrives. Its Action is also the automation trigger key (a user can trigger on "page_view").

func (*CollectedEvent) Backfilled

func (e *CollectedEvent) Backfilled() bool

func (*CollectedEvent) EventID

func (e *CollectedEvent) EventID() string

func (*CollectedEvent) EventName

func (*CollectedEvent) EventName() string

func (*CollectedEvent) EventVersion

func (*CollectedEvent) EventVersion() int

func (*CollectedEvent) Project

func (e *CollectedEvent) Project() Projection

func (*CollectedEvent) Workspace

func (e *CollectedEvent) Workspace() int64

type Consumer

type Consumer struct {
	Name   string
	Handle func(ctx context.Context, env Envelope) error
}

Consumer is a bus subscriber registered from outside the events package. Name is both its router handler name and its consumer group.

func (Consumer) Handler

Handler adapts the consumer to a watermill handler that decodes the envelope.

type ContactCreated

type ContactCreated struct {
	WorkspaceID int64  `json:"workspaceId"`
	ContactID   int64  `json:"contactId"`
	Email       string `json:"email"`
}

ContactCreated is emitted when a contact is created in a workspace.

func (*ContactCreated) EventName

func (*ContactCreated) EventName() string

func (*ContactCreated) EventVersion

func (*ContactCreated) EventVersion() int

func (*ContactCreated) Project

func (e *ContactCreated) Project() Projection

func (*ContactCreated) Workspace

func (e *ContactCreated) Workspace() int64

type ContactErased

type ContactErased struct {
	WorkspaceID    int64  `json:"workspaceId"`
	SubjectID      string `json:"subjectId"`
	IdentifierKind string `json:"identifierKind"` // what the requester identified the person by
	RequesterKind  string `json:"requesterKind"`  // user|api_token
	RequesterID    int64  `json:"requesterId"`

	// The keys Erasure matched on (ADR 0045), for the Erasures Export stream: the erased
	// Contact's id (the row is gone, so the id points at nothing), the anonymous devices
	// erased with it, and SHA-256 (hex) of the lowercased address and of the phone, computed
	// before publishing so no address or phone reaches the outbox. Empty when there was none.
	ContactID  int64    `json:"contactId,omitempty"`
	VisitorIDs []string `json:"visitorIds,omitempty"`
	EmailHash  string   `json:"emailHash,omitempty"`
	PhoneHash  string   `json:"phoneHash,omitempty"`
}

ContactErased is emitted once per Erasure (ADR 0021): the PII-free accountability record, and the signal the customer's webhook uses to erase downstream copies. SubjectID is the Contact's own subject_id, or its sphericon id when it had none. It exists only in this message (so the webhook payload carries it): Project() leaves it, and any Contact reference or address, off the stored Event. The keys below it are for the Erasures Export stream and stay off the stored Event too.

func (*ContactErased) EventName

func (*ContactErased) EventName() string

func (*ContactErased) EventVersion

func (*ContactErased) EventVersion() int

func (*ContactErased) Project

func (e *ContactErased) Project() Projection

func (*ContactErased) Workspace

func (e *ContactErased) Workspace() int64

type DomainEvent

type DomainEvent interface {
	EventName() string   // bus type / registry key
	EventVersion() int   // schema version
	Workspace() int64    // owning workspace
	Project() Projection // how this event maps to the Event row + trigger
}

DomainEvent is a typed, producer-side domain event. Implementations are registered in registry and must use pointer receivers so Decode can unmarshal.

func Decode

func Decode(env Envelope) (DomainEvent, error)

Decode reconstructs the typed event from an envelope.

type EmailDelivery

type EmailDelivery struct {
	Action      string `json:"action"` // email.delivered|email.delayed|email.dropped|email.bounced|email.complained
	WorkspaceID int64  `json:"workspaceId"`
	ContactID   int64  `json:"contactId,omitempty"`
	Email       string `json:"email"`
	BounceKind  string `json:"bounceKind,omitempty"` // permanent|transient (bounces only)
	Provider    string `json:"provider,omitempty"`   // ses, ...
	// SendingDomain is the domain the failed message was sent from (ADR 0011), so the
	// complaint/bounce rate can be grained per Sending domain.
	SendingDomain string `json:"sendingDomain,omitempty"`
	// Reason is the provider's short explanation of a bounce, delay or drop, at most
	// 500 characters.
	Reason string `json:"reason,omitempty"`
	// ProviderMessageID is the id the notice named, kept even when no Outbound message
	// matched it.
	ProviderMessageID string `json:"providerMessageId,omitempty"`
	// The Outbound message the notice was linked to, and where that message came from.
	OutboundMessageID int64  `json:"outboundMessageId,omitempty"`
	Surface           string `json:"surface,omitempty"`
	SendingSource     string `json:"sendingSource,omitempty"`
	BroadcastID       int64  `json:"broadcastId,omitempty"`
	AutomationID      int64  `json:"automationId,omitempty"`
	// DedupID is the stable upstream id (e.g. SNS messageId + recipient) used as
	// the envelope id so a redelivered provider notification dedupes at persist.
	DedupID string `json:"dedupId,omitempty"`
	// Captured is set when the message went through a sender that delivers nowhere outside.
	Captured bool `json:"captured,omitempty"`
}

EmailDelivery is emitted when a provider reports what became of one of our sends: a Delivery notice (ADR 0053) ingested from a provider callback, e.g. SES via SNS. Action is the specific name. BounceKind distinguishes a permanent (hard) bounce from a transient (soft) one and is empty for the other kinds; only permanent bounces and complaints suppress the address. ContactID is 0 when the address has no contact in the workspace; OutboundMessageID is 0 when the notice named no message or none matched, and the Event then stays at the level of the address.

func (*EmailDelivery) EventID

func (e *EmailDelivery) EventID() string

func (*EmailDelivery) EventName

func (e *EmailDelivery) EventName() string

func (*EmailDelivery) EventVersion

func (*EmailDelivery) EventVersion() int

func (*EmailDelivery) Project

func (e *EmailDelivery) Project() Projection

func (*EmailDelivery) Workspace

func (e *EmailDelivery) Workspace() int64

type EmailEngagement

type EmailEngagement struct {
	Action      string `json:"action"` // email.sent|email.opened|email.clicked|email.unsubscribed
	WorkspaceID int64  `json:"workspaceId"`
	ContactID   int64  `json:"contactId"`
	Email       string `json:"email"`
	BroadcastID int64  `json:"broadcastId"`
	URL         string `json:"url,omitempty"`
	DedupID     string `json:"dedupId,omitempty"`
	// Machine is set on an open or click made by software (ADR 0053): it stays in the Event log
	// but counts toward no figure, sets no rollup and starts no Automation.
	Machine bool `json:"machine,omitempty"`

	// The envelope an Outbound send stamps on email.sent (ADR 0015, ADR 0011):
	// which surface sent it, under which Sending source and Sending domain, and the
	// ids that tie the Event back to the Outbound message and the provider's copy.
	Surface           string `json:"surface,omitempty"`
	SendingSource     string `json:"sendingSource,omitempty"`
	SendingDomain     string `json:"sendingDomain,omitempty"`
	AutomationID      int64  `json:"automationId,omitempty"`
	OutboundMessageID int64  `json:"outboundMessageId,omitempty"`
	ProviderMessageID string `json:"providerMessageId,omitempty"`
	// Captured is set when the send went through a sender that delivers nowhere outside (a
	// Test inbox): Activity says "captured, not delivered".
	Captured bool `json:"captured,omitempty"`
}

EmailEngagement is emitted when one of our emails is sent to a recipient, or when the recipient opens, clicks, or unsubscribes from a broadcast email. Action is the specific name; URL is set for clicks only. DedupID, when set, makes the persist projection idempotent under at-least-once redelivery — the send path sets it (a deterministic per-recipient/per-step key) so a job retry can't write a second email.sent row; open/click/unsubscribe leave it empty and fall back to the envelope ULID.

func (*EmailEngagement) EventID

func (e *EmailEngagement) EventID() string

func (*EmailEngagement) EventName

func (e *EmailEngagement) EventName() string

func (*EmailEngagement) EventVersion

func (*EmailEngagement) EventVersion() int

func (*EmailEngagement) Project

func (e *EmailEngagement) Project() Projection

func (*EmailEngagement) Workspace

func (e *EmailEngagement) Workspace() int64

type Enqueuer

type Enqueuer interface {
	Enqueue(ctx context.Context, args river.JobArgs, opts *river.InsertOpts) error
}

Enqueuer puts a job on the queue inside the open transaction. Only WithinScopedEnqueueTx hands one out, so a state change and the job it needs commit or roll back together: no claim is left behind by a job that was never queued, and no job runs for a claim that was never written.

type Enroller

type Enroller interface {
	OnEvent(ctx context.Context, workspaceID, contactID int64, action string, sourceAutomationID int64) error
}

Enroller enrolls a contact into automations matching an event action. The automations subscriber delegates to it, keeping durable execution in river (the implementation enqueues an EvaluateTrigger job).

type Envelope

type Envelope struct {
	ID          string          `json:"id"`          // ULID; the watermill message id
	Name        string          `json:"name"`        // bus event type (registry key)
	Version     int             `json:"version"`     // schema version of Data
	WorkspaceID int64           `json:"workspaceId"` // every event is workspace-scoped
	OccurredAt  time.Time       `json:"occurredAt"`
	Data        json.RawMessage `json:"data"` // the typed event, marshaled
	// DedupKey is the persist idempotency key. Empty ⇒ use ID. An event with a
	// natural upstream id (a provider notification) sets it so at-least-once
	// redelivery dedupes, while ID stays a short ULID for the message-id column.
	DedupKey string `json:"dedupKey,omitempty"`
	// RecordedAt is when the event was published, as opposed to OccurredAt, which a client may
	// backdate. Rules act only on events recorded after they were activated (ADR 0054).
	RecordedAt time.Time `json:"recordedAt"`
	// CausedBy names what published the event when it was not a customer or a provider: an
	// Action of a Rule stamps it on any event it publishes, and the Rule consumer skips an event
	// that carries it, so Rules never chain (ADR 0054). No Action publishes an event today, so
	// nothing stamps it yet; the consumer honours it already.
	CausedBy string `json:"causedBy,omitempty"`
}

Envelope is the generic bus message. It carries transport metadata plus the typed event as Data; subscribers decode Data via the registry.

type JobInserter

type JobInserter interface {
	InsertTx(ctx context.Context, tx *sql.Tx, args river.JobArgs, opts *river.InsertOpts) (*rivertype.JobInsertResult, error)
}

JobInserter is the queue's transactional insert: the river client over database/sql satisfies it. The job row joins the caller's transaction, so it exists iff that transaction commits.

type MarketingConfirmed

type MarketingConfirmed struct {
	WorkspaceID int64  `json:"workspaceId"`
	ContactID   int64  `json:"contactId,omitempty"`
	Email       string `json:"email"`
	Provenance  string `json:"provenance"`   // double_opt_in|grandfathered|imported
	IP          string `json:"ip,omitempty"` // confirming client IP (double_opt_in only)
}

MarketingConfirmed is emitted when a destination confirms its subscription (double opt-in, ADR 0013). It is the immutable source of truth for the confirmation — the derived Confirmation row is the read-model — and carries the GDPR Art. 7 proof: when, how (provenance), and the confirming IP. Publishing it also lets a welcome automation trigger on "marketing.confirmed".

func (*MarketingConfirmed) EventName

func (*MarketingConfirmed) EventName() string

func (*MarketingConfirmed) EventVersion

func (*MarketingConfirmed) EventVersion() int

func (*MarketingConfirmed) Project

func (e *MarketingConfirmed) Project() Projection

func (*MarketingConfirmed) Workspace

func (e *MarketingConfirmed) Workspace() int64

type Projection

type Projection struct {
	Subject    string         // → events.subject_id snapshot; "" ⇒ anonymous
	Action     string         // → events.action; also the automation trigger key
	Email      string         // "" ⇒ unset
	Phone      string         // "" ⇒ unset
	Properties map[string]any // → events.properties (opaque for customer events)
	OccurredAt time.Time      // zero ⇒ publish time
	// ContactID is the authoritative Contact link (→ events.contact_id) AND the
	// automations enroll target; 0 ⇒ none (anonymous, pre-Identify).
	ContactID int64
	// VisitorID is the anonymous device (→ events.visitor_id), kept so an anonymous
	// event can be stitched onto a Contact at Identify. "" ⇒ unset.
	VisitorID string
	// AutomationID is the Automation whose email the Event came from, 0 for any other Event. An
	// Automation never starts from an Event of its own email (ADR 0053).
	AutomationID int64
	// Machine marks an open or click made by software (ADR 0053). It is recorded, but starts no
	// Automation.
	Machine bool
}

Projection is the durable Event-row an event maps to. Each event type computes its own via Project(), so the projection logic lives with the type rather than being flattened by producers.

type PruneResult

type PruneResult struct {
	// Deleted is the number of outbox rows removed.
	Deleted int64
	// RetiredGroups is the number of offsets rows removed for consumer groups no
	// longer registered in code.
	RetiredGroups int64
	// Skipped is true when the run deleted no outbox rows because a registered
	// consumer group has no usable cursor yet (no offsets row, or a NULL ack).
	Skipped bool
}

PruneResult reports one PruneOutbox run.

func PruneOutbox

func PruneOutbox(ctx context.Context, client *ent.Client, floor time.Duration, batchSize int) (PruneResult, error)

PruneOutbox deletes outbox rows in batches of batchSize that sort strictly below the lowest registered consumer group's cursor, by the same (transaction_id, offset) tuple the subscribers read in, and are older than floor (ADR 0019). The row at the cursor is kept so an at-least-once redelivery still finds its place; the events table is the durable record, and its unique source_id dedupes a redelivered message.

It first drops the offsets rows of groups that are no longer registered in code (ConsumerGroups): a retired group's frozen cursor would otherwise pin the outbox forever. It then skips the run when any registered group has no offsets row or a NULL ack, because that group has not consumed anything and may still need every row.

type Publisher

type Publisher interface {
	Publish(_ context.Context, ev DomainEvent) error
}

Publisher publishes a typed domain event onto the bus. The implementation handed to Bus.WithinTx writes into the same transaction as the state change, so the event is published iff the state commits (transactional outbox).

type PurgingPublisher

type PurgingPublisher interface {
	Publisher
	QueuePurger
}

PurgingPublisher is the Publisher of a transaction that may also clear the internal queues (see QueuePurger). Only WithinScopedPurgeTx hands one out.

type QueuePurger

type QueuePurger interface {
	// PurgeOutbox deletes the Workspace's outbox envelopes that name the Contact,
	// by id or by one of its destinations (matched case-insensitively).
	PurgeOutbox(ctx context.Context, workspaceID, contactID int64, destinations []string) error
	// PurgeJobs deletes the river jobs of kind that are not running and whose
	// args integer field key is one of ids. A running job is left to finish: its
	// worker re-checks that what it refers to still exists.
	PurgeJobs(ctx context.Context, key jobkind.Key, ids []int64) error
	// PurgeWebhookJobs deletes the queued deliveries to the Workspace's own webhook
	// endpoints whose body names the Contact (contactId) or one of its
	// destinations (data.email, case-insensitively). The body is the event payload
	// the endpoint would receive. A running delivery is left to finish.
	PurgeWebhookJobs(ctx context.Context, workspaceID, contactID int64, destinations []string) error
}

QueuePurger removes what the internal queues still hold about a data subject: the domain-event outbox and the river job table. The Publisher handed to Bus.WithinTx implements it over the same transaction, so a purge commits (or rolls back) with the state change that asks for it. Erasure is its only caller (ADR 0021); it is a separate interface so ordinary producers cannot reach it.

The job deletes go through the RiverJob skipped entity (river's table; ent only generates its client): river's Tx API has no args predicate, so ent matches the args.

type RejectionError

type RejectionError struct{ Reason string }

RejectionError is why a customer's Event was refused. Its message is worded for the caller.

func AsRejection

func AsRejection(err error) (*RejectionError, bool)

AsRejection reports whether err is a RejectionError, and which.

func (*RejectionError) Error

func (r *RejectionError) Error() string

type RuleEvaluator

type RuleEvaluator interface {
	OnRuleEvent(ctx context.Context, ev RuleEvent) error
}

RuleEvaluator queues the evaluation of an Event against the Workspace's Rules. The Rules subscriber delegates to it, keeping durable execution in river (the implementation enqueues an evaluation job), like Enroller does for Automations.

type RuleEvent

type RuleEvent struct {
	WorkspaceID int64
	ContactID   int64
	Action      string
	SourceID    string
	RecordedAt  time.Time
	Properties  map[string]any
}

RuleEvent is an Event a Rule may react to, as the Rules consumer hands it to the queue: the Contact it is for, its action, the id a Firing is unique on (the DedupKey, else the envelope id: what persist stores as source_id), when it was recorded and the properties it carries.

type SubscribeOption

type SubscribeOption func(*watermillsql.SubscriberConfig)

SubscribeOption adjusts a subscriber's watermill configuration. Production uses none: the default schema adapter only delivers a row once every transaction that began before it has finished (see testhelper.PrivateOutbox for the one reason to change that).

func WithSchemaAdapter

func WithSchemaAdapter(a watermillsql.SchemaAdapter) SubscribeOption

WithSchemaAdapter replaces the adapter that builds the subscriber's select query. The adapter must keep the outbox table layout of the producer.

type TrackingDomainChanged

type TrackingDomainChanged struct {
	WorkspaceID     int64  `json:"workspaceId"`
	SendingDomainID int64  `json:"sendingDomainId"`
	Host            string `json:"host"`
	From            string `json:"from"`
	To              string `json:"to"`
}

TrackingDomainChanged is published in the transaction that stores the new state, by the periodic verification. It is not a data-plane fact, so it is Unprojected: no Event row, no automation trigger, no webhook.

func (*TrackingDomainChanged) EventName

func (*TrackingDomainChanged) EventName() string

func (*TrackingDomainChanged) EventVersion

func (*TrackingDomainChanged) EventVersion() int

func (*TrackingDomainChanged) Project

func (*TrackingDomainChanged) Project() Projection

Project is never persisted (the event is Unprojected); it only names the action.

func (*TrackingDomainChanged) Unprojected

func (*TrackingDomainChanged) Unprojected()

func (*TrackingDomainChanged) Workspace

func (e *TrackingDomainChanged) Workspace() int64

type Unprojected

type Unprojected interface {
	Unprojected()
}

Unprojected is an optional DomainEvent capability: an event that is not a data-plane fact. The persist consumer (no Event row), the automation trigger consumer and the webhooks consumer skip it, and an endpoint with an empty subscription list never matches it (see IsUnprojected).

type WebhookDispatcher

type WebhookDispatcher interface {
	Dispatch(ctx context.Context, s *ent.Scoped, eventName, deliveryID string, body []byte) error
}

WebhookDispatcher fans an event out to the workspace's matching webhook endpoints. The webhooks subscriber delegates to it, keeping durable delivery (per-endpoint jobs, retries) in river.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL