Providers¶
An entity hides its backend behind a typed connect point: consumers call
Store.insert(...) and never see what stores the data. Providers make that backend
pluggable. An entity type defines a small interface toward its backend, and a provider
implements it for one engine. The default provider is an embedded engine that needs no
configuration. One config value selects a third party engine (PostgreSQL, MySQL, MongoDB,
Redis, or your own) behind the same entity, and the rest of the system and its security
model stay the same.
A database entity takes one line. A database entity backed by a managed PostgreSQL cluster over verified TLS with a connection pool takes a few more lines in the same place, and nothing else in the system notices.
The two faces of an entity¶
Every service entity has two faces: the one its consumers call, and the one that talks to an engine.
flowchart LR
subgraph mesh["SynQt mesh (typed, mutually authenticated)"]
W["web edge<br/>(consumer)"]
end
subgraph ent["database entity (the mask)"]
direction TB
CP["connect point Source<br/>(the store's contract)"]
PI["provider interface<br/>(IPersistenceProvider)"]
CP --> PI
end
W -->|"Store.insert(...)<br/>authenticated + authorized"| CP
PI -->|"embedded, in process"| SQLITE[("SQLite<br/>file")]
PI -->|"QPSQL over TLS"| PG[("<span style='color:#1a1a2e'>PostgreSQL</span>")]
PI -->|"mongo client over TLS"| MG[("<span style='color:#1a1a2e'>MongoDB</span>")]
classDef ext fill:#eee,stroke:#999,color:#1a1a2e,stroke-dasharray:4 3;
class PG,MG ext;
- The mesh face is the connect point: the typed contract other entities consume,
carried over the authenticated mesh and authorized in every slot through
Caller. It never changes when you swap engines. - The backend face is the provider, which fulfills the connect point. Only the provider knows the engine, holds the backend credentials, and opens a connection to an external engine.
The entity sits between the two, so the engine stays hidden. A consumer cannot reach the
engine, see its credentials, or bypass the Caller checks the entity runs before any
provider call. Swapping SQLite for PostgreSQL changes the provider and its config, and
nothing else.
Entity types define a provider family and interface¶
Each entity type with an engine targets a family of engines, and defines one interface that every provider in the family implements. A provider declares which family it serves.
flowchart TB
subgraph fam1["persistence (relational)"]
IP["IPersistenceProvider<br/>connect, exec, query, tx, migrate"]
IP --- sqlite["sqlite (default, embedded)"]
IP --- postgres["postgres (QPSQL)"]
IP --- mysql["mysql / mariadb (QMYSQL)"]
IP --- custom1["custom:YourEngine (yours)"]
end
subgraph fam2["document"]
ID["IDocumentProvider<br/>connect, insert, find, update, remove"]
ID --- memdoc["memory (default, embedded, not durable)"]
ID --- mongodb["mongodb (mongo client)"]
ID --- custom2["custom:YourEngine (yours)"]
end
subgraph fam3["cache / key value"]
IC["ICacheProvider<br/>connect, get, set, del, incr, expire"]
IC --- memory["memory (default, embedded)"]
IC --- redis["redis (redis client)"]
IC --- custom3["custom:YourEngine (yours)"]
end
fam1 ~~~ fam2
fam2 ~~~ fam3
The interface is small, native C++, and the same for every provider in the family. A
provider implements the lifecycle (connect, disconnect, health) and the family's
operations, nothing more. The connect point Source calls the interface, never a specific
engine, so the same Store.qml works with SQLite or PostgreSQL.
The family interfaces (illustrative; the exact C++ signatures are in the framework headers):
- Persistence (relational):
connect(),health(),query(sql, params) -> rows,exec(sql, params) -> affected/id,begin()/commit()/rollback(),migrate(steps). Always parameterized: parameters arrive separately, so a provider never receives concatenated SQL. - Document:
connect(),health(),insert(collection, doc),find(collection, filter, options) -> docs,update(collection, filter, change),remove(collection, filter). - Cache:
connect(),health(),get(key),set(key, value, ttl),del(key),incr(key, by),expire(key, ttl). A TTL of zero or less means no expiry, forexpireas well asset. This is spelled out because engines disagree: Redis treatsEXPIRE key 0as "already expired" and deletes the key, so a Redis provider must sendPERSISTinstead. Otherwise the same line of QML would keep a value forever behind one engine and drop it behind another, which swapping a provider must never do.
The entity's QML never holds the interface. The runtime gives every owned connect point
Source one helper per type: Db for persistence, Docs for documents, Cache for
caches, and, outside the provider families, Jobs for a jobs entity. The helper forwards
to whichever provider the config selected, so the Source never names an engine. (Http
and Api are helpers too, but the entity's
network: block
grants them, not its type, because what an entity may reach is a deployment decision.)
The type helpers lists every member of every
helper.
Bundled providers and how each reaches its engine¶
SynQt bundles providers for every family. They reach their engines in different ways, which affects the build and the trust model, so this section spells it out.
Relational providers use Qt SQL driver plugins. Qt ships plugins for PostgreSQL (QPSQL), MySQL and MariaDB (QMYSQL), Oracle (QOCI), ODBC (QODBC), DB2 (QDB2), InterBase and Firebird (QIBASE), Mimer (QMIMER) and SQLite (QSQLITE). Two facts from the Qt SQL driver documentation set SynQt's defaults:
- SQLite always works. It is the in process database with the best test coverage and platform support, and the only driver that always works straight from a binary Qt build. So it is the default relational provider: no configuration, no external engine, no extra build step.
- Every other driver needs a client library and a matching plugin. SynQt cannot supply the engine's client library on the machine, or a plugin that loads against it. A binary Qt build ships more plugin files than SQLite (the pinned Linux kit also has QPSQL, QMYSQL, QODBC, QOCI, QIBASE and QMIMER), but a plugin file that exists may still fail to load, because each is bound to the client library it was built against. PostgreSQL usually works, because the shipped QPSQL loads against a normal libpq install. MySQL does not, for licensing reasons.
Qt's prebuilt QMYSQL links against Oracle's libmysqlclient and uses its versioned
symbols. SynQt cannot use that plugin, for two reasons:
- It cannot be distributed.
libmysqlclientis GPLv2 only, which is incompatible with the LGPLv3 Qt modules in the same entity, so an entity linking both cannot legally be distributed at all (see licensing). - It would not work anyway. It imports Oracle's versioned symbols, which MariaDB
Connector/C does not export, so pointing the shipped plugin at Connector/C fails to
load. Qt then reports only
Driver not loaded, without naming the cause.
So a mysql provider needs the QMYSQL plugin rebuilt against MariaDB Connector/C
(LGPLv2.1), the client whose license lets a SynQt deployment ship it. It is a one time
step per machine, outside synqt build, done by
tools/qmysql-plugin/build-qmysql-plugin.sh
in the SynQt repository:
The script needs the Qt sources for your pinned version (the installer's Sources
component) and Connector/C's headers and library. It refuses to build against Oracle's
client, and checks the result's linkage before installing it. synqt doctor reports the
plugin state for every SQL backed entity in your project.
That build decides how the mysql provider requests TLS. A Connector/C plugin has no SSL
mode option (Qt compiles it out for that client); naming a CA is what turns TLS on. So any
sslmode other than disable needs ca_cert, and without it the provider refuses to
connect instead of silently opening a plaintext connection. The certificate check the CA
enables also covers the host name, so verify-ca is at least as strict as verify-full,
never looser.
The document and cache providers request TLS in their engine's own terms, and each checks that it got it:
redis:tls: trueupgrades the connection before anything is sent, verifying the server againstca_cert(or the system trust store when no CA is named) and checking that its certificate names thehostyou wrote, as a DNS name or, for an address, an IP entry. A build withouthiredis_ssl, or a server that offers no TLS, gets a refused connection with a reason, never a plaintext one.tlsdefaults to true, so only a development engine on your machine without a certificate needs a line: writetls: false, which a release build accepts only for a loopback host.mongodb:tls: trueis checked against the connection string handed to the driver. Aurithat does not enable TLS is refused, and so is one that turns off the certificate check withtlsInsecure,tlsAllowInvalidCertificatesortlsAllowInvalidHostnames.
In both cases, what the entity claims must match what goes on the wire.
Document and cache providers wrap an external client library, because Qt has no official MongoDB or Redis module. The MongoDB provider wraps the MongoDB C client, and the Redis provider wraps hiredis. Both come from the system's packages, and each provider is built only when its library is found. SynQt wraps a maintained client behind the entity instead of reimplementing the engine, so the rest of the system never speaks Mongo.
The embedded defaults (SQLite for persistence, memory for the cache and for documents) need no engine and no extra build. That is why they are the defaults, and why a new project runs with none of this configured.
They promise different things. SQLite writes a file, so a relational entity on the
default keeps its data across a restart. The other two keep data in the entity's memory.
The cache is bounded and meant to forget. The document store is unbounded, keeps
everything until the process stops, then loses it all. Move a document entity onto
mongodb before its data matters. synqt build names every entity still on the embedded
default.
Selecting a provider: graduated configuration¶
Default. A relational entity with no provider line uses the embedded SQLite provider.
This is the common case and needs nothing more. With no settings.file, the entity opens
<entity dir>/data/app.db, the path synqt add entity writes, and creates the directory
on its first start.
entities:
- name: store
type: relational # provider defaults to sqlite (embedded)
settings:
file: db/relational/store/data/app.db
journal_mode: wal
busy_timeout_ms: 5000
A third party relational engine. Point the same entity at PostgreSQL. The connect point, the contract and every consumer stay the same.
entities:
- name: store
type: relational
provider:
name: postgres
host: db.internal # a private address, never public
port: 5432
database: app
user: app
password: env:DB_PASSWORD # entity .env only, never the client, never logged
sslmode: verify-full # the entity verifies the engine's certificate
ca_cert: certs/db-ca.pem # the CA that signed the engine's certificate
pool_size: 8
A document engine. A different type and a third party engine, hidden the same way.
entities:
- name: docs
type: document
provider:
name: mongodb
uri: env:MONGODB_URI # full connection string with credentials, from env
tls: true
ca_cert: certs/mongo-ca.pem
A cache engine.
entities:
- name: cache
type: cache
provider:
name: redis
host: cache.internal
port: 6379
password: env:REDIS_PASSWORD
tls: true
ca_cert: certs/redis-ca.pem
The pattern is always the same: provider.name names the engine, the rest of the
provider section holds the connection, and secrets are env: references resolved only
on that entity. Moving from the default to a managed third party engine changes one
entity block and nothing else.
The request path through a provider¶
A provider behind the entity changes nothing about the mesh, the authorization or the contract. The entity calls the provider internally, after it has authenticated and authorized the caller.
sequenceDiagram
participant B as browser (user)
participant E as web edge
participant D as database entity
participant G as engine (e.g. PostgreSQL)
B->>E: Server.add("milk") (wss, session)
Note over E: edge authorizes the user (Caller.hasScope)
E->>D: Store.insert(row) (mesh, mutual TLS)
Note over D: the store authorizes the entity (Caller.entity == "edge")
D->>G: provider.exec("INSERT ...", params) (TLS to engine, credentials)
G-->>D: ok
D-->>E: changed()
E-->>B: items model updates (no refresh code)
Two SynQt authorizations happen before the provider is called. The provider then reaches the engine over the entity's own authenticated, encrypted connection, with credentials only the entity holds.
Writing a custom provider¶
When no bundled provider fits (a niche engine, an in-house store, a SaaS data API), implement the family interface yourself.
Tip
This section is the reference. For a step by step version with two complete adaptors, see the Advanced tutorials: a database of your own puts a relational entity in front of an engine reached through a Qt SQL driver, and a cache of your own implements an engine's wire protocol by hand where Qt has no driver. An identity service of your own covers the one customization that is not a provider.
synqt add provider MyEngine --family relational writes the skeleton below into
providers/custom/myengineprovider.cpp. The three steps explain what it wrote and why.
- Implement the family interface (for example
IPersistenceProvider) in a small native module in the entity: the lifecycle (connect, disconnect, health), the operations, and the error mapping the interface expects. - Register it under a name with its family's macro. Registration makes the name selectable, and it runs during static initialization, so linking the file into the entity is enough:
(SYNQT_REGISTER_CACHE_PROVIDER and SYNQT_REGISTER_DOCUMENT_PROVIDER for the other
two families.) Register the bare name, without the custom: prefix.
3. Select it with provider.name: custom:MyEngine. The rest of the provider section
holds settings your provider reads from its ProviderConfig. That selection also
compiles providers/custom/ into the entity, so the registration runs. The build
writes generated/synqt.cmake from the topology every time, and the project's root
CMakeLists.txt only includes it, so there is no CMake to edit.
custom: is a namespace: only names that carry it are looked up among your
registrations, so a custom provider can never shadow a bundled one. sqlite always means
the bundled SQLite provider, whatever you register. If the name selects nothing, the
entity refuses to start and lists the providers the family has, instead of starting with a
connect point whose every call would fail.
The interface documents the contract your provider must follow: parameters are passed
separately (never concatenate), errors go through the interface's error type (never
thrown across the boundary), and health() reports readiness, so the entity can report
not ready and retry instead of crashing. A custom provider is your code, so review it
like any entity code; the framework does not weaken its boundary for it.
CLI support¶
synqt providers # List available providers per family.
synqt add entity db --type relational --provider postgres
# Scaffold an entity with a chosen provider,
# a provider config stub, and .env.example
# entries for its secrets.
synqt add provider <name> --family <fam> # Scaffold a custom provider skeleton that
# implements a family interface.
A relational provider loads its Qt SQL driver plugin from the kit at run time: SQLite needs
nothing, and mysql needs the plugin build described above. For a document or cache
provider, the build links the client library it finds on the system, and compiles the
provider out when there is none. synqt doctor reports any provider whose engine client or
driver plugin is missing, before you run.
Security of third party backends¶
An external engine adds a connection that leaves the entity, so the security model covers it too. Security has the full treatment. For providers:
- The entity is the trust boundary, and hiding the engine is a security property. Only
the entity reaches the engine, and it runs every
Callercheck before any provider call, so its fine grained checks sit in front of an engine whose own authorization may be coarser. Mesh consumers and browsers never reach the engine or its credentials. - Credentials are
env:only, on that entity only: never insynqt.yaml, never in a client target, never logged. The build rejects a client target that references a provider secret. - The connection to an external engine uses verified TLS. Relational providers set the
engine's verify mode to full (
sslmode: verify-fullor the driver's equivalent) against a configured CA. Document and cache providers enable TLS and verify the engine certificate. An unverified or plaintext connection is allowed only in dev on localhost; a release build refuses it. - The engine sits on a private address that only its entity can reach, like any sensitive entity. It is never public, and the mesh never exposes it.
- Provider client libraries are maintained upstream clients, taken from the system's packages: the MongoDB C driver and hiredis, never a reimplementation of an engine's protocol. A custom provider is reviewed like entity code.
Why this design¶
In the entity model, the contract is the stable boundary, and the backend can change
without touching consumers. Providers deliver on that. The same store entity can run on
embedded SQLite during early development and on a managed PostgreSQL or MySQL server in
production, chosen by one config value. The mesh authentication, the Caller
authorization, the contract's data minimization and the topology that denies by default
all stay the same. The embedded provider needs no configuration; a third party engine
needs a provider selection and a connection block, hidden behind an entity that keeps the
security model intact.