oficina / suite Public

Clone
git clone https://amadan.net/oficina/suite

Plain git — no account needed to clone.

Download

Download this file

1package suite
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "time"
8)
9
10// FeedRow is one row of the S1 change feed, and it is the whole row.
11//
12// Doctrine §6: (item_id, revision, gone, touched_at) — no title, no content,
13// no visibility flag. memoria's draft 1 carried a visible-to-team field and it
14// was a leak: `visible=false, gone=false` told a consumer that a guest-only
15// item existed, at what revision and when it was touched, which is what S3's
16// "404, never 403" is there to hide. §4.2: departure from team-visibility is
17// `gone`, indistinguishable from deletion.
18type FeedRow struct {
19 ItemID string `json:"item_id"`
20 Revision int64 `json:"revision"`
21 Gone bool `json:"gone"`
22 TouchedAt time.Time `json:"touched_at"`
23}
24
25// Feed is one page of the change feed. The cursor is opaque: a position in the
26// sibling's own event log, issued by the sibling and stored per consumer.
27type Feed struct {
28 Cursor string `json:"cursor"`
29 Rows []FeedRow `json:"rows"`
30}
31
32// DecodeS1 reads a change-feed page, refusing anything the contract does not
33// allow on the wire.
34func DecodeS1(status int, body []byte) (Feed, error) {
35 if status < 200 || status > 299 {
36 // Not an empty feed. An empty feed means "nothing changed", and
37 // returning that for a failed call advances a consumer's cursor past
38 // changes it never saw.
39 return Feed{}, fmt.Errorf("suite: S1 answered %d", status)
40 }
41
42 // A duplicated key is resolved silently by encoding/json, and a duplicated
43 // `gone` would lose a tombstone. Trailing content is a second document
44 // riding along unread, since Decode reads one value and stops.
45 if err := rejectAmbiguousJSON(body); err != nil {
46 return Feed{}, fmt.Errorf("suite: S1 body is ambiguous: %w", err)
47 }
48
49 dec := json.NewDecoder(bytes.NewReader(body))
50 // A row carrying more than the four fields is either a sibling leaking or
51 // a sibling running a different contract. Ignoring the extra field would
52 // let either go unnoticed until somebody grepped the store.
53 dec.DisallowUnknownFields()
54
55 // Pointers so a missing field and an explicit null are both visible. Left
56 // to the plain types, either one decodes to a zero value and the row
57 // arrives looking like a real item at revision 0 that is present rather
58 // than gone.
59 var wire struct {
60 Cursor *string `json:"cursor"`
61 Rows *[]struct {
62 ItemID *string `json:"item_id"`
63 Revision *int64 `json:"revision"`
64 Gone *bool `json:"gone"`
65 TouchedAt *time.Time `json:"touched_at"`
66 } `json:"rows"`
67 }
68 if err := dec.Decode(&wire); err != nil {
69 return Feed{}, fmt.Errorf("suite: S1 body outside the contract: %w", err)
70 }
71
72 if wire.Cursor == nil || *wire.Cursor == "" {
73 // Without a cursor a consumer cannot advance, and committing the empty
74 // one loses its place in the sibling's log.
75 return Feed{}, fmt.Errorf("suite: S1 answered without a cursor")
76 }
77 if wire.Rows == nil {
78 return Feed{}, fmt.Errorf("suite: S1 answered without a rows array")
79 }
80
81 feed := Feed{Cursor: *wire.Cursor, Rows: make([]FeedRow, 0, len(*wire.Rows))}
82 for i, r := range *wire.Rows {
83 switch {
84 case r.ItemID == nil || *r.ItemID == "":
85 return Feed{}, fmt.Errorf("suite: S1 row %d has no item_id", i)
86 case r.Revision == nil:
87 return Feed{}, fmt.Errorf("suite: S1 row %d has no revision", i)
88 case r.Gone == nil:
89 return Feed{}, fmt.Errorf("suite: S1 row %d has no gone", i)
90 case r.TouchedAt == nil:
91 return Feed{}, fmt.Errorf("suite: S1 row %d has no touched_at", i)
92 }
93 feed.Rows = append(feed.Rows, FeedRow{
94 ItemID: *r.ItemID,
95 Revision: *r.Revision,
96 Gone: *r.Gone,
97 TouchedAt: *r.TouchedAt,
98 })
99 }
100 return feed, nil
101}
102