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))