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 Became(to any) map[string]any
- func Change(from, to any) map[string]any
- func CheckAction(action string) error
- func ConsumerGroups() []string
- func DedupKey(source string, workspaceID int64, clientEventID string) string
- func HumanOnly(sel *sql.Selector)
- func Ingest(s *ent.Scoped) *ent.Scoped
- func IsBackfill(ev DomainEvent) bool
- func IsEngagement(action string) bool
- func IsMachine(props map[string]any) bool
- func IsStaff(kind string) bool
- func IsUnprojected(name string) bool
- func Moment(sel *sql.Selector) string
- func MomentBetween(sel *sql.Selector, since, until time.Time)
- func NewRouter() (*message.Router, error)
- func NewSubscriber(db *sql.DB, consumerGroup string, opts ...SubscribeOption) (message.Subscriber, error)
- func NotCaptured(sel *sql.Selector)
- 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 PruneRuleFirings(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 RuleProperties(props map[string]any) map[string]any
- 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 EmailDelivery
- 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 RuleEvaluator
- type RuleEvent
- 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" // 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.
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).
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.
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 EmailDelivery; what each means for Suppression is the suppression module's rule.
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.
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.
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).
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 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.
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 HumanOnly ¶
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 ¶
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 ¶
IsEngagement reports whether action is an open or a click, the Events a machine can make.
func IsMachine ¶
IsMachine reports whether the properties of a stored Event mark it as made by software.
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 Moment ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
ReservedAction reports whether action belongs to the system and is refused from a customer's Event.
func RuleProperties ¶
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 ¶
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 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 ¶
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.