Examples¶
These are complete applications written in the SynQt programming model. They show everything you write (contracts, configuration, server code and client views) and none of the framework's internals. Each example builds on the previous one.
- Examples 1 to 3 have a client and a web edge, and the edge keeps its state in memory.
They use
Clientto reach the calling browser session; on a web edge,Clientis an alias forCaller, described in the programming model. - Example 4 adds a database entity and shows the mesh: the edge authorizes the user, then calls the database, which authorizes the edge.
- Example 5 keeps those three entities and adds remote pages: views the web edge delivers on demand instead of compiling them into the client bundle.
SynQt also ships five complete projects: the finished apps of the tutorials, and the
storefront below. synqt examples lists them, and synqt new shop --example stall copies
one into a project of your own. The quick start walks through this.
Example 1: a shared live counter (no login)¶
The smallest useful app: a counter that every connected client sees update in real time. It shows state shared by every browser, a property the edge owns, and a request from the client to the edge.
Configuration, synqt.yaml¶
project:
name: counter
version: 0.1.0
qt_version: 6.12.0
entities:
- name: app
type: client
- name: edge
type: web_edge
public:
port: 8443
connect_points:
- owner: edge # the edge holds the authoritative Source
consumers: [app] # the browser may acquire it
server: web/edge/Edge.qml
export: |
prop int value // edge owned; clients read, edge writes
slot increment() // a request; the edge performs the change
slot decrement()
# the edge is shared (the default), so one Source holds one number for every
# browser watching, and each slot still arrives with its own Caller.
# no scope: any session may use it
There is no identity section, so every connection runs at the default anonymous scope.
This is a development configuration, which synqt dev serves in plaintext on localhost.
A release build is refused without TLS, so running it with synqt serve also needs a
tls section with a certificate on the web entity, as in Example 2 (see the
validation rules).
The edge, web/edge/Edge.qml¶
One file is both the entity and the surface it exports. The edge is shared, so one
instance holds one number however many browsers watch, and every slot still has a Caller
to authorize.
import SynQt
Edge {
id: counter
value: 0
function increment() { counter.value = counter.value + 1; } // the edge is the writer
function decrement() { counter.value = counter.value - 1; }
}
value is a contract property, so the framework pushes every change to all replicas,
with no broadcast code.
Client, client/app/Main.qml¶
import SynQt
import QtQuick.Controls
ApplicationWindow {
visible: true
title: "Counter"
Column {
anchors.centerIn: parent
spacing: 12
Label {
text: Session.state === "connected" ? ("Value: " + Server.value)
: "Connecting..."
font.pixelSize: 28
}
Row {
spacing: 8
Button { text: "-"; onClicked: Server.decrement() }
Button { text: "+"; onClicked: Server.increment() }
}
}
}
Open the page in two tabs: the counter stays in sync, because both mirror the one Source the edge holds.
Example 2: the authenticated Todo app¶
The reference SynQt example: a shared todo list that anyone may read, but only signed-in users may add to. A user may remove only their own items; moderators may remove any. It shows login, scopes, per row ownership that never leaves the edge, and refusals sent from the edge to the client.
Configuration, synqt.yaml¶
The model's role list below has no ownerId. The edge keeps an owner id per row for
authorization, and it never reaches a client, because it is not a declared role.
project:
name: todo
version: 0.1.0
qt_version: 6.12.0
scopes:
order: [anonymous, user, moderator, admin]
hierarchical: true
default: anonymous
entities:
- name: app
type: client
- name: edge
type: web_edge
public:
port: 8443
tls:
cert_file: certs/fullchain.pem
key_file: certs/privkey.pem
env:
file: web/edge/.env
identity:
required: false # anonymous users may read; only writing needs a scope
login: /auth/login
callback: /auth/callback
providers:
- name: github
authorize_url: https://github.com/login/oauth/authorize
token_url: https://github.com/login/oauth/access_token
userinfo_url: https://api.github.com/user
client_id: your-github-client-id
client_secret: env:GITHUB_CLIENT_SECRET
scopes: [read:user, user:email]
mapping:
hook: web/edge/identity/map.qml
connect_points:
- owner: edge
consumers: [app]
server: web/edge/Edge.qml
export: |
prop int count // number of items, edge owned
model items(string[280] text, string[80] author, bool done) // only these cross
slot add(string[280] text)
slot remove(int index)
signal rejected(string[120] reason) // the edge explains a refusal to one client
# the edge is shared (the default), so one Source holds the one list everybody
# sees, and each slot still arrives with its own Caller.
# no scope on the connect point: anonymous users may acquire it and read.
# write permission is enforced inside the slots, not at acquisition.
web/edge/.env (edge only, never shipped):
Identity mapping, web/edge/identity/map.qml¶
This optional hook turns a provider identity into a SynQt scope after login. It runs only on the edge.
import SynQt
IdentityMapping {
// Return the scope a freshly authenticated identity should hold, as a member of the
// Scope enum SynQt generates from scopes.order beside this file.
// Keyed on the GitHub login, which every GitHub identity has. An email can be null.
readonly property var admins: ["your-github-username"]
readonly property var moderators: ["a-moderator-login"]
function scopeFor(identity): int {
if (admins.indexOf(identity.login) !== -1) {
return Scope.Admin;
}
if (moderators.indexOf(identity.login) !== -1) {
return Scope.Moderator;
}
return Scope.User; // any successfully authenticated user
}
}
The edge, web/edge/Edge.qml¶
One file is both the entity and the surface it exports. There is one list for the whole
app, and each slot still gets its own Client (the edge's name for a browser Caller).
ownerId is kept on each row but is not a declared model role, so it cannot reach a
browser, however the file is written.
import SynQt
Edge {
id: todo
property var rows: []
count: todo.rows.length
function add(text) {
if (!Client.hasScope("user")) {
Client.emitRejected("Sign in to add items.");
return;
}
const clean = ("" + text).trim();
if (clean.length === 0 || clean.length > 280) {
Client.emitRejected("Items must be 1 to 280 characters.");
return;
}
todo.rows = todo.rows.concat([{
text: clean,
author: Client.identity.login,
done: false,
ownerId: Client.id // edge only authorization data
}])
}
function remove(index) {
if (index < 0 || index >= todo.rows.length) {
Client.emitRejected("No such item.");
return;
}
const row = todo.rows[index];
const isOwner = row.ownerId === Client.id;
if (!isOwner && !Client.hasScope("moderator")) {
Client.emitRejected("You can only remove your own items.");
return;
}
const next = todo.rows.slice();
next.splice(index, 1);
todo.rows = next;
}
// One binding, so a change any user makes reaches every browser watching.
// `itemsRows` keeps only the roles `items` declares, so ownerId is dropped at this
// boundary and never crosses to a browser.
itemsRows: todo.rows
}
Client, client/app/Main.qml¶
import SynQt
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
visible: true
width: 360; height: 480
title: "Todo"
header: ToolBar {
RowLayout {
anchors.fill: parent
Label { text: "Todo"; Layout.fillWidth: true }
Button {
text: Session.identity ? Session.identity.login : "Sign in"
onClicked: if (!Session.identity) Session.login() // sends browser to /auth/login
}
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Label { text: "Items: " + (Server.count || 0) }
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
model: Server.items
delegate: RowLayout {
width: ListView.view.width
CheckBox { checked: model.done; enabled: false }
Label { text: model.text + " (" + model.author + ")"; Layout.fillWidth: true }
Button {
text: "Remove"
// UX hint only; the edge enforces ownership regardless.
visible: Session.hasScope("user")
onClicked: Server.remove(index)
}
}
}
RowLayout {
Layout.fillWidth: true
TextField {
id: input
Layout.fillWidth: true
placeholderText: "New item"
enabled: Session.hasScope("user")
}
Button {
text: "Add"
enabled: Session.hasScope("user") && input.text.trim().length > 0
onClicked: { Server.add(input.text); input.text = "" }
}
}
}
// The edge's refusal channel: show why an action was rejected.
Edge.onRejected: reason => { toast.text = reason; toast.open() }
Popup { id: toast; property alias text: msg.text; Label { id: msg } }
}
What this example demonstrates¶
- Requests, not commands. The client treats
addandremoveas requests, and the edge authorizes them. - Data minimization. Every row carries
ownerIdfor authorization, and it never crosses the boundary, because it is not a declared model role. - Client checks are cosmetic.
Session.hasScope("user")on the client only hides and disables UI. A modified client that callsServer.addwhile anonymous still reaches an edge that refuses, withrejected("Sign in to add items."). - Ownership uses a value the client cannot forge, never anything the client sends: here
Client.id, the session key.Clientis the edge's alias forCaller, the mechanism Example 4 uses. A session key is enough for this in-memory list, which lives only as long as the edge process. Durable rows key ownership onCaller.identity.subinstead (see Example 4), so ownership survives a new session and a restart. - The login runs on the edge. The GitHub redirect, the code exchange with the client secret, and the session cookie all happen there. The browser holds only the opaque session cookie.
Example 3: a private per session draft (sketch)¶
shared: false on the entity makes a draft private by giving each caller a Source of
their own, and a scope on the point keeps anonymous clients from acquiring it at all:
entities:
- name: edge
type: web_edge
shared: false # a draft Source per session
connect_points:
- owner: edge
consumers: [app]
server: web/edge/Edge.qml
scope: user # only signed in users may acquire it at all
export: |
prop string[4000] body
slot save(string[4000] text)
Each user's draft lives in a separate Source instance, so no client can observe another's.
With scope: user, an anonymous client never acquires the replica.
Example 4: a three entity todo with durable storage¶
The reference mesh example: a browser client, a web edge and a database entity. The edge owns the connect point users reach and authorizes each user. The database owns durable storage and authorizes the edge. Items survive a restart, because they live in the database entity, not in the edge's memory.
Topology, synqt.yaml¶
project:
name: todo
version: 0.1.0
qt_version: 6.12.0
scopes:
order: [anonymous, user, moderator, admin]
hierarchical: true
default: anonymous
entities:
- name: app
type: client
- name: edge
type: web_edge
public:
host: 0.0.0.0
port: 8443
tls:
cert_file: certs/edge/fullchain.pem
key_file: certs/edge/privkey.pem
mesh:
transport: mtls # the default: mutual TLS, over loopback on one host
host: 127.0.0.1
port: 9443
env:
file: web/edge/.env
- name: store
type: relational
mesh:
transport: mtls # certificate identity: the database can trust Caller.entity
host: 127.0.0.1
port: 9444
settings:
file: db/relational/store/data/app.db
journal_mode: wal
busy_timeout_ms: 5000
connect_points:
- owner: edge # the edge owns the user facing object
consumers: [app] # the browser may acquire it
server: web/edge/Edge.qml
export: |
model items(string[280] text, string[80] author, bool done) // only these cross
slot add(string[280] text)
slot remove(int index)
signal rejected(string[120] reason)
# the edge is shared (the default): one Source, and each slot still arrives
# with its own Caller
- owner: store # the store entity owns durable storage
consumers: [edge] # only the edge may reach it; never the browser
server: db/relational/store/Store.qml
export: |
record ItemRow(string[280] text, string[80] author, string[64] ownerSub)
slot var list() // rows { id, text, author, ownerSub } to the edge
slot insert(ItemRow row)
slot remove(int id)
signal changed() // tells the edge the data moved
synqt add auth github adds authentication (see authentication); it
is left out here.
The mesh link uses mutual TLS even though both entities share a host, so the single entity
on the consumer list is proven by its certificate. synqt mesh cert --all issues the
certificates for deployment; synqt dev creates throwaway development certificates
automatically.
Below, ownerSub is on the store entity's point (the edge needs it to enforce ownership)
but not among the edge's items roles, so it never reaches the browser.
The database entity, db/relational/store/Store.qml¶
import SynQt
Store {
id: items
// Nothing here asks who is calling: the consumer list has one name in it, so the
// mesh opens no link to anything else and nothing else can acquire this.
function list() {
return Db.query("SELECT id, text, author, owner_sub AS ownerSub"
+ " FROM items ORDER BY id DESC LIMIT 200");
}
function insert(row) {
Db.exec("INSERT INTO items(text, author, owner_sub) VALUES(?, ?, ?)",
[row.text, row.author, row.ownerSub]); // parameterized: no injection
items.changed(); // notify the edge
}
function remove(id) {
Db.exec("DELETE FROM items WHERE id = ?", [id]);
items.changed();
}
}
db/relational/store/schema.sql:
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
author TEXT NOT NULL,
owner_sub TEXT NOT NULL
);
The web edge, web/edge/Edge.qml¶
import SynQt
Edge {
id: todo
// The last fetched internal rows (id and ownerSub included): edge memory only,
// used to authorize removals. Never a model role, so it never reaches a browser.
property var rows: []
// Keep the browser facing model in sync with the database.
function refresh() {
if (!Store.ready) return; // the link to the database is not up yet
// list() returns a value, so this cross entity call resolves asynchronously.
Store.list().then(fetched => {
todo.rows = fetched;
// Map internal rows to the browser facing roles (drop id and ownerSub).
todo.setItems(fetched.map(r => ({ text: r.text, author: r.author, done: false })));
})
}
Component.onCompleted: refresh()
Store.onReadyChanged: todo.refresh() // the link came up, or came back
Store.onChanged: todo.refresh() // the database moved; repull
function add(text) {
// The edge authorizes the user.
if (!Caller.hasScope("user")) { Caller.emitRejected("Sign in to add items."); return; }
const clean = ("" + text).trim();
if (clean.length === 0 || clean.length > 280) {
Caller.emitRejected("Items must be 1 to 280 characters."); return;
}
// Persist via the database entity, which nothing but the edge can reach.
Store.insert({ text: clean, author: Caller.identity.login,
ownerSub: Caller.identity.sub });
}
function remove(index) {
if (index < 0 || index >= rows.length) {
Caller.emitRejected("No such item."); return;
}
// Ownership is decided against the verified identity, never a client value:
// a user removes only rows whose ownerSub matches their own sub; a moderator
// removes any.
const row = rows[index];
const isOwner = Caller.identity && row.ownerSub === Caller.identity.sub;
if (!isOwner && !Caller.hasScope("moderator")) {
Caller.emitRejected("You can only remove your own items."); return;
}
// The database deletes by id. Only the edge can reach it.
Store.remove(row.id);
}
}
The client, client/app/Main.qml¶
The same as in Example 2: it reads Server.items, calls Server.add(...) and
Server.remove(index), and shows a refusal's reason through Edge.onRejected. The client
only talks to the edge and never learns a database exists.
What this example demonstrates¶
- Three entities, two boundaries. The edge authorizes the user (
Callerin every slot), and the topology keeps the database out of the browser's reach by listing one consumer. - The edge holds the whole user authorization matrix. Anonymous users cannot add, a
user removes only rows whose
ownerSubmatches theirCaller.identity.sub, and a moderator removes any. The ownership decision uses no value from the client: the edge compares its own cachedownerSubwith the verified identity. - The browser cannot reach the store. Its point lists only
edgeas a consumer, and a browser cannot reach an entity that is not a web edge anyway. - Data minimization across two hops.
ownerSubis on the internal contract for the edge's ownership logic and is dropped before anything reaches the browser, because it is not one of the edge'sitemsroles. It holdsCaller.identity.sub, the stable identity subject, instead of the session key (Client.id) used in Example 2: a durable row must stay owned across sessions and restarts, so it is keyed on the identity, not the session. - Durable storage without a database server. Items live in the persistence entity's embedded store and survive restarts. The store is a SynQt entity with the same toolchain and security model, with no separate database product to run, configure or secure.
- One mechanism for both links.
Server(browser to edge, over wss) andStore(edge to database, over the mesh) use the same programming model over different transports.
Example 5: a storefront with edge-delivered campaign pages¶
The stall example has the
three entities of Example 4 (a browser client, a web edge, and a stock database the
browser reaches only through the edge), plus one addition: its campaign pages are
remote pages, delivered by the edge on demand instead of compiled into
the client bundle. The product grid and the cart ship in the bundle. A merchandiser can
change or add a campaign without a client rebuild.
Topology, synqt.yaml¶
The entities are a type: client, a type: web_edge and a type: relational database.
The route table and the router block are top level keys:
routes:
- path: /
view: Home.qml # compiled into the client bundle
- path: /cart
view: Cart.qml
- path: /c/:campaign
remote: Campaign.qml # delivered by the edge, from web/edge/pages/Campaign.qml
seed: web/edge/campaign-seed.qml
- path: /members
remote: Members.qml # delivered by the edge, and members only
scope: user
router:
fallback: /
base: /
palette: [QtQuick, QtQuick.Layouts] # what a delivered page may import
connect_points:
- owner: edge # the edge owns the browser-facing live catalog
consumers: [app]
server: web/edge/Edge.qml
export: |
model offers(string[80] title, int price)
slot addToCart(string[40] sku)
- owner: stock # the stock entity owns the durable stock
consumers: [edge] # only the edge; a client consumer here fails synqt check
server: db/relational/stock/Stock.qml
export: |
model items(string[40] sku, string[80] title, int price)
slot restock(string[40] sku, string[80] title, int price)
slot var list() // the shelves, for an edge that just came up
signal itemStocked(string[40] sku, string[80] title, int price)
The delivered page, web/edge/pages/Campaign.qml¶
One file serves every slug. Its root is an Item, not a window, because the client loads
a delivered page into its Loader, and it imports only palette modules. It paints its
headline from the seed on the first frame, then keeps the offers live through
Server.offers:
import QtQuick
import QtQuick.Layouts
Item {
id: campaign
readonly property string headline: Router.pageSeed.headline ?? "Today's offers"
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 12
Text {
text: campaign.headline
font.pixelSize: 24
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: Server.offers
delegate: Text {
required property string title
required property int price
width: ListView.view.width
text: title + " - " + price
}
}
}
}
The page seed, web/edge/campaign-seed.qml¶
The seed runs on the edge after the route's scope check, and turns the slug into the headline the page paints first, so the first frame is never empty:
import SynQt
PageSeed {
// Leave the parameters untyped: the edge invokes this hook generically, passing
// every argument as a QVariant, so annotating one (route: string) would change the
// method signature and the edge would silently deliver the page with no seed. The
// return may be annotated var.
function seedFor(route, parameters, caller): var {
const slug = parameters.campaign ?? "";
const words = slug.split("-").filter(part => part.length > 0);
const headline = words
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
return { headline: headline.length > 0 ? headline : "Today's offers" };
}
}
What this example demonstrates¶
- Two kinds of route. A route is compiled in (
view:) or delivered by the edge (remote:). The campaign and members pages areremote:, so they never enter the bundle and change without a client rebuild. - The palette is the trust boundary.
router.palettelists every QML module a delivered page may import, and the client enforces it. - The seed paints the first frame. It runs on the edge per request, is keyed on the
path parameter, and becomes
Router.pageSeedon the client, so oneCampaign.qmlgives each slug its own headline. Its parameters stay untyped so the edge's generic call matches. - A page's
scopeprotects the page, not the data. A session below the scope getsMembers.qmlrefused, with no markup, hash or seed, but the data any page reads is still governed by the connect point's own scope. - The browser cannot reach the database. The stock entity's connect point is owned by
stockand consumed only byedge. Adding the client as a consumer failssynqt check, because the browser can only reach a web edge.
The light storefront tutorial builds this shop step by step and runs three hands-on checks against it.