importu.bend

cswap-bend-cli@0.1.0.0 · 440 lines · 68 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/urec.bend as Urec
3import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
4import cswap-bend-cli@0.1.0.0/usage/text.bend as UText
5import cswap-bend-cli@0.1.0.0/json.bend as Json
6import cswap-bend-cli@0.1.0.0/clock.bend as Clock
7import cswap-bend-cli@0.1.0.0/view.bend as View
8import cswap-bend-cli@0.1.0.0/ustore.bend as Ustore
9
10# `import-usage`: read another machine's `list --json` document and validate
11# every row before anything is written (transfer.py import_usage). The result
12# is the readings to adopt, how many rows were skipped, or the TransferError.
13
14def jv(+k: String, +j: Json.Json) -> Json.Json:
15  Maybe.default(&2, Json.Json, Json.get(k, j), Json.JNull{})
16
17def neg.pick(neg: Bool, +s: String) -> Maybe<&2, String>:
18  match neg:
19    case True{}:
20      None{}
21    case False{}:
22      Some{s}
23
24def nonneg(m: Maybe<&2, String>) -> Maybe<&2, String>:
25  match m:
26    case Some{+s}:
27      neg.pick(String.starts_with(s, "-"), s)
28    case None{}:
29      None{}
30
31def ok_float(t: String) -> Maybe<&2, String>:
32  nonneg(UText.float(t))
33
34# A JSON number that is finite and not negative, as Python-float text.
35def num_text(j: Json.Json) -> Maybe<&2, String>:
36  match j:
37    case Json.JNum{t}:
38      ok_float(t)
39    case _:
40      None{}
41
42def with_reset(+s: String, +out: Json.Json, +now: U32, +off: U32) -> Json.Json:
43  View.with_cell(Json.set("resets_at", Json.JStr{s}, out), View.fresh(Json.of_pairs([("resets_at", Json.JStr{s})]), now, off))
44
45def win.parse(+label: String, +s: String, t: Maybe<&2, U32>, out: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
46  match t:
47    case None{}:
48      Fail{label ++ ".resetsAt is not an ISO-8601 time: '" ++ s ++ "'"}
49    case Some{u}:
50      Done{with_reset(s, out, now, off)}
51
52def win.reset(+label: String, +resets: Json.Json, out: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
53  match resets:
54    case Json.JNull{}:
55      Done{out}
56    case Json.JStr{s}:
57      win.parse(label, s, Clock.parse_iso8601(s), out, now, off)
58    case _:
59      Fail{label ++ ".resetsAt must be an ISO-8601 string"}
60
61def win.pct(m: Maybe<&2, String>, +label: String, +w: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
62  match m:
63    case None{}:
64      Fail{label ++ ".pct must be a non-negative number"}
65    case Some{p}:
66      win.reset(label, jv("resetsAt", w), Json.of_pairs([("pct", Json.JNum{p})]), now, off)
67
68def win.obj(w: Json.Json, +label: String, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
69  match w:
70    case Json.JObj{+f}:
71      win.pct(num_text(jv("pct", w)), label, w, now, off)
72    case _:
73      Fail{label ++ " must be an object"}
74
75# One window back to the stored shape, fetch-time strings rebuilt.
76def window(+w: Json.Json, +label: String, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
77  win.obj(w, label, now, off)
78
79def put.res(+k: String, r: Result<&2, &2, String, Json.Json>, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
80  match r acc:
81    case Fail{e} _:
82      Fail{e}
83    case Done{v} Fail{e}:
84      Fail{e}
85    case Done{v} Done{a}:
86      Done{Json.set(k, v, a)}
87
88def put_win(+k: String, +label: String, +w: Maybe<&2, Json.Json>, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
89  match w:
90    case None{}:
91      acc
92    case Some{x}:
93      put.res(k, window(x, label, now, off), acc)
94
95def spend.num2(m: Maybe<&2, String>, +k: String, out: Json.Json) -> Result<&2, &2, String, Json.Json>:
96  match m:
97    case Some{t}:
98      Done{Json.set(k, Json.JNum{t}, out)}
99    case None{}:
100      Fail{"spend." ++ k ++ " must be a number"}
101
102# Any finite number (the spend amounts may be signed).
103def num_any(j: Json.Json) -> Maybe<&2, String>:
104  match j:
105    case Json.JNum{t}:
106      UText.float(t)
107    case _:
108      None{}
109
110def spend.num(+k: String, +s: Json.Json, out: Json.Json) -> Result<&2, &2, String, Json.Json>:
111  spend.num2(num_any(jv(k, s)), k, out)
112
113def spend.cur(cur: Json.Json, r: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
114  match cur r:
115    case _ Fail{e}:
116      Fail{e}
117    case Json.JStr{c} Done{o}:
118      Done{Json.set("currency", Json.JStr{c}, o)}
119    case _ Done{o}:
120      Fail{"spend.currency must be a string"}
121
122def spend.limit(+s: Json.Json, r: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
123  match r:
124    case Fail{e}:
125      Fail{e}
126    case Done{o}:
127      spend.cur(jv("currency", s), spend.num("limit", s, o))
128
129def spend.chain(+s: Json.Json, w: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
130  match w:
131    case Fail{e}:
132      Fail{e}
133    case Done{o}:
134      spend.limit(s, spend.num("used", s, o))
135
136def spend.of(+s: Json.Json, w: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
137  spend.chain(s, w)
138
139def put_spend2(m: Maybe<&2, Json.Json>, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
140  match m:
141    case None{}:
142      acc
143    case Some{+s}:
144      put.res("spend", spend.of(s, window(s, "spend", now, off)), acc)
145
146def put_spend(+u: Json.Json, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
147  put_spend2(Json.get("spend", u), now, off, acc)
148
149def name.keep(empty: Bool, +n: String, +label: String, out: Json.Json) -> Result<&2, &2, String, Json.Json>:
150  match empty:
151    case True{}:
152      Fail{label ++ ".name must be a non-empty string"}
153    case False{}:
154      Done{Json.set("name", Json.JStr{n}, out)}
155
156def name.of(m: Maybe<&2, String>, +label: String, out: Json.Json) -> Result<&2, &2, String, Json.Json>:
157  match m:
158    case Some{+n}:
159      name.keep(String.is_empty(n), n, label, out)
160    case None{}:
161      Fail{label ++ ".name must be a non-empty string"}
162
163def scoped.entry(r: Result<&2, &2, String, Json.Json>, +w: Json.Json, +label: String) -> Result<&2, &2, String, Json.Json>:
164  match r:
165    case Fail{e}:
166      Fail{e}
167    case Done{o}:
168      name.of(Json.as_string(jv("name", w)), label, o)
169
170def scoped.add(e: Result<&2, &2, String, Json.Json>, acc: Result<&2, &2, String, List<&2, Json.Json>>) -> Result<&2, &2, String, List<&2, Json.Json>>:
171  match e acc:
172    case Fail{m} _:
173      Fail{m}
174    case Done{v} Fail{m}:
175      Fail{m}
176    case Done{v} Done{xs}:
177      Done{List.append(&2, Json.Json, xs, [v])}
178
179def scoped.list(ls: List<&2, Json.Json>, +i: Nat, +now: U32, +off: U32, acc: Result<&2, &2, String, List<&2, Json.Json>>) -> Result<&2, &2, String, List<&2, Json.Json>>:
180  match ls:
181    case Nil{}:
182      acc
183    case Con{+w, t}:
184      scoped.list(t, 1n+i, now, off, scoped.add(scoped.entry(window(w, "scoped[" ++ Nat.show(i) ++ "]", now, off), w, "scoped[" ++ Nat.show(i) ++ "]"), acc))
185
186def scoped.put(r: Result<&2, &2, String, List<&2, Json.Json>>, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
187  match r acc:
188    case Fail{e} _:
189      Fail{e}
190    case Done{xs} Fail{e}:
191      Fail{e}
192    case Done{xs} Done{a}:
193      Done{Json.set("scoped", Json.arr(xs), a)}
194
195def put_scoped2(m: Maybe<&2, Json.Json>, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
196  match m:
197    case None{}:
198      acc
199    case Some{Json.JArr{items}}:
200      scoped.put(scoped.list(Json.to_list(Json.JArr{items}), 0n, now, off, Done{[]}), acc)
201    case Some{x}:
202      Fail{"scoped must be a list"}
203
204def put_scoped(+u: Json.Json, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
205  put_scoped2(Json.get("scoped", u), now, off, acc)
206
207def nonempty_keys(ks: List<&2, String>, o: Json.Json) -> Result<&2, &2, String, Json.Json>:
208  match ks:
209    case Nil{}:
210      Fail{"usage carries no windows"}
211    case Con{h, t}:
212      Done{o}
213
214def nonempty_res(r: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
215  match r:
216    case Fail{e}:
217      Fail{e}
218    case Done{+o}:
219      nonempty_keys(Json.keys(o), o)
220
221def usage.fields(+u: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
222  nonempty_res(put_scoped(u, now, off, put_spend(u, now, off,
223    put_win("seven_day", "sevenDay", Json.get("sevenDay", u), now, off,
224      put_win("five_hour", "fiveHour", Json.get("fiveHour", u), now, off, Done{Json.of_pairs([])})))))
225
226def usage.obj(+u: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
227  match u:
228    case Json.JObj{f}:
229      usage.fields(u, now, off)
230    case _:
231      Fail{"usage must be an object"}
232
233# usage_from_json: the stored usage shape from a `list --json` usage object.
234def usage_from_json(+u: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
235  usage.obj(u, now, off)
236
237# Rows accepted so far, rows skipped, and the first error (TransferError text).
238type IA is Data:
239  IA{readings: List<&2, Urec.Reading>, skipped: U32, slots: List<&2, String>, err: String}
240
241def ia.err(msg: String, ia: IA) -> IA:
242  match ia:
243    case IA{rs, sk, slots, err}:
244      IA{rs, sk, slots, msg}
245
246def ia.skip(ia: IA) -> IA:
247  match ia:
248    case IA{rs, sk, slots, err}:
249      IA{rs, (sk + 1 : U32), slots, err}
250
251def has_slot(+s: String, xs: List<&2, String>) -> Bool:
252  match xs:
253    case Nil{}:
254      False{}
255    case Con{h, t}:
256      String.eq(h, s) || has_slot(s, t)
257
258def org_label(+org: String) -> String:
259  Clock.sel.s(String.is_empty(org), "personal", org)
260
261def ia.add(+id: Ustore.Id, +usage: Json.Json, +age: U32, ia: IA) -> IA:
262  match ia:
263    case IA{rs, sk, slots, err}:
264      IA{Urec.Reading{id, usage, age} <> rs, sk, Ustore.slot_of(id) <> slots, err}
265
266def ia.dup(dup: Bool, +id: Ustore.Id, +email: String, +org: String, +usage: Json.Json, +age: U32, ia: IA) -> IA:
267  match dup:
268    case True{}:
269      ia.err("duplicate account in usage document: " ++ email ++ " (org=" ++ org_label(org) ++ ")", ia)
270    case False{}:
271      ia.add(id, usage, age, ia)
272
273def ia.dup2(+slot: String, +email: String, +org: String, +usage: Json.Json, +age: U32, ia: IA) -> IA:
274  match ia:
275    case IA{rs, sk, +slots, err}:
276      ia.dup(has_slot(slot, slots), Ustore.Id{slot, email, org}, email, org, usage, age, IA{rs, sk, slots, err})
277
278def ia.slot(m: Maybe<&2, String>, +email: String, +org: String, +usage: Json.Json, +age: U32, ia: IA) -> IA:
279  match m:
280    case None{}:
281      ia.skip(ia)
282    case Some{+slot}:
283      ia.dup2(slot, email, org, usage, age, ia)
284
285def ia.usage(r: Result<&2, &2, String, Json.Json>, +email: String, +org: String, +age: U32, +seq: Json.Json, ia: IA) -> IA:
286  match r:
287    case Fail{e}:
288      ia.err("usage for " ++ email ++ ": " ++ e, ia)
289    case Done{u}:
290      ia.slot(SSeq.find_slot(seq, email, org), email, org, u, age, ia)
291
292def age.int(ps: List<&2, String>) -> Maybe<&2, U32>:
293  match ps:
294    case Con{i, t}:
295      U32.read(i)
296    case Nil{}:
297      None{}
298
299def age_secs(m: Maybe<&2, String>) -> Maybe<&2, U32>:
300  match m:
301    case Some{t}:
302      age.int(String.split(t, '.'))
303    case None{}:
304      None{}
305
306def ia.age(m: Maybe<&2, U32>, +row: Json.Json, +email: String, +org: String, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
307  match m:
308    case None{}:
309      ia.err("usageAgeSeconds for " ++ email ++ " must be a non-negative number", ia)
310    case Some{+age}:
311      ia.usage(usage_from_json(jv("usage", row), now, off), email, org, age, seq, ia)
312
313def ia.status(ok: Bool, +row: Json.Json, +email: String, +org: String, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
314  match ok:
315    case False{}:
316      ia.skip(ia)
317    case True{}:
318      ia.age(age_secs(num_text(jv("usageAgeSeconds", row))), row, email, org, seq, now, off, ia)
319
320def is_ok_status(j: Json.Json) -> Bool:
321  match j:
322    case Json.JStr{s}:
323      String.eq(s, "ok")
324    case _:
325      False{}
326
327def ia.ident3(empty: Bool, +e: String, +o: String, +row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
328  match empty:
329    case True{}:
330      ia.err("each account row needs an email and an organizationUuid", ia)
331    case False{}:
332      ia.status(is_ok_status(jv("usageStatus", row)), row, e, o, seq, now, off, ia)
333
334def ia.ident2(+e: String, +o: String, +row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
335  ia.ident3(String.is_empty(e), e, o, row, seq, now, off, ia)
336
337def ia.identity(email: Maybe<&2, String>, org: Json.Json, +row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
338  match email org:
339    case Some{e} Json.JNull{}:
340      ia.ident2(e, "", row, seq, now, off, ia)
341    case Some{e} Json.JStr{o}:
342      ia.ident2(e, o, row, seq, now, off, ia)
343    case _ _:
344      ia.err("each account row needs an email and an organizationUuid", ia)
345
346def ia.row(+row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
347  match row:
348    case Json.JObj{f}:
349      ia.identity(Json.as_string(jv("email", row)), jv("organizationUuid", row), row, seq, now, off, ia)
350    case _:
351      ia.err("each account row must be a JSON object", ia)
352
353def ia.go1(live: Bool, +row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
354  match live:
355    case True{}:
356      ia.row(row, seq, now, off, ia)
357    case False{}:
358      ia
359
360def ia.step(+row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
361  match ia:
362    case IA{rs, sk, slots, +err}:
363      ia.go1(String.is_empty(err), row, seq, now, off, IA{rs, sk, slots, err})
364
365def ia.rows(rs: List<&2, Json.Json>, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
366  match rs:
367    case Nil{}:
368      ia
369    case Con{h, t}:
370      ia.rows(t, seq, now, off, ia.step(h, seq, now, off, ia))
371
372# What an import will do: the readings to adopt (with their identities) and
373# how many rows were skipped.
374type Plan is Data:
375  Plan{readings: List<&2, Urec.Reading>, skipped: U32}
376
377def plan.err(ok: Bool, err: String, p: Plan) -> Result<&2, &2, String, Plan>:
378  match ok:
379    case True{}:
380      Done{p}
381    case False{}:
382      Fail{err}
383
384def plan.of(ia: IA) -> Result<&2, &2, String, Plan>:
385  match ia:
386    case IA{rs, sk, slots, +err}:
387      plan.err(String.is_empty(err), err, Plan{List.reverse(&2, Urec.Reading, rs), sk})
388
389def plan.accounts(m: Maybe<&2, Json.Json>, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
390  match m:
391    case Some{Json.JArr{items}}:
392      plan.of(ia.rows(Json.to_list(Json.JArr{items}), seq, now, off, IA{[], 0, [], ""}))
393    case _:
394      Fail{"usage document has no accounts list"}
395
396def version_text(j: Json.Json) -> String:
397  match j:
398    case Json.JNum{t}:
399      t
400    case Json.JStr{s}:
401      "'" ++ s ++ "'"
402    case Json.JNull{}:
403      "None"
404    case _:
405      "?"
406
407def plan.version(ok: Bool, +t: String, +doc: Json.Json, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
408  match ok:
409    case True{}:
410      plan.accounts(Json.get("accounts", doc), seq, now, off)
411    case False{}:
412      Fail{"unsupported usage document schemaVersion: " ++ t ++ " (expected 1, as printed by 'cswap list --json')"}
413
414def plan.doc2(ver: Json.Json, +doc: Json.Json, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
415  match ver:
416    case Json.JNum{+t}:
417      plan.version(String.eq(t, "1"), t, doc, seq, now, off)
418    case v:
419      Fail{"unsupported usage document schemaVersion: " ++ version_text(v) ++ " (expected 1, as printed by 'cswap list --json')"}
420
421def plan.doc(+doc: Json.Json, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
422  plan.doc2(jv("schemaVersion", doc), doc, seq, now, off)
423
424def plan.obj(+doc: Json.Json, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
425  match doc:
426    case Json.JObj{f}:
427      plan.doc(doc, seq, now, off)
428    case _:
429      Fail{"usage document must be a JSON object"}
430
431def plan.parsed(r: Result<&2, &2, String, Json.Json>, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
432  match r:
433    case Fail{e}:
434      Fail{"usage document is not valid JSON: " ++ e}
435    case Done{doc}:
436      plan.obj(doc, seq, now, off)
437
438# The plan for the text of a usage document, validated whole before any write.
439def import_plan(+text: String, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
440  plan.parsed(Json.parse(text), seq, now, off)