yichus 0.3.2 → 0.3.4
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 +27 -0
- package/dist/cli.js.map +1 -1
- package/dist/main.js +400 -118
- package/dist/main.js.map +1 -1
- package/dist/studio-page/system-map-record.js +93 -7
- package/dist/studio-page/system-map.js +60 -23
- package/dist/studio-page/what-happens-record.js +90 -6
- package/dist/studio-page/what-happens.js +72 -13
- package/dist/studio-reference/calculator.md +1 -0
- package/dist/studio-reference/deploy-platforms.md +3 -0
- package/dist/studio-reference/mutants/double-signup.bosatsu +149 -0
- package/dist/studio-reference/mutants/overfill.bosatsu +153 -0
- package/dist/studio-reference/near-miss-case.json +104 -0
- package/dist/studio-reference/near-miss-signups.bosatsu +139 -0
- package/dist/studio-reference/protocol-cases.md +7 -1
- package/dist/studio-reference/signups-case.json +122 -0
- package/dist/studio-reference/signups-laws.bosatsu +32 -0
- package/dist/studio-reference/signups.bosatsu +155 -0
- package/dist/studio-reference/spots.bosatsu +27 -0
- package/dist/studio-reference/the-page.md +7 -5
- 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
|
|
@@ -5,6 +5,7 @@ The studio gives you this file when the program has no actions people call and k
|
|
|
5
5
|
## What the studio still does for it
|
|
6
6
|
|
|
7
7
|
- Every rule the person states about the numbers is a law the studio runs after each change: "more debt never raises what they can borrow", "the payment is never negative". Write each one (`yichus_reference` topic `laws`) and record it with `yichus_rules`. A law checked over every input is proven; one checked on sample inputs is bounded, and the page says which.
|
|
8
|
+
- `api_suggest_properties` drafts a law for each answer the calculation offers: a floor every answer stays at or above, for the inputs the person means. Fill in the floor and which inputs are in use from the person's words; the draft checks inconclusive until then, never broken.
|
|
8
9
|
- The page shows the shape of the program in the person's words.
|
|
9
10
|
|
|
10
11
|
## Putting it on a page
|
|
@@ -13,9 +13,12 @@ The studio gives you this file when the person has said where their app will run
|
|
|
13
13
|
An app that already signs people in with Supabase Auth keeps it: set `YICHUS_JWT_PROFILE=supabase-session`, `YICHUS_JWT_ISSUER=https://<project>.supabase.co/auth/v1` and `YICHUS_JWKS_URL` to that issuer plus `/.well-known/jwks.json`. A project still on its legacy JWT secret (HS256) sets `YICHUS_JWT_SECRET` to that secret as well as, or instead of, `YICHUS_JWKS_URL`; ask the person which their project uses (Supabase's JWT settings show it), and keep the secret in the engine host's environment only.
|
|
14
14
|
- `clerk-api-key` is for API keys.
|
|
15
15
|
- `proxy-header` is only for running behind a proxy you control that sets the caller's id.
|
|
16
|
+
- `public` is for an app where everyone may do everything and nobody signs in. The engine starts in this mode only when every action is open to everyone, and otherwise it names the ones that need to know the caller.
|
|
16
17
|
- `dev` is never used in production.
|
|
17
18
|
- **HTTPS in front.** Every platform below terminates TLS for you.
|
|
18
19
|
|
|
20
|
+
An app that keeps no records and lets everyone use every action, such as a calculator served as an API, needs less than this. It needs no database, because the memory store has nothing to lose. It needs no identity provider either: run it with `YICHUS_AUTH=public`. It still needs a Node.js process and HTTPS. A calculator that only runs in the page people open needs no server at all (`yichus_reference` topic `calculator`).
|
|
21
|
+
|
|
19
22
|
Where the person chose "one server", configure the platform so that exactly one copy runs. Each platform below says how. Several platforms add copies on their own when traffic grows, and then the one-server assumption is false. If the person can't accept that limit, go back and answer the topology question "several copies".
|
|
20
23
|
|
|
21
24
|
Work that takes longer than one request, waits on an outside service, or must finish later should never be one long request. Write it as a portable step program (`yichus_reference` topic `portable-programs`). Then have a scheduled job or a worker call the route that advances it. Every platform below can run that job.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
package Example/Signups
|
|
2
|
+
|
|
3
|
+
# A mutant of ../signups.bosatsu, kept so cases/signups-case.json is shown
|
|
4
|
+
# to fail on it: the same app without the check that the volunteer already
|
|
5
|
+
# holds a spot on the shift, so one volunteer takes two spots, and
|
|
6
|
+
# cancelling removes both sign-ups but gives back only one spot.
|
|
7
|
+
|
|
8
|
+
from Yichus/IO import IO, pure, flat_map
|
|
9
|
+
from Yichus/Transaction import TxTable, Txn, stored_table, tx_bind, tx_pure, tx_read, tx_write, tx_abort, transaction, replied
|
|
10
|
+
from Yichus/Access import Principal, AccessRule, AccessSpec, Public, OwnerScoped
|
|
11
|
+
from Yichus/Data import Db, Table, table, db_read, db_query
|
|
12
|
+
from Yichus/Service import Reply, Done, Refused, TryAgain, handler, route, service_def, ReadPerm, WritePerm, QueryPerm
|
|
13
|
+
|
|
14
|
+
export (signups_api, access_rules, Shift(), Signup(), take_spot)
|
|
15
|
+
exposes (Yichus/Access, Yichus/Service)
|
|
16
|
+
|
|
17
|
+
struct Shift(title: String, starts_at: String, spots: Int, taken: Int)
|
|
18
|
+
|
|
19
|
+
struct Signup(shift_id: String, volunteer_name: String)
|
|
20
|
+
|
|
21
|
+
# The decision: a shift with `spots` places, `taken` already taken,
|
|
22
|
+
# has room for one more only while taken is below spots.
|
|
23
|
+
def has_room(spots: Int, taken: Int) -> Bool:
|
|
24
|
+
cmp_Int(taken, spots) matches LT
|
|
25
|
+
|
|
26
|
+
# The shift after one more volunteer takes a spot, or None when full.
|
|
27
|
+
def take_spot(shift: Shift) -> Option[Shift]:
|
|
28
|
+
Shift(title, starts_at, spots, taken) = shift
|
|
29
|
+
if has_room(spots, taken):
|
|
30
|
+
Some(Shift(title, starts_at, spots, taken.add(1)))
|
|
31
|
+
else:
|
|
32
|
+
None
|
|
33
|
+
|
|
34
|
+
# The declared access surface. `api_verify` proves each rule against
|
|
35
|
+
# the handlers below — and keeps proving it as this file is edited.
|
|
36
|
+
access_rules = AccessSpec([
|
|
37
|
+
AccessRule("shifts", Public),
|
|
38
|
+
AccessRule("signups", OwnerScoped),
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
# Why a change was refused; the error body's code is its name in
|
|
42
|
+
# snake_case. maybe_saved: the commit was not confirmed, so sending
|
|
43
|
+
# the change again could repeat it. The others were refused before
|
|
44
|
+
# anything was saved, and sending the same change again is refused again.
|
|
45
|
+
enum ChangeRefused:
|
|
46
|
+
MaybeSaved
|
|
47
|
+
ShiftFull
|
|
48
|
+
NoSuchShift
|
|
49
|
+
AlreadySignedUp
|
|
50
|
+
|
|
51
|
+
def unsaved[a](reason: ChangeRefused) -> Reply[ChangeRefused, a]:
|
|
52
|
+
match reason:
|
|
53
|
+
case ShiftFull: Refused("the shift is full", reason)
|
|
54
|
+
case NoSuchShift: Refused("there is no such shift", reason)
|
|
55
|
+
case AlreadySignedUp: Refused("the volunteer is already signed up for this shift", reason)
|
|
56
|
+
case MaybeSaved: Refused("the change may already be saved; list the signups before sending it again", reason)
|
|
57
|
+
|
|
58
|
+
# Shifts are public: shared rows, no key discipline claimed.
|
|
59
|
+
def shifts_table(db: Db) -> Table[Shift]:
|
|
60
|
+
table(db, "shifts")
|
|
61
|
+
|
|
62
|
+
def get_shift(db: Db, id: String) -> IO[Shift]:
|
|
63
|
+
db_read(shifts_table(db), id)
|
|
64
|
+
|
|
65
|
+
def list_shifts(db: Db) -> IO[List[Shift]]:
|
|
66
|
+
db_query(shifts_table(db))
|
|
67
|
+
|
|
68
|
+
# Signups are owner-scoped: one row per user, keyed by exactly
|
|
69
|
+
# Principal.user_id, holding that user's list. The analyzer proves no
|
|
70
|
+
# handler can reach another caller's row.
|
|
71
|
+
def signups_table(db: Db) -> Table[List[Signup]]:
|
|
72
|
+
table(db, "signups")
|
|
73
|
+
|
|
74
|
+
# The same table inside a transaction. Every change to a caller's row
|
|
75
|
+
# goes through one, so changes made at the same moment, on one server
|
|
76
|
+
# or several, never lose one another.
|
|
77
|
+
signups_rows: TxTable[s, List[Signup]] = stored_table("signups")
|
|
78
|
+
|
|
79
|
+
def list_signups(db: Db, p: Principal) -> IO[List[Signup]]:
|
|
80
|
+
Principal(uid, _) = p
|
|
81
|
+
db_read(signups_table(db), uid)
|
|
82
|
+
|
|
83
|
+
shifts_rows: TxTable[s, Shift] = stored_table("shifts")
|
|
84
|
+
|
|
85
|
+
def add_signup_plan[s](uid: String, item: Signup) -> Txn[s, ChangeRefused, List[Signup]]:
|
|
86
|
+
Signup(shift_id, _) = item
|
|
87
|
+
found <- tx_bind(tx_read(signups_rows, uid))
|
|
88
|
+
saved = match found:
|
|
89
|
+
case Some(saved): saved
|
|
90
|
+
case None: []
|
|
91
|
+
shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
|
|
92
|
+
match shift_found:
|
|
93
|
+
case None: tx_abort(NoSuchShift)
|
|
94
|
+
case Some(shift):
|
|
95
|
+
match take_spot(shift):
|
|
96
|
+
case None: tx_abort(ShiftFull)
|
|
97
|
+
case Some(updated):
|
|
98
|
+
_ <- tx_bind(tx_write(shifts_rows, shift_id, updated))
|
|
99
|
+
items = [item, *saved]
|
|
100
|
+
_ <- tx_bind(tx_write(signups_rows, uid, items))
|
|
101
|
+
tx_pure(items)
|
|
102
|
+
|
|
103
|
+
def add_signup(db: Db, p: Principal, item: Signup) -> IO[Reply[ChangeRefused, List[Signup]]]:
|
|
104
|
+
Principal(uid, _) = p
|
|
105
|
+
outcome <- flat_map(transaction(db, _ -> add_signup_plan(uid, item)))
|
|
106
|
+
pure(replied(outcome, Done, unsaved, Refused("the signup may already be saved; list the signups before adding it again", MaybeSaved)))
|
|
107
|
+
|
|
108
|
+
def without(items: List[Signup], shift_id: String) -> List[Signup]:
|
|
109
|
+
items.foldr_List((item, kept) -> (
|
|
110
|
+
Signup(id, _) = item
|
|
111
|
+
kept if eq_String(id, shift_id) else [item, *kept]
|
|
112
|
+
), [])
|
|
113
|
+
|
|
114
|
+
def give_back(shift: Shift) -> Shift:
|
|
115
|
+
Shift(title, starts_at, spots, taken) = shift
|
|
116
|
+
Shift(title, starts_at, spots, taken.sub(1))
|
|
117
|
+
|
|
118
|
+
def cancel_signup_plan[s](uid: String, shift_id: String) -> Txn[s, ChangeRefused, List[Signup]]:
|
|
119
|
+
found <- tx_bind(tx_read(signups_rows, uid))
|
|
120
|
+
items = match found:
|
|
121
|
+
case Some(saved): saved
|
|
122
|
+
case None: []
|
|
123
|
+
kept = without(items, shift_id)
|
|
124
|
+
if eq_Int(length_List(kept), length_List(items)):
|
|
125
|
+
tx_pure(items)
|
|
126
|
+
else:
|
|
127
|
+
shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
|
|
128
|
+
match shift_found:
|
|
129
|
+
case None: tx_abort(NoSuchShift)
|
|
130
|
+
case Some(shift):
|
|
131
|
+
_ <- tx_bind(tx_write(shifts_rows, shift_id, give_back(shift)))
|
|
132
|
+
_ <- tx_bind(tx_write(signups_rows, uid, kept))
|
|
133
|
+
tx_pure(kept)
|
|
134
|
+
|
|
135
|
+
def cancel_signup(db: Db, p: Principal, shift_id: String) -> IO[Reply[ChangeRefused, List[Signup]]]:
|
|
136
|
+
Principal(uid, _) = p
|
|
137
|
+
outcome <- flat_map(transaction(db, _ -> cancel_signup_plan(uid, shift_id)))
|
|
138
|
+
pure(replied(outcome, Done, unsaved, TryAgain("the sign-up may not have been cancelled; send the request again")))
|
|
139
|
+
|
|
140
|
+
signups_api = service_def(
|
|
141
|
+
"signups",
|
|
142
|
+
[
|
|
143
|
+
route("/shifts/get", handler("get_shift", get_shift), [ReadPerm("shifts")]),
|
|
144
|
+
route("/shifts", handler("list_shifts", list_shifts), [QueryPerm("shifts")]),
|
|
145
|
+
route("/signups", handler("list_signups", list_signups), [ReadPerm("signups")]),
|
|
146
|
+
route("/signups/add", handler("add_signup", add_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
|
|
147
|
+
route("/signups/cancel", handler("cancel_signup", cancel_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
|
|
148
|
+
]
|
|
149
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
package Example/Signups
|
|
2
|
+
|
|
3
|
+
# A mutant of ../signups.bosatsu, kept so cases/signups-case.json is shown
|
|
4
|
+
# to fail on it: sign-up takes a spot without asking take_spot whether one
|
|
5
|
+
# is left, so a full shift takes one more volunteer.
|
|
6
|
+
|
|
7
|
+
from Yichus/IO import IO, pure, flat_map
|
|
8
|
+
from Yichus/Transaction import TxTable, Txn, stored_table, tx_bind, tx_pure, tx_read, tx_write, tx_abort, transaction, replied
|
|
9
|
+
from Yichus/Access import Principal, AccessRule, AccessSpec, Public, OwnerScoped
|
|
10
|
+
from Yichus/Data import Db, Table, table, db_read, db_query
|
|
11
|
+
from Yichus/Service import Reply, Done, Refused, TryAgain, handler, route, service_def, ReadPerm, WritePerm, QueryPerm
|
|
12
|
+
|
|
13
|
+
export (signups_api, access_rules, Shift(), Signup(), take_spot)
|
|
14
|
+
exposes (Yichus/Access, Yichus/Service)
|
|
15
|
+
|
|
16
|
+
struct Shift(title: String, starts_at: String, spots: Int, taken: Int)
|
|
17
|
+
|
|
18
|
+
struct Signup(shift_id: String, volunteer_name: String)
|
|
19
|
+
|
|
20
|
+
# The decision: a shift with `spots` places, `taken` already taken,
|
|
21
|
+
# has room for one more only while taken is below spots.
|
|
22
|
+
def has_room(spots: Int, taken: Int) -> Bool:
|
|
23
|
+
cmp_Int(taken, spots) matches LT
|
|
24
|
+
|
|
25
|
+
# The shift after one more volunteer takes a spot, or None when full.
|
|
26
|
+
def take_spot(shift: Shift) -> Option[Shift]:
|
|
27
|
+
Shift(title, starts_at, spots, taken) = shift
|
|
28
|
+
if has_room(spots, taken):
|
|
29
|
+
Some(Shift(title, starts_at, spots, taken.add(1)))
|
|
30
|
+
else:
|
|
31
|
+
None
|
|
32
|
+
|
|
33
|
+
# The declared access surface. `api_verify` proves each rule against
|
|
34
|
+
# the handlers below — and keeps proving it as this file is edited.
|
|
35
|
+
access_rules = AccessSpec([
|
|
36
|
+
AccessRule("shifts", Public),
|
|
37
|
+
AccessRule("signups", OwnerScoped),
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
# Why a change was refused; the error body's code is its name in
|
|
41
|
+
# snake_case. maybe_saved: the commit was not confirmed, so sending
|
|
42
|
+
# the change again could repeat it. The others were refused before
|
|
43
|
+
# anything was saved, and sending the same change again is refused again.
|
|
44
|
+
enum ChangeRefused:
|
|
45
|
+
MaybeSaved
|
|
46
|
+
ShiftFull
|
|
47
|
+
NoSuchShift
|
|
48
|
+
AlreadySignedUp
|
|
49
|
+
|
|
50
|
+
def unsaved[a](reason: ChangeRefused) -> Reply[ChangeRefused, a]:
|
|
51
|
+
match reason:
|
|
52
|
+
case ShiftFull: Refused("the shift is full", reason)
|
|
53
|
+
case NoSuchShift: Refused("there is no such shift", reason)
|
|
54
|
+
case AlreadySignedUp: Refused("the volunteer is already signed up for this shift", reason)
|
|
55
|
+
case MaybeSaved: Refused("the change may already be saved; list the signups before sending it again", reason)
|
|
56
|
+
|
|
57
|
+
# Shifts are public: shared rows, no key discipline claimed.
|
|
58
|
+
def shifts_table(db: Db) -> Table[Shift]:
|
|
59
|
+
table(db, "shifts")
|
|
60
|
+
|
|
61
|
+
def get_shift(db: Db, id: String) -> IO[Shift]:
|
|
62
|
+
db_read(shifts_table(db), id)
|
|
63
|
+
|
|
64
|
+
def list_shifts(db: Db) -> IO[List[Shift]]:
|
|
65
|
+
db_query(shifts_table(db))
|
|
66
|
+
|
|
67
|
+
# Signups are owner-scoped: one row per user, keyed by exactly
|
|
68
|
+
# Principal.user_id, holding that user's list. The analyzer proves no
|
|
69
|
+
# handler can reach another caller's row.
|
|
70
|
+
def signups_table(db: Db) -> Table[List[Signup]]:
|
|
71
|
+
table(db, "signups")
|
|
72
|
+
|
|
73
|
+
# The same table inside a transaction. Every change to a caller's row
|
|
74
|
+
# goes through one, so changes made at the same moment, on one server
|
|
75
|
+
# or several, never lose one another.
|
|
76
|
+
signups_rows: TxTable[s, List[Signup]] = stored_table("signups")
|
|
77
|
+
|
|
78
|
+
def list_signups(db: Db, p: Principal) -> IO[List[Signup]]:
|
|
79
|
+
Principal(uid, _) = p
|
|
80
|
+
db_read(signups_table(db), uid)
|
|
81
|
+
|
|
82
|
+
shifts_rows: TxTable[s, Shift] = stored_table("shifts")
|
|
83
|
+
|
|
84
|
+
# A volunteer takes at most one spot on a shift.
|
|
85
|
+
def signed_up(items: List[Signup], shift_id: String) -> Bool:
|
|
86
|
+
items.foldl_List(False, (seen, Signup(id, _)) -> seen.or(eq_String(id, shift_id)))
|
|
87
|
+
|
|
88
|
+
def add_signup_plan[s](uid: String, item: Signup) -> Txn[s, ChangeRefused, List[Signup]]:
|
|
89
|
+
Signup(shift_id, _) = item
|
|
90
|
+
found <- tx_bind(tx_read(signups_rows, uid))
|
|
91
|
+
saved = match found:
|
|
92
|
+
case Some(saved): saved
|
|
93
|
+
case None: []
|
|
94
|
+
if signed_up(saved, shift_id):
|
|
95
|
+
tx_abort(AlreadySignedUp)
|
|
96
|
+
else:
|
|
97
|
+
shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
|
|
98
|
+
match shift_found:
|
|
99
|
+
case None: tx_abort(NoSuchShift)
|
|
100
|
+
case Some(shift):
|
|
101
|
+
Shift(title, starts_at, spots, taken) = shift
|
|
102
|
+
_ <- tx_bind(tx_write(shifts_rows, shift_id, Shift(title, starts_at, spots, taken.add(1))))
|
|
103
|
+
items = [item, *saved]
|
|
104
|
+
_ <- tx_bind(tx_write(signups_rows, uid, items))
|
|
105
|
+
tx_pure(items)
|
|
106
|
+
|
|
107
|
+
def add_signup(db: Db, p: Principal, item: Signup) -> IO[Reply[ChangeRefused, List[Signup]]]:
|
|
108
|
+
Principal(uid, _) = p
|
|
109
|
+
outcome <- flat_map(transaction(db, _ -> add_signup_plan(uid, item)))
|
|
110
|
+
pure(replied(outcome, Done, unsaved, Refused("the signup may already be saved; list the signups before adding it again", MaybeSaved)))
|
|
111
|
+
|
|
112
|
+
def without(items: List[Signup], shift_id: String) -> List[Signup]:
|
|
113
|
+
items.foldr_List((item, kept) -> (
|
|
114
|
+
Signup(id, _) = item
|
|
115
|
+
kept if eq_String(id, shift_id) else [item, *kept]
|
|
116
|
+
), [])
|
|
117
|
+
|
|
118
|
+
def give_back(shift: Shift) -> Shift:
|
|
119
|
+
Shift(title, starts_at, spots, taken) = shift
|
|
120
|
+
Shift(title, starts_at, spots, taken.sub(1))
|
|
121
|
+
|
|
122
|
+
def cancel_signup_plan[s](uid: String, shift_id: String) -> Txn[s, ChangeRefused, List[Signup]]:
|
|
123
|
+
found <- tx_bind(tx_read(signups_rows, uid))
|
|
124
|
+
items = match found:
|
|
125
|
+
case Some(saved): saved
|
|
126
|
+
case None: []
|
|
127
|
+
kept = without(items, shift_id)
|
|
128
|
+
if eq_Int(length_List(kept), length_List(items)):
|
|
129
|
+
tx_pure(items)
|
|
130
|
+
else:
|
|
131
|
+
shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
|
|
132
|
+
match shift_found:
|
|
133
|
+
case None: tx_abort(NoSuchShift)
|
|
134
|
+
case Some(shift):
|
|
135
|
+
_ <- tx_bind(tx_write(shifts_rows, shift_id, give_back(shift)))
|
|
136
|
+
_ <- tx_bind(tx_write(signups_rows, uid, kept))
|
|
137
|
+
tx_pure(kept)
|
|
138
|
+
|
|
139
|
+
def cancel_signup(db: Db, p: Principal, shift_id: String) -> IO[Reply[ChangeRefused, List[Signup]]]:
|
|
140
|
+
Principal(uid, _) = p
|
|
141
|
+
outcome <- flat_map(transaction(db, _ -> cancel_signup_plan(uid, shift_id)))
|
|
142
|
+
pure(replied(outcome, Done, unsaved, TryAgain("the sign-up may not have been cancelled; send the request again")))
|
|
143
|
+
|
|
144
|
+
signups_api = service_def(
|
|
145
|
+
"signups",
|
|
146
|
+
[
|
|
147
|
+
route("/shifts/get", handler("get_shift", get_shift), [ReadPerm("shifts")]),
|
|
148
|
+
route("/shifts", handler("list_shifts", list_shifts), [QueryPerm("shifts")]),
|
|
149
|
+
route("/signups", handler("list_signups", list_signups), [ReadPerm("signups")]),
|
|
150
|
+
route("/signups/add", handler("add_signup", add_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
|
|
151
|
+
route("/signups/cancel", handler("cancel_signup", cancel_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
|
|
152
|
+
]
|
|
153
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "protocol-case-1.0",
|
|
3
|
+
"name": "Every action that changes sign-ups keeps the shift count",
|
|
4
|
+
"assumptions": [
|
|
5
|
+
"serializable-atomic-transactions",
|
|
6
|
+
"rollback-on-abort-and-retry-exhaustion",
|
|
7
|
+
"commit-unknown-may-commit",
|
|
8
|
+
"stable-time-within-attempt",
|
|
9
|
+
"no-unmodeled-writers",
|
|
10
|
+
"preauthenticated-principals"
|
|
11
|
+
],
|
|
12
|
+
"initialRows": {
|
|
13
|
+
"shifts": {
|
|
14
|
+
"s1": {
|
|
15
|
+
"title": "Sorting",
|
|
16
|
+
"starts_at": "9am",
|
|
17
|
+
"spots": 1,
|
|
18
|
+
"taken": 0
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"signups": {}
|
|
22
|
+
},
|
|
23
|
+
"initialize": [],
|
|
24
|
+
"requests": [
|
|
25
|
+
{
|
|
26
|
+
"instance": "a",
|
|
27
|
+
"delivery": "a",
|
|
28
|
+
"call": {
|
|
29
|
+
"binding": "Example/Signups::add_signup",
|
|
30
|
+
"arguments": {
|
|
31
|
+
"p": {
|
|
32
|
+
"user_id": "ann",
|
|
33
|
+
"roles": []
|
|
34
|
+
},
|
|
35
|
+
"item": {
|
|
36
|
+
"shift_id": "s1",
|
|
37
|
+
"volunteer_name": "Ann"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"instance": "b",
|
|
44
|
+
"delivery": "b",
|
|
45
|
+
"call": {
|
|
46
|
+
"binding": "Example/Signups::put_signups",
|
|
47
|
+
"arguments": {
|
|
48
|
+
"p": {
|
|
49
|
+
"user_id": "bo",
|
|
50
|
+
"roles": []
|
|
51
|
+
},
|
|
52
|
+
"items": [
|
|
53
|
+
{
|
|
54
|
+
"shift_id": "s1",
|
|
55
|
+
"volunteer_name": "Bo"
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"instance": "c",
|
|
63
|
+
"delivery": "c",
|
|
64
|
+
"call": {
|
|
65
|
+
"binding": "Example/Signups::clear_signups",
|
|
66
|
+
"arguments": {
|
|
67
|
+
"p": {
|
|
68
|
+
"user_id": "ann",
|
|
69
|
+
"roles": []
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"times": [
|
|
76
|
+
1000
|
|
77
|
+
],
|
|
78
|
+
"externalResults": {},
|
|
79
|
+
"invariants": [
|
|
80
|
+
{
|
|
81
|
+
"name": "spots-match-signups",
|
|
82
|
+
"binding": "Example/SignupsCase::spots_match_signups",
|
|
83
|
+
"arguments": {
|
|
84
|
+
"after": "after"
|
|
85
|
+
},
|
|
86
|
+
"when": "always"
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"traceInvariants": [],
|
|
90
|
+
"witnesses": [
|
|
91
|
+
{
|
|
92
|
+
"name": "someone-signed-up",
|
|
93
|
+
"kind": "table-nonempty",
|
|
94
|
+
"table": "signups"
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"bounds": {
|
|
98
|
+
"maxStates": 100000,
|
|
99
|
+
"maxDepth": 30,
|
|
100
|
+
"maxOperations": 60,
|
|
101
|
+
"maxFaults": 0,
|
|
102
|
+
"maxCrashes": 0
|
|
103
|
+
}
|
|
104
|
+
}
|