1 # Versioned subscription records: validated JSON decode, proved migrations, and encode. Source: https://github.com/paymog/bend-kit/tree/main/migrate 2 import Base 3 import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes 4 import bend-kit-json@0.6.1.1/json.bend as Json 5 import bend-kit-encoding@0.4.0.0/encoding.bend as Encoding 6 7 type Period is Data: 8 Monthly{} 9 Yearly{} 10 11 type Status is Data: 12 Active{} 13 Paused{} 14 Cancelled{} 15 16 # v1: a monthly price and an on/off flag. 17 type V1 is Data: 18 V1{id: U32, plan: String, monthly_cents: U32, active: Bool} 19 20 # v2: a price per billing period, a three-state status, and a support note. 21 type V2 is Data: 22 V2{id: U32, plan: String, cents: U32, period: Period, status: Status, note: String} 23 24 # v3: v2 without the support note. 25 type V3 is Data: 26 V3{id: U32, plan: String, cents: U32, period: Period, status: Status} 27 28 # A decoded record and the version it was written in. 29 type Record is Data: 30 R1{r: V1} 31 R2{r: V2} 32 R3{r: V3} 33 34 # What a subscription means to billing: who, which plan, the price per period, and the status. 35 type Terms is Data: 36 Terms{id: U32, plan: String, cents: U32, period: Period, status: Status} 37 38 # What v2 to v3 discards. 39 type Dropped is Data: 40 Dropped{note: String} 41 42 type Error is Data: 43 Malformed{} 44 Unsupported{version: U32} 45 Missing{field: String} 46 Unknown{field: String} 47 Duplicate{field: String} 48 BadField{field: String} 49 Invalid{field: String} 50 NotInV1{field: String} 51 52 # Meaning 53 # ------- 54 55 # v1 had no pause: an inactive v1 subscription is cancelled. 56 def Status.of_active(a: Bool) -> Status: 57 match a: 58 case True{}: 59 Active{} 60 case False{}: 61 Cancelled{} 62 63 def V1.terms(r: V1) -> Terms: 64 V1{i, p, c, a} = r 65 Terms{i, p, c, Monthly{}, Status.of_active(a)} 66 67 def V2.terms(r: V2) -> Terms: 68 V2{i, p, c, pe, s, n} = r 69 Terms{i, p, c, pe, s} 70 71 def V3.terms(r: V3) -> Terms: 72 V3{i, p, c, pe, s} = r 73 Terms{i, p, c, pe, s} 74 75 def Record.terms(r: Record) -> Terms: 76 match r: 77 case R1{x}: 78 V1.terms(x) 79 case R2{x}: 80 V2.terms(x) 81 case R3{x}: 82 V3.terms(x) 83 84 def Terms.cents(+c: U32) -> Maybe<&2, String>: 85 Bool.pick(Maybe<&2, String>, U32.is_eq(c, 0), Some{"cents"}, None{}) 86 87 def none(m: Maybe<&2, String>) -> Bool: 88 match m: 89 case None{}: 90 True{} 91 case Some{f}: 92 False{} 93 94 # The first business rule the terms break: an empty plan or a zero price. 95 def Terms.problem(t: Terms) -> Maybe<&2, String>: 96 Terms{i, p, +c, pe, s} = t 97 Bool.pick(Maybe<&2, String>, String.is_empty(p), Some{"plan"}, Terms.cents(c)) 98 99 def Terms.valid(t: Terms) -> Bool: 100 none(Terms.problem(t)) 101 102 def Record.valid(r: Record) -> Bool: 103 Terms.valid(Record.terms(r)) 104 105 # Migrations 106 # ---------- 107 108 # v1 to v2. Total; V2.down undoes it. 109 def V1.up(r: V1) -> V2: 110 V1{i, p, c, a} = r 111 V2{i, p, c, Monthly{}, Status.of_active(a), ""} 112 113 def V2.down.note(n: String, i: U32, p: String, c: U32, a: Bool) -> Result<&2, &2, Error, V1>: 114 match n: 115 case SNil{}: 116 Done{V1{i, p, c, a}} 117 case SCon{h, t}: 118 Fail{NotInV1{"note"}} 119 120 def V2.down.status(s: Status, i: U32, p: String, c: U32, n: String) -> Result<&2, &2, Error, V1>: 121 match s: 122 case Active{}: 123 V2.down.note(n, i, p, c, True{}) 124 case Paused{}: 125 Fail{NotInV1{"status"}} 126 case Cancelled{}: 127 V2.down.note(n, i, p, c, False{}) 128 129 def V2.down.period(pe: Period, i: U32, p: String, c: U32, s: Status, n: String) -> Result<&2, &2, Error, V1>: 130 match pe: 131 case Monthly{}: 132 V2.down.status(s, i, p, c, n) 133 case Yearly{}: 134 Fail{NotInV1{"period"}} 135 136 # v2 to v1, for a monthly record that is not paused and has no note. 137 def V2.down(r: V2) -> Result<&2, &2, Error, V1>: 138 V2{i, p, c, pe, s, n} = r 139 V2.down.period(pe, i, p, c, s, n) 140 141 # v2 to v3. Lossy: it drops the note and returns it. 142 def V2.up(r: V2) -> V3 & Dropped: 143 V2{i, p, c, pe, s, n} = r 144 (V3{i, p, c, pe, s}, Dropped{n}) 145 146 # The v2 record that V2.up turned into r and d. 147 def V3.restore(r: V3, d: Dropped) -> V2: 148 V3{i, p, c, pe, s} = r 149 Dropped{n} = d 150 V2{i, p, c, pe, s, n} 151 152 # v3 to v2, with an empty note. 153 def V3.down(r: V3) -> V2: 154 V3.restore(r, Dropped{""}) 155 156 # Any version to v3, with what the chain dropped. 157 def Record.upgrade(r: Record) -> V3 & Dropped: 158 match r: 159 case R1{x}: 160 V2.up(V1.up(x)) 161 case R2{x}: 162 V2.up(x) 163 case R3{x}: 164 (x, Dropped{""}) 165 166 # Decode 167 # ------ 168 169 # A JSON field value, reduced to what records use. 170 type Scalar is Data: 171 SNum{n: U32} 172 SStr{s: String} 173 SFlag{b: Bool} 174 SOther{} 175 176 def scalar.num(m: Maybe<&2, U32>) -> Scalar: 177 match m: 178 case Some{n}: 179 SNum{n} 180 case None{}: 181 SOther{} 182 183 def scalar(v: Json.Val) -> Scalar: 184 match v: 185 case Json.Num{s}: 186 scalar.num(Json.u32(Json.Num{s})) 187 case Json.Str{s}: 188 SStr{Encoding.utf8.decode(s)} 189 case Json.Flag{b}: 190 SFlag{b} 191 case Json.Null{}: 192 SOther{} 193 case Json.Arr{xs}: 194 SOther{} 195 case Json.Obj{kvs}: 196 SOther{} 197 198 # One object field: its decoded key and its value. 199 type Field is Data: 200 Field{key: String, value: Scalar} 201 202 def fields.of(kvs: List<&1, Bytes.Bytes & Json.Val>) -> List<&2, Field>: 203 match kvs: 204 case Nil{}: 205 Nil{} 206 case Con{(k, v), t}: 207 Con{Field{Encoding.utf8.decode(k), scalar(v)}, fields.of(t)} 208 209 def fields.get(fs: List<&2, Field>, +k: String) -> Maybe<&2, Scalar>: 210 match fs: 211 case Nil{}: 212 None{} 213 case Con{Field{key, s}, t}: 214 Bool.pick(Maybe<&2, Scalar>, String.eq(k, key), Some{s}, fields.get(t, k)) 215 216 def fields.has(fs: List<&2, Field>, +k: String) -> Bool: 217 match fs: 218 case Nil{}: 219 False{} 220 case Con{Field{key, s}, t}: 221 Bool.or(String.eq(k, key), fields.has(t, k)) 222 223 def has(xs: List<&2, String>, +k: String) -> Bool: 224 match xs: 225 case Nil{}: 226 False{} 227 case Con{x, t}: 228 Bool.or(String.eq(k, x), has(t, k)) 229 230 # The first key that the version does not define, or that repeats. 231 def fields.bad(fs: List<&2, Field>, +known: List<&2, String>) -> Maybe<&2, Error>: 232 match fs: 233 case Nil{}: 234 None{} 235 case Con{Field{+k, s}, +t}: 236 Bool.pick(Maybe<&2, Error>, has(known, k), Bool.pick(Maybe<&2, Error>, fields.has(t, k), Some{Duplicate{k}}, fields.bad(t, known)), Some{Unknown{k}}) 237 238 def only(m: Maybe<&2, Error>) -> Result<&2, &2, Error, Unit>: 239 match m: 240 case None{}: 241 Done{Unit{}} 242 case Some{e}: 243 Fail{e} 244 245 def need.u32(m: Maybe<&2, Scalar>, +f: String) -> Result<&2, &2, Error, U32>: 246 match m: 247 case None{}: 248 Fail{Missing{f}} 249 case Some{SNum{n}}: 250 Done{n} 251 case Some{s}: 252 Fail{BadField{f}} 253 254 def need.flag(m: Maybe<&2, Scalar>, +f: String) -> Result<&2, &2, Error, Bool>: 255 match m: 256 case None{}: 257 Fail{Missing{f}} 258 case Some{SFlag{b}}: 259 Done{b} 260 case Some{s}: 261 Fail{BadField{f}} 262 263 def need.str(m: Maybe<&2, Scalar>, +f: String) -> Result<&2, &2, Error, String>: 264 match m: 265 case None{}: 266 Fail{Missing{f}} 267 case Some{SStr{s}}: 268 Done{s} 269 case Some{s}: 270 Fail{BadField{f}} 271 272 # An optional string field: absent gives d. 273 def opt.str(m: Maybe<&2, Scalar>, d: String, +f: String) -> Result<&2, &2, Error, String>: 274 match m: 275 case None{}: 276 Done{d} 277 case Some{SStr{s}}: 278 Done{s} 279 case Some{s}: 280 Fail{BadField{f}} 281 282 def Period.of(+s: String) -> Result<&2, &2, Error, Period>: 283 Bool.pick(Result<&2, &2, Error, Period>, String.eq(s, "monthly"), Done{Monthly{}}, Bool.pick(Result<&2, &2, Error, Period>, String.eq(s, "yearly"), Done{Yearly{}}, Fail{BadField{"period"}})) 284 285 def Status.of(+s: String) -> Result<&2, &2, Error, Status>: 286 Bool.pick(Result<&2, &2, Error, Status>, String.eq(s, "active"), Done{Active{}}, Bool.pick(Result<&2, &2, Error, Status>, String.eq(s, "paused"), Done{Paused{}}, Bool.pick(Result<&2, &2, Error, Status>, String.eq(s, "cancelled"), Done{Cancelled{}}, Fail{BadField{"status"}}))) 287 288 def decode.v1(+fs: List<&2, Field>) -> Result<&2, &2, Error, Record>: 289 do Result<&2, &2, Error, Record>: 290 u : Unit <- only(fields.bad(fs, ["version", "id", "plan", "monthly_cents", "active"])) 291 i : U32 <- need.u32(fields.get(fs, "id"), "id") 292 p : String <- need.str(fields.get(fs, "plan"), "plan") 293 c : U32 <- need.u32(fields.get(fs, "monthly_cents"), "monthly_cents") 294 a : Bool <- need.flag(fields.get(fs, "active"), "active") 295 return R1{V1{i, p, c, a}} 296 297 def decode.v2(+fs: List<&2, Field>) -> Result<&2, &2, Error, Record>: 298 do Result<&2, &2, Error, Record>: 299 u : Unit <- only(fields.bad(fs, ["version", "id", "plan", "cents", "period", "status", "note"])) 300 i : U32 <- need.u32(fields.get(fs, "id"), "id") 301 p : String <- need.str(fields.get(fs, "plan"), "plan") 302 c : U32 <- need.u32(fields.get(fs, "cents"), "cents") 303 ps : String <- opt.str(fields.get(fs, "period"), "monthly", "period") 304 pe : Period <- Period.of(ps) 305 ss : String <- need.str(fields.get(fs, "status"), "status") 306 s : Status <- Status.of(ss) 307 n : String <- opt.str(fields.get(fs, "note"), "", "note") 308 return R2{V2{i, p, c, pe, s, n}} 309 310 def decode.v3(+fs: List<&2, Field>) -> Result<&2, &2, Error, Record>: 311 do Result<&2, &2, Error, Record>: 312 u : Unit <- only(fields.bad(fs, ["version", "id", "plan", "cents", "period", "status"])) 313 i : U32 <- need.u32(fields.get(fs, "id"), "id") 314 p : String <- need.str(fields.get(fs, "plan"), "plan") 315 c : U32 <- need.u32(fields.get(fs, "cents"), "cents") 316 ps : String <- need.str(fields.get(fs, "period"), "period") 317 pe : Period <- Period.of(ps) 318 ss : String <- need.str(fields.get(fs, "status"), "status") 319 s : Status <- Status.of(ss) 320 return R3{V3{i, p, c, pe, s}} 321 322 def decode.at(+n: U32, +fs: List<&2, Field>) -> Result<&2, &2, Error, Record>: 323 match n: 324 case 1: 325 decode.v1(fs) 326 case 2: 327 decode.v2(fs) 328 case 3: 329 decode.v3(fs) 330 case _: 331 Fail{Unsupported{n}} 332 333 def decode.version(m: Maybe<&2, Scalar>, +fs: List<&2, Field>) -> Result<&2, &2, Error, Record>: 334 match m: 335 case None{}: 336 Fail{Missing{"version"}} 337 case Some{SNum{n}}: 338 decode.at(n, fs) 339 case Some{s}: 340 Fail{BadField{"version"}} 341 342 def decode.shape(v: Json.Val) -> Result<&2, &2, Error, Record>: 343 match v: 344 case Json.Obj{kvs}: 345 +fs = fields.of(kvs) 346 decode.version(fields.get(fs, "version"), fs) 347 case _: 348 Fail{Malformed{}} 349 350 def Record.check.of(m: Maybe<&2, String>, r: Record) -> Result<&2, &2, Error, Record>: 351 match m: 352 case None{}: 353 Done{r} 354 case Some{f}: 355 Fail{Invalid{f}} 356 357 # r when its terms keep the business rules. 358 def Record.check(r: Record) -> Result<&2, &2, Error, Record>: 359 +s = r 360 Record.check.of(Terms.problem(Record.terms(s)), s) 361 362 # One record from a parsed JSON value. Only a valid record decodes. 363 def decode.val(v: Json.Val) -> Result<&2, &2, Error, Record>: 364 Result.bind(&2, &2, Error, Record, Record, decode.shape(v), Record.check) 365 366 def decode.parsed(m: Maybe<&1, Json.Val>) -> Result<&2, &2, Error, Record>: 367 match m: 368 case None{}: 369 Fail{Malformed{}} 370 case Some{v}: 371 decode.val(v) 372 373 # One record from UTF-8 JSON: an object nested no deeper than one level. 374 def decode(b: Bytes.Bytes) -> Result<&2, &2, Error, Record>: 375 decode.parsed(Json.parse.strict.bytes(b, 1)) 376 377 # Encode 378 # ------ 379 380 def num(+n: U32) -> Json.Val: 381 Json.Num{Json.utf8(U32.show(n))} 382 383 def str(s: String) -> Json.Val: 384 Json.Str{Json.utf8(s)} 385 386 def kv(k: String, v: Json.Val) -> Bytes.Bytes & Json.Val: 387 (Json.utf8(k), v) 388 389 def Period.name(p: Period) -> String: 390 match p: 391 case Monthly{}: 392 "monthly" 393 case Yearly{}: 394 "yearly" 395 396 def Status.name(s: Status) -> String: 397 match s: 398 case Active{}: 399 "active" 400 case Paused{}: 401 "paused" 402 case Cancelled{}: 403 "cancelled" 404 405 def V1.json(r: V1) -> Json.Val: 406 V1{i, p, c, a} = r 407 Json.Obj{[kv("version", num(1)), kv("id", num(i)), kv("plan", str(p)), kv("monthly_cents", num(c)), kv("active", Json.Flag{a})]} 408 409 def V2.json(r: V2) -> Json.Val: 410 V2{i, p, c, pe, s, n} = r 411 Json.Obj{[kv("version", num(2)), kv("id", num(i)), kv("plan", str(p)), kv("cents", num(c)), kv("period", str(Period.name(pe))), kv("status", str(Status.name(s))), kv("note", str(n))]} 412 413 def V3.json(r: V3) -> Json.Val: 414 V3{i, p, c, pe, s} = r 415 Json.Obj{[kv("version", num(3)), kv("id", num(i)), kv("plan", str(p)), kv("cents", num(c)), kv("period", str(Period.name(pe))), kv("status", str(Status.name(s)))]} 416 417 # The record as a JSON object in its own version. 418 def Record.json(r: Record) -> Json.Val: 419 match r: 420 case R1{x}: 421 V1.json(x) 422 case R2{x}: 423 V2.json(x) 424 case R3{x}: 425 V3.json(x) 426 427 def encode(r: Record) -> Bytes.Bytes: 428 Json.encode.bytes(Record.json(r))