The app's data goes to the store its shape calls for. The core's storage crate owns the stores and the calls that write private files; the app crate's workspace, column-width and filter files go through those same calls in tablepro_storage::fs. The stores that come from the desktop, GSettings and the Secret Service, are in tablepro-linux-platform (crates/platform).
| Data | Backend | API |
|---|---|---|
| Saved connections | JSON file | ConnectionStore |
| Database passwords, SSH passwords and key passphrases | oo7: the host's Secret Service, or the Secret portal inside Flatpak |
SecretStore, read at each connect by KeyringCredentials |
| The TablePro account's refresh token | the same keyring, or memory alone when none answers | SecretStore through the app's KeyringRefreshToken, a tablepro_account::RefreshTokenVault |
| The account's device ID and last entitlement, or an imported offline file | JSON file | tablepro-account (core ADR 0016, ADR 0038) |
| The usage data's anonymous ID and what it sent once | JSON file | Telemetry in the app crate (ADR 0039) |
| Preferences and window geometry | gio::Settings |
AppSettings |
| Query history and saved queries | SQLite, one database | QueryHistory, SavedQueries |
| Editor tab text | one file per tab | DraftStore |
| Open tabs, open sessions, column layouts, filters | JSON files the app crate writes | WorkspaceStore, OpenSessions, crates/app/src/persistence/ |
Where files go has the paths.
ConnectionStore holds the saved-connection list, one store per storage root:
load_blocking,list_blocking,upsert_blocking,insert_after_blocking,update_blocking,remove_blocking,touch_last_opened_blocking, the group and favourite operations below,preview_import_blocking,apply_import_blockingandexport_blockingrun off the GTK thread. A change holds the store's lock across read, change and write, so two writers never lose an entry.update_blockingchanges one entry as the list holds it at the write, so a last-opened stamp from another window in between is kept.- A write goes through
write_document_blockingfirst and the cached list changes only once it landed, so a failed write leaves both as they were. The first write over a file an older build wrote keeps a copy of it first (Copies before a format upgrade). - A file that another version wrote in a newer format is refused; one in the same format is read and written whichever build wrote it.
subscribehands out a watch ofConnectionListSnapshot, so every picker sees every writer's change.- A file that cannot be read, or that holds a format newer than this build's, leaves the store
Unavailable, and it refuses every write until the user picks Reset (reset_unreadable_blocking), which moves the file aside. - A
SavedConnectiondoes not carry a password. Secrets live in the keyring, keyed by the connection's UUID, so a rename or a new host keeps them. safe_modeis the connection's Safe Mode level:off,confirm_writesorread_only. It has no default, since guessing Off would drop a guard.external_accessis what MCP clients may do on the connection:blocked,read_onlyorread_write(ADR 0022). A new connection starts atread_only. It has no default either.ai_policyis what the AI assistant may send from the connection:ask,allowornever. A new connection starts atask. It has no default either.query_timeout_secsis the editor's query timeout on the connection, in seconds, with 0 for none. Left out, the connection follows the timeout in Preferences, and it is written back without one.- The file's
groupslist is the library's folders, a tree (core ADR 0006). EachConnectionGrouphas anid, aname(ConnectionGroupName: in a connection name's form, not empty, of any length as on macOS, no control characters, which a read turns into spaces), aparent_id(none for a top-level group), anorderamong its siblings, and an optionalcolorandicon(GroupIcon: one of theConnectionIconnames, or the SF Symbol a macOS group wears, which Linux does not draw). Groups nest at most 3 levels. A new or moved group may not share a name with a sibling in any case; an imported file can still bring two that differ only in case. A group reordered inside its own parent is checked for neither rule, so two such groups, or one a hand-edited file nests deeper than 3 levels, still reorder. - A connection's
group_idnames its group, none for the top of the list, and itsorderis its place there. Every write numbers each group's groups and connections from 0, so a new entry, or one moved to another group, goes last there.insert_after_blockingsaves a new entry right after another one, in that one's group, in one write; when that one is gone it is saved asupsert_blockingsaves it. A read roots a group whose parent is missing or that sits on a cycle, keeps the first of two entries with one id, and moves a connection whose group is missing to the top. create_group_blocking,rename_group_blocking,set_group_color_blocking,set_group_icon_blocking,move_groups_blocking(to another parent, before a sibling),delete_group_blocking,move_connections_blocking(to a group, before a connection) andmove_connections_to_group_named_blockingchange the tree. Each refuses what would break it with aStorageErrorcarrying a stable code:GroupNotFound,GroupExists,GroupNameTaken,GroupCycle,GroupTooDeep,ConnectionNotFound. Deleting a group deletes the groups inside it and moves their connections to the top of the list.GroupTree::can_placeandGroupTree::can_create_inapply the same parent, cycle and depth rules without writing, so the app can turn off a move or a New Subgroup the store would refuse.favoriteslists the favourite connections in their order.add_favorites_blockingadds or moves some before a given one,remove_favorites_blockingtakes them off, and deleting a connection drops it.ConnectionList::recentis the opened connections, newest first, without the favourites.- The Linux app's picker and the window's tree show the groups as a tree in the library's order, empty ones too, each group's subgroups above its connections. Only the Move to Group menus and the search results name a group by its path from the top, such as
Work / Staging. The picker also has a Favourites section, in the favourites' order, and a Recent section with the last 5 connections opened, favourites left out. New Group makes a group at the top and New Subgroup makes one inside a group, up to 3 levels. Groups and connections move by drag and drop, or with Move Up and Move Down. - The file's
tagslist holds everyConnectionTag: anid, aname(ConnectionTagName: in a connection name's form, 1 to 32 characters, no control characters, cleaned on read as a group's is and cut to 32) and acolorfrom the same palette ascolor. No two tags share a name in any case:create_tag_blockingandupdate_tag_blockingrefuse one withTagNameTaken. - A connection's
tagsare ids from that list. Every write drops an id the list lacks and a repeated one, sodelete_tag_blockingtakes the tag off every connection in the same write, and a connection saved with a tag deleted meanwhile loses it. iconis one of theConnectionIconnames. The app draws each with a symbolic icon from the theme (crates/app/src/ui/icons.rs).- A connection's
nameis stored and compared as the list shows it: characters that show as nothing (U+200B, U+FEFF) dropped, control characters and every other space (U+00A0, U+2007) as a plain space, no blanks around it, and in Unicode NFC, so a name that looks like a saved one is the same name. Tag and group names take the same form. - A stored group or tag name made only of invisible characters, which macOS accepts for a group, reads as its Unicode escapes, so the group still shows. A new name of that kind is refused.
- An import is previewed first:
preview_import_blockingworks out every entry as it will be saved and writes nothing, the app shows each one with its host, andapply_import_blockingwrites that result, or refuses withImportOutdatedwhen the list changed in between. For an added entry the preview also names its SSH server and a TLS mode looser than a new connection's; for an updated one, the name it had when the file renames it (ImportChange::Updated,renamed_from). An imported entry never takes a name another entry holds or held before the import, whatever order the file lists them in. An updated entry whose name the file keeps keeps it, even when another entry has the same one. - On import, a tag is matched by id, then by name in any case, and added only when neither matches, so importing a file twice leaves one tag of each name. A tag matched by id takes the file's colour, and its name unless another tag has it. A connection updated by an import takes the file's tags and icon.
- On import, a group is matched by id, then by the exact name under the same parent, then by that name in any case among the groups the list had before the import, and otherwise added last in its place. So a file's own
Workandworkstay two groups, as a read of that file gives. A group matched by id keeps its place and takes the file's colour, icon, and name unless a sibling has it. Those renames come first, so a name one of them frees or takes decides how the rest match, whatever order the file lists them in. A group the file nests below the third level is not added: it and the groups under it map to the third-level group above them, so their connections land there, as macOS cuts the path. A connection updated by an import takes the file's group, going last there when the group changed; an added one keeps the file's order after the list's own. The file's favourites are added, and an import never removes one. - An edit, a copy or a move to another group goes through the app's
SavedConnectionEditor, which writes the keyring first. A copy is saved withinsert_after_blocking, right after its original, as on macOS. A refused secret stops before the list changes, and the secrets of a copy whose entry did not land are deleted again. Edits and copies run one at a time, so two windows editing one connection never leave the host of one with the password of the other.
SavedConnection.ssh holds an optional SavedSshConfig:
host, plus optionalportandusername. Leaving them out lets~/.ssh/configdecide.jump_hosts:[user@]host[:port]entries, empty for a direct connection.auth, tagged bykind:agent,private_key(optionalpathandhas_passphrase),passwordorkeyboard_interactive.
SSH passwords and key passphrases sit in the keyring next to database passwords. The system OpenSSH client runs every combination (ADR 0013), and asks for a missing secret through the app's prompts.
SecretStore (tablepro_linux_platform) is the app's SecretVault: store, load, relabel, delete_kind and delete_connection, keyed by connection id and SecretKind, with the secret as a SecretString. It is also the app's SecretItems, which holds the MCP access token and the AI providers' API keys and labels them through the app's SecretItemLabeler.
Backed by the oo7 crate (ADR 0004). Outside Flatpak it talks to the host's Secret Service over D-Bus, which GNOME Keyring and KWallet both implement. Inside Flatpak it keeps the items in an encrypted file in the sandbox, unlocked with a key from the Secret portal. Without the portal, oo7 0.6 reports the portal's error rather than trying the Secret Service, which the sandbox cannot reach anyway, so the store reports PortalUnavailable.
Notes:
- Schema name: the application id plus
.Password, soapp.tablepro.TablePro.Passwordfor an installed build andapp.tablepro.TablePro.Devel.Passwordfor a development one. Neither reads the other's secrets. Attributes:xdg:schema,connection-idandkind(db_password,ssh_password,ssh_passphrase). - The MCP server's access token is one item of its own, with
xdg:schemaandkindmcp_tokenand noconnection-id, so deleting a connection never matches it. It is made on the server's first start and replaced from Preferences. - Each AI provider's API key is one item, with
xdg:schema,kindai_api_keyandprovider(anthropic,openaioropenai-compatible) and noconnection-id. Preferences > AI stores and removes it; the assistant reads it before each request. - The TablePro account's refresh token is one item, with
xdg:schemaandkindaccount_refresh_tokenand noconnection-id. The server rotates it on every refresh, and each one replaces the item. Sign-out deletes it. When no keyring takes it, it lives in memory until the app quits and the next launch starts signed out; the item the keyring held before is deleted then, since the server refuses a rotated token. - Labels name the kind and the target, for example
TablePro database password for app@db.example.com:5432/shop, so the item is identifiable in Seahorse on a host install. Under Flatpak the items sit in the app's own file keyring. - The store unlocks the keyring and the item before reading. A locked keyring otherwise looks exactly like a connection with no saved password.
- Failures are typed (
SecretError): a dismissed prompt, a locked keyring, a missing service, a missing portal and a bad encoding each get their own message and their own next step. - With no Secret Service on the host, or no Secret portal inside Flatpak, the store reports
ServiceUnavailableorPortalUnavailableand the UI prompts at connect time. The app never writes passwords to a plain file as a fallback. - Never log a password, ever. Wrap them in
secrecy::SecretStringfrom thesecrecycrate before they leave the storage layer.
KeyringCredentials reads each secret at every connect. What happens when
it cannot depends on whether someone is there to answer:
- A connect the user started, and the connect dialog's Test, run attended. A secret that is not stored, or that a locked or missing keyring cannot give, is asked for in a dialog over the window. A missing Secret Service or portal offers no Remember switch, since nothing could keep it.
- An SSH key with no stored passphrase is left to ssh, which asks through the same prompts at the hop that needs it.
- A secret typed with Remember on is stored only once the login it was typed for works. A store the keyring refuses is reported in a toast, and the connection stays open.
- A login the server refused offers Enter Password on the failed page. That
retry passes
CredentialDemand::reenter, which asks again without reading the keyring's copy, the one just refused. - A secret typed for a session that the keyring does not hold, because
Remember was off or the keyring refused it, is the one that session's
reconnects use (
KeyringCredentials::keeping_session_secrets). Otherwise a reconnect would log in with the stored copy the server refused. - Background reconnects run unattended and never ask. A missing secret parks the session until the user presses Retry, which runs attended.
- A typed secret lives in a
SecretString, and the prompt clears its rows as it hands the answer over.
ConnectionDeletion::run removes a saved connection in an order that
leaves nothing unreachable behind when a step fails:
- The keyring secrets. If the keyring refuses, nothing else runs and the entry stays: an entry with no secrets only asks for them again, while a secret with no entry is orphaned in the keyring.
- The entry in
connections.json. - Everything kept for it: column layouts, filters, the saved tabs with any
copy Reset moved aside or an upgrade kept, the editor drafts
(
WorkspaceStore::purge), the saved queries, and the query history when the user turned that on. A failure here is reported and does not bring the entry back.
A connection open or connecting in a window is not deleted; the dialog
offers that window instead. It asks once, in that one dialog. The delete
claims the connection's key while it runs, so a connect started meanwhile
from a picker that still lists it is refused. Every picker follows the list
the store publishes, so the row goes from each once the entry is removed,
and a menu still open on it finds no connection and does nothing.
The purge waits for the drafts a just-closed workspace is still writing
(WorkspaceStore::writes_in_flight). The window holds the process until the
delete ends, as it does for every other write to the connection list.
tablepro_storage::StoragePaths resolves every directory once at startup
through GLib, so the app agrees with the rest of the desktop about the
XDG base directories
and their fallbacks. Under Flatpak they resolve inside the sandbox, and
nothing reaches outside it.
| Accessor | Path |
|---|---|
connections_file() |
$XDG_CONFIG_HOME/<dir>/connections.json |
column_layouts_file() |
$XDG_CONFIG_HOME/<dir>/column_layouts.json: each table's column layout (order, hidden columns, widths the user set) |
filter_settings_file() |
$XDG_CONFIG_HOME/<dir>/filter_settings.json |
history_database() |
$XDG_STATE_HOME/<dir>/history.db |
workspaces_dir() |
$XDG_STATE_HOME/<dir>/workspaces: one file per session, and open-sessions.json: the sessions open at the last quit as windows, one list of keys per window, windows in the order of their first recorded session and keys in the order each window adopted them |
drafts_dir() |
$XDG_DATA_HOME/<dir>/drafts |
snippets_dir() |
$XDG_DATA_HOME/<dir>/snippets: the user's SQL editor snippets, made when Preferences opens it (README) |
crashes_dir() |
$XDG_STATE_HOME/<dir>/crashes: crash reports (error-handling.md) |
telemetry_file() |
$XDG_STATE_HOME/<dir>/telemetry.json: the usage data's random anonymous_id, the account PostHog knows the install as (identified), the day app_opened last went out (opened_on) and the events sent once per install (sent_once). Written only by a build with a PostHog key, once the user shares usage data |
account_file() |
$XDG_STATE_HOME/<dir>/account.json, version 1: the device_id this machine has on the TablePro account, the last entitlement token, which is signed and verified again on read, and the account_id it names, kept once the token expires so an offline file of another account is still refused. offline_only marks an offline file imported with no sign-in: the launch then never opens the keyring, and a file that no longer verifies is removed. A file of another version reads as empty, and the device is registered again. Sign-out removes it. Without it, a launch never opens the keyring for the account |
<dir> is tablepro for an installed build and tablepro-devel for a
development one, so cargo run never writes over an installed build's
files. StoragePaths::under(root, ..) builds the same layout under a
temporary root, which is how the tests stay off the developer's own data.
Everything private goes through tablepro_storage::fs:
ensure_private_dircreates the directory tree and sets the leaf to exactly 0700.DirBuilder::modeonly applies to directories the call creates, so an existing 0755 directory still needs the explicit chmod.ensure_user_dircreates a folder the user fills, such as the snippets folder, and what is missing above it as 0700, and leaves one that exists as it is: it may be a link to a folder other tools share.write_private_blockingwrites a 0600 temporary file beside the target, syncs it, renames it over the target and syncs the directory: a reader sees the old file or the new one, never a mix, and the new one survives a power cut. The rename replaces a symbolic link rather than its target, and the new file is 0600 whatever mode the old one had.append_private_blockingappends to a file it creates 0600 when absent, for the crash report, which a second panic adds to.document::write_document_blockingencodes a versioned document and writes it withwrite_private_blocking, after keeping a copy of a file an older build wrote (Copies before a format upgrade). Every store writes its documents through it.create_private_file_if_missingpre-creates an empty 0600 file. SQLite creates its database, WAL and SHM with the umask mode, so pre-creating the database is what keeps all three off world-readable.
These calls block on fsync, so async callers run them through
Tasks::spawn_blocking_task, never on the GTK thread.
QueryHistory is the query history and, in the same database, the saved
queries (QueryHistory::saved_queries). It opens history.db with one
pooled connection in WAL mode, with sqlx's statement logging off, since
query text is the user's own SQL. It links the system SQLite: the trigram
tokenizer needs 3.34 or later, and trigram_index_available_on_system_sqlite
fails against an older one.
core/crates/storage/migrations/ holds the schema as sqlx migrations, embedded
at compile time with sqlx::migrate! and run by QueryHistory::open.
20260914000000_create_history.sql creates the history table with
query_folded, the trigram index history_search over it,
history_page_idx on (pinned DESC, executed_at DESC, id DESC) for
paging, and the saved_queries table.
An applied migration is never edited. Each change is a new timestamped file with its own fixture test. Editing one in place changes its checksum, which makes sqlx refuse every database that already applied it.
A database carrying a migration this build does not have is refused with
StorageError::HistoryNewerThanApp and nothing is written, so a build never
changes a schema newer than its own. A database at the same schema is read
and written by any build. Any other migration failure is
StorageError::HistoryMigration.
Before a migration runs on a database that already has some, open keeps a
copy of it (Copies before a format upgrade).
QueryHistory::record refuses a query over 1 MiB with
StorageError::TooLarge.
QueryHistory::prune(retention_days) removes unpinned rows in two passes:
first anything past the retention window, then anything past
MAX_UNPINNED_ENTRIES (100,000), oldest first. Pinned rows survive both.
When it deleted anything it runs FTS5 optimize, because the delete
triggers otherwise leave tombstones that grow the index as the table
shrinks. It returns a PruneReport { expired, over_cap }.
QueryHistory::search_page(&HistoryQuery, HistoryPageRequest) finds
entries that hold every search term anywhere, in any case. Each row keeps
its query lowercased by Unicode rules in query_folded, which SQLite's
lower() cannot do, so the app writes it. history_search,
an FTS5 trigram index over that column, answers a term of three
characters or more as a quoted phrase, so quotes and FTS5 operators stay
literal; a shorter term goes through instr.
HistoryQuery also narrows by connection, by StatusFilter and by a
start time. A cancelled run is stored with success = 0, so Failed leaves
it out explicitly.
A page is read by keyset on (pinned, executed_at, id), newest first
with pinned entries ahead, never by offset. The request's limit is clamped
to MAX_PAGE_SIZE (1,000), one row past it says whether another page
exists, and HistoryPage::next is the HistoryCursor the next page
starts after. Rows recorded while the user scrolls never shift a page, and no
row shows twice. Cursors compare as the query orders rows, so the dialog
can tell whether a re-pinned entry falls in the pages already read.
delete_returning(ids)deletes in one transaction, 500 ids to a statement, and returns the entries it removed. An id already gone is skipped.restore(entries)puts them back under their own ids, pinned as they were and folded again; the insert trigger indexes them. The history dialog deletes at once and offers Undo in a toast, which is this pair.clear_allempties the history, pinned entries included.delete_for_connectionremoves one connection's entries, pinned ones included, for a connection deleted along with its history, then runsoptimizeas a prune does.- A deletion the user asks for (
delete_returning,clear_all,delete_for_connectionand the saved queries'deleteanddelete_for_connection) reaches each copy an upgrade kept before the database, so a copy that fails leaves the database as it was and an Undo never loses its entries.prunetrims the copies after its own deletes. Either waytrim_upgrade_copiesalso makes each copy hold no history entry or saved query the database no longer has. It attaches the database and deletes the rest in one transaction withsecure_deleteon, optimizing the copy's full-text indexes, so the words leave its blocks and freed pages together. A copy that cannot be trimmed, being damaged, is deleted, and so is what a crash left of one: an unfinished copy, a temporary file and the journals of a copy that is gone. connections_with_historylists each connection the history holds under the name of its latest run, so a renamed connection shows once.
HistoryService::record is the one way in, for editor runs, saves and
imports. It records nothing while save-query-history is off, or when the
connection has no dialect, such as while its link is down. Otherwise the
dialect's history_recording decides, reading the SQL with the editor's own
script lexer:
- A statement that can carry a credential withholds the whole entry. Each
engine lists its own in a
CredentialRules: logins, roles, keys and links to other servers (CREATE USER,CHANGE MASTER), statements that do so only with a given word (GRANT ... IDENTIFIED), and functions or engines that take a password (dblink,OPENROWSET,s3). A routine body is read for the statements inside it, whether dollar-quoted or, on PostgreSQL, a plain string afterDOorAS. So is every string of a script that hands the server text to run (T-SQLEXECandsp_executesql, MySQLPREPARE ... FROMandEXECUTE IMMEDIATE, PL/pgSQLEXECUTE) or stores a string in a T-SQL or MySQL@variable, since the text may reach them through a variable, in this run or a later one. On SQL Server a named argument whose name holdspassword,passwdorpwdwithholds the entry whatever the procedure. The text is read every way the server may read it: with and without backslash escapes, and with each MySQL/*!comment run or skipped as each server version would. - A string, quoted name or comment that never closes withholds it too, since what is inside a literal is then a guess.
- Redis commands carry a password in their arguments, so their rules read the
command and its options rather than its words:
AUTH,HELLO ... AUTH,MIGRATE ... AUTH,ACL SETUSER,CONFIG SETof a password parameter,SENTINEL SETand a script that names one of them (redis.md). - MongoDB statements are read the same way, from their tokens:
createUser,updateUser,changeUserPassword,auth,connect,Mongo, by name or in brackets, apwd,passwordorpasswordDigestorkey, and a string holding a connection string with a password (mongodb.md).
An entry is kept whole or not at all, never rewritten to cut a secret out. Turning the setting off keeps what is already there; Clear removes it.
data/app.tablepro.TablePro.gschema.xml.in holds every app-wide preference, the window geometry and the newest release that has run. Meson installs it to $datadir/glib-2.0/schemas and compiles it; see ADR 0007.
The schema id is the application id and the path follows it: app.tablepro.TablePro at /app/tablepro/TablePro/ for an installed build, app.tablepro.TablePro.Devel at /app/tablepro/TablePro/Devel/ for a development one. Neither reads the other's settings.
| Key | Type | Default | Range |
|---|---|---|---|
window-width |
i |
1200 |
360 to 32767 |
window-height |
i |
760 |
294 to 32767 |
is-maximized |
b |
false |
|
default-page-size |
u |
1000 |
100 to 100000 |
confirm-bulk-delete |
b |
true |
|
restore-session |
b |
true |
|
last-seen-version |
s |
empty | |
share-usage-data |
b |
true |
|
usage-days |
u |
0 |
|
last-usage-day |
s |
empty | YYYY-MM-DD |
license-ask-day |
s |
empty | YYYY-MM-DD |
mcp-server-enabled |
b |
false |
|
mcp-server-port |
u |
23508 |
1024 to 65535 |
ai-provider |
enum | none |
none, anthropic, openai, openai-compatible |
ai-anthropic-model |
s |
claude-opus-5-5 |
|
ai-openai-model |
s |
empty | |
ai-compatible-model |
s |
empty | |
ai-compatible-url |
s |
http://localhost:11434/v1 |
|
ai-inline-suggestions |
b |
false |
|
connect-dialog-driver |
s |
postgres |
|
save-query-history |
b |
true |
|
history-retention-days |
u |
30 |
0 to 365 |
query-timeout-secs |
u |
60 |
0 to 3600 |
use-system-font |
b |
true |
|
custom-font |
s |
Monospace 12 |
|
style-scheme |
s |
Adwaita |
|
vim-keybindings |
b |
false |
|
csv-null-to-empty |
b |
true |
|
csv-line-break-to-space |
b |
false |
|
csv-header-row |
b |
true |
|
csv-sanitize-formulas |
b |
true |
|
csv-delimiter |
enum | comma |
comma, semicolon, tab, pipe |
csv-quote |
enum | if-needed |
always, if-needed, never |
csv-line-break |
enum | lf |
lf, crlf, cr |
csv-decimal |
enum | period |
period, comma |
tablepro_storage::AppSettings wraps it in typed accessors:
pub fn open(schema_id: &str) -> Result<Self, SettingsError>;
pub fn gio(&self) -> &gio::Settings;
pub fn default_page_size(&self) -> u32;
pub fn query_timeout(&self) -> Option<Duration>;
pub fn editor_font(&self) -> EditorFont;
pub fn csv_options(&self) -> CsvOptions;
pub fn window_geometry(&self) -> WindowGeometry;Three points worth knowing:
gio::Settings::newaborts the process when the schema is missing, soopenlooks the id up throughSettingsSchemaSource::default()first and returnsSettingsError::SchemaNotFound.- A
<range>makes GSettings refuse an out-of-bounds write.AppSettingssurfaces that refusal asSettingsError::Writerather than silently storing it. query_timeout()returnsOption<Duration>; zero seconds means no timeout, and the type says so.
Preference rows bind straight to the schema with settings.gio().bind(key, &row, prop), so a change reaches every open view through changed::<key> without a save step.
High-churn keyed data stays in files: column layouts, per-table filters and workspace tabs are unbounded maps, which is not what GSettings is for.
StorageError is the crate's thiserror enum:
- Files:
IoandPermissionswith the path,Writewith the path and GLib's error,Serde,DocumentUnavailablefor a JSON document that cannot be used, andDraftNotUtf8. Schemawith a reason: a Reset of a connection list that is readable, a nameDraftScope::newrefuses, or, fromtablepro-session, a connections write task that failed.- The history database:
Database,HistoryNewerThanApp,HistoryMigration, andTooLargefor a query over the size limit. - The saved queries:
EmptyName,NotFound, andTooLargefor a name or query over its limit. Encodefor a history CSV export.NotInitialisedandSettingsare declared, but nothing returns them:AppSettingsreturns its ownSettingsError.
A keyring failure is a SecretError from tablepro-core, not a StorageError; KeyringError::Secret carries it. UI code matches on the variants to say something useful, never the raw Display text. See error-handling.md.
- A JSON document is a
VersionedDocumentwith aversionfield. Every document starts at version 1 with no upgrade step: builds before the first release left no format to read (ADR 0033). A new shape bumps itsVERSIONand appends a step to itsUPGRADES, which turns a file of the version before into the new shape.decode_documentruns an older file through every step it is behind, oldest first, so a user who skipped releases keeps their data. A step that shipped is never changed or dropped. - The upgrade happens in memory. The file stays as it was until the next save, which keeps a copy of it and then writes the current version (Copies before a format upgrade).
- A file older than the first step, one a step cannot read, or one in a format newer than the build's is never written over. The connection list and a workspace wait for the user's Reset, which moves the file aside; the smaller state files move an unusable file aside themselves.
- Each released version of the connection list and the workspace file has a golden file that a test reads into the model it was written from:
core/crates/storage/tests/fixtures/connections-v1.jsonandcrates/app/tests/fixtures/workspace-v1.json. Once a release ships it, a fixture is never edited. A format change adds the new version's fixture and updates the expected models of the old ones, which then read through the upgrade. - The history database moves forward by sqlx migration: a new timestamped file under
core/crates/storage/migrations/with its own fixture test, never an edit to an applied one. - Update this page, and add a fragment to the Linux changelog.
A file an older build wrote is copied beside itself before the first write that replaces it with a newer format (core ADR 0012):
- The copy is
<file>.v<version>-<unix seconds>, with-<n>after it for a second copy in the same second, such asconnections.json.v1-1762300000.<version>is the format the copy holds, the document'sversionor the history's newest migration. tablepro_storage::upgrade_copiesmakes them. A JSON document's copy is its bytes as read, written 0600 bywrite_private_blocking; a copy whose bytes match the newest one is not made again. The history's isVACUUM INTOthrough the open connection, so it holds what the WAL holds too, into a 0600.partialfile. Its full-text indexes are then optimized withsecure_deleteon, sinceVACUUM INTOcopies the words of entries deleted before, and only then is it synced and renamed into place.- Each file keeps its newest 3 copies (
KEPT_PER_FILE); a new one deletes the oldest past that. - A copy of a session's tabs names their editor drafts and holds none of their text. Drafts have no format version, so the older build reads them as they are now; a tab closed since the copy comes back without its text.
- Every write of the connection list first takes the connections it no longer
has out of each copy of
connections.json, and deletes a copy it cannot read that way; a failed trim fails the write, so the next one tries again. Deleting a connection deletes the copies of the column layout and filter files, which name its tables and filter values. - A copy that cannot be made fails the write or the open, and the file is left as it was. The error names the copy's path.
- Reading never makes a copy. Nothing restores one on its own: troubleshooting.md has the steps.
upgrade_copiestests the hook with a synthetic version bump of a document and of a migration list, so it needs no format the app shipped.