yichus 0.3.3 → 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 +2 -2
- package/dist/cli.js +27 -0
- package/dist/cli.js.map +1 -1
- package/dist/main.js +210 -88
- package/dist/main.js.map +1 -1
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
package Example/Signups
|
|
2
|
+
|
|
3
|
+
# The near miss: the same sign-up with room checked, beside the starter
|
|
4
|
+
# actions that replace or clear a volunteer's list without the count.
|
|
5
|
+
# The law over the room check still passes; a scenario that runs every
|
|
6
|
+
# action that changes sign-ups does not.
|
|
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_delete, 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, DeletePerm, 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
|
+
# A change that may or may not have been saved (the commit was not
|
|
84
|
+
# confirmed) is answered so the caller knows whether sending it again
|
|
85
|
+
# is safe: replacing or clearing the row again is; adding again could
|
|
86
|
+
# add the item twice.
|
|
87
|
+
def put_signups(db: Db, p: Principal, items: List[Signup]) -> IO[Reply[ChangeRefused, List[Signup]]]:
|
|
88
|
+
Principal(uid, _) = p
|
|
89
|
+
outcome <- flat_map(transaction(db, _ -> tx_bind(tx_write(signups_rows, uid, items), _ -> tx_pure(items))))
|
|
90
|
+
pure(replied(outcome, Done, unsaved, TryAgain("the signups may not have been saved; send them again")))
|
|
91
|
+
|
|
92
|
+
shifts_rows: TxTable[s, Shift] = stored_table("shifts")
|
|
93
|
+
|
|
94
|
+
# A volunteer takes at most one spot on a shift.
|
|
95
|
+
def signed_up(items: List[Signup], shift_id: String) -> Bool:
|
|
96
|
+
items.foldl_List(False, (seen, Signup(id, _)) -> seen.or(eq_String(id, shift_id)))
|
|
97
|
+
|
|
98
|
+
def add_signup_plan[s](uid: String, item: Signup) -> Txn[s, ChangeRefused, List[Signup]]:
|
|
99
|
+
Signup(shift_id, _) = item
|
|
100
|
+
found <- tx_bind(tx_read(signups_rows, uid))
|
|
101
|
+
saved = match found:
|
|
102
|
+
case Some(saved): saved
|
|
103
|
+
case None: []
|
|
104
|
+
if signed_up(saved, shift_id):
|
|
105
|
+
tx_abort(AlreadySignedUp)
|
|
106
|
+
else:
|
|
107
|
+
shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
|
|
108
|
+
match shift_found:
|
|
109
|
+
case None: tx_abort(NoSuchShift)
|
|
110
|
+
case Some(shift):
|
|
111
|
+
match take_spot(shift):
|
|
112
|
+
case None: tx_abort(ShiftFull)
|
|
113
|
+
case Some(updated):
|
|
114
|
+
_ <- tx_bind(tx_write(shifts_rows, shift_id, updated))
|
|
115
|
+
items = [item, *saved]
|
|
116
|
+
_ <- tx_bind(tx_write(signups_rows, uid, items))
|
|
117
|
+
tx_pure(items)
|
|
118
|
+
|
|
119
|
+
def add_signup(db: Db, p: Principal, item: Signup) -> IO[Reply[ChangeRefused, List[Signup]]]:
|
|
120
|
+
Principal(uid, _) = p
|
|
121
|
+
outcome <- flat_map(transaction(db, _ -> add_signup_plan(uid, item)))
|
|
122
|
+
pure(replied(outcome, Done, unsaved, Refused("the signup may already be saved; list the signups before adding it again", MaybeSaved)))
|
|
123
|
+
|
|
124
|
+
def clear_signups(db: Db, p: Principal) -> IO[Reply[ChangeRefused, Unit]]:
|
|
125
|
+
Principal(uid, _) = p
|
|
126
|
+
outcome <- flat_map(transaction(db, _ -> tx_delete(signups_rows, uid)))
|
|
127
|
+
pure(replied(outcome, Done, unsaved, TryAgain("the signups may not have been cleared; send the request again")))
|
|
128
|
+
|
|
129
|
+
signups_api = service_def(
|
|
130
|
+
"signups",
|
|
131
|
+
[
|
|
132
|
+
route("/shifts/get", handler("get_shift", get_shift), [ReadPerm("shifts")]),
|
|
133
|
+
route("/shifts", handler("list_shifts", list_shifts), [QueryPerm("shifts")]),
|
|
134
|
+
route("/signups", handler("list_signups", list_signups), [ReadPerm("signups")]),
|
|
135
|
+
route("/signups/put", handler("put_signups", put_signups), [WritePerm("signups")]),
|
|
136
|
+
route("/signups/add", handler("add_signup", add_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
|
|
137
|
+
route("/signups/clear", handler("clear_signups", clear_signups), [DeletePerm("signups")]),
|
|
138
|
+
]
|
|
139
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Failure scenarios for an action
|
|
2
2
|
|
|
3
|
-
The studio asks for one of these when the person chose "all or nothing" for an action that changes records in several steps, or chose a plan for an action that calls an outside service. You write the case as JSON at the path `yichus_next` names (`.yichus/cases/<question>.json`). Each `yichus_next` runs it with `api_protocol_check` against the program in the folder. You never run a command for it.
|
|
3
|
+
The studio asks for one of these when the person chose "all or nothing" for an action that changes records in several steps, or chose a plan for an action that calls an outside service. It also asks for one for a rule the person gave when other actions change the same records as the actions that decide with the rule's laws (see below). You write the case as JSON at the path `yichus_next` names (`.yichus/cases/<question>.json`, or `.yichus/cases/rule-<the rule's words>.json`). Each `yichus_next` runs it with `api_protocol_check` against the program in the folder. You never run a command for it.
|
|
4
4
|
|
|
5
5
|
A case lists the starting rows, the requests (two deliveries of one request on two servers, for example), the clock values and outside results to try, and the rules that must hold in every order the steps can run, with crashes between them. The checker tries every order within those bounds. "Holds" means it holds for every order in the case, not for every input. The page says so.
|
|
6
6
|
|
|
@@ -8,4 +8,10 @@ Observation predicates the case names are Bosatsu functions. Put them in `.yichu
|
|
|
8
8
|
|
|
9
9
|
Write the rules from the person's choice ("a withdrawal is taken once, or not at all"), never from what the code does. Then test the case on a deliberately broken copy of the program: the check must fail there with a named counterexample. A case that passes against broken code checks nothing.
|
|
10
10
|
|
|
11
|
+
## A rule across actions
|
|
12
|
+
|
|
13
|
+
A law checks the function it calls, so it speaks only for the actions that decide with that function. When another action changes the same records without it (a starter action that replaces or clears a list, beside the sign-up that checks the spots left), the law still passes while that action goes around the rule. So the studio asks for a scenario that sends a request to every one of those actions, from starting records where the rule matters, with the rule as an invariant over the records after every step. A case that leaves one of the actions out is not run.
|
|
14
|
+
|
|
15
|
+
For a limit, the invariant says that what a record counts matches the records it counts, and never passes the limit, and it fails when a record it names is missing, so it never passes by finding nothing. `signups.bosatsu`, `signups-laws.bosatsu`, `signups-case.json` and `spots.bosatsu` are a complete example: signing up checks the spots left and turns away a second sign-up for the same shift, cancelling gives the spot back, and the case holds. The case starts from one shift with a spot left and one that is full, so both halves of the invariant can fail: it fails on `mutants/double-signup.bosatsu` (one volunteer takes two spots) and on `mutants/overfill.bosatsu` (a sign-up that never checks the spots left). `near-miss-signups.bosatsu` (`near-miss/signups.bosatsu` in the repository) with `near-miss-case.json` is the same app with the starter actions that replace or clear a volunteer's list, and the case fails there.
|
|
16
|
+
|
|
11
17
|
`withdrawal-case.json` and `withdrawal.bosatsu`, which come with this topic, are a complete case and the program it checks. Two servers deliver the same withdrawal, and the case requires exactly one durable receipt. The specification follows.
|