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.
@@ -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
- // whether a base was compared, always in this place: a fix is then read
227
- // off the frame, also when nothing is green
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
- out.push(tg([`effect:${key}:${e.op}:${e.tables.join(',')}`], `<g data-layer="${e.layer || 'core'}">${title(where)}${ring}${rows.map((l, i) => T(x + r + 6 + (i ? 8 : 0), cy + 3.5 + i * P.row * 0.8, l, { size: P.fsS, fill: isCore(e) ? color : C.hullInk })).join('')}</g>`));
374
- if (rows.length > 1) out.push(line(x, cy, x, cy + P.row * 1.2));
375
- y = cy + P.row * 0.6 + (rows.length - 1) * P.row * 0.8;
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
- for (const [i, l] of lines(t.test, P.fs, room, !guard, 2, guard ? 600 : 400).entries())
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 (width(t.test, P.fs, !guard, guard ? 600 : 400) > room) yy += P.row * 0.85;
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
+ }