LibreDB Mobile is a thin client of LibreDB Studio.
Studio holds every database credential, opens every database connection and enforces who may use which connection; the app signs the user in, sends requests and shows the answers.
This page describes how the app is put together and the Studio contract it follows.
The contract was read from Studio's code at dbfea263 (main, with the bearer channel of libredb/libredb-studio#1914); the rate limit, catalog, cancel and revocation notes were checked against main at 02dc2605.
┌──────────────┐
│ Keycloak │ realm with the clients libredb-studio (web) and libredb-mobile (public)
└──────┬───────┘
sign in (PKCE) │ verify the access token (JWKS)
┌────────────┴────────────┐
│ │
┌─────────┴─────────┐ bearer ┌──┴───────────────┐ credentials ┌───────────┐
│ LibreDB Mobile │ ─────────▶ │ LibreDB Studio │ ──────────────▶ │ Databases │
│ tokens only │ ◀───────── │ connections, │ └───────────┘
└───────────────────┘ JSON │ queries, audit │
└────────┬─────────┘
│ bridge views (governance mode only)
┌────────┴─────────┐
│ LibreDB Platform │ grants, revocations, audit log
└──────────────────┘
- Keycloak signs the user in.
The app is the public client
libredb-mobile: authorization code with PKCE, no client secret. Its access token carries the audiencelibredb-studio-apiand the user's role. - Studio accepts that access token as
Authorization: BearerwhenOIDC_BEARER_AUDIENCEis set. It checks the signature, the issuer, the audience, the client (OIDC_BEARER_CLIENT_IDS), the identity (emailorpreferred_username) and the role on every request, and refuses an ID token. - Platform, when present, governs Studio: Studio's connections become the user's grants, every statement is recorded, and Platform can revoke a session. The app does not talk to Platform until milestone M9 (roadmap.md).
Settings for each party are in studio.md.
The app follows the feature-first clean architecture of feature-first-clean-boundaries.md: a page talks to a BLoC, the BLoC to use cases, the use cases to a repository interface, and the repository implementation to ApiClient.
| Place | What it holds |
|---|---|
lib/features/auth/ |
Sign-in through Keycloak, the only sign-in (oidc.md), StudioIdentityRepository for GET /api/auth/me and the sign-out record at POST /api/auth/logout, and StudioIdentityCubit, which holds Studio's latest answer about the user for the shell and every feature |
lib/features/connections/ |
The managed connections, the connection policy and the connection test |
lib/features/object_browser/ |
Containers, counts, objects, details, source, search, key scan, and the rows and row count of an object |
lib/features/sql_editor/ |
Statements, scripts, cancel and transactions |
lib/features/studio_status/ |
Studio's liveness and readiness: the unreachable page and the wording of a governance outage (below) |
lib/shared/models/studio_identity.dart |
Who Studio says the user is, with Studio's role as the sealed StudioRole: StudioAdminRole, StudioUserRole, or StudioUnknownRole for a role this app does not know, which gets the least the app grants |
lib/shared/models/studio_connection.dart |
A connection Studio offers, read by the connection list, the active connection, the object browser and the SQL editor |
lib/shared/active_connection/ |
ActiveConnectionCubit, the one connection Browse and SQL work on, which the top bar's switcher changes and every feature follows; it is remembered on the device per signed-in subject (a SHA-256 of the identity provider's sub), opens again only when the first managed list of the session still holds it, and vanishes with the message ' is no longer available' when a list no longer holds it or a feature gets studio.connection_not_available for it |
lib/shared/models/provider_meta.dart |
What the engine of a connection can do (ProviderMeta, ContainerLevel, ObjectKind, KeyScanSpec, ProviderLabels), read by the connections, the object browser, the SQL editor and the result view |
lib/shared/models/provider_meta_repository.dart |
IProviderMetaRepository, the one way a feature asks for ProviderMeta |
lib/shared/models/query_result.dart, query_page.dart, query_id.dart, vector_column.dart |
The shared statement models: QueryResult with its QueryWarnings and VectorColumns, appendResultPage (Load more), QueryPage and QueryPagination, and newQueryId, read by the object browser, the SQL editor and the result view |
lib/shared/models/statement_repository.dart |
IStatementRepository, the one way a feature runs a statement and reads its QueryResult |
lib/shared/security/result_masking/ |
The port of Studio's default result masking: the patterns, the mask functions, the role rules, MaskingPolicy and maskResult (security.md) |
lib/shared/widgets/studio_role_scope.dart |
StudioRoleScope, the role Studio named for the user, or null before it has, which AppShell provides from StudioIdentityCubit so a feature builds its MaskingPolicy without importing the auth feature |
lib/shared/widgets/result_view/ |
The result view of a statement (milestone M4), shared by the SQL editor and the object browser: ResultViewBloc holds a result its caller masked and the policy it was masked with, and fetches, masks and appends the next page through the caller's loadPage when Studio's grid would offer it; ResultView draws a card per row or a lazy table under a stats strip, or an empty state, with Load more under the rows; the card or table choice is a device setting, ResultViewModeCubit over IResultViewModeStore (lib/infrastructure/storage/result_view_mode_preferences.dart) |
lib/infrastructure/studio/ |
What every Studio request shares: the error mapping, the session rule, ProviderMetaRepository with its reader, and StatementRepository with its reader query_result_json.dart |
lib/infrastructure/http/ |
ApiClient and its interceptor chain (auth, token refresh, retry, cache, logging, mock), described in http-client.md |
A model that more than one feature reads lives in lib/shared/models/, never in one feature's domain/, because a feature must not import another feature's internals (test/architecture/import_guard_test.dart).
A repository that answers with such a model has its interface beside the model and its implementation in lib/infrastructure/studio/.
A widget that more than one feature shows lives in lib/shared/widgets/.
Each repository returns a Result: the entity, or an AppError with a translated AppErrorCode.
The readers in each feature's data/models/ (and in lib/infrastructure/studio/ for a shared model) turn Studio's JSON into entities, throw a FormatException when a required field is missing, and leave out an optional field of an unexpected type, so a newer Studio that adds fields reads as before.
In mock mode (main_local.dart) a debug-only double stands in for Keycloak (oidc.md) and MockInterceptor answers every request from assets/mock/, and test/architecture/mock_asset_coverage_test.dart fails when a repository method has no recorded answer.
StudioStatusGate (lib/features/studio_status/) sits in the MaterialApp builder and tells an unreachable Studio apart from a failed sign-in or an expired session.
StudioStatusBlocchecksGET /api/healthwhen the app starts, without waiting for the session restore, when the user retries, when the app comes back after more than 5 minutes in the background (StudioStatusBloc.defaultRecheckAfter), and when the device comes back online.- A failed check shows the full-screen "Can't reach Studio" page, with the host of
PROD_API_URLand a Retry, over the session restore and the router. They stay mounted underneath, offstage and without tickers, focus or semantics, so the open pages keep their state, unsaved input included. It fails on no connection (an offline device fails at once), a timeout of 10 seconds (studioProbeTimeout), an untrusted certificate (the page adds why), or an answer that is not Studio's. Both probes bypass the client's retries and circuit breaker (bypassResilience): one check sends one request, so 10 seconds is the whole wait, and a Retry reaches Studio as soon as it answers again. - The check never touches the session: a Retry that reaches Studio shows the app again where it was, signed in as before. A governance outage shown before Studio stopped answering shows again, until a connection route answers again. The app is not held back while the check runs.
- Both endpoints need no session and spend no rate-limit slot, so the app sends them without a token (
publicEndpoints). GET /api/readyonly chooses the wording of a governance outage, whose trigger is a connection route's own 503GOVERNANCE_UNAVAILABLE(StudioGovernanceOutageReported, sent by the connection list throughGovernanceStatus). 200degradedand 503loadingread as "temporarily unavailable", and 503misconfiguredorincompatibleas "Studio cannot serve connections right now", with Studio'sdetailshown to admins only (StudioGovernanceNotice). 200degradednever reads as healthy: a stale snapshot answers it while every connection route answers 503. A readiness that cannot be read still shows the outage, worded as temporary, and is logged.
ConnectivityCubit (lib/app/connectivity/) follows the device's connectivity, and AppConnectivityScope passes it to every screen as the shared OfflineStatus (lib/shared/widgets/offline_status.dart), so a feature reads it without importing the app layer.
AppShellshows a thin offline banner above every tab, which a screen reader announces as a live region.ConnectivityInterceptorrefuses a request that would reach the network while the device is offline, andstudioErrorreports it withAppErrorCode.networkOffline.- A screen that holds loaded data keeps it on such a failure instead of showing an error, and
OfflineStaleMarkershows it as "Offline: showing what was loaded at ", with the date before the time when the data was loaded on an earlier day. The connection list does this today; the browser levels and the result view follow the same rule. - A pull to refresh while offline shows the banner's message and sends nothing.
- An action that needs Studio, such as Run or Check, is built with
OnlineOnlyAction, which disables it offline and shows why. - When the device comes back online,
OnReconnectrefreshes the visible screen once and, while the unreachable page is up,AppConnectivityScopechecks Studio's liveness again, so that page clears without a Retry. A Studio that was answering is not checked again on reconnect, so one failed probe cannot cover a working screen.
Phones show the app in portrait and in both landscape orientations, never upside down (#127, a product decision of 2026-10-24).
AppBootstrapConfig.preferredOrientations asks for portraitUp, landscapeLeft and landscapeRight at start.
On Android no activity sets android:screenOrientation, so that list alone decides; Flutter turns it into SCREEN_ORIENTATION_USER, which follows the user's auto-rotate setting.
On iOS the iPhone key of UISupportedInterfaceOrientations in Info.plist lists the same three; the iPad key keeps all four, which iPad multitasking requires, and since iOS ignores the run-time list on an iPad with multitasking on, all four apply there.
test/architecture/orientation_platform_setup_test.dart keeps the platform files in line with the list.
On the Android emulator, a rotation kept the state of the connection list, Browse, the rows page, the row detail sheet, the key value page and the SQL editor with its result, and sent no request (the device table of PR #179).
Below a screen height of 480, Material's compact height class (AppBreakpoints.isCompactHeight), such as a phone held sideways, the layout gives the content the height (#177).
The shell shows the tabs in a NavigationRail at the side instead of the bottom bar.
A Browse heading puts its wrapped actions on the title row, and the key browser puts Load more and Load all beside its progress line.
The result view puts Load more beside the card and table toggle, except for a failed page, whose banner stays under the rows.
The connection list puts the search and the environment chips on one row, and the badges on the name line.
The statement capture note stays fixed above the list.
The test reads the screen size, which the keyboard does not change, so typing never switches the layout, and the page keeps its place in the tree, so a rotation keeps its state.
- "Sign in with SSO" runs the authorization code flow with PKCE in a Custom Tab (Android) or
ASWebAuthenticationSession(iOS). The tokens go to the platform's secure storage. The sign-in then callsGET /api/auth/meand succeeds only on its 200; any other answer clears the stored tokens and shows why, so an account Studio refuses never opens the app. The role comes only from that answer (StudioIdentity.role): the app reads no role from a token, maps none and stores none. AuthInterceptoradds the access token to every request asAuthorization: Bearer.- When Studio refuses the token,
TokenRefreshInterceptorrefreshes it at Keycloak once and repeats the request. The session ends when the refresh fails at Keycloak, or when Studio answers the repeated request with 401AUTH_REQUIRED(isStudioSessionRefused). - Logout ends the Keycloak session (RP-initiated logout) and deletes the app's tokens.
Once #18 lands, the app first posts
POST /api/auth/logout, so Studio writes a logout line in its audit trail as it does for a web sign-out. That revokes nothing: Studio keeps no state for a bearer session.
Which 401 starts a refresh is Studio's rule, in isStudioSessionExpired, and which 401 after the refresh ends the session is the rule of isStudioSessionRefused:
| Studio answers | The app |
|---|---|
401 AUTH_REQUIRED |
refreshes the token and repeats the request once; a second 401 AUTH_REQUIRED ends the session |
401 without a code (/api/auth/me's {"authenticated":false}, Studio before libredb-studio#1922) |
refreshes the token and repeats the request once; a second 401 without a code shows the error with a retry, and the session stays |
| a failed refresh at Keycloak (expired or revoked refresh token) | ends the session |
401 AUTH_ERROR (the database refused its credential) |
shows the error; the session stays |
401 LLM_AUTH |
the same; the AI routes are not used |
503 IDENTITY_PROVIDER_UNAVAILABLE, on any route and from libredb-studio#1922 on also on GET /api/auth/me |
shows the error as transient with the screen's retry; no refresh, the token stays |
403 BEARER_NO_ROLE or BEARER_NO_IDENTITY (from libredb-studio#1922 on) |
no refresh, the session stays; signed in, the shell covers every screen with the refusal page, which offers Retry and Logout, and once Retry is accepted a screen that failed under the page loads once by itself unless its request runs a statement; at sign-in the sign-in is refused |
401 AUTH_REQUIRED on POST /api/auth/logout (from libredb-studio#1922 on) |
refreshes the token and posts the sign-out record again, within the sign-out's 3 seconds; the sign-out goes on either way |
The rule after a refresh (#12):
- A 401 without a code that comes back after a successful refresh leaves the session alone; the error reaches the screen, which offers a retry.
- A 401
AUTH_REQUIREDthat comes back after a successful refresh ends the session, because Studio refused a token Keycloak has just issued. - A refresh that Keycloak refuses ends the session, as before.
Every request follows these rules:
- The base URL is
PROD_API_URL, Studio's origin plus/api; the repositories use paths such as/connections/managed. - Every
POSTsendsContent-Type: application/jsonand noOriginheader, which is how Studio's CSRF check lets a native client through. - A managed connection is named in the body as
{"connectionId": "seed:<id>"}, never inline; the app never sends or stores database credentials. - Nothing that runs a statement is retried on its own: a statement may have changed data.
- The app waits 30 seconds for an answer, and longer for the routes that run a statement or reach a database host, because Studio cancels a run whose request the phone gives up on.
/db/queryand/db/transactionwait the connection's query timeout plus 15 seconds,/db/multi-querywaits without a limit, and/db/profile,/db/monitoring,/db/maintenanceand/db/test-connectionwait 75 seconds (studioStatementReceiveTimeout), Studio's default statement timeout of 60 seconds plus the margin. Statements records why. - Most
/api/db/*requests spend a slot of the user'squeryrequest budget, 120 per minute by default, shared with the same user's Studio sessions in the browser: the object browser caches metadata per connection. See Rate limit for which requests do. - Requests stay well under 10 MiB, above which Studio truncates the body and reads it as malformed.
| Studio endpoint | Repository method | Answer |
|---|---|---|
GET /api/auth/me |
IStudioIdentityRepository.fetch |
StudioIdentity |
POST /api/auth/logout |
IStudioIdentityRepository.recordSignOut |
none; Studio only writes its logout audit line |
GET /api/health |
IStudioStatusRepository.liveness |
whether Studio answers |
GET /api/ready |
IStudioStatusRepository.readiness |
StudioReadiness, the 503 answers included |
GET /api/connections/managed |
IConnectionRepository.listManaged |
ManagedConnections (StudioConnection list and cacheHint) |
GET /api/connections/policy |
IConnectionRepository.fetchPolicy |
ConnectionPolicy |
POST /api/db/test-connection |
IConnectionRepository.test |
ConnectionTestResult |
POST /api/db/provider-meta |
IProviderMetaRepository.providerMeta |
ProviderMeta |
POST /api/db/objects/containers |
IObjectBrowserRepository.containers |
List<ObjectContainer> |
POST /api/db/objects/counts |
IObjectBrowserRepository.counts |
Map<String, KindCount> |
POST /api/db/objects/list |
IObjectBrowserRepository.list |
List<DatabaseObject> |
POST /api/db/objects/describe |
IObjectBrowserRepository.describe |
ObjectDetail |
POST /api/db/objects/source |
IObjectBrowserRepository.source |
ObjectSource |
POST /api/db/objects/search |
IObjectBrowserRepository.search |
List<DatabaseObject> |
POST /api/db/keys/scan |
IObjectBrowserRepository.scanKeys |
KeyScanPage |
POST /api/db/query |
IStatementRepository.run, which ISqlEditorRepository.run calls |
QueryResult |
POST /api/db/multi-query |
ISqlEditorRepository.runScript |
ScriptResult |
POST /api/db/cancel |
IStatementRepository.cancel, which ISqlEditorRepository.cancel calls |
whether the engine stopped the run |
POST /api/db/transaction |
beginTransaction, runInTransaction, commit, rollback, transactionStatus |
the state of the transaction; TransactionEnd for commit and rollback |
Studio counts requests per user in buckets, per Studio process (src/lib/api/rate-limit.ts).
- These spend no slot:
GET /api/connections/managed,GET /api/connections/policy,GET /api/auth/me,POST /api/auth/logout,GET /api/health,GET /api/readyandGET /api/db/health. POST /api/db/health,POST /api/db/provider-meta, every other/api/db/*route, the object routes under/api/db/objectsand the storage routes spend the sharedquerybucket. Studio declined S-13, which asked it to stop chargingprovider-meta, so the session-longCachedProviderMetaRepositoryis what keeps that cost down.CachedProviderMetaRepositorykeeps each connection's provider-meta in memory for the session, andCachedObjectBrowserRepositorykeeps the containers, counts, lists and describe answers for 10 minutes; source, search and key pages are always read afresh.app_dependencies.darthands every feature the cached ones. Two callers asking for the same answer while its request is in flight share it, and a failure, a 429 included, is returned without touching the entry that was there. A pull to refresh passesrefresh: true, which reads afresh and replaces the entry.SchemaChangeNotifier.statementSucceededdrops a connection's browser answers after a statement that matches the engine'sschemaRefreshPattern, then tells the open screens onchanges.invalidateandinvalidateContainerdrop answers on demand, andevictExpireddrops the answers past their time when the user switches connection.- Both caches are emptied when the session ends, and an answer still on its way is stored only while the session generation it was sent under has not ended and no invalidation covering it ran since it was sent, so it never refills a cache after a sign-out or brings back a dropped table. Nothing is written to disk.
- A refused request answers 429 with
Retry-After, which reaches the caller asstudio.rate_limited. POST /api/db/test-connectionspends a slot and opens a connection to the database, so the app checks a connection only when the user taps Check in its details sheet, never on the list or on open, and drops a second tap while a check runs. Like Studio, it does not check a connection whoseprovider-metadeclaresresumesBilledCompute, since the check would wake compute the engine bills for, unless the user chooses Check anyway. ThewarningsStudio sends on either success shape, such as Materialize opening a database that does not exist, show under Reachable in a caution style, each as Studio'sdescribeWarningwrites it (the severity, then the message, orWarningand the code for a warning without one); awarningsthat is not a list fails the check, and an entry without a string message is left out.
provider-meta describes the engine: how many container levels it has (none, a schema, or a database and its schemas), their names in the engine's words, and its object kinds.
Kind ids such as table or collection are opaque: their labels come from Studio, never from the app, so a new engine needs no app change.
Its labels give the engine's words for what a screen shows (Collection, document, Find Documents); each is optional in the app, and a screen that finds one missing uses its own translated wording.
ProviderMeta.refreshesSchema tests a statement against Studio's schemaRefreshPattern, case-insensitively as Studio does, and answers false for a pattern Dart cannot compile.
The tree is containers, then counts for a container, then list for one kind, then describe or source for one object, as each kind's hasColumns and hasSource allow.
The object routes carry no catalog field: on a server-level connection the catalog is container[0] or path[0] (src/app/api/db/objects/list/route.ts).
Only the routes that run statements or read a connection's state take a catalog body field: query, multi-query, transaction, profile, monitoring, maintenance and its preview, POST /api/db/health and pool statistics.
Key-value engines page their keys with scanKeys until the cursor is 0.
runsends one statement, with an optional page, bind values and a run id. Every feature sends it throughIStatementRepository, so the app has one/db/querycall; the SQL editor'srundelegates to it. Studio pages a SELECT only when it adds the row bound itself;QueryPage.afterasks for the next page, andpagination.hasMoresays whether there is one.appendResultPageappends that page to the rows on screen by Studio's own rule (src/lib/results/append-page.ts).database, sent only when set and always as a JSON number, names the numbered database a key was walked in; Studio refuses it with a 400 on an engine that scans no keys, so only a Redis key read sends it.- The object browser's View rows and Count rows run the statements Studio's own generators write (
generateTableQueryandgenerateCountQuery), ported toPreviewStatementGenerator, throughIStatementRepository: the preview with a page of 50 rows and no row bound in its text, in the path'scatalogon a server-level connection, and the count once per tap. Neither is ever retried by itself, after a failure or a schema change; only the user's pull, retry or tap runs one again. An engine whose preview the app does not write yet (a declared query dialect,previewProjection,previewTimeWindow,containerNameSpelling: "engine", Couchbase, or a query language other thansqlandjson) shows View rows disabled and offers no count. Open in editor is offered wherever View rows opens and nowhere else: it hands the same preview, with itscatalog, to the editor as anEditorOpenRequestthroughopenEditor, runs nothing and sends nothing to Studio, so it stays enabled offline. The rows page shows the database's own words forstudio.query_error, as the editor does, on screen only. - A tap on a key in the key browser follows Studio's
onOpenKey: an engine with a key-browser kind (etcd'skey, Firestore'sdocument) opens that kind's source for the path[key]and runs nothing; Redis runs the read Studio's key browser writes for the type the key's page read, ported toRedisKeyReadGenerator, orTYPE <key>when the page named none of the seven types it reads. The read carriesdatabasewhen the walked container is all digits, and a key ending in:*is read like any other, where Studio scans it as a prefix group. After aTYPEanswer only the user's 'Read value' runs the typed read; a(nil)or(empty list)answer withrowCount: 0, orTYPEansweringnone, says the key no longer exists. - The fields of a result that report lost or incomplete data (
paginationwith itswasLimited,warnings, andopenTransaction, which Studio sends only asrolled-back) are read strictly in every reader that builds aQueryResult: present but malformed fails the read, so the screen shows a failure instead of hiding that rows or notices are missing. runScriptsends several statements, which Studio splits and runs in order, stopping at the first failure. It answers 200 even then, so a failed statement is part of theScriptResult, not an error; it takes no bind values. A page given to it is sent asoptionsand bounds the last statement when that is a SELECT, but Studio answers no paging for a script, so there is no next page. Deciding whether a text holds one statement or several is the editor's job (milestone M5).- The SQL editor (#113) sends one statement through
run, whole, with a fresh run id, and never sends it again by itself. A text over 1,000,000 characters is refused on the phone, since Studio bounds the text of a console engine only. Any other text the check flags runs only after the write confirmation, and so does each Load more of it, because every page runs the whole statement again on Studio; the text sent is the one confirmed. Astudio.query_errorshows the database's own words under the editor and places the cursor atdetails.position, which counts characters from 1 and is converted to a UTF-16 offset. The rolled-back notice advises the engine's commit keyword,EXECon Redis andCOMMITelsewhere, as Studio's web does. - The script run (#117): on a SQL connection a text that
InterimWriteCheckreads as several statements goes torunScriptwith the first page and the run id, after the write confirmation every script asks; any other text goes whole torun, and a non-SQL text is never split. Run sends the editor's non-blank selection when there is one, read and routed the same way, and a selection read as one statement on an engine whosestatementTerminatorisnoneis sent without its trailing;, as Studio'sselectionTargetdoes. The answer shows a card per statement Studio ran, the statements it did not run as one line, and the last statement with rows open in the result view, masked with the run's policy and without Load more; a successful statement'srowCountmust be a whole number, else the read fails. The rolled-back notice keeps the commit advice only when every statement succeeded, and each successful statement's text is told toSchemaChangeNotifier. A script shows its elapsed time while it runs, and Cancel stops it by its run id as it stops a run. A script runs in the active database on a server-level connection, and writes one history entry for its whole text, Failed when a statement failed. - On a server-level connection (
catalogSessions, #115) the editor sends the active database ascatalogon each run and on each later page of its result; Studio refuses a statement there that names none with a 400CONFIG_ERROR, and acatalogon any other connection the same way, so the editor sends none elsewhere.ActiveCatalogCubitkeeps one active database per connection, as Studio's web does: Browse makes a database active when the user opens it, the editor's database chip when the user picks one, and on opening the editor picks the one chosen in this session, else the one remembered on the device (ActiveCatalogPreferences, per signed-in subject and connection) while the server still lists it, else the first listed. The editor reads the server's databases throughIContainersRepository, the object browser's cached repository under a narrow shared interface, so the list costs no request Browse already made. - After a successful statement the editor calls
SchemaChangeNotifier.statementSucceeded. Decided in #113 for #88 item 3: when provider-meta cannot be read after a successful statement, the connection's browser answers are dropped andchangesis told all the same, and the failure is logged by its code, because a needless reload costs one request while a stale tree shows a table that no longer exists. cancelstops a run by the id the app gave it, fromnewQueryId, a UUID: Studio checks no owner on a cancel, so the id must be unguessable. The cancelled run fails withstudio.query_cancelled.ApiClient.posttakes aCancelSignal, which drops the request through Dio's cancel token; the dropped request fails withRequestCancelledException, whichstudioErrorreports asRequestCancelledError, no error any screen shows, and which is neither retried nor counted against the circuit breaker.- The editor's Cancel (#116): with the engine's
supportsQueryCancelit postscancelwith the run's id while the run's request is still open, and drops the request once Studio answered. The order is measured: Studio's abort bridge stops the run as soon as its request is dropped, so a cancel posted after the drop, the order of Studio's web Stop, answers{"cancelled": false}for a run that did stop.{"cancelled": true}or a 499QUERY_CANCELLEDsays Cancelled,{"cancelled": false}says Studio could not confirm the cancel; a failed cancel, a 429 with its countdown included, leaves the run going, and no cancel is sent again by itself. Without the engine's cancel, Stop waiting drops the request only and says the statement may still run. Offline, Cancel is disabled with theOnlineOnlyActionreason and posts nothing (#164); Stop waiting posts nothing, so it stays enabled offline, both as the label of an engine without cancel and beside Cancel after a failed cancel. - The object browser's rows page and Redis key value page drop their requests in flight when they close, and post no
cancel, so leaving spends no request slot; Studio's abort bridge then stops the run where the engine supports it. - A transaction's statements cannot be cancelled.
Without governance Studio keeps one transaction per connection for every user of it, owned by the account that began it, and refuses others with
studio.transaction_not_ownedfor up to five idle minutes. On a server-level connection it keeps one per database, socommit,rollbackandtransactionStatusname thecatalogthe transaction began in; without it Studio finds no open transaction. - Studio answers a commit
{"status": "committed"}and a rollback{"status": "rolled_back"}, both 2xx and only after the engine ended the transaction; a commit or rollback the engine refused is an error answer (src/app/api/db/transaction/route.ts). So a 2xx is aSuccesswhatever its body:TransactionEnd.confirmedwhen the body names that end,TransactionEnd.unreadableAnswerwhen it does not, and the transaction is over either way, so the UI never offers Commit again. Only aFailureleaves the transaction open.
Studio cancels a statement whose request is aborted (src/lib/api/abort-cancel.ts), so a phone that gives up sooner than Studio would both fail and cancel a statement Studio was still allowed to finish.
Decided in #88:
/db/queryand/db/transactionwait the connection's query timeout plus 15 seconds. The managed list carriesqueryTimeoutin milliseconds when the connection sets its own or governance sends its runtime settingquery.timeoutMs(src/lib/governance/visible.ts:105-106), and the reader keeps it only when it is a positive number.ConnectionQueryTimeoutsholds the timeouts of the last list read;IConnectionRepository.listManagedreplaces it and the statement repositories read it, so no caller passes a timeout. A connection without one, or one the last list did not name, waits 75 seconds, Studio's default of 60 (DEFAULT_QUERY_TIMEOUT,src/lib/db/types.ts:55) plus the margin./db/multi-querysets no receive timeout (option (b) of #88): Studio gives each statement of a script the connection's query timeout (src/app/api/db/multi-query/route.ts:335-353), so a script may run for its statement count times that, which the phone cannot count before Studio's statement analysis (S-06). Any finite limit would cut a script Studio may still finish and cancel it through the abort bridge (:296-297), leaving its first statements committed and the rest not run; Studio's web sets none either (appFetch,src/lib/config/base-path.ts:144-150).runScriptsendsreceiveTimeout: Duration.zero, which Dio 5 reads as none, since null would keep the client's 30 seconds. Cancel (#116) and the backgrounding rule (#118) end a script the user gives up on, and the editor shows the elapsed time while one runs (#117).- None of these requests is ever sent twice, and no statement text is logged.
Studio's error body is {error, code?, ...}.
ApiClient keeps the HTTP status, the code, the Retry-After delay, the scrubbed error text and, on a 400, details.position (a whole number or a string of digits, else none) on the exception, and studioError maps them, the code first and the status after:
| Studio code | AppErrorCode |
|---|---|
AUTH_REQUIRED |
auth.invalid_access_token |
AUTH_ERROR (401) |
studio.database_auth |
AUTH_ERROR (403), CONNECTION_NOT_FOUND |
studio.connection_not_available |
PERMISSION_DENIED |
studio.permission_denied |
CUSTOM_CONNECTIONS_DISABLED |
studio.custom_connections_disabled |
QUERY_ERROR |
studio.query_error |
QUERY_CANCELLED |
studio.query_cancelled |
CONFIG_ERROR |
studio.config_error |
TIMEOUT_ERROR |
studio.timeout |
CONNECTION_ERROR, POOL_EXHAUSTED |
studio.database_unreachable |
GOVERNANCE_UNAVAILABLE, AUDIT_UNAVAILABLE |
studio.governance_unavailable |
IDENTITY_PROVIDER_UNAVAILABLE |
studio.identity_provider_unavailable |
BEARER_NO_ROLE (403, from libredb-studio#1922 on) |
studio.account_has_no_role |
BEARER_NO_IDENTITY (403, from libredb-studio#1922 on) |
studio.account_has_no_identity |
RATE_LIMITED, any 429 |
studio.rate_limited, as a RateLimitedError with the delay |
TRANSACTION_NOT_OWNED |
studio.transaction_not_owned |
TRANSACTION_CREDENTIAL_CHANGED |
studio.transaction_credential_changed |
| any 413, which only a console engine's text bound answers | studio.statement_too_large |
| an answer the app cannot read | studio.unreadable_answer |
A sign-in that GET /api/auth/me refuses with any other AuthError, a 401 that reached the caller, shows studio.account_refused.
A screen shows the translated message of the code.
The SQL editor and the object browser's rows and key value pages also show the database's own words, the message of the error, because a syntax error or a refused read is what the user needs to read there.
When Platform governs Studio, fetchPolicy answers governance: true, and:
- the connection list is the user's unexpired grants, for admins too;
- a connection that is not granted answers
studio.connection_not_available; - Studio answers
studio.governance_unavailablewhile it cannot serve its copy of the grants, which the app shows as such, never as an empty list; - every statement is recorded for Platform's audit log, which the app tells the user through the policy's
statementCapture; - an empty list says that no database has been granted yet and that an administrator gives access, as Studio's web client does.
The connection list follows grant changes while it is open (ConnectionListBloc, #24):
- It asks for
GET /api/connections/managedagain everycacheHint, clamped to 5 to 60 seconds as Studio's web client does, and at once when the app comes back to the foreground, but only while the list is on screen, the app is in the foreground and the device is online. Each new list asks for the policy again (ConnectionPolicyCubit.refresh), which keeps the policy it had when that fails. Neither request spends a rate-limit slot. - A 503
GOVERNANCE_UNAVAILABLEisConnectionListUnavailable: the rows loaded before stay greyed under the outage notice, or the notice covers the page with a Retry on a cold start, and the list asks again every 10 seconds until Studio answers. The page reports the outage and its end through the sharedGovernanceStatus, whichAppShellbacks withStudioStatusBloc, so readiness words the notice. A retry that fails before it reaches governance, such as a 429, a 503IDENTITY_PROVIDER_UNAVAILABLEor a 500, keeps the rows greyed and the outage, and a 429 makes the next retry wait as long as Studio asked. Each page's first list reports governance as answering, so an outage left by a page closed during it ends there. - Another failure of a refresh, such as a 429 or a 503
IDENTITY_PROVIDER_UNAVAILABLE, keeps the rows withStudioErrorBannerand the timer running; on a cold start it is the failure page with a Retry. - An
AuthError, such as the 403BEARER_NO_ROLE, drops the rows and stops the timer until the user retries, because Studio would refuse and audit every tick the same way. - While
statementCaptureisfull, a line under the app bar says that statements are recorded in full in the server's query log. - each user has their own transactions.
Grants refer to the Keycloak subject, so the app must sign in to the same realm as Studio's web sign-in.
Studio masks sensitive columns in results only in the browser (src/lib/data-masking.ts); POST /api/db/query answers raw rows.
The app therefore applies the same masking rules before it shows a row (M4-03, in #32).
- The phone holds Keycloak tokens in secure storage and nothing else: no database credential, no connection the server does not manage.
- Studio sends the credentials of an unmanaged seed connection in the connection list; the app's reader does not read them.
- The HTTP log carries method, path, status and body length; bodies are logged only in verbose mode, which is off by default, and with secrets masked. The repositories never log statement text or database error text.
- Certificate pinning by public key is built in and off by default;
STUDIO_CERT_PINSandOIDC_CERT_PINSturn it on per host for a release build (security.md). Screen capture protection keeps results, a row's detail, a key's value and an object's source out of screenshots while they are on screen, on by default in the prod build. security.md describes them, the token storage and the inactivity sign-out, and crash-reporting.md what a crash report may carry.
- Under governance, Studio still refuses a session as 401 when a route cannot read its account registry (
getSession,src/lib/auth.ts:81-86at libredb-studio c9989a6b5), which the app reads as a refused token. - Studio refuses an access token that carries
nonce; Keycloak 26.8.1 does not put it there, which the walkthrough in studio.md confirmed. - Studio cancels a run when its request is aborted (
src/lib/api/abort-cancel.ts, used bysrc/lib/api/editor-query.ts:135andsrc/app/api/db/multi-query/route.ts:297). So a client receive timeout shorter than Studio's query timeout kills the statement on the server (#15; Statement timeouts says how the app avoids it). Going to the background during a run can drop its request too, since iOS suspends an app's sockets soon after it leaves the screen (#118). The app asks iOS for background time (beginBackgroundTask) while a run is in flight and gives it back when the answer arrives, on cancel and when the editor closes; Android asks for nothing. A run that fails without an answer from Studio while or after the app was in the background shows, on return, that it was interrupted: a read that the write check runs without asking offers Run again (a script never counts as one, since the check asks before every script), anything else says that its outcome is unknown and offers no Run again, and nothing is sent again by itself. A page of Load more follows the same rule (#150), since Studio runs the whole statement again for each page: it asks for background time while in flight, and a page dropped that way keeps the rows on screen and says so, with the result view's retry for a read and no retry for anything else. The Browse rows page follows it too (#156): its preview and each page of Load more ask for background time while in flight, and since both are reads, a dropped preview says it was interrupted with the page's Retry and a dropped page keeps the rows with the result view's retry. Studio cannot say afterwards whether a dropped run finished. Measured on the Android emulator (AVDlibredb-dev-api30, Android 11, debug build) againsttool/studio-dev(Studioc9989a6b5): with 60 seconds in another app, Android kept the socket, soSELECT pg_sleep(20)and anUPDATEthat sleeps 20 seconds both finished in the background and showed their results on return; with the connection dropped 3 seconds into the background (a restart of the Caddy proxy), theUPDATEshowed 'Outcome unknown' with no Run again while Studio logged the query as cancelled, and theSELECTshowed the interrupted message with Run again. iOS was not measured here, since no macOS was available; it goes to the macOS run of #91. Measured in #116 ontool/studio-dev(Studioc9989a6b5, through its Caddy TLS proxy): aSELECT pg_sleep(30)onseed:dev-postgreswhose request the client dropped after one second was gone frompg_stat_activitywithin 0.1 seconds, so a dropped request is passed through the proxy and stops the run. - Granting a connection needs an API in Platform that does not exist yet (milestone M9).
- Native Sentry events and breadcrumbs on iOS are not scrubbed (crash-reporting.md).
- Studio offers no maintenance mode and no minimum app version, so the app has no maintenance or forced update gate; milestone M7 brings the gate back when Studio offers one.
- Governance revocation: Studio compares the token's
iatwith the subject'srevoked_before(src/lib/local-accounts.ts:445-449, 479-484). The app's token refresh produces a neweriat, so a refreshed token passes again unless Platform also ends the Keycloak session or revokes its refresh tokens.