package projection import ( "errors" "strconv" ) // Emitter is the producer half of the seam: it frames a full projection as a // begin → chunk* → end snapshot stream (and per-batch deltas) and hands each // frame to a caller-supplied publish function. It is transport-agnostic — it // owns only the framing (the Meta* keys, chunking, epoch tagging); the caller // wires PublishFunc to its bus (rabbit.PublishEvent, etc.) and EncodeFunc to // its wire schema. This is the symmetric counterpart to Store/Decoder. // // One authoritative producer per projection: receivers' Stores assemble their // own .bin from this stream. Two producers publishing the same projection would // make receivers thrash between epochs — don't. // // catalog's finder writeradmin predates this and keeps its own framing for now; // new domains (tracking popularity/suggestions) use this. // PublishFunc publishes one framed event: meta carries the framing keys // (snapshot phase, epoch, seq, count, tenant) already set; payload is the // chunk/delta body. The caller may add its own keys to meta before sending // (e.g. a country tag). A non-nil error aborts the current snapshot stream. type PublishFunc func(meta map[string]string, payload []byte) error // EncodeFunc serialises a batch of wire values into a chunk/delta payload — the // inverse of Decoder, and the one place the domain wire schema lives on the // producer side. It MUST encode a nil/empty slice as a payload the matching // Decoder reads back as zero updates (begin/end frames carry no entries). // JSONEncode satisfies this for the generic Update[W] schema. type EncodeFunc[W any] func([]Update[W]) ([]byte, error) // ErrEmptySnapshot is returned by Snapshot when handed no entries. An empty // snapshot would swap a receiver's base to nothing; refusing it mirrors the // catalog producer, which never propagates an empty catalog. var ErrEmptySnapshot = errors.New("projection: refusing to emit an empty snapshot (would wipe receivers)") // DefaultChunkSize is the entries-per-chunk used when WithChunkSize isn't set. // Mirrors catalog's projectionSnapshotChunk. const DefaultChunkSize = 500 // Emitter frames and publishes a projection of wire type W. type Emitter[W any] struct { encode EncodeFunc[W] publish PublishFunc tenant string chunkSize int } // emitterConfig is set via options then copied onto the typed Emitter (options // can't be generic over W, so they carry the un-typed knobs). type emitterConfig struct { tenant string chunkSize int } // WithTenant tags every frame with a tenant namespace (lands in MetaTenant and, // for snapshots, in the receiver's base header). func WithTenant(tenant string) func(*emitterConfig) { return func(c *emitterConfig) { c.tenant = tenant } } // WithChunkSize overrides the entries-per-chunk (default DefaultChunkSize). // Bounds per-message size; smaller = more frames. func WithChunkSize(n int) func(*emitterConfig) { return func(c *emitterConfig) { if n > 0 { c.chunkSize = n } } } // NewEmitter builds an emitter. encode and publish are REQUIRED. Options: // WithTenant, WithChunkSize. func NewEmitter[W any](encode EncodeFunc[W], publish PublishFunc, opts ...func(*emitterConfig)) *Emitter[W] { cfg := emitterConfig{chunkSize: DefaultChunkSize} for _, o := range opts { o(&cfg) } return &Emitter[W]{ encode: encode, publish: publish, tenant: cfg.tenant, chunkSize: cfg.chunkSize, } } // Snapshot publishes the full entry set as a begin → chunk* → end stream tagged // with epoch (a monotonic Unix-ms stamp the caller provides). Returns the // number of chunk frames emitted. ErrEmptySnapshot if items is empty; a // publish/encode error aborts the stream (receivers keep their previous base) // and is returned. The same epoch must later tag any Delta built on this base. func (e *Emitter[W]) Snapshot(epoch int64, items []Update[W]) (chunks int, err error) { if len(items) == 0 { return 0, ErrEmptySnapshot } if err := e.frame(epoch, SnapshotBegin, 0, len(items), nil); err != nil { return 0, err } seq := 0 for start := 0; start < len(items); start += e.chunkSize { end := start + e.chunkSize if end > len(items) { end = len(items) } if err := e.frame(epoch, SnapshotChunk, seq, 0, items[start:end]); err != nil { return seq, err } seq++ } if err := e.frame(epoch, SnapshotEnd, seq, len(items), nil); err != nil { return seq, err } return seq, nil } // Delta publishes one delta batch tagged with the base epoch it builds on. // Entries with Deleted=true tombstone the key in receivers' overlays. A // receiver ignores a delta whose epoch predates the base it holds. Empty batch // is a no-op (no error). func (e *Emitter[W]) Delta(epoch int64, items []Update[W]) error { if len(items) == 0 { return nil } return e.frame(epoch, "", 0, 0, items) } // frame encodes items and publishes one frame. phase "" ⇒ delta (no snapshot // phase key). begin/end carry MetaCount; chunks/deltas carry the entries. func (e *Emitter[W]) frame(epoch int64, phase string, seq, count int, items []Update[W]) error { // Always encode — even begin/end with no entries. encode(nil) must yield a // payload the Decoder reads as zero updates (see EncodeFunc), so a // framing-unaware decode never sees an empty/undecodable body. payload, err := e.encode(items) if err != nil { return err } meta := map[string]string{ MetaEpoch: strconv.FormatInt(epoch, 10), } if e.tenant != "" { meta[MetaTenant] = e.tenant } if phase != "" { meta[MetaSnapshotPhase] = phase // presence marks a snapshot frame vs a delta meta[MetaSeq] = strconv.Itoa(seq) } if phase == SnapshotBegin || phase == SnapshotEnd { meta[MetaCount] = strconv.Itoa(count) } return e.publish(meta, payload) }