yichus 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/cli.js.map +1 -1
- package/dist/main.js +191 -31
- package/dist/main.js.map +1 -1
- package/dist/studio-page/system-map-record.js +93 -7
- package/dist/studio-page/system-map.js +82 -26
- package/dist/studio-page/what-happens-record.js +90 -6
- package/dist/studio-page/what-happens.js +72 -13
- package/dist/studio-reference/refund-desk-one-round.bosatsu +21 -18
- package/dist/studio-reference/refund-desk.bosatsu +21 -18
- package/dist/studio-reference/withdrawal.bosatsu +11 -15
- package/package.json +2 -2
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// (poteto) TLA+ diagrams.
|
|
42
42
|
|
|
43
43
|
import { mark, tg, MIN_LABEL, COLORS as C } from './system-map.js';
|
|
44
|
-
import { walk, leavesOf, pathEffects, allEffects, stationLabel, outcomeLabel, isWriteOp } from './what-happens-record.js';
|
|
44
|
+
import { walk, leavesOf, pathEffects, allEffects, stationLabel, stationDetail, fromWord, outcomeLabel, isWriteOp } from './what-happens-record.js';
|
|
45
45
|
import { SANS, MONO, textWidth, clipText, boxAttr, fitAttr, labelAttr, labelled } from './label-fit.js';
|
|
46
46
|
|
|
47
47
|
const RED = C.guard, GREEN = '#1f7a45', GREEN_SOFT = '#e4f4ea', RED_SOFT = '#fbe9e7';
|
|
@@ -223,9 +223,9 @@ export function renderWhatHappens(record, opts = {}) {
|
|
|
223
223
|
y = frameTop + (phone ? 18 : 22);
|
|
224
224
|
// the entry rack: the described entries, then one line for the others
|
|
225
225
|
y = rack(record, entry, P, y, out, !!opts.ghosts || opts.open === 'ghosts');
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
y = compared(record, P, y + (phone ? 13 : 15), out);
|
|
226
|
+
// what a compared base says, in this place: a fix is then read off the
|
|
227
|
+
// frame, also when nothing is green; with no base, nothing is drawn here
|
|
228
|
+
if (record.diff) y = compared(record, P, y + (phone ? 13 : 15), out);
|
|
229
229
|
y += phone ? 10 : 14;
|
|
230
230
|
// the entry's own line
|
|
231
231
|
const o = parseOpen(opts.open);
|
|
@@ -243,6 +243,13 @@ export function renderWhatHappens(record, opts = {}) {
|
|
|
243
243
|
}).map(effectKey));
|
|
244
244
|
const x0 = P.pad + (phone ? 6 : 12);
|
|
245
245
|
y = entryHead(entry, root || stepNode, record, P, x0, y, out, opts.open);
|
|
246
|
+
// entries that take the same steps, on other tables and checks
|
|
247
|
+
if (entry.sameShape && !root && !stepNode) {
|
|
248
|
+
const names = entry.sameShape.entries.map(id => id.replace(/^route:/, ''));
|
|
249
|
+
lines(`same steps as ${names.join(', ')} · on their own tables and checks`, P.fsS, P.W - P.pad - x0 - 12, false, 2)
|
|
250
|
+
.forEach(l => { out.push(tg([`sameShape:${entry.entry}`], T(x0 + 12, y + P.row * 0.2, l, { size: P.fsS, fill: C.muted, mono: false }))); y += P.row * 0.8; });
|
|
251
|
+
y += P.row * 0.3;
|
|
252
|
+
}
|
|
246
253
|
const subject = root ? root.next : stepNode || entry.tree;
|
|
247
254
|
// work beside the wrapper, folded; a wrapper's work after the handler is
|
|
248
255
|
// folded on each path that runs it, just before its outcome
|
|
@@ -263,6 +270,35 @@ export function renderWhatHappens(record, opts = {}) {
|
|
|
263
270
|
y = tree(subject, 0, x0, y, P, out, entry, true);
|
|
264
271
|
}
|
|
265
272
|
y = witnessList(record, entry, subject, P, x0, y + (phone ? 10 : 14), out);
|
|
273
|
+
// what each table it touches holds, by its checked row type, and then the tables only
|
|
274
|
+
// the entries with the same steps touch (which this tree stands for), each said to be theirs
|
|
275
|
+
const touched = new Map();
|
|
276
|
+
const touch = (es, by) => { for (const e of es || []) for (const tb of e.tables || []) if (!touched.has(tb)) touched.set(tb, by); };
|
|
277
|
+
for (const e of [entry, ...(entry.sameShape?.entries || []).map(id => record.entries.find(x => x.entry === id)).filter(Boolean)]) {
|
|
278
|
+
const by = e === entry ? null : e.entry.replace(/^route:/, '');
|
|
279
|
+
touch(e.effects, by);
|
|
280
|
+
walk(e === entry ? subject : e.tree, x => { if (!x.kind) touch(x.effects, by); });
|
|
281
|
+
}
|
|
282
|
+
for (const [tb, by] of [...touched].filter(([tb]) => record.rows && record.rows[tb])) {
|
|
283
|
+
for (const l of lines(`${by ? `in ${by}, ` : ''}${tb} holds ${record.rows[tb]}`, P.fsS, P.W - P.pad - x0 - 6, true, 3)) { y += P.row * 0.8; out.push(tg([`rows:${tb}`], T(x0, y, l, { size: P.fsS, fill: C.muted }))); }
|
|
284
|
+
y += P.row * 0.2;
|
|
285
|
+
}
|
|
286
|
+
// the checks it names are the author's labels: what each decides is in its code
|
|
287
|
+
const library = id => /^(Bosatsu|Yichus)\//.test(id);
|
|
288
|
+
const namedIds = [];
|
|
289
|
+
walk(subject, t => { if (t.kind === 'node' && t.from?.kind === 'call' && !library(t.from.callee) && !namedIds.includes(t.from.callee)) namedIds.push(t.from.callee); });
|
|
290
|
+
const named = [...new Set(namedIds.map(bare))];
|
|
291
|
+
if (named.length) {
|
|
292
|
+
const note = `${named.join(', ')}: names the author chose; this shows where each runs, what it is given and what happens when it fails, not what it decides`;
|
|
293
|
+
for (const l of lines(note, P.fsS, P.W - P.pad - x0 - 6, false, 3)) { y += P.row * 0.8; out.push(tg(['names'], T(x0, y, l, { size: P.fsS, fill: C.muted, mono: false }))); }
|
|
294
|
+
y += P.row * 0.3;
|
|
295
|
+
}
|
|
296
|
+
// what the author wrote above each named check: their words, in italic, not checked
|
|
297
|
+
for (const id of namedIds.filter(id => record.docs && record.docs[id])) {
|
|
298
|
+
const said = `${bare(id)}, in its author's comment (unchecked): ${record.docs[id].replace(/\s+/g, ' ')}`;
|
|
299
|
+
for (const l of lines(said, P.fsS, P.W - P.pad - x0 - 6, false, 3)) { y += P.row * 0.8; out.push(tg([`docs:${id}`], T(x0, y, l, { size: P.fsS, fill: C.muted, mono: false, italic: true }))); }
|
|
300
|
+
y += P.row * 0.2;
|
|
301
|
+
}
|
|
266
302
|
const H = Math.ceil(y + (phone ? 10 : 16));
|
|
267
303
|
const frame = labelled('frame', `<rect x="2.5" y="${frameTop}" width="${W - 5}" height="${H - frameTop - 3}" rx="10" fill="none" stroke="${C.frame}" stroke-width="1"/>` +
|
|
268
304
|
`<text x="14" y="${frameTop + 4}" font-family="${SANS}" font-size="${phone ? 9 : 10}" font-weight="600" fill="${C.muted}" stroke="${C.page}" stroke-width="6" paint-order="stroke">Yichus output</text>`);
|
|
@@ -277,6 +313,12 @@ function rack(record, selected, P, y, out, expanded) {
|
|
|
277
313
|
const x0 = pad + (phone ? 6 : 12);
|
|
278
314
|
let x = x0, yy = y;
|
|
279
315
|
const ghosts = record.entries.filter(e => !e.described);
|
|
316
|
+
// no entry described: one line that opens to list them all
|
|
317
|
+
if (ghosts.length === record.entries.length && !expanded) {
|
|
318
|
+
const n = ghosts.length, text = `${n} ${n === 1 ? 'entry' : 'entries'}`;
|
|
319
|
+
out.push(tg([`ghosts:${n}`], `<g data-ghosts="folded"${opener('ghosts', `Show the ${n} entries no scenario describes`)}>${title(`${n} entries no scenario describes`)}${ghostHead(x, yy)}${T(x + 11, yy, text, { size: fs, fill: C.muted, mono: false })}</g>`));
|
|
320
|
+
return yy + 4;
|
|
321
|
+
}
|
|
280
322
|
out.push(T(x, yy, ghosts.length ? `entries · ${record.entries.length - ghosts.length} of ${record.entries.length} described by a scenario` : `entries · every one described`,
|
|
281
323
|
{ size: fs, fill: C.muted, mono: false }));
|
|
282
324
|
yy += lh;
|
|
@@ -315,10 +357,6 @@ function rack(record, selected, P, y, out, expanded) {
|
|
|
315
357
|
}
|
|
316
358
|
function compared(record, P, y, out) {
|
|
317
359
|
const x = P.pad + (P.phone ? 6 : 12), d = record.diff;
|
|
318
|
-
if (!d) {
|
|
319
|
-
out.push(tg(['compared:none'], `<g${labelAttr('compared')}>${title('no earlier version of the program was compared, so nothing here says what was fixed')}${T(x, y, 'no base compared', { size: P.fsS, fill: C.faint, mono: false })}</g>`));
|
|
320
|
-
return y + 4;
|
|
321
|
-
}
|
|
322
360
|
const parts = [[`compared with ${d.base}`, C.muted, null], [`${d.fixed.length} fixed`, d.fixed.length ? GREEN : C.muted, 600], [`${d.stillRed.length} still failing`, d.stillRed.length ? RED : C.muted, 600]];
|
|
323
361
|
if (d.unresolved.length) parts.push([`${d.unresolved.length} no longer checked`, C.muted, 600]);
|
|
324
362
|
if (d.goneAtHead.length) parts.push([`${d.goneAtHead.length} gone with ${d.goneAtHead.length === 1 ? 'its entry' : 'their entries'}`, C.muted, null]);
|
|
@@ -370,13 +408,25 @@ function stations(effects, x, y, P, out, key) {
|
|
|
370
408
|
const text = stationLabel(e) + (isCore(e) ? '' : ` · ${e.phase === 'after' ? `after the handler${whose}${after ? (after.everyPath ? ' · on every path that runs it' : ' · not on every path that runs it') : ''}` : e.layer === 'beside' ? 'beside the wrapper' : `before the handler${whose}`}`);
|
|
371
409
|
const where = `${text} in ${String(e.site).split('::').pop()}${e.line ? `, line ${e.line}` : ''}`;
|
|
372
410
|
const rows = lines(text, P.fsS, P.W - P.pad - x - r - 10, true, 2);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
411
|
+
// the row it touches and where what it writes comes from, under the label
|
|
412
|
+
// (measured at the size it is drawn at: never below the smallest label)
|
|
413
|
+
const dsz = Math.max(MIN_LABEL, P.fsS - 1);
|
|
414
|
+
const detail = stationDetail(e), more = detail ? lines(detail, dsz, P.W - P.pad - x - r - 18, true, 2) : [];
|
|
415
|
+
const all = [...rows.map(l => [l, P.fsS, isCore(e) ? color : C.hullInk]), ...more.map(l => [l, dsz, C.muted])];
|
|
416
|
+
out.push(tg([`effect:${key}:${e.op}:${e.tables.join(',')}`], `<g data-layer="${e.layer || 'core'}">${title(detail ? `${where}: ${detail}` : where)}${ring}${all.map(([l, size, fill], i) => T(x + r + 6 + (i ? 8 : 0), cy + 3.5 + i * P.row * 0.8, l, { size, fill })).join('')}</g>`));
|
|
417
|
+
if (all.length > 1) out.push(line(x, cy, x, cy + P.row * (0.6 + (all.length - 1) * 0.8)));
|
|
418
|
+
y = cy + P.row * 0.6 + (all.length - 1) * P.row * 0.8;
|
|
376
419
|
}
|
|
377
420
|
return y;
|
|
378
421
|
}
|
|
379
422
|
|
|
423
|
+
const forkSources = t => {
|
|
424
|
+
const f = t.from || {};
|
|
425
|
+
if (f.kind === 'call' || f.kind === 'derived') return (f.sources || []).map(fromWord).join(', ') || null;
|
|
426
|
+
if (f.kind === 'read') return f.op === 'mint id' ? 'new id' : `${f.table || bare(f.primitive)} as read`;
|
|
427
|
+
if (f.kind === 'request' || f.kind === 'caller') return [f.kind, ...(f.path || [])].join('.');
|
|
428
|
+
return null;
|
|
429
|
+
};
|
|
380
430
|
// One subtree from (x, y) down. Returns the y where it ends.
|
|
381
431
|
// A folded step: one shaded box on the line (the map's hull), its role,
|
|
382
432
|
// what it answers instead of running the handler, and its effects.
|
|
@@ -467,9 +517,18 @@ function tree(t, depth, x, y, P, out, entry, top) {
|
|
|
467
517
|
out.push(tg([`words:${t.id}`], `<g>${title(`${t.test} (${t.key})`)}${T(lx, yy, clip(t.words, P.fs, room, false, 400, true), { size: P.fs, italic: true, mono: false, fill: crossNode ? C.hullInk : C.ink })}</g>`));
|
|
468
518
|
if (!P.phone) out.push(T(lx + Math.min(room, width(clip(t.words, P.fs, room, false, 400, true), P.fs, false, 400, true)) + 8, yy, clip(t.test, P.fsS, Math.max(0, room - width(t.words, P.fs, false, 400, true) - 10)), { size: P.fsS, fill: C.faint }));
|
|
469
519
|
} else {
|
|
470
|
-
|
|
520
|
+
const rows = lines(t.test, P.fs, room, !guard, 2, guard ? 600 : 400);
|
|
521
|
+
for (const [i, l] of rows.entries())
|
|
471
522
|
out.push(T(lx, yy + i * P.row * 0.85, l, { size: P.fs, mono: !guard, fill: guard ? RED : crossNode ? C.hullInk : C.ink2, weight: guard ? 600 : null }));
|
|
472
|
-
if (
|
|
523
|
+
if (rows.length > 1) yy += P.row * 0.85;
|
|
524
|
+
// what the branch tests comes from: the request, the caller, a read
|
|
525
|
+
const src = forkSources(t);
|
|
526
|
+
if (src) {
|
|
527
|
+
const after = width(rows[rows.length - 1], P.fs, !guard, guard ? 600 : 400) + 8, left = room - after;
|
|
528
|
+
const text = `← ${src}`;
|
|
529
|
+
if (width(text, P.fsS, true) <= left) out.push(T(lx + after, yy, text, { size: P.fsS, fill: C.muted }));
|
|
530
|
+
else { yy += P.row * 0.8; out.push(T(lx, yy, clip(text, P.fsS, room), { size: P.fsS, fill: C.muted })); }
|
|
531
|
+
}
|
|
473
532
|
}
|
|
474
533
|
y = yy + P.row * 0.35;
|
|
475
534
|
// the spine continues straight down: the arm that reaches the main
|
|
@@ -20,7 +20,7 @@ from Yichus/IO import IO, flat_map, pure
|
|
|
20
20
|
from Yichus/Access import Principal, AccessRule, AccessSpec, OwnerScoped, owner_key
|
|
21
21
|
from Yichus/Data import Db
|
|
22
22
|
from Yichus/Service import Reply, Done, Invalid, NotFound, Refused as Turned, TryAgain, handler, route, service_def, ReadPerm, WritePerm, CreatePerm
|
|
23
|
-
from Yichus/Transaction import
|
|
23
|
+
from Yichus/Transaction import TxTable, Txn, Committed, transaction, replied, stored_table, tx_bind, tx_pure, tx_read, tx_write, tx_insert, tx_new_token
|
|
24
24
|
from Yichus/OpenRouter import ProviderPolicy, ModelRequest, ModelResult, Completed, Refused, RetryableFailure, PermanentFailure, TimedOut, openrouter_call
|
|
25
25
|
from Yichus/Json import JsonObject, JsonString, JsonArray, JsonBool, Parsed, InvalidJson, Rendered, RenderError, Decoded, DecodeError, parse_json, render_json, decode_bind, required_field, as_bool
|
|
26
26
|
from Zafu/Control/IterState import IterState, Continue, Done as Finished
|
|
@@ -53,34 +53,33 @@ access_rules = AccessSpec([
|
|
|
53
53
|
AccessRule("refunds", OwnerScoped),
|
|
54
54
|
])
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
cases: TxTable[s, Case] = stored_table("refunds")
|
|
57
57
|
|
|
58
58
|
# Open the order's refund, unless it already has one.
|
|
59
|
-
def open_plan[s](
|
|
60
|
-
reference
|
|
61
|
-
tx_insert(cases(scope), key, Case(reference, 0, refund, [], None))
|
|
59
|
+
def open_plan[s](key: String, refund: Refund, reference: String) -> Txn[s, RefundError, Unit]:
|
|
60
|
+
tx_insert(cases, key, Case(reference, 0, refund, [], None))
|
|
62
61
|
|
|
63
|
-
def read_plan[s](
|
|
64
|
-
tx_read(cases
|
|
62
|
+
def read_plan[s](key: String) -> Txn[s, RefundError, Option[Case]]:
|
|
63
|
+
tx_read(cases, key)
|
|
65
64
|
|
|
66
65
|
# Append an exchange to an undecided case, or decide it, only if its
|
|
67
66
|
# version is still the one the round read; either raises the version.
|
|
68
|
-
def append_plan[s](
|
|
69
|
-
found <- tx_bind(tx_read(cases
|
|
67
|
+
def append_plan[s](key: String, seen: Int, exchange: Exchange) -> Txn[s, RefundError, Bool]:
|
|
68
|
+
found <- tx_bind(tx_read(cases, key))
|
|
70
69
|
match found:
|
|
71
70
|
case Some(Case(reference, version, refund, record, None)):
|
|
72
71
|
if eq_Int(version, seen):
|
|
73
|
-
_ <- tx_bind(tx_write(cases
|
|
72
|
+
_ <- tx_bind(tx_write(cases, key, Case(reference, version.add(1), refund, record.concat([exchange]), None)))
|
|
74
73
|
tx_pure(True)
|
|
75
74
|
else: tx_pure(False)
|
|
76
75
|
case _: tx_pure(False)
|
|
77
76
|
|
|
78
|
-
def decide_plan[s](
|
|
79
|
-
found <- tx_bind(tx_read(cases
|
|
77
|
+
def decide_plan[s](key: String, seen: Int, decision: Decision) -> Txn[s, RefundError, Bool]:
|
|
78
|
+
found <- tx_bind(tx_read(cases, key))
|
|
80
79
|
match found:
|
|
81
80
|
case Some(Case(reference, version, refund, record, None)):
|
|
82
81
|
if eq_Int(version, seen):
|
|
83
|
-
_ <- tx_bind(tx_write(cases
|
|
82
|
+
_ <- tx_bind(tx_write(cases, key, Case(reference, version.add(1), refund, record, Some(decision))))
|
|
84
83
|
tx_pure(True)
|
|
85
84
|
else: tx_pure(False)
|
|
86
85
|
case _: tx_pure(False)
|
|
@@ -139,20 +138,20 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
|
|
|
139
138
|
# carries on with the stored refund; a different one for the same order
|
|
140
139
|
# is turned away.
|
|
141
140
|
def round(_: Unit, _: Int) -> IO[IterState[Unit, Reply[RefundError, Decision]]]:
|
|
142
|
-
found <- flat_map(transaction(db,
|
|
141
|
+
found <- flat_map(transaction(db, _ -> read_plan(key)))
|
|
143
142
|
match found:
|
|
144
143
|
case Committed(Some(Case(reference, seen, stored, record, decided))):
|
|
145
144
|
if same_refund(stored, refund):
|
|
146
145
|
match (decided, replay(stored, record)):
|
|
147
146
|
case (Some(decision), _): pure(Finished(Done(decision)))
|
|
148
147
|
case (None, Some(Ruled(decision))):
|
|
149
|
-
saved <- flat_map(transaction(db,
|
|
148
|
+
saved <- flat_map(transaction(db, _ -> decide_plan(key, seen, decision)))
|
|
150
149
|
match saved:
|
|
151
150
|
case Committed(True): pure(Finished(Done(decision)))
|
|
152
151
|
case _: pure(Continue(()))
|
|
153
152
|
case (None, Some(Perform(request))):
|
|
154
153
|
answer <- flat_map(perform(reference, length_List(record), request))
|
|
155
|
-
_ <- flat_map(transaction(db,
|
|
154
|
+
_ <- flat_map(transaction(db, _ -> append_plan(key, seen, Exchange(request, answer))))
|
|
156
155
|
pure(Continue(()))
|
|
157
156
|
case (None, None): pure(Finished(Turned("this refund's record cannot be read back", Unreadable)))
|
|
158
157
|
else: pure(Finished(Turned("this order already has a refund request for another amount or reason", AlreadyRequested)))
|
|
@@ -160,7 +159,11 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
|
|
|
160
159
|
# The store was busy: read again next round.
|
|
161
160
|
case _: pure(Continue(()))
|
|
162
161
|
def decide():
|
|
163
|
-
|
|
162
|
+
def open_case():
|
|
163
|
+
scope <- transaction(db)
|
|
164
|
+
reference <- tx_bind(tx_new_token(scope))
|
|
165
|
+
open_plan(key, refund, reference)
|
|
166
|
+
opened <- flat_map(open_case())
|
|
164
167
|
match opened:
|
|
165
168
|
case Committed(_):
|
|
166
169
|
ended <- flat_map(round((), 0))
|
|
@@ -177,7 +180,7 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
|
|
|
177
180
|
|
|
178
181
|
def refund_status(db: Db, p: Principal, input: StatusRequest) -> IO[Reply[RefundError, RefundView]]:
|
|
179
182
|
StatusRequest(order) = input
|
|
180
|
-
found <- flat_map(transaction(db,
|
|
183
|
+
found <- flat_map(transaction(db, _ -> read_plan(owner_key(p, order))))
|
|
181
184
|
pure(replied(found,
|
|
182
185
|
c -> (
|
|
183
186
|
match c:
|
|
@@ -14,7 +14,7 @@ from Yichus/IO import IO, flat_map, pure, fold_until
|
|
|
14
14
|
from Yichus/Access import Principal, AccessRule, AccessSpec, OwnerScoped, owner_key
|
|
15
15
|
from Yichus/Data import Db
|
|
16
16
|
from Yichus/Service import Reply, Done, Invalid, NotFound, Refused as Turned, TryAgain, handler, route, service_def, ReadPerm, WritePerm, CreatePerm
|
|
17
|
-
from Yichus/Transaction import
|
|
17
|
+
from Yichus/Transaction import TxTable, Txn, Committed, transaction, replied, stored_table, tx_bind, tx_pure, tx_read, tx_write, tx_insert, tx_new_token
|
|
18
18
|
from Yichus/OpenRouter import ProviderPolicy, ModelRequest, ModelResult, Completed, Refused, RetryableFailure, PermanentFailure, TimedOut, openrouter_call
|
|
19
19
|
from Yichus/Json import JsonObject, JsonString, JsonArray, JsonBool, Parsed, InvalidJson, Rendered, RenderError, Decoded, DecodeError, parse_json, render_json, decode_bind, required_field, as_bool
|
|
20
20
|
from Zafu/Control/IterState import IterState, Continue, Done as Finished
|
|
@@ -47,34 +47,33 @@ access_rules = AccessSpec([
|
|
|
47
47
|
AccessRule("refunds", OwnerScoped),
|
|
48
48
|
])
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
cases: TxTable[s, Case] = stored_table("refunds")
|
|
51
51
|
|
|
52
52
|
# Open the order's refund, unless it already has one.
|
|
53
|
-
def open_plan[s](
|
|
54
|
-
reference
|
|
55
|
-
tx_insert(cases(scope), key, Case(reference, 0, refund, [], None))
|
|
53
|
+
def open_plan[s](key: String, refund: Refund, reference: String) -> Txn[s, RefundError, Unit]:
|
|
54
|
+
tx_insert(cases, key, Case(reference, 0, refund, [], None))
|
|
56
55
|
|
|
57
|
-
def read_plan[s](
|
|
58
|
-
tx_read(cases
|
|
56
|
+
def read_plan[s](key: String) -> Txn[s, RefundError, Option[Case]]:
|
|
57
|
+
tx_read(cases, key)
|
|
59
58
|
|
|
60
59
|
# Append an exchange to an undecided case, or decide it, only if its
|
|
61
60
|
# version is still the one the round read; either raises the version.
|
|
62
|
-
def append_plan[s](
|
|
63
|
-
found <- tx_bind(tx_read(cases
|
|
61
|
+
def append_plan[s](key: String, seen: Int, exchange: Exchange) -> Txn[s, RefundError, Bool]:
|
|
62
|
+
found <- tx_bind(tx_read(cases, key))
|
|
64
63
|
match found:
|
|
65
64
|
case Some(Case(reference, version, refund, record, None)):
|
|
66
65
|
if eq_Int(version, seen):
|
|
67
|
-
_ <- tx_bind(tx_write(cases
|
|
66
|
+
_ <- tx_bind(tx_write(cases, key, Case(reference, version.add(1), refund, record.concat([exchange]), None)))
|
|
68
67
|
tx_pure(True)
|
|
69
68
|
else: tx_pure(False)
|
|
70
69
|
case _: tx_pure(False)
|
|
71
70
|
|
|
72
|
-
def decide_plan[s](
|
|
73
|
-
found <- tx_bind(tx_read(cases
|
|
71
|
+
def decide_plan[s](key: String, seen: Int, decision: Decision) -> Txn[s, RefundError, Bool]:
|
|
72
|
+
found <- tx_bind(tx_read(cases, key))
|
|
74
73
|
match found:
|
|
75
74
|
case Some(Case(reference, version, refund, record, None)):
|
|
76
75
|
if eq_Int(version, seen):
|
|
77
|
-
_ <- tx_bind(tx_write(cases
|
|
76
|
+
_ <- tx_bind(tx_write(cases, key, Case(reference, version.add(1), refund, record, Some(decision))))
|
|
78
77
|
tx_pure(True)
|
|
79
78
|
else: tx_pure(False)
|
|
80
79
|
case _: tx_pure(False)
|
|
@@ -137,20 +136,20 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
|
|
|
137
136
|
# carries on with the stored refund; a different one for the same order
|
|
138
137
|
# is turned away.
|
|
139
138
|
def round(_: Unit, _: Int) -> IO[IterState[Unit, Reply[RefundError, Decision]]]:
|
|
140
|
-
found <- flat_map(transaction(db,
|
|
139
|
+
found <- flat_map(transaction(db, _ -> read_plan(key)))
|
|
141
140
|
match found:
|
|
142
141
|
case Committed(Some(Case(reference, seen, stored, record, decided))):
|
|
143
142
|
if same_refund(stored, refund):
|
|
144
143
|
match (decided, replay(stored, record)):
|
|
145
144
|
case (Some(decision), _): pure(Finished(Done(decision)))
|
|
146
145
|
case (None, Some(Ruled(decision))):
|
|
147
|
-
saved <- flat_map(transaction(db,
|
|
146
|
+
saved <- flat_map(transaction(db, _ -> decide_plan(key, seen, decision)))
|
|
148
147
|
match saved:
|
|
149
148
|
case Committed(True): pure(Finished(Done(decision)))
|
|
150
149
|
case _: pure(Continue(()))
|
|
151
150
|
case (None, Some(Perform(request))):
|
|
152
151
|
answer <- flat_map(perform(reference, length_List(record), request))
|
|
153
|
-
_ <- flat_map(transaction(db,
|
|
152
|
+
_ <- flat_map(transaction(db, _ -> append_plan(key, seen, Exchange(request, answer))))
|
|
154
153
|
pure(Continue(()))
|
|
155
154
|
case (None, None): pure(Finished(Turned("this refund's record cannot be read back", Unreadable)))
|
|
156
155
|
else: pure(Finished(Turned("this order already has a refund request for another amount or reason", AlreadyRequested)))
|
|
@@ -158,7 +157,11 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
|
|
|
158
157
|
# The store was busy: read again next round.
|
|
159
158
|
case _: pure(Continue(()))
|
|
160
159
|
def decide():
|
|
161
|
-
|
|
160
|
+
def open_case():
|
|
161
|
+
scope <- transaction(db)
|
|
162
|
+
reference <- tx_bind(tx_new_token(scope))
|
|
163
|
+
open_plan(key, refund, reference)
|
|
164
|
+
opened <- flat_map(open_case())
|
|
162
165
|
match opened:
|
|
163
166
|
case Committed(_):
|
|
164
167
|
ended <- flat_map(fold_until(range(rounds), (), round))
|
|
@@ -175,7 +178,7 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
|
|
|
175
178
|
|
|
176
179
|
def refund_status(db: Db, p: Principal, input: StatusRequest) -> IO[Reply[RefundError, RefundView]]:
|
|
177
180
|
StatusRequest(order) = input
|
|
178
|
-
found <- flat_map(transaction(db,
|
|
181
|
+
found <- flat_map(transaction(db, _ -> read_plan(owner_key(p, order))))
|
|
179
182
|
pure(replied(found,
|
|
180
183
|
c -> (
|
|
181
184
|
match c:
|
|
@@ -27,7 +27,7 @@ from Yichus/Service import (
|
|
|
27
27
|
)
|
|
28
28
|
from Yichus/Transaction import (
|
|
29
29
|
Scope, TxTable, Txn, Outcome,
|
|
30
|
-
|
|
30
|
+
stored_table, tx_pure, tx_bind, tx_abort, tx_read, tx_write, tx_insert,
|
|
31
31
|
tx_database_time_millis, transaction
|
|
32
32
|
)
|
|
33
33
|
|
|
@@ -159,11 +159,9 @@ access_rules = AccessSpec([
|
|
|
159
159
|
AccessRule("withdrawal_receipts", OwnerScoped),
|
|
160
160
|
])
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
tx_table(scope, "accounts")
|
|
162
|
+
accounts_table: TxTable[s, Account] = stored_table("accounts")
|
|
164
163
|
|
|
165
|
-
|
|
166
|
-
tx_table(scope, "withdrawal_receipts")
|
|
164
|
+
receipts_table: TxTable[s, Receipt] = stored_table("withdrawal_receipts")
|
|
167
165
|
|
|
168
166
|
def account_rows(db: Db) -> Table[Account]:
|
|
169
167
|
table(db, "accounts")
|
|
@@ -178,12 +176,11 @@ def receipt_rows(db: Db) -> Table[Receipt]:
|
|
|
178
176
|
# ---------------------------------------------------------------------------
|
|
179
177
|
|
|
180
178
|
def move_balance[s](
|
|
181
|
-
scope: Scope[s],
|
|
182
179
|
principal: Principal,
|
|
183
180
|
delta: Int
|
|
184
181
|
) -> Txn[s, LedgerError, Movement]:
|
|
185
182
|
Principal(uid, _) = principal
|
|
186
|
-
row <- tx_bind(tx_read(accounts_table
|
|
183
|
+
row <- tx_bind(tx_read(accounts_table, uid))
|
|
187
184
|
match row:
|
|
188
185
|
case None: tx_abort(UnknownAccount(uid))
|
|
189
186
|
case Some(Account(id, owner, balance)):
|
|
@@ -191,7 +188,7 @@ def move_balance[s](
|
|
|
191
188
|
if int_lt(next, 0):
|
|
192
189
|
tx_abort(InsufficientFunds(balance, sub(0, delta)))
|
|
193
190
|
else:
|
|
194
|
-
_ <- tx_bind(tx_write(accounts_table
|
|
191
|
+
_ <- tx_bind(tx_write(accounts_table, uid, Account(id, owner, next)))
|
|
195
192
|
tx_pure(Movement(balance, next))
|
|
196
193
|
|
|
197
194
|
# ---------------------------------------------------------------------------
|
|
@@ -207,7 +204,7 @@ def withdraw_plan[s](
|
|
|
207
204
|
case Some(error): tx_abort(error)
|
|
208
205
|
case None:
|
|
209
206
|
key = owner_key(principal, command_local_key(input))
|
|
210
|
-
previous <- tx_bind(tx_read(receipts_table
|
|
207
|
+
previous <- tx_bind(tx_read(receipts_table, key))
|
|
211
208
|
match previous:
|
|
212
209
|
case Some(receipt): tx_pure(receipt)
|
|
213
210
|
case None:
|
|
@@ -215,17 +212,16 @@ def withdraw_plan[s](
|
|
|
215
212
|
if int_lt(now, 0): tx_abort(InvalidDatabaseTime(now))
|
|
216
213
|
else:
|
|
217
214
|
WithdrawInput(_, amount) = input
|
|
218
|
-
moved <- tx_bind(move_balance(
|
|
215
|
+
moved <- tx_bind(move_balance(principal, sub(0, amount)))
|
|
219
216
|
Movement(_, after) = moved
|
|
220
217
|
receipt = Receipt(input, after, now)
|
|
221
|
-
_ <- tx_bind(tx_insert(receipts_table
|
|
218
|
+
_ <- tx_bind(tx_insert(receipts_table, key, receipt))
|
|
222
219
|
# The receipt filed in the ledger and the receipt in the
|
|
223
220
|
# response are one value, so the customer's copy and the
|
|
224
221
|
# bank's copy cannot say different things.
|
|
225
222
|
tx_pure(receipt)
|
|
226
223
|
|
|
227
224
|
def deposit_plan[s](
|
|
228
|
-
scope: Scope[s],
|
|
229
225
|
principal: Principal,
|
|
230
226
|
input: DepositInput
|
|
231
227
|
) -> Txn[s, LedgerError, Movement]:
|
|
@@ -233,7 +229,7 @@ def deposit_plan[s](
|
|
|
233
229
|
case Some(error): tx_abort(error)
|
|
234
230
|
case None:
|
|
235
231
|
DepositInput(_, amount) = input
|
|
236
|
-
move_balance(
|
|
232
|
+
move_balance(principal, amount)
|
|
237
233
|
|
|
238
234
|
# ---------------------------------------------------------------------------
|
|
239
235
|
# Handlers. The engine injects Db and Principal; nothing here
|
|
@@ -253,8 +249,8 @@ def deposit(
|
|
|
253
249
|
principal: Principal,
|
|
254
250
|
input: DepositInput
|
|
255
251
|
) -> IO[Outcome[LedgerError, Movement]]:
|
|
256
|
-
|
|
257
|
-
deposit_plan(
|
|
252
|
+
_ <- transaction(db)
|
|
253
|
+
deposit_plan(principal, input)
|
|
258
254
|
|
|
259
255
|
def withdrawal_status(
|
|
260
256
|
db: Db,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yichus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Local Bosatsu compilation, Yichus analysis, and generated apps using the WebMCP engine",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://yich.us",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"zod": "^4.3.6",
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
23
|
-
"@yichus/api-mcp": "0.1.
|
|
23
|
+
"@yichus/api-mcp": "0.1.5"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/node": "^22.0.0",
|