feat(events): derive SSE names from the taxonomy; retire item.updated_with_comment (TASK-2714)

SPEC-3 §"the choke point owns the canonical→surface name mapping". SSE's
snake_case vocabulary and the webhook dot-form vocabulary drifted because
nothing tied them together — each was hand-passed at its own call sites. This
ties them.

v1.5 pins what "derive" means: NAME derivation, not delivery path. SSE stays
direct-published at the mutation site, because it carries request-scoped
attribution (Actor / ActorName / Source) that a frozen outbox payload
deliberately does not hold; only its NAME now comes from the taxonomy. Moving
SSE behind the drain is TASK-2722.

- eventSpec gains an `sse` field — ONE table, not a second map, for the reason
  round 11 of the last unit established: a separate map can disagree with the
  first and fails open exactly when it matters. Empty is a real value (attachment,
  member and pack events have no SSE surface) and SurfaceSSE reports false for it,
  so silence can't be mistaken for a name.
- Several canonical events derive the SAME SSE name — status_changed and moved
  both surface as item_updated — because the SSE vocabulary is coarser than
  events/1 and the UI never distinguished them. The finer name is what the
  webhook wire and bindings get.
- The 12 canonical SSE publish sites take their names from derived package vars,
  resolved AT INIT. Every call site is a compile-time constant, so a missing
  surface is a startup panic rather than a per-request decision between "log and
  drop" and "publish under an empty name".
- handlers_item_links.go keeps the events.ItemUpdated literal, commented: link
  mutations are silent in events/1 (v1.5), so there is no canonical name to
  derive from. TASK-2723 carries link.created / link.removed.
