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
|
@@ -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.
|
|
@@ -0,0 +1,122 @@
|
|
|
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": 2,
|
|
18
|
+
"taken": 1
|
|
19
|
+
},
|
|
20
|
+
"s2": {
|
|
21
|
+
"title": "Packing",
|
|
22
|
+
"starts_at": "1pm",
|
|
23
|
+
"spots": 1,
|
|
24
|
+
"taken": 1
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"signups": {
|
|
28
|
+
"ann": [
|
|
29
|
+
{
|
|
30
|
+
"shift_id": "s1",
|
|
31
|
+
"volunteer_name": "Ann"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"bo": [
|
|
35
|
+
{
|
|
36
|
+
"shift_id": "s2",
|
|
37
|
+
"volunteer_name": "Bo"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"initialize": [],
|
|
43
|
+
"requests": [
|
|
44
|
+
{
|
|
45
|
+
"instance": "ann-again",
|
|
46
|
+
"delivery": "ann-again",
|
|
47
|
+
"call": {
|
|
48
|
+
"binding": "Example/Signups::add_signup",
|
|
49
|
+
"arguments": {
|
|
50
|
+
"p": {
|
|
51
|
+
"user_id": "ann",
|
|
52
|
+
"roles": []
|
|
53
|
+
},
|
|
54
|
+
"item": {
|
|
55
|
+
"shift_id": "s1",
|
|
56
|
+
"volunteer_name": "Ann"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"instance": "ann-cancel",
|
|
63
|
+
"delivery": "ann-cancel",
|
|
64
|
+
"call": {
|
|
65
|
+
"binding": "Example/Signups::cancel_signup",
|
|
66
|
+
"arguments": {
|
|
67
|
+
"p": {
|
|
68
|
+
"user_id": "ann",
|
|
69
|
+
"roles": []
|
|
70
|
+
},
|
|
71
|
+
"shift_id": "s1"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"instance": "cy",
|
|
77
|
+
"delivery": "cy",
|
|
78
|
+
"call": {
|
|
79
|
+
"binding": "Example/Signups::add_signup",
|
|
80
|
+
"arguments": {
|
|
81
|
+
"p": {
|
|
82
|
+
"user_id": "cy",
|
|
83
|
+
"roles": []
|
|
84
|
+
},
|
|
85
|
+
"item": {
|
|
86
|
+
"shift_id": "s2",
|
|
87
|
+
"volunteer_name": "Cy"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"times": [
|
|
94
|
+
1000
|
|
95
|
+
],
|
|
96
|
+
"externalResults": {},
|
|
97
|
+
"invariants": [
|
|
98
|
+
{
|
|
99
|
+
"name": "spots-match-signups",
|
|
100
|
+
"binding": "Example/SignupsCase::spots_match_signups",
|
|
101
|
+
"arguments": {
|
|
102
|
+
"after": "after"
|
|
103
|
+
},
|
|
104
|
+
"when": "always"
|
|
105
|
+
}
|
|
106
|
+
],
|
|
107
|
+
"traceInvariants": [],
|
|
108
|
+
"witnesses": [
|
|
109
|
+
{
|
|
110
|
+
"name": "someone-signed-up",
|
|
111
|
+
"kind": "table-nonempty",
|
|
112
|
+
"table": "signups"
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
"bounds": {
|
|
116
|
+
"maxStates": 100000,
|
|
117
|
+
"maxDepth": 30,
|
|
118
|
+
"maxOperations": 60,
|
|
119
|
+
"maxFaults": 0,
|
|
120
|
+
"maxCrashes": 0
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
package Example/SignupsLaws
|
|
2
|
+
|
|
3
|
+
from Example/Signups import Shift, take_spot
|
|
4
|
+
from Yichus/Law import Domain, Law, Obligation, Witness
|
|
5
|
+
|
|
6
|
+
export (never_overbooked,)
|
|
7
|
+
exposes Yichus/Law
|
|
8
|
+
|
|
9
|
+
def show(input: (Int, Int)) -> String:
|
|
10
|
+
(spots, taken) = input
|
|
11
|
+
concat_String(["spots ", int_to_String(spots), ", taken ", int_to_String(taken)])
|
|
12
|
+
|
|
13
|
+
# "each shift has a limited number of spots": a sign-up never takes a
|
|
14
|
+
# shift past its spots; a full shift turns the volunteer away.
|
|
15
|
+
def never_over(input: (Int, Int)) -> Bool:
|
|
16
|
+
(spots, taken) = input
|
|
17
|
+
match take_spot(Shift("s", "t", spots, taken)):
|
|
18
|
+
case Some(Shift(_, _, _, after)): cmp_Int(after, spots) matches (LT | EQ)
|
|
19
|
+
case None: cmp_Int(taken, spots) matches (GT | EQ)
|
|
20
|
+
|
|
21
|
+
def has_space(input: (Int, Int)) -> Bool:
|
|
22
|
+
(spots, taken) = input
|
|
23
|
+
cmp_Int(taken, spots) matches LT
|
|
24
|
+
|
|
25
|
+
def full(input: (Int, Int)) -> Bool:
|
|
26
|
+
(spots, taken) = input
|
|
27
|
+
eq_Int(taken, spots)
|
|
28
|
+
|
|
29
|
+
never_overbooked = Obligation(
|
|
30
|
+
Law("a shift never has more volunteers than spots", Domain("a shift's spots and spots taken", show, None, None), never_over),
|
|
31
|
+
[Witness("a volunteer signs up for a shift with room", has_space), Witness("a volunteer tries a full shift", full)]
|
|
32
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
package Example/Signups
|
|
2
|
+
|
|
3
|
+
# A food bank's shifts: a volunteer signs up while a spot is left, and
|
|
4
|
+
# cancelling gives the spot back. Every action that changes sign-ups
|
|
5
|
+
# changes the shift's count in the same transaction.
|
|
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
|
+
match take_spot(shift):
|
|
102
|
+
case None: tx_abort(ShiftFull)
|
|
103
|
+
case Some(updated):
|
|
104
|
+
_ <- tx_bind(tx_write(shifts_rows, shift_id, updated))
|
|
105
|
+
items = [item, *saved]
|
|
106
|
+
_ <- tx_bind(tx_write(signups_rows, uid, items))
|
|
107
|
+
tx_pure(items)
|
|
108
|
+
|
|
109
|
+
def add_signup(db: Db, p: Principal, item: Signup) -> IO[Reply[ChangeRefused, List[Signup]]]:
|
|
110
|
+
Principal(uid, _) = p
|
|
111
|
+
outcome <- flat_map(transaction(db, _ -> add_signup_plan(uid, item)))
|
|
112
|
+
pure(replied(outcome, Done, unsaved, Refused("the signup may already be saved; list the signups before adding it again", MaybeSaved)))
|
|
113
|
+
|
|
114
|
+
def without(items: List[Signup], shift_id: String) -> List[Signup]:
|
|
115
|
+
items.foldr_List((item, kept) -> (
|
|
116
|
+
Signup(id, _) = item
|
|
117
|
+
kept if eq_String(id, shift_id) else [item, *kept]
|
|
118
|
+
), [])
|
|
119
|
+
|
|
120
|
+
def give_back(shift: Shift) -> Shift:
|
|
121
|
+
Shift(title, starts_at, spots, taken) = shift
|
|
122
|
+
Shift(title, starts_at, spots, taken.sub(1))
|
|
123
|
+
|
|
124
|
+
def cancel_signup_plan[s](uid: String, shift_id: String) -> Txn[s, ChangeRefused, List[Signup]]:
|
|
125
|
+
found <- tx_bind(tx_read(signups_rows, uid))
|
|
126
|
+
items = match found:
|
|
127
|
+
case Some(saved): saved
|
|
128
|
+
case None: []
|
|
129
|
+
kept = without(items, shift_id)
|
|
130
|
+
if eq_Int(length_List(kept), length_List(items)):
|
|
131
|
+
tx_pure(items)
|
|
132
|
+
else:
|
|
133
|
+
shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
|
|
134
|
+
match shift_found:
|
|
135
|
+
case None: tx_abort(NoSuchShift)
|
|
136
|
+
case Some(shift):
|
|
137
|
+
_ <- tx_bind(tx_write(shifts_rows, shift_id, give_back(shift)))
|
|
138
|
+
_ <- tx_bind(tx_write(signups_rows, uid, kept))
|
|
139
|
+
tx_pure(kept)
|
|
140
|
+
|
|
141
|
+
def cancel_signup(db: Db, p: Principal, shift_id: String) -> IO[Reply[ChangeRefused, List[Signup]]]:
|
|
142
|
+
Principal(uid, _) = p
|
|
143
|
+
outcome <- flat_map(transaction(db, _ -> cancel_signup_plan(uid, shift_id)))
|
|
144
|
+
pure(replied(outcome, Done, unsaved, TryAgain("the sign-up may not have been cancelled; send the request again")))
|
|
145
|
+
|
|
146
|
+
signups_api = service_def(
|
|
147
|
+
"signups",
|
|
148
|
+
[
|
|
149
|
+
route("/shifts/get", handler("get_shift", get_shift), [ReadPerm("shifts")]),
|
|
150
|
+
route("/shifts", handler("list_shifts", list_shifts), [QueryPerm("shifts")]),
|
|
151
|
+
route("/signups", handler("list_signups", list_signups), [ReadPerm("signups")]),
|
|
152
|
+
route("/signups/add", handler("add_signup", add_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
|
|
153
|
+
route("/signups/cancel", handler("cancel_signup", cancel_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
|
|
154
|
+
]
|
|
155
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package Example/SignupsCase
|
|
2
|
+
|
|
3
|
+
from Example/Signups import Shift, Signup
|
|
4
|
+
from Yichus/Data import Db, table, db_query, db_read_opt
|
|
5
|
+
from Yichus/IO import IO, pure, flat_map
|
|
6
|
+
|
|
7
|
+
export (spots_match_signups,)
|
|
8
|
+
exposes (Yichus/Data, Yichus/IO)
|
|
9
|
+
|
|
10
|
+
def signed_up_for(lists: List[List[Signup]], shift_id: String) -> Int:
|
|
11
|
+
lists.foldl_List(0, (n, list) -> list.foldl_List(n, (m, Signup(id, _)) -> m.add(1) if eq_String(id, shift_id) else m))
|
|
12
|
+
|
|
13
|
+
# The case's shifts, "s1" and "s2": each one's taken count is the number
|
|
14
|
+
# of sign-ups for it across every volunteer, and never more than its spots.
|
|
15
|
+
def shift_matches(after: Db, lists: List[List[Signup]], shift_id: String) -> IO[Bool]:
|
|
16
|
+
shift <- flat_map(db_read_opt(table(after, "shifts"), shift_id))
|
|
17
|
+
match shift:
|
|
18
|
+
case None: pure(False)
|
|
19
|
+
case Some(Shift(_, _, spots, taken)):
|
|
20
|
+
within = cmp_Int(taken, spots) matches (LT | EQ)
|
|
21
|
+
pure(within.and(eq_Int(taken, signed_up_for(lists, shift_id))))
|
|
22
|
+
|
|
23
|
+
def spots_match_signups(after: Db) -> IO[Bool]:
|
|
24
|
+
lists <- flat_map(db_query(table(after, "signups")))
|
|
25
|
+
first <- flat_map(shift_matches(after, lists, "s1"))
|
|
26
|
+
second <- flat_map(shift_matches(after, lists, "s2"))
|
|
27
|
+
pure(first.and(second))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Guiding the person through the studio
|
|
2
2
|
|
|
3
|
-
The person does not read documentation. The yich.us studio page only tells them what the studio is and how to connect you. Everything they need to know while building comes from you, a little at a time, when it becomes relevant. This file is what you need to know to do that. Do not read it out. Bring in one piece when the conversation reaches it, in their words, and point at the part of the page you mean with `yichus_point`.
|
|
3
|
+
The person does not read documentation. The yich.us studio page only tells them what the studio is, that the QuickStart is the route for browser data apps, and how to connect you. Everything they need to know while building comes from you, a little at a time, when it becomes relevant. This file is what you need to know to do that. Do not read it out. Bring in one piece when the conversation reaches it, in their words, and point at the part of the page you mean with `yichus_point`.
|
|
4
4
|
|
|
5
5
|
`yichus_page` shows you what the page shows them right now, and what they have on screen and last clicked.
|
|
6
6
|
|
|
@@ -19,7 +19,7 @@ What it costs them, with what limits each cost:
|
|
|
19
19
|
- It is slower to start than a quick build: a few questions first, and fixes until the rules hold.
|
|
20
20
|
- The checks cover the rules as written. Read each rule back to them in plain words, because a wrong rule passes. Payments, email, file uploads and scheduled jobs are not asked about yet. They can be built beside the checked part, unchecked; a download they click, such as a monthly export, is an ordinary action and is checked.
|
|
21
21
|
|
|
22
|
-
Recommend it when the app keeps records several people touch and a leak, a double booking or a half-saved change would hurt: bookings, sign-ups, approvals, anything with customers, patients or money. When some parts are not covered, recommend it for the part with the records and the people. For a live app, the port is a side copy: the old app keeps running untouched while the checks run on the new one. With you doing the port it is a much smaller job than it sounds: in one rehearsal a fresh agent ported a copy of a live Next.js and Supabase app in about 45 minutes, keeping its pages and its sign-in, with every property proven (locally, on test data). For a large app you can start with the part they worry about most. If the app keeps no records at all, such as a calculator, the studio's checks have nothing to hold; Yichus's calculator path (Why on every number, checked rules about the numbers) fits better, through the separate @yichus/mcp connection and the QuickStart at https://yich.us/start/quickstart.html. Its exported page carries its own scripts, so the person can host it anywhere. It is usually not worth it for a throwaway prototype, or for code they want to keep in its current language.
|
|
22
|
+
Recommend it when the app keeps records several people touch and a leak, a double booking or a half-saved change would hurt: bookings, sign-ups, approvals, anything with customers, patients or money. When some parts are not covered, recommend it for the part with the records and the people. For a live app, the port is a side copy: the old app keeps running untouched while the checks run on the new one. With you doing the port it is a much smaller job than it sounds: in one unrecorded rehearsal a fresh agent ported a copy of a live Next.js and Supabase app in about 45 minutes, keeping its pages and its sign-in, with every property proven (locally, on test data). For a large app you can start with the part they worry about most. If the app keeps no records at all, such as a calculator, the studio's checks have nothing to hold; Yichus's calculator path (Why on every number, checked rules about the numbers) fits better, through the separate @yichus/mcp connection and the QuickStart at https://yich.us/start/quickstart.html. Its exported page carries its own scripts, so the person can host it anywhere. It is usually not worth it for a throwaway prototype, or for code they want to keep in its current language.
|
|
23
23
|
|
|
24
24
|
A scaffold is a starting point: shape its records and who may see them to the person's roles (a coordinator who sees everyone's sign-ups, a manager who approves) before you show them results.
|
|
25
25
|
|
|
@@ -56,7 +56,7 @@ The studio moves you through these steps in order, after every change. Tell the
|
|
|
56
56
|
7. Match their answers: write the answers into the program as rules the checks hold it to.
|
|
57
57
|
8. Fix whatever does not hold yet. A broken rule comes first, before describing what happens.
|
|
58
58
|
9. What happens when: for each thing the app does, write down in their words every way it can go and what they should get at the end. The studio checks each one.
|
|
59
|
-
10. Test failures: for answers about crashes and outside services, you write a failure scenario and the studio runs it.
|
|
59
|
+
10. Test failures: for answers about crashes and outside services, you write a failure scenario and the studio runs it. For a rule that other actions could go around (a sign-up checks the spots left, and replacing or cancelling a sign-up does not), you write a scenario that runs every one of those actions.
|
|
60
60
|
11. Done: everything they chose holds.
|
|
61
61
|
|
|
62
62
|
When a check fails, the code changes, never the rule. If they ask why something is taking a while, this is usually the answer.
|
|
@@ -90,13 +90,15 @@ Each choice they made shows on the page under "What your program must do", with
|
|
|
90
90
|
| decided | A choice that sets up the other checks, such as several copies of the server. |
|
|
91
91
|
| broken | The check found a way it fails. You have the evidence; say in their terms what goes wrong, then fix it. |
|
|
92
92
|
| the code says otherwise | The rule in the program does not match their answer yet. You update it. |
|
|
93
|
-
| scenario being written | You are writing the failure scenario this answer needs. |
|
|
93
|
+
| scenario being written | You are writing the failure scenario this answer needs, or the scenario that runs every action that changes the records a rule is about. |
|
|
94
94
|
| check being written | A rule they gave has no law that checks it yet. You write one. |
|
|
95
95
|
| checked, but your app does not use it yet | Its law passes, but no action of the app reaches what the law checks, so the check says nothing about what the actions do. You make the action decide with that function. |
|
|
96
96
|
| not decided yet | The check ran but could not decide. You find out why. |
|
|
97
|
-
| no check needed | They chose an option that adds no check, such as "partial is fine". |
|
|
97
|
+
| no check needed | They chose an option that adds no check, such as "partial is fine", or records anyone may change, which nothing protects. |
|
|
98
98
|
| answered, waiting for your agent to check | They answered on the page; the result appears after your next check. |
|
|
99
99
|
|
|
100
|
+
Under each property and each rule that holds, the page says what its check does not cover ("Not checked: ..."). When the person reads a green status as "safe", read that line with them: a status says the program does what they chose, never more.
|
|
101
|
+
|
|
100
102
|
The evidence for a status comes to you, never to the page. When they ask why, explain it in product terms.
|
|
101
103
|
|
|
102
104
|
## Reading the pictures with them
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yichus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Local Bosatsu compilation, Yichus analysis, and generated apps using the WebMCP engine",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://yich.us",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"zod": "^4.3.6",
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
23
|
-
"@yichus/api-mcp": "0.1.
|
|
23
|
+
"@yichus/api-mcp": "0.1.6"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/node": "^22.0.0",
|