migrate.bend

bend-kit-migrate@0.1.0.0 · 428 lines · 0 definitions

1# Versioned subscription records: validated JSON decode, proved migrations, and encode. Source: https://github.com/paymog/bend-kit/tree/main/migrate
2import Base
3import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes
4import bend-kit-json@0.6.1.1/json.bend as Json
5import bend-kit-encoding@0.4.0.0/encoding.bend as Encoding
6
7type Period is Data:
8  Monthly{}
9  Yearly{}
10
11type Status is Data:
12  Active{}
13  Paused{}
14  Cancelled{}
15
16# v1: a monthly price and an on/off flag.
17type 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.
21type 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.
25type 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.
29type 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.
35type Terms is Data:
36  Terms{id: U32, plan: String, cents: U32, period: Period, status: Status}
37
38# What v2 to v3 discards.
39type Dropped is Data:
40  Dropped{note: String}
41
42type 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.
56def Status.of_active(a: Bool) -> Status:
57  match a:
58    case True{}:
59      Active{}
60    case False{}:
61      Cancelled{}
62
63def V1.terms(r: V1) -> Terms:
64  V1{i, p, c, a} = r
65  Terms{i, p, c, Monthly{}, Status.of_active(a)}
66
67def V2.terms(r: V2) -> Terms:
68  V2{i, p, c, pe, s, n} = r
69  Terms{i, p, c, pe, s}
70
71def V3.terms(r: V3) -> Terms:
72  V3{i, p, c, pe, s} = r
73  Terms{i, p, c, pe, s}
74
75def 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
84def Terms.cents(+c: U32) -> Maybe<&2, String>:
85  Bool.pick(Maybe<&2, String>, U32.is_eq(c, 0), Some{"cents"}, None{})
86
87def 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.
95def 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
99def Terms.valid(t: Terms) -> Bool:
100  none(Terms.problem(t))
101
102def 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.
109def V1.up(r: V1) -> V2:
110  V1{i, p, c, a} = r
111  V2{i, p, c, Monthly{}, Status.of_active(a), ""}
112
113def 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
120def 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
129def 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.
137def 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.
142def 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.
147def 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.
153def V3.down(r: V3) -> V2:
154  V3.restore(r, Dropped{""})
155
156# Any version to v3, with what the chain dropped.
157def 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.
170type Scalar is Data:
171  SNum{n: U32}
172  SStr{s: String}
173  SFlag{b: Bool}
174  SOther{}
175
176def scalar.num(m: Maybe<&2, U32>) -> Scalar:
177  match m:
178    case Some{n}:
179      SNum{n}
180    case None{}:
181      SOther{}
182
183def 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.
199type Field is Data:
200  Field{key: String, value: Scalar}
201
202def 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
209def 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
216def 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
223def 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.
231def 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
238def 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
245def 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
254def 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
263def 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.
273def 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
282def 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
285def 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
288def 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
297def 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
310def 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
322def 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
333def 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
342def 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
350def 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.
358def 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.
363def decode.val(v: Json.Val) -> Result<&2, &2, Error, Record>:
364  Result.bind(&2, &2, Error, Record, Record, decode.shape(v), Record.check)
365
366def 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.
374def decode(b: Bytes.Bytes) -> Result<&2, &2, Error, Record>:
375  decode.parsed(Json.parse.strict.bytes(b, 1))
376
377# Encode
378# ------
379
380def num(+n: U32) -> Json.Val:
381  Json.Num{Json.utf8(U32.show(n))}
382
383def str(s: String) -> Json.Val:
384  Json.Str{Json.utf8(s)}
385
386def kv(k: String, v: Json.Val) -> Bytes.Bytes & Json.Val:
387  (Json.utf8(k), v)
388
389def Period.name(p: Period) -> String:
390  match p:
391    case Monthly{}:
392      "monthly"
393    case Yearly{}:
394      "yearly"
395
396def Status.name(s: Status) -> String:
397  match s:
398    case Active{}:
399      "active"
400    case Paused{}:
401      "paused"
402    case Cancelled{}:
403      "cancelled"
404
405def 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
409def 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
413def 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.
418def 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
427def encode(r: Record) -> Bytes.Bytes:
428  Json.encode.bytes(Record.json(r))