- item.updated_with_comment retired (v1.2, Dave's ruling). One producer deleted;
  the events.ItemUpdatedWithComment constant deleted with it — it had no producer
  and no web consumer (grepped .go/.ts/.svelte), so leaving it would leave a name
  a future publisher could reach for.

The compat guard is what makes this a refactor rather than a wire change:
TestDerivedSSENamesMatchTheLegacyWireVocabulary asserts each derived name equals
the events.* constant clients are pinned to. A derivation producing
"item.created" or "item_deleted" would break the live UI while every other Go
test still passed.

Mutation matrix, 3/3 caught: rename item.deleted's SSE surface to item_deleted
(both the taxonomy test and the compat guard fail), split item.moved onto its own
SSE name (same), make SurfaceSSE return (spec.sse, ok) so no-surface events fail
open (the taxonomy test's silence leg names all four). Running total 9/9.

go test ./internal/server ./internal/store ./internal/events: all green.
This commit is contained in:
xarmian
2026-08-21 00:06:29 +00:00
parent 1d35018b06
commit 33662da0cf
10 changed files with 223 additions and 53 deletions
+6 -2
View File
@@ -42,8 +42,12 @@ const (
ItemStarred = "item_starred"
ItemUnstarred = "item_unstarred"
// Composite events
ItemUpdatedWithComment = "item_updated_with_comment"
// Composite events: NONE. item_updated_with_comment was declared here
// and never published by anything — its only producer was a hand-called
// webhook dispatch using the dot-form name, retired under SPEC-3 v1.2
// (Dave's ruling, day-48) because it folds into item.updated +
// comment.created. The constant went with it rather than being left as
// a name a future publisher could reach for.
// Batch events. Emitted once for a whole bulk mutation (TASK-1668)
// instead of one ItemUpdated/ItemArchived per row — the lane-header
+56 -16
View File
@@ -147,6 +147,26 @@ const (
type eventSpec struct {
subject string
payload string
// sse is the snake_case name this event publishes under on the SSE
// surface, or "" when the event has no SSE surface at all.
//
// SPEC-3 §"The choke point owns the canonical→surface name mapping":
// SSE (snake_case) and webhooks (dot-form) drifted because nothing tied
// their vocabularies together. This field is the tie. SPEC-3 v1.5 pins
// what "derive" means — NAME derivation, not delivery path: webhooks
// deliver through the drain, SSE stays direct-published at the mutation
// site (it carries request-scoped Actor/ActorName/Source that a frozen
// payload deliberately does not hold) and takes only its NAME from here.
//
// The empty string is a real value, not a gap: attachment, member and
// pack events have no SSE surface, and SurfaceSSE reports false for them
// so a caller cannot mistake silence for a name. Several canonical events
// map onto ONE SSE name — status_changed and moved both surface as
// item_updated — because the SSE vocabulary is coarser than events/1 and
// the UI has never distinguished them. That is a mapping, not a loss:
// the fine-grained name is what the webhook wire and bindings receive.
sse string
}
// canonical is the closed events/1 set. Adding an entry here is the ONLY way
@@ -186,22 +206,22 @@ const (
)
var canonical = map[string]eventSpec{
ItemCreated: {SubjectItem, PayloadItemSnapshot},
ItemUpdated: {SubjectItem, PayloadItemSnapshot},
ItemStatusChanged: {SubjectItem, PayloadItemSnapshot},
ItemMoved: {SubjectItem, PayloadItemSnapshot},
ItemDeleted: {SubjectItem, PayloadItemSnapshot},
ItemRestored: {SubjectItem, PayloadItemSnapshot},
ItemBulkUpdated: {SubjectItemBatch, PayloadItemBatch},
CommentCreated: {SubjectComment, PayloadCommentSnapshot},
CommentUpdated: {SubjectComment, PayloadCommentSnapshot},
CommentDeleted: {SubjectComment, PayloadRefOnly},
AttachmentAdded: {SubjectAttachment, PayloadAttachmentSnapshot},
AttachmentRemoved: {SubjectAttachment, PayloadRefOnly},
MemberJoined: {SubjectMember, PayloadMember},
PackInstalled: {SubjectPack, PayloadPack},
PackUpgraded: {SubjectPack, PayloadPack},
PackDisabled: {SubjectPack, PayloadPack},
ItemCreated: {SubjectItem, PayloadItemSnapshot, "item_created"},
ItemUpdated: {SubjectItem, PayloadItemSnapshot, "item_updated"},
ItemStatusChanged: {SubjectItem, PayloadItemSnapshot, "item_updated"},
ItemMoved: {SubjectItem, PayloadItemSnapshot, "item_updated"},
ItemDeleted: {SubjectItem, PayloadItemSnapshot, "item_archived"},
ItemRestored: {SubjectItem, PayloadItemSnapshot, "item_restored"},
ItemBulkUpdated: {SubjectItemBatch, PayloadItemBatch, "items_bulk_updated"},
CommentCreated: {SubjectComment, PayloadCommentSnapshot, "comment_created"},
CommentUpdated: {SubjectComment, PayloadCommentSnapshot, "comment_updated"},
CommentDeleted: {SubjectComment, PayloadRefOnly, "comment_deleted"},
AttachmentAdded: {SubjectAttachment, PayloadAttachmentSnapshot, ""},
AttachmentRemoved: {SubjectAttachment, PayloadRefOnly, ""},
MemberJoined: {SubjectMember, PayloadMember, ""},
PackInstalled: {SubjectPack, PayloadPack, ""},
PackUpgraded: {SubjectPack, PayloadPack, ""},
PackDisabled: {SubjectPack, PayloadPack, ""},
}
// PayloadFamily returns the payload shape a canonical event carries, and false
@@ -247,3 +267,23 @@ func Canonical() []string {
}
return names
}
// SurfaceSSE returns the SSE wire name a canonical event publishes under, and
// false when it has none — either because the name is not canonical, or
// because the event is deliberately absent from the SSE surface.
//
// ONE BOOL FOR BOTH CASES, deliberately. A caller has the same job either way:
// do not publish. Splitting them would invite a caller to branch on
// "canonical but unmapped" and invent a name, which is the drift this table
// exists to end.
//
// The webhook wire name needs no accessor: it IS the canonical name. That
// asymmetry is the point of SPEC-3's mapping sentence — the dot-form vocabulary
// is the contract, and SSE's snake_case is a surface rendering of it.
func SurfaceSSE(name string) (string, bool) {
spec, ok := canonical[name]
if !ok || spec.sse == "" {
return "", false
}
return spec.sse, true
}
+58
View File
@@ -0,0 +1,58 @@
package server
import (
"fmt"
"github.com/PerpetualSoftware/pad/internal/kernelevents"
)
// The SSE wire names, DERIVED from the events/1 taxonomy rather than written
// out again here.
//
// SPEC-3 §"The choke point owns the canonical→surface name mapping" is the
// reason this file exists: SSE's snake_case vocabulary and the webhook
// dot-form vocabulary drifted because nothing tied them together, each being
// hand-passed at its own call sites. v1.5 pins what "derive" means — NAME
// derivation, not delivery path. Webhooks deliver through the outbox drain;
// SSE stays direct-published at the mutation site, because it carries
// request-scoped attribution (Actor / ActorName / Source) that a frozen outbox
// payload deliberately does not hold. What changes is only where the NAME
// comes from.
//
// DERIVED AT INIT, not per publish, and that is the whole safety argument. A
// canonical event with no SSE surface is a programming error at these call
// sites — every one of them is a compile-time constant — so the check belongs
// where a failure is impossible to miss and impossible to reach a user:
// process start. A per-request lookup would have to decide what to do with the
// "no surface" answer in a void helper, and every available answer (log and
// drop, publish under an empty name) is worse than not starting.
//
// Several canonical events derive the SAME name: status_changed and moved both
// surface as item_updated, because the SSE vocabulary is coarser than events/1
// and the UI has never distinguished them. The finer name is what the webhook
// wire and bindings receive.
var (
sseItemCreated = mustSurfaceSSE(kernelevents.ItemCreated)
sseItemUpdated = mustSurfaceSSE(kernelevents.ItemUpdated)
sseItemMoved = mustSurfaceSSE(kernelevents.ItemMoved)
sseItemArchived = mustSurfaceSSE(kernelevents.ItemDeleted)
sseItemRestored = mustSurfaceSSE(kernelevents.ItemRestored)
sseItemsBulk = mustSurfaceSSE(kernelevents.ItemBulkUpdated)
sseCommentCreated = mustSurfaceSSE(kernelevents.CommentCreated)
sseCommentUpdated = mustSurfaceSSE(kernelevents.CommentUpdated)
)
// mustSurfaceSSE resolves a canonical event's SSE wire name or panics.
//
// The panic is deliberate and it fires at package init, before the server
// listens: a canonical event that reaches here without an SSE surface means
// the taxonomy and this file disagree, and the only honest outcomes are
// "publish nothing" (silently breaking the live UI) or "refuse to start".
// Refusing to start is the one a person notices.
func mustSurfaceSSE(canonical string) string {
name, ok := kernelevents.SurfaceSSE(canonical)
if !ok {
panic(fmt.Sprintf("kernelevents: %q has no SSE surface name", canonical))
}
return name
}
+44
View File
@@ -0,0 +1,44 @@
package server
import (
"testing"
"github.com/PerpetualSoftware/pad/internal/events"
)
// TestDerivedSSENamesMatchTheLegacyWireVocabulary is the compat guard for the
// rewire: deriving the SSE names from the events/1 taxonomy must reproduce the
// strings the bus already published, byte for byte.
//
// This is what makes the rewire a REFACTOR rather than a wire change. Every
// live client — the web app's SSE handler, delta-sync's cursor logic — matches
// on these literals, so a derivation that quietly produced "item.created" or
// "item_deleted" would break the UI while every Go test still passed.
//
// The assertion runs against events.* rather than against fresh literals on
// purpose: those constants are what the CLIENTS are pinned to, so comparing
// against them is comparing against the thing that actually has to hold.
func TestDerivedSSENamesMatchTheLegacyWireVocabulary(t *testing.T) {
for _, tc := range []struct {
name string
derived string
legacy string
}{
{"item created", sseItemCreated, events.ItemCreated},
{"item updated", sseItemUpdated, events.ItemUpdated},
{"item moved surfaces as updated", sseItemMoved, events.ItemUpdated},
{"item deleted surfaces as archived", sseItemArchived, events.ItemArchived},
{"item restored", sseItemRestored, events.ItemRestored},
{"bulk", sseItemsBulk, events.ItemsBulkUpdated},
{"comment created", sseCommentCreated, events.CommentCreated},
{"comment updated", sseCommentUpdated, events.CommentUpdated},
} {
if tc.derived != tc.legacy {
t.Errorf("%s: derived SSE name = %q, legacy wire name = %q — clients match the legacy string",
tc.name, tc.derived, tc.legacy)
}
if tc.derived == "" {
t.Errorf("%s: derived SSE name is empty", tc.name)
}
}
}
+3 -3
View File
@@ -136,7 +136,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
}
// Publish SSE event
s.publishCommentEvent(events.CommentCreated, workspaceID, item.ID, comment.ID, item.Title, item.CollectionSlug, actor, source)
s.publishCommentEvent(sseCommentCreated, workspaceID, item.ID, comment.ID, item.Title, item.CollectionSlug, actor, source)
s.dispatchWebhook(workspaceID, "comment.created", comment)
if s.watchEvents != nil {
@@ -263,7 +263,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
title = item.Title
collSlug = item.CollectionSlug
}
s.publishCommentEvent(events.CommentUpdated, workspaceID, updated.ItemID, updated.ID, title, collSlug, actor, source)
s.publishCommentEvent(sseCommentUpdated, workspaceID, updated.ItemID, updated.ID, title, collSlug, actor, source)
s.dispatchWebhook(workspaceID, "comment.updated", updated)
writeJSON(w, http.StatusOK, updated)
@@ -365,7 +365,7 @@ func (s *Server) handleCreateReply(w http.ResponseWriter, r *http.Request) {
replyItemRef = replyItem.Ref
replyCollID = replyItem.CollectionID
}
s.publishCommentEvent(events.CommentCreated, workspaceID, parentComment.ItemID, comment.ID, parentComment.ItemTitle, replyCollSlug, actor, source)
s.publishCommentEvent(sseCommentCreated, workspaceID, parentComment.ItemID, comment.ID, parentComment.ItemTitle, replyCollSlug, actor, source)
// TASK-2533 (codex round 1, finding 2): a reply is a SEPARATE code
// path from handleCreateComment — it calls store.CreateComment
+8
View File
@@ -266,5 +266,13 @@ func (s *Server) publishStructuralLinkSourceEvent(r *http.Request, workspaceID,
return
}
actor, source := actorFromRequest(r)
// events.ItemUpdated LITERAL, not derived from the taxonomy, and that is
// the decision rather than an oversight. Item-link mutations are SILENT in
// events/1 (SPEC-3 v1.5): a link is plausibly its own subject kind, and
// faking item.updated would ship a snapshot in which nothing the consumer
// caches has changed. There is no canonical event to derive a name from
// here, so this publish stays a pure SSE nudge for the live UI. TASK-2723
// carries the link.created / link.removed shape and this call site with
// it.
s.publishItemEventWithName(events.ItemUpdated, workspaceID, item.ID, item.Title, item.CollectionSlug, actor, actorNameFromRequest(r), source, item.Seq)
}
+14 -11
View File
@@ -807,7 +807,7 @@ func (s *Server) createItemChecked(r *http.Request, workspaceID string, coll *mo
actor, source := actorFromRequest(r)
actorNameForCreate := actorNameFromRequest(r)
s.logActivity(workspaceID, item.ID, "created", r)
s.publishItemEventWithName(events.ItemCreated, workspaceID, item.ID, item.Title, coll.Slug, actor, actorNameForCreate, source, item.Seq)
s.publishItemEventWithName(sseItemCreated, workspaceID, item.ID, item.Title, coll.Slug, actor, actorNameForCreate, source, item.Seq)
s.dispatchWebhook(workspaceID, "item.created", item)
// Assignment-at-creation (TASK-2533): unlike an update, a freshly
@@ -1694,7 +1694,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
actor, source := actorFromRequest(r)
activityID, _ := s.logActivityWithMetaReturningID(workspaceID, updated.ID, "updated", r, meta)
actorNameForUpdate := actorNameFromRequest(r)
s.publishItemEventWithName(events.ItemUpdated, workspaceID, updated.ID, updated.Title, updated.CollectionSlug, actor, actorNameForUpdate, source, updated.Seq)
s.publishItemEventWithName(sseItemUpdated, workspaceID, updated.ID, updated.Title, updated.CollectionSlug, actor, actorNameForUpdate, source, updated.Seq)
s.dispatchWebhook(workspaceID, "item.updated", updated)
s.publishWatchNotifications(workspaceID, updated, actor, actorNameForUpdate)
@@ -1715,12 +1715,15 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
slog.Warn("failed to create comment on item update", "item_id", updated.ID, "error", cerr)
}
if cerr == nil && comment != nil {
s.publishCommentEvent(events.CommentCreated, workspaceID, updated.ID, comment.ID, updated.Title, updated.CollectionSlug, actor, source)
s.dispatchWebhook(workspaceID, "item.updated_with_comment", map[string]interface{}{
"item": updated,
"comment": comment,
"changes": meta,
})
s.publishCommentEvent(sseCommentCreated, workspaceID, updated.ID, comment.ID, updated.Title, updated.CollectionSlug, actor, source)
// item.updated_with_comment is RETIRED (SPEC-3 v1.2, Dave's
// ruling): a hand-rolled composite with no flood argument,
// which folds into item.updated + comment.created — both of
// which this path already emits, transactionally, from the
// store. It is never emitted under events/1, with no
// deprecation window, because webhooks are CLI-registered
// only today (no UI surface) and there were no known
// consumers at ruling time.
// TASK-2533: this comment is created via a DIFFERENT code
// path than handleCreateComment (store.CreateComment called
// directly, not through POST .../comments) — a bypass Rider
@@ -1791,7 +1794,7 @@ func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) {
actor, source := actorFromRequest(r)
s.logActivity(workspaceID, item.ID, "archived", r)
s.publishItemEventWithName(events.ItemArchived, workspaceID, item.ID, item.Title, item.CollectionSlug, actor, actorNameFromRequest(r), source, deleteSeq)
s.publishItemEventWithName(sseItemArchived, workspaceID, item.ID, item.Title, item.CollectionSlug, actor, actorNameFromRequest(r), source, deleteSeq)
s.dispatchWebhook(workspaceID, "item.deleted", item)
w.WriteHeader(http.StatusNoContent)
@@ -1844,7 +1847,7 @@ func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
actor, source := actorFromRequest(r)
s.logActivity(workspaceID, restored.ID, "restored", r)
s.publishItemEventWithName(events.ItemRestored, workspaceID, restored.ID, restored.Title, restored.CollectionSlug, actor, actorNameFromRequest(r), source, restored.Seq)
s.publishItemEventWithName(sseItemRestored, workspaceID, restored.ID, restored.Title, restored.CollectionSlug, actor, actorNameFromRequest(r), source, restored.Seq)
restoreVisIDs, _ := s.visibleCollectionIDs(r, workspaceID)
if err := s.enrichItemForResponse(restored, restoreVisIDs); err != nil {
@@ -2128,7 +2131,7 @@ func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
// Publish events for both old and new collections
actorNameForMove := actorNameFromRequest(r)
s.publishItemEventWithName(events.ItemUpdated, workspaceID, moved.ID, moved.Title, targetColl.Slug, actor, actorNameForMove, source, moved.Seq)
s.publishItemEventWithName(sseItemMoved, workspaceID, moved.ID, moved.Title, targetColl.Slug, actor, actorNameForMove, source, moved.Seq)
s.dispatchWebhook(workspaceID, "item.moved", moved)
s.publishWatchNotifications(workspaceID, moved, actor, actorNameForMove)
+1 -1
View File
@@ -697,7 +697,7 @@ func (s *Server) publishBulkItemsEvent(workspaceID, op, collection string, count
return
}
s.events.Publish(events.Event{
Type: events.ItemsBulkUpdated,
Type: sseItemsBulk,
WorkspaceID: workspaceID,
Collection: collection,
Op: op,
+2 -3
View File
@@ -8,7 +8,6 @@ import (
"net/http"
"runtime/debug"
"github.com/PerpetualSoftware/pad/internal/events"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
@@ -662,7 +661,7 @@ func (s *Server) emitCopyFanout(r *http.Request, res *store.CrossWorkspaceCopyRe
// --- Destination: always all three. ---
targetWorkspaceID := res.Item.WorkspaceID
s.logActivity(targetWorkspaceID, res.Item.ID, "created", r)
s.publishItemEventWithName(events.ItemCreated, targetWorkspaceID, res.Item.ID, res.Item.Title,
s.publishItemEventWithName(sseItemCreated, targetWorkspaceID, res.Item.ID, res.Item.Title,
res.TargetCollection.Slug, actor, actorName, actorSource, res.Item.Seq)
s.dispatchWebhook(targetWorkspaceID, "item.created", res.Item)
@@ -674,7 +673,7 @@ func (s *Server) emitCopyFanout(r *http.Request, res *store.CrossWorkspaceCopyRe
return
}
s.logActivity(res.SourceWorkspaceID, res.Source.ID, "archived", r)
s.publishItemEventWithName(events.ItemArchived, res.SourceWorkspaceID, res.Source.ID, res.Source.Title,
s.publishItemEventWithName(sseItemArchived, res.SourceWorkspaceID, res.Source.ID, res.Source.Title,
res.SourceCollection.Slug, actor, actorName, actorSource, *res.SourceSeq)
s.dispatchWebhook(res.SourceWorkspaceID, "item.deleted", res.Source)
}
+31 -17
View File
@@ -1375,23 +1375,23 @@ func TestCanonicalEventsAreFullyDeclared(t *testing.T) {
// The events/1 set at SPEC-3 v1.4. Adding, removing or re-homing an entry
// here is a CONTRACT CHANGE: update the spec version and the taxonomy's
// doc comment in the same commit.
want := map[string]struct{ subject, family string }{
"item.created": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot},
"item.updated": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot},
"item.status_changed": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot},
"item.moved": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot},
"item.deleted": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot},
"item.restored": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot},
"item.bulk_updated": {kernelevents.SubjectItemBatch, kernelevents.PayloadItemBatch},
"comment.created": {kernelevents.SubjectComment, kernelevents.PayloadCommentSnapshot},
"comment.updated": {kernelevents.SubjectComment, kernelevents.PayloadCommentSnapshot},
"comment.deleted": {kernelevents.SubjectComment, kernelevents.PayloadRefOnly},
"attachment.added": {kernelevents.SubjectAttachment, kernelevents.PayloadAttachmentSnapshot},
"attachment.removed": {kernelevents.SubjectAttachment, kernelevents.PayloadRefOnly},
"member.joined": {kernelevents.SubjectMember, kernelevents.PayloadMember},
"pack.installed": {kernelevents.SubjectPack, kernelevents.PayloadPack},
"pack.upgraded": {kernelevents.SubjectPack, kernelevents.PayloadPack},
"pack.disabled": {kernelevents.SubjectPack, kernelevents.PayloadPack},
want := map[string]struct{ subject, family, sse string }{
"item.created": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_created"},
"item.updated": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_updated"},
"item.status_changed": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_updated"},
"item.moved": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_updated"},
"item.deleted": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_archived"},
"item.restored": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_restored"},
"item.bulk_updated": {kernelevents.SubjectItemBatch, kernelevents.PayloadItemBatch, "items_bulk_updated"},
"comment.created": {kernelevents.SubjectComment, kernelevents.PayloadCommentSnapshot, "comment_created"},
"comment.updated": {kernelevents.SubjectComment, kernelevents.PayloadCommentSnapshot, "comment_updated"},
"comment.deleted": {kernelevents.SubjectComment, kernelevents.PayloadRefOnly, "comment_deleted"},
"attachment.added": {kernelevents.SubjectAttachment, kernelevents.PayloadAttachmentSnapshot, ""},
"attachment.removed": {kernelevents.SubjectAttachment, kernelevents.PayloadRefOnly, ""},
"member.joined": {kernelevents.SubjectMember, kernelevents.PayloadMember, ""},
"pack.installed": {kernelevents.SubjectPack, kernelevents.PayloadPack, ""},
"pack.upgraded": {kernelevents.SubjectPack, kernelevents.PayloadPack, ""},
"pack.disabled": {kernelevents.SubjectPack, kernelevents.PayloadPack, ""},
}
// The name constants are pinned to their wire strings separately, because
@@ -1444,6 +1444,17 @@ func TestCanonicalEventsAreFullyDeclared(t *testing.T) {
if !kernelevents.IsCanonical(name) {
t.Errorf("IsCanonical(%q) = false for an event Canonical() returned", name)
}
// The SSE surface name, including the events that deliberately have
// none. An empty want.sse asserts SILENCE — SurfaceSSE must report
// false rather than handing back a name a caller would publish under.
sse, sseOK := kernelevents.SurfaceSSE(name)
if expected.sse == "" {
if sseOK || sse != "" {
t.Errorf("SurfaceSSE(%q) = (%q, %v), want (\"\", false) — this event has no SSE surface", name, sse, sseOK)
}
} else if !sseOK || sse != expected.sse {
t.Errorf("SurfaceSSE(%q) = (%q, %v), want (%q, true)", name, sse, sseOK, expected.sse)
}
}
for name := range want {
if !seen[name] {
@@ -1464,6 +1475,9 @@ func TestCanonicalEventsAreFullyDeclared(t *testing.T) {
if kernelevents.IsCanonical(name) {
t.Errorf("IsCanonical(%q) = true", name)
}
if _, ok := kernelevents.SurfaceSSE(name); ok {
t.Errorf("SurfaceSSE(%q) reported ok for a non-canonical name", name)
}
}
}