線路
a web framework for kanso. write the parts of the app that are yours; conventions place the rest while it compiles.
import "../../models"
# The guestbook's schema, as the changes that built it, oldest first. The
# models derivation folds these into the schema the app is compiled against.
pub all = [
signed
joined
emailed
confirmable
counted
lockable
tracked
arrived
arrived_before
arrived_from
arrived_from_before
pictured
]
signed = models/create_table "signatures" [id name message]
id = models/column "id" "serial"
joined = models/create_table "accounts" [id name digest]
# The address a reset link is sent to.
emailed = models/add_column "accounts" email
# When the address was confirmed, or empty until it is.
confirmable = models/add_column "accounts" confirmed
# How many wrong passwords in a row the account has had, and when too many
# of them locked it, or empty while it is open.
counted = models/add_column "accounts" failures
lockable = models/add_column "accounts" locked
# How many times the account has signed in, and when and from where the
# latest sign-in and the one before it came, as Devise's trackable keeps them.
tracked = models/add_column "accounts" sign_in_count
arrived = models/add_column "accounts" current_sign_in_at
arrived_before = models/add_column "accounts" last_sign_in_at
arrived_from = models/add_column "accounts" current_sign_in_ip
arrived_from_before = models/add_column "accounts" last_sign_in_ip
# The key of the photo a signer chose, or empty for an entry without one.
pictured = models/add_column "signatures" photo
name = models/column "name" "text"
message = models/column "message" "text"
digest = models/column "digest" "text"
email = models/column "email" "text"
confirmed = models/column "confirmed" "text"
failures = models/column "failures" "integer not null default 0"
locked = models/column "locked" "text not null default ''"
sign_in_count = models/column "sign_in_count" "integer not null default 0"
current_sign_in_at = models/column "current_sign_in_at" blank_text
last_sign_in_at = models/column "last_sign_in_at" blank_text
current_sign_in_ip = models/column "current_sign_in_ip" blank_text
last_sign_in_ip = models/column "last_sign_in_ip" blank_text
photo = models/column "photo" blank_text
blank_text = "text not null default ''"
senro (線路) is japanese for a railway line: the track a train runs on. Rails gave web development its conventions; senro lays the same track for a language whose compiler can check them.
senro's first app is a guestbook: visitors make an account, sign in, sign the book and remove what they signed. These are its own files. The panel above is its schema.
A model is a module named for its table, holding the rules for the table's records. senro declares the record itself, signature, from the table. The policies say who may create and remove a signature, and problems says what makes one unfit to keep.
import "../../models"
import "std/text"
# A signature is a row of the `signatures` table. This module is named for
# the table, so senro/models declares `signature` here with a field for each
# column, and writes `signature_from`, which builds one from a row.
#
# Only the person who signed an entry may remove it. The models derivation
# reads this policy and writes `signature_destroyed`, which asks it before it
# deletes anything, so there is no other way to remove a signature.
pub fn destroy? viewer s
s.name == viewer
# Anyone may sign the book.
pub fn create? _ _
true
# A signature needs a name and a message, and a message short enough to
# read at a glance.
pub fn problems s
models/problems [
(models/rule "name" (text/trim s.name == "") "Say who you are.")
(models/rule "message" (text/trim s.message == "") "Say something.")
(models/rule "message" (length s.message > 280) "Keep it under 280.")
]
An action is a public function, and its name places it. entries/index answers GET /entries and entries/create answers POST /entries. Nothing else declares a route.
import "../../models"
import "../../pg"
import "../../web"
import "./removal"
import "./signatures"
import "./views"
import "std/list"
# The session says who is signed in, and carries a notice across the
# redirect that follows a signature or a sign-in; the page that shows the
# notice clears it.
pub fn index req db
s = web/session req secret
pg/query db newest [] .> (r -> shown req r.rows s (filled "" {}) 200)
# A development secret. A deployed guestbook signs its sessions with its own.
pub secret = "the guestbook's development secret"
# A signature is made through its model, whose rules decide whether it is
# fit to keep. It is signed with the name of the account signed in. The
# fields go to Postgres as parameters, so what a visitor types is stored as
# text and never read as SQL.
pub fn create req db
viewer = web/viewer req secret
f = put (web/form req.body) "name" viewer
models/created viewer db signatures/signature_model f
.> (w -> signed req db w)
A view is a kata template. It names what it takes on its first line, and the compiler checks every field it reads against the record it is handed.
takes signatures form flash removals viewer
header
h1 Guestbook
p.lede Leave a line for whoever comes next.
if viewer == ""
nav.door
a href="/session/new" Sign in
a href="/account/new" Make an account
else
form.out method="post" action="/session"
input type="hidden" name="_method" value="DELETE"
span Signed in as {viewer}.
a href="/account/edit" Your account
button Sign out
if flash != ""
p.flash = flash
if viewer != ""
form.sign method="post" action="/entries" enctype="multipart/form-data"
textarea name="message" placeholder="say something" required="required" = form.message
if form.problems["message"] != none
p.problem = form.problems["message"]
label
span A photo, if you like
input type="file" name="photo" accept="image/*"
button Sign
ol.entries
for e in signatures
li
p.message = e.message
if e.photo != ""
img.photo src="/senro/blobs/{e.photo}" alt="a photo from {e.name}"
p.who = e.name
== removals["{e.id}"]
each convention is a kanso function the compiler runs during the build. what it writes is checked like code someone wrote, and kanso derived prints it.
every route is one of a resource's seven actions, placed by its module and its name. singular resources such as /profile follow the same rule. an old URL is kept with legacy_route_aliases, which redirects to the resource's route.
there is no schema file. the migrations are values, folded into the schema while the app compiles, and each model's record is declared from its table's columns. when the server starts it compares the live tables with that schema.
a model's record, its reader, its checked writes and its policies come from its table and the functions in its module. a write the policy refuses has no other way into the table.
templates are kata files, compiled to kanso functions. the same functions run on the server and, compiled to wasm, in the browser, so a page can be rendered from data on either side and the two agree byte for byte.
a session is a signed cookie. sign-in stretches passwords with PBKDF2 and salts them with the operating system's random bytes, and a session older than its limit is refused.
senro speaks Postgres's wire protocol in a client written in kanso, over TLS when the server asks for it. there is no C binding and no second database to abstract over.
a mistake Rails reports when a request reaches it is a compile error here. a misspelled action is the plainest case.
pub fn index _
"every post"
pub fn destory _
"gone"
error[derive]: `destory` is not an action: name it index, new, create, show, edit, update or destroy
--> ./posts.kso:4:8
4 | pub fn destory _
^
the same goes for two routes that could answer one request, a path_to that names no route, a model field with no column, and a template that reads a field its record lacks.
getting started builds the guestbook one file at a time. the guides cover each part of the framework in turn, and PRINCIPLES.md states the beliefs behind its design. senro is young: the guestbook and a pantry planner are the apps it has been built against, and its source is at github.com/kanso-lang/senro.