package suite import ( "bytes" "encoding/json" "fmt" "time" ) // FeedRow is one row of the S1 change feed, and it is the whole row. // // Doctrine §6: (item_id, revision, gone, touched_at) — no title, no content, // no visibility flag. memoria's draft 1 carried a visible-to-team field and it // was a leak: `visible=false, gone=false` told a consumer that a guest-only // item existed, at what revision and when it was touched, which is what S3's // "404, never 403" is there to hide. §4.2: departure from team-visibility is // `gone`, indistinguishable from deletion. type FeedRow struct { ItemID string `json:"item_id"` Revision int64 `json:"revision"` Gone bool `json:"gone"` TouchedAt time.Time `json:"touched_at"` } // Feed is one page of the change feed. The cursor is opaque: a position in the // sibling's own event log, issued by the sibling and stored per consumer. type Feed struct { Cursor string `json:"cursor"` Rows []FeedRow `json:"rows"` } // DecodeS1 reads a change-feed page, refusing anything the contract does not // allow on the wire. func DecodeS1(status int, body []byte) (Feed, error) { if status < 200 || status > 299 { // Not an empty feed. An empty feed means "nothing changed", and // returning that for a failed call advances a consumer's cursor past // changes it never saw. return Feed{}, fmt.Errorf("suite: S1 answered %d", status) } // A duplicated key is resolved silently by encoding/json, and a duplicated // `gone` would lose a tombstone. Trailing content is a second document // riding along unread, since Decode reads one value and stops. if err := rejectAmbiguousJSON(body); err != nil { return Feed{}, fmt.Errorf("suite: S1 body is ambiguous: %w", err) } dec := json.NewDecoder(bytes.NewReader(body)) // A row carrying more than the four fields is either a sibling leaking or // a sibling running a different contract. Ignoring the extra field would // let either go unnoticed until somebody grepped the store. dec.DisallowUnknownFields() // Pointers so a missing field and an explicit null are both visible. Left // to the plain types, either one decodes to a zero value and the row // arrives looking like a real item at revision 0 that is present rather // than gone. var wire struct { Cursor *string `json:"cursor"` Rows *[]struct { ItemID *string `json:"item_id"` Revision *int64 `json:"revision"` Gone *bool `json:"gone"` TouchedAt *time.Time `json:"touched_at"` } `json:"rows"` } if err := dec.Decode(&wire); err != nil { return Feed{}, fmt.Errorf("suite: S1 body outside the contract: %w", err) } if wire.Cursor == nil || *wire.Cursor == "" { // Without a cursor a consumer cannot advance, and committing the empty // one loses its place in the sibling's log. return Feed{}, fmt.Errorf("suite: S1 answered without a cursor") } if wire.Rows == nil { return Feed{}, fmt.Errorf("suite: S1 answered without a rows array") } feed := Feed{Cursor: *wire.Cursor, Rows: make([]FeedRow, 0, len(*wire.Rows))} for i, r := range *wire.Rows { switch { case r.ItemID == nil || *r.ItemID == "": return Feed{}, fmt.Errorf("suite: S1 row %d has no item_id", i) case r.Revision == nil: return Feed{}, fmt.Errorf("suite: S1 row %d has no revision", i) case r.Gone == nil: return Feed{}, fmt.Errorf("suite: S1 row %d has no gone", i) case r.TouchedAt == nil: return Feed{}, fmt.Errorf("suite: S1 row %d has no touched_at", i) } feed.Rows = append(feed.Rows, FeedRow{ ItemID: *r.ItemID, Revision: *r.Revision, Gone: *r.Gone, TouchedAt: *r.TouchedAt, }) } return feed, nil }