One program
A page, its live data and the function behind a button — one language, one compile, and nothing in between them to build. This walks a real app end to end and shows where the compiler put each part, and why none of it was yours to decide.
01
There is nothing in this project except the app
Five files, and every one of them is your model, your rules or your screens. No client project. No API project, no DTOs, no serializer, no schema migration, no state-management library — not one file whose job is to carry something from one place to another. That is not because the app is small: the platform's own Admin is 55 files of exactly this and nothing else.
| file | what is in it |
|---|---|
app.osy | the manifest — the app's name and what it uses |
model/auth.osy | the user, the login, and the login page |
model/chat.osy | three entities, their security, and two functions |
model/room.osy | the page |
model/theme.osy | the colours, in light and dark |
02
One file, and the compiler splits it
This is the page. Some of it becomes browser code and some of it becomes server code, and the markers say which. Nothing in the source says which — that is what the compiler decided, from what each line touches.
runs in the browser runs on the server— and nothing in the file says so
// THE PAGE — and it is small on purpose. Everything interesting about this spike is in what the page does NOT say. [Page("/")] [Render(CSR)] [Title("The room")] component Room() { Guid roomId = Guid.Empty; 1 string draft = ""; 2 live var messages = Message .Where(m => m.Conversation.Id == roomId) 3 .Include(m => m.Author) .OrderBy(m => m.SentAt) .ToList(); 4 on mount { roomId = JoinTheRoom(); } 5 action Send() { 6 Say(roomId, draft); draft = ""; } 7 render { Stack(gap: 4, p: 6, maxW: "720px", bg: Colors.Bg, color: Colors.OnBg, minH: "100vh") { Text("The room", fontSize: FontSize.Hero, fontWeight: FontWeight.Medium); // The viewer's scalar fields ride the boot bag, so this is CLIENT state and costs no query. // Binding the whole entity (`var me = Session.CurrentUser;`) would have been a server read — // the row under this app's security — to render one string the browser already had. 8 Text("Signed in as " + Session.CurrentUser.Email, fontSize: FontSize.Caption, color: Colors.TextMuted); Stack(gap: 2) { 9 foreach (var m in messages) { Row(gap: 2) { Text(m.Author.Email + ":", fontWeight: FontWeight.Medium); Text(m.Body); } } } Row(gap: 2) { Input(value: draft, label: "Message", placeholder: "say something"); Osyrin.Button("Send", onClick: Send); } } } }
Client. A plain field on a component is browser state. Typing updates it at 60fps and no one else ever hears about it.
Server, and it is a SUBSCRIPTION. This is an entity query, so it runs on the server, under the security this app declared. live is what makes it stay true: a message somebody else posts lands here without a poll, a refetch or an invalidation key.
One statement, not one per row. The author of every message comes back with the messages — the N+1 is not optimised away later, it is not written.
Both, and that is the interesting one. The hook is client code; JoinTheRoom() is a server function. The hop is the call. There is no endpoint, no fetch, no request type and no response type.
Client. An action is a gesture — it runs where the click happened, sets client state, and calls the server where it needs to.
Server. Same shape as any other call you write. The argument is a string on both sides because there is only one declaration of it.
Client. Layout is arguments — gap, p, maxW, bg — not classes, and not a stylesheet that has to agree with the markup.
Client, and it costs nothing. The viewer’s scalar fields ride the boot descriptor, so reading one in a render is browser state — no round trip. Binding the whole entity is not: var me = Session.CurrentUser; is a QUERY (side: server), because that is the row under this app’s security rather than the display bag. This page used to do exactly that, to print one string the browser already had — a server read for nothing.
Client. Ordinary control flow over the rows the server sent. m.Author.Email is already loaded, because of ④.
03
The other half of the same program
The entities the page queried, the rule that decides who may read them, and the two functions it called. Same language, same file extension, same compile.
entity Conversation { 1 [Required][MaxLength(120)] string Topic; [Required] DateTime StartedAt; 2 security { allow read, create when IsAuthenticated; } } entity Participant { [Required] Conversation Conversation; [Required] User Person; [Required] DateTime JoinedAt; security { 3 allow read where Participant.Any(p => p.Conversation == Conversation && p.Person == user); allow create where Person == user; } } entity Message { [Required] Conversation Conversation; [Required] User Author; [Required][MaxLength(2000)] string Body; [Required] DateTime SentAt; security { allow read where Participant.Any(p => p.Conversation == Conversation && p.Person == user); allow create where Author == user && Participant.Any(p => p.Conversation == Conversation && p.Person == user); }
The type and its constraints are one declaration. This is the C# type the page sees, the Postgres column, and the validation the server enforces — written once.
⭐ The whole security model of this app is these three blocks. No entity is readable by default; each says who may read and write it, and every query anywhere in the program obeys — including the live var in §2.
A row filter, not a check. It becomes part of the SQL: a non-participant's query does not fail, it returns nothing, because the rows were never in scope. There is no call site that could forget it.
Guid JoinTheRoom() { 1 var room = Conversation.OrderBy(c => c.StartedAt).FirstOrDefault(); if (room == null) { 2 room = new Conversation { Topic = "The room", StartedAt = DateTime.UtcNow }; } var me = Session.CurrentUser; if (!Participant.Any(p => p.Conversation == room && p.Person == me)) { new Participant { Conversation = room, Person = me, JoinedAt = DateTime.UtcNow }; } 3 UnitOfWork.Commit(); return room.Id; }
LINQ over your entities, compiled to one statement. This is the same expression language the page used — because it is the same language.
Constructing an entity is how a row is created. There is no repository, no Add, and no place to forget one.
The unit of work commits once, at the end. Everything above it either happened or did not.
04
Ask the compiler where it put each line
You do not have to take the markers on trust, and you should not have to. The decision is in the resolved model, and the model is a command.
$ osy model --json # in demo/chat-room "name": "Room", "route": "/", "members": [ { "name": "me", "kind": "query", "side": "server", "live": false }, { "name": "roomId", "kind": "state", "side": "client", "live": false }, { "name": "draft", "kind": "state", "side": "client", "live": false }, { "name": "messages", "kind": "query", "side": "server", "live": true } ] "name": "Say", "effects": { "reads": ["Conversation", "User"], "creates": ["Message"], "writesData": true }
That is the whole basis for the decision: Say touches entities, so it is a server function; draft touches nothing, so it is browser state. You never wrote either fact down, and you can always ask.
05
What happens when you change it
The changes that break a two-codebase app, and what they cost here
Message.Bodym.Body, so a stale name is a compile error rather than a field that arrives undefined in a browser.security rulelive var in §2 narrows with it, immediately, along with every other read in the program. Nothing at a call site changes, because nothing at a call site was checking.renderlive var to an ordinary server functionstream<T>, which pushes. See the agents guide.Where to go next
The same seam, with a model on the far side of it.
The app on this page, in full, one command away.
Every construct in §2, one page each.