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
- Variables
- func CheckAction(action string) error
- func ConsumerGroups() []string
- func DedupKey(source string, workspaceID int64, clientEventID string) string
- func Ingest(s *ent.Scoped) *ent.Scoped
- func IsBackfill(ev DomainEvent) bool
- func IsStaff(kind string) bool
- func IsUnprojected(name string) bool
- func NewRouter() (*message.Router, error)
- func NewSubscriber(db *sql.DB, consumerGroup string, opts ...SubscribeOption) (message.Subscriber, error)
- func Persist(ctx context.Context, client *ent.Client, env Envelope) error
- func PruneEvents(ctx context.Context, client *ent.Client, retention time.Duration, ...) (int64, error)
- func RecordAudit(ctx context.Context, pub Publisher, entry *AuditEntry) error
- func RegisterLagGauge(client *ent.Client) (func() error, error)
- func RegisterSubscribers(router *message.Router, db *sql.DB, client *ent.Client, enroller Enroller, ...) error
- func ReservedAction(action string) bool
- func Suppress(ctx context.Context, client *ent.Client, env Envelope) error
- func Unaudited(s *ent.Scoped) *ent.Scoped
- type Actor
- type AuditEntry
- type AuditFilter
- type Bus
- func (b *Bus) Act(s *ent.Scoped, actor Actor) *ent.Scoped
- func (b *Bus) Use(hooks ...ent.Hook)
- func (b *Bus) UseJobs(q JobInserter)
- func (b *Bus) WithinAuditTx(ctx context.Context, s *ent.Scoped, fn func(ts *ent.Scoped) error) error
- func (b *Bus) WithinHandOverTx(ctx context.Context, s *ent.Scoped, fromID int64, ...) error
- func (b *Bus) WithinScopedEnqueueTx(ctx context.Context, s *ent.Scoped, fn func(ts *ent.Scoped, q Enqueuer) error) error
- func (b *Bus) WithinScopedPurgeTx(ctx context.Context, s *ent.Scoped, ...) error
- func (b *Bus) WithinScopedTx(ctx context.Context, s *ent.Scoped, ...) error
- func (b *Bus) WithinTx(ctx context.Context, fn func(tx *ent.Client, pub Publisher) error) error
- type CollectedEvent
- type Consumer
- type ContactCreated
- type ContactErased
- type DomainEvent
- type EmailDeliveryFailure
- type EmailEngagement
- type Enqueuer
- type Enroller
- type Envelope
- type JobInserter
- type MarketingConfirmed
- type Projection
- type PruneResult
- type Publisher
- type PurgingPublisher
- type QueuePurger
- type RejectionError
- type SubscribeOption
- type TrackingDomainChanged
- type Unprojected
- type WebhookDispatcher
Constants ¶
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.
const ( ActionMembershipUpdate = "membership.update" // A Membership was removed: by an Owner or Admin, or by the User leaving (ADR 0050). ActionMembershipDelete = "membership.delete" // 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" // A successful sign-in; its diff names the method: "password" or a Sign-in provider (ADR 0048). ActionUserLogin = "user.login" // 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" // The User proved its email by the emailed link and set its password (ADR 0030). ActionUserEmailVerify = "user.email_verify" ActionUserPasswordChange = "user.password_change" // "Sign out everywhere": every session of the User ends (ADR 0020). ActionUserSignOutEverywhere = "user.sign_out_everywhere" // The Second factor (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" // 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.
const ( NameContactCreated = "contact.created" NameContactErased = "contact.erased" NameEmailSent = "email.sent" 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.
const ( SourceCollect = "collect" SourceAPI = "api" )
The surfaces a customer's client event id arrives on; each has its own id space.
const ( BounceKindPermanent = suppression.BounceKindPermanent BounceKindTransient = suppression.BounceKindTransient )
Bounce kinds carried by EmailDeliveryFailure; what each means for Suppression is the suppression module's rule.
const ( GroupPersist = "persist" GroupAutomations = "automations" 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.
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.
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.
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.
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 ¶
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.
var ExplicitAuditActions = []string{ ActionMembershipUpdate, ActionMembershipDelete, ActionMembershipDomainJoin, ActionInvitationCreate, ActionInvitationAccept, ActionInvitationRevoke, ActionUserLogin, ActionUserIdentityLink, ActionUserIdentityUnlink, ActionUserEmailVerify, ActionUserPasswordChange, ActionUserSignOutEverywhere, ActionUserSecondFactorEnroll, ActionUserSecondFactorDisable, ActionUserSecondFactorReset, ActionUserRecoveryCodesRegenerate, ActionUserRecoveryCodeUse, ActionWorkspaceCreate, ActionWorkspaceUpdate, ActionWorkspaceDelete, ActionWorkspaceRestore, ActionWorkspaceSuspend, ActionWorkspaceUnsuspend, ActionWorkspaceAbuseExempt, ActionWorkspaceAbuseUnexempt, ActionContactImport, ActionAuditLogExport, ActionWebhookEndpointTest, }
ExplicitAuditActions lists every action emitted by an explicit RecordAudit call.
Functions ¶
func CheckAction ¶
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 ¶
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 Ingest ¶
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 IsStaff ¶
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 ¶
IsUnprojected reports whether the bus event type opts out of the Event projection.
func NewRouter ¶
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 Persist ¶
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 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 ¶
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, 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 ¶
ReservedAction reports whether action belongs to the system and is refused from a customer's Event.
func Suppress ¶
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 ¶
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 ¶
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 ¶
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 (*Bus) Act ¶
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 ¶
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 ¶
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 ¶
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 ¶
func (c Consumer) Handler() message.NoPublishHandlerFunc
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 EmailDeliveryFailure ¶
type EmailDeliveryFailure struct {
Action string `json:"action"` // 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"`
// 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"`
}
EmailDeliveryFailure is emitted when a provider reports a bounce or a spam complaint for one of our sends (ingested from a provider webhook, 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 complaints; only permanent bounces and complaints suppress the address. ContactID is 0 when the address has no contact in the workspace.
func (*EmailDeliveryFailure) EventID ¶
func (e *EmailDeliveryFailure) EventID() string
func (*EmailDeliveryFailure) EventName ¶
func (e *EmailDeliveryFailure) EventName() string
func (*EmailDeliveryFailure) EventVersion ¶
func (*EmailDeliveryFailure) EventVersion() int
func (*EmailDeliveryFailure) Project ¶
func (e *EmailDeliveryFailure) Project() Projection
func (*EmailDeliveryFailure) Workspace ¶
func (e *EmailDeliveryFailure) 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"`
// 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"`
}
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) 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"`
}
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
}
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(ctx 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 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.