Where to go next¶
You have built a real time application with sign-in and storage, across three
entities. Below are five independent recipes to grow it. They skip the steps you already
know (creating a file, wiring a connect point, running synqt dev) and show only the new
pieces.
Resume the lot in progress after a restart¶
A restart keeps the Hall of Fame but forgets the bid in progress. Store the current lot too.
Add to the books entity's export: in synqt.yaml:
slot saveCurrent(string[120] item, int amount, string[80] bidder)
slot var loadCurrent() // returns the saved lot, or null if none
Add one row to db/relational/books/schema.sql (a single row table for "the current lot"):
CREATE TABLE IF NOT EXISTS current (
id INTEGER PRIMARY KEY CHECK (id = 1),
item TEXT NOT NULL,
amount INTEGER NOT NULL,
bidder TEXT NOT NULL
);
Add to db/relational/books/Books.qml:
function saveCurrent(item, amount, bidder) {
Db.exec("INSERT INTO current(id, item, amount, bidder) VALUES(1, ?, ?, ?)"
+ " ON CONFLICT(id) DO UPDATE SET item = excluded.item,"
+ " amount = excluded.amount, bidder = excluded.bidder",
[item, amount, bidder]);
}
function loadCurrent() {
const rows = Db.query("SELECT item, amount, bidder FROM current WHERE id = 1");
return rows.length > 0 ? rows[0] : null;
}
The lot lives in web/edge/Edge.qml, so load and save it there: once at startup for the
whole entity, not once per browser. The link to the books entity opens after the edge
starts, so the load waits for Books.ready, the same way the Hall of Fame's refresh()
does. Call auction.load() in Component.onCompleted and in the Books.onReadyChanged
handler, auction.saveNow() at the end of placeBid and closeLot, and add:
property bool loaded: false
// Once: a reconnect keeps the lot the edge is already holding.
function load() {
if (!Books.ready || auction.loaded) {
return;
}
auction.loaded = true;
// loadCurrent() returns a value, so it resolves asynchronously.
Books.loadCurrent().then(saved => {
if (saved) {
auction.itemName = saved.item;
auction.highBid = saved.amount;
auction.highBidder = saved.bidder;
}
});
}
function saveNow() {
Books.saveCurrent(auction.itemName, auction.highBid, auction.highBidder);
}
Restart, and the lot resumes where it was.
Move to PostgreSQL with one config change¶
The embedded engine is a good start. To keep the data in a managed PostgreSQL instead,
change only the database entity's config. The QML stays the same: the engine is hidden
behind the entity, so Db.exec and Db.query work as before.
In synqt.yaml, add a provider section to the books entity that names the engine and
its connection:
provider:
name: postgres
host: 127.0.0.1 # a private address, never public
port: 5432
database: gavel
user: gavel
password: env:DB_PASSWORD # the value lives in the books entity's .env rather than here
sslmode: verify-full # the entity verifies the engine certificate
ca_cert: certs/db-ca.pem
Put the password in db/relational/books/.env as DB_PASSWORD=..., and run
synqt doctor, which checks that your Qt kit has the PostgreSQL driver plugin. That is
the whole change. (For a
quick local trial against a PostgreSQL without TLS, drop sslmode and ca_cert. SynQt
allows that only in dev on localhost, and refuses it in a release build.)
Close each lot automatically on a timer (a jobs entity)¶
Make it a speed auction where each lot closes after a minute. Add a jobs entity, which runs scheduled work:
The ticker calls closeLot, so it must reach the auction. In synqt.yaml, add it as a
consumer of the edge's connect point:
One detail needs care. closeLot is exported as <admin> slot closeLot(...), and a
scope belongs to a user's session. The ticker is an entity with no session, so the gate
would refuse it. A member that both users and entities call cannot be gated on a scope,
so remove the <admin> in synqt.yaml:
Then decide in the slot, where Caller tells the two kinds of caller apart. Send the
rejection only to a user, because emit<Signal> targets a browser session. In
web/edge/Edge.qml:
function closeLot(nextItem) {
const fromTicker = Caller.isEntity && Caller.entity === "ticker";
if (!fromTicker && !Caller.hasScope("admin")) {
if (Caller.isUser) {
Caller.emitBidRejected("Not allowed to close this lot.");
}
return;
}
// ... close the lot as before
}
This is the trade between the two forms. <scope> is shorter, runs before your code and
cannot be forgotten, but it only knows about people. As soon as another entity must reach
the same member, the decision moves back into the slot.
Then put the schedule in the ticker's QML file, which synqt add entity scaffolded with
an example job. It calls the edge it now consumes, under the edge's capitalized name; an
entity has one connect point, so Edge is the whole address:
import SynQt
QtObject {
id: root
// One minute per lot. `Jobs` runs it off the request path.
Component.onCompleted: Jobs.every(60000, function () {
Edge.closeLot("Next mystery lot");
})
}
Each lot now closes on its own and records its winner, and the next lot opens.
Give each bidder a private maximum bid¶
Let a signed-in user set a private maximum that only they can see.
Add two members to the edge's point, gated so only signed-in users have them:
shared: false on the edge entity makes the value private: each bidder gets a Source of
their own instead of a mirror of one shared Source:
That line changes where the lot must live. Edge.qml is now created per caller, so the
auction would become one lot per bidder. Move the shared part into a pragma Shared file
beside it, web/edge/Lot.qml:
pragma Shared
import QtQuick
QtObject {
id: lot
property string itemName: "A homemade lasagna, baked fresh this morning"
property int highBid: 0
property string highBidder: "nobody yet"
}
Then web/edge/Edge.qml binds the shared members to Lot and keeps the private one:
itemName: Lot.itemName
highBid: Lot.highBid
highBidder: Lot.highBidder
property int maxBid: 0
function setMax(amount) {
auction.maxBid = amount;
}
placeBid and closeLot write to Lot instead of auction, so one bidder's raise still
reaches every session.
In the client, read and set the private value with Server.maxBid and
Server.setMax(...). The entity creates a Source per caller, so no shared object exists
through which one user could see another's maximum, and the bidder's second tab opens on
the maximum they set. A natural next step is to make placeBid raise a user up to their
stored maximum automatically.
Pin the rules you checked by hand¶
Three times in this tutorial you proved a rule by hand: the lower bid the edge refused, the
placeBid that failed while signed out, and the closeLot only the auctioneer may call.
A check by hand never runs again.
The first rule is in your QML, so a test can pin it. Write tests/tst_Auction.qml:
import QtQuick
import QtTest
import SynQt.Test
TestCase {
name: "Auction"
EntityTest {
id: harness
source: "../web/edge/Edge.qml"
}
SignalSpy {
id: rejections
target: harness.subject
signalName: "bidRejected"
}
function init() {
verify(harness.load(), harness.errorString);
rejections.clear();
}
function test_a_bid_names_the_bidder() {
harness.callerIsUser("user", { sub: "alice", name: "Alice" });
harness.subject.placeBid(40);
compare(harness.subject.highBid, 40);
compare(harness.subject.highBidder, "Alice");
}
function test_a_lower_bid_is_refused() {
harness.callerIsUser("user", { sub: "alice", name: "Alice" });
harness.subject.placeBid(40);
harness.callerIsUser("user", { sub: "bob", name: "Bob" });
harness.subject.placeBid(30);
compare(harness.subject.highBid, 40);
compare(harness.subject.highBidder, "Alice");
compare(rejections.count, 1);
}
}
It needs no browser, no certificates, no database and no C++. The slots read the real
Caller, created the way the web edge creates it, so a test cannot pass by stubbing the
check it tests.
The other two rules are the <user> and <admin> gates in the export: block.
harness.subject.placeBid(...) calls your QML function, past the gate. harness.call(...)
calls the generated slot in front of it, so the gate runs first:
function test_a_signed_out_visitor_cannot_bid() {
harness.callerIsUser("anonymous");
ignoreWarning(/placeBid: refused, the caller does not hold the scope it needs/);
harness.call("placeBid", [40]);
compare(harness.subject.highBid, 0);
}
The books entity's rule (only the edge may call recordWinner) is its consumer list,
which synqt check enforces.
A test of closeLot has a catch. closeLot records the winner through Books, and in a
test the books entity is never connected (Books.ready stays false). Close a lot that has
a bid and the test fails with
SynQt: the 'Books' connect point is not available, so recordWinner() was not sent.
Testing your app covers the rest of EntityTest, this limit and how to work
around it, and how to point schema at your schema.sql so a slot backed by Db has
its tables.
Recap¶
You started with one live value shared across browsers and grew it, one idea at a time, into a system of three entities:
- Connect points let entities share live, typed objects. The owner is the single authority: consumers ask, and the owner decides.
- Sign-in gives you real identity. Authorization happens in the owner's slots,
against
Caller, never in the UI. - Entities (an edge, a database) each own their data, authenticate each other, and are segmented so the browser reaches only the edge.
The reference pages go deeper on each piece. Read the programming model next: it describes everything you just did. Before the app grows, set up testing so the rules you checked by hand stay checked. To put the app on a server, Shipping it moves this auction onto real hosts with its own pipeline and certificates, and deploying a SynQt system is the checklist to keep open while you do.