Persistent variables (pvars)
Persistent variables (often called pvars) are Peers observables (reactive values in peers-sdk) whose state is stored in the PersistentVars table and survives restarts. They are the usual way to keep UI preferences, feature flags, and small bits of shared state that should not be hard-coded.
Implementation lives in @peers-app/peers-sdk (persistent-vars.ts). Device-level tests are in peers-device (persistent-vars.test.ts).
API overview
| Factory | Scope | Where it lives | Typical use |
|---|---|---|---|
deviceVar(name, opts?) | device | Current device, user’s personal database | Machine-local preferences, not synced to other devices |
userVar(name, opts?) | user | User’s personal database, synced across that user’s devices | Account-wide settings |
groupVar(name, opts?) | group | Group database for the active group context | Shared settings for everyone in the group |
groupDeviceVar(name, opts?) | groupDevice | Personal DB, name disambiguated per group | Per-group value on this device only (not synced to other devices). Example: package version prefs (packagePrefs_${packageId}) — see Package lifecycle. |
groupUserVar(name, opts?) | groupUser | Personal DB, name disambiguated per group | Per-group, per-user value synced across your devices |
Optional opts include defaultValue, userContext, dataContext, and isSecret (see below).
Instances are cached by scope, logical name, user context, and data context. Calling the same factory with the same arguments returns the same observable.
dataContext and where a row is stored
Only groupVar stores its row in a group database. For groupDeviceVar and groupUserVar, opts.dataContext (or, when omitted, the current default group) selects which group's value is addressed by suffixing the stored name with that data context id (${name}_${dataContextId}); the row itself always lives in the user's personal database. Two groups using the same logical name therefore get two distinct rows and IDs and never collide.
This matters because server-side code usually pins a dataContext (for example packagePrefsVar(packageId, dataContext) in the package version resolver) while renderer code follows the default group and passes none. Both address the same personal-DB row, so a version selected in the Packages UI is the version the main process loads.
Earlier releases stored a pinned groupDevice / groupUser var in the pinned group's database. On first load, a var that has no personal-DB row copies any such legacy row forward (secrets excepted, since their ciphertext is bound to the group key). The legacy row is left in place; the personal copy is authoritative from then on.
Duplicate names
name is unique. Current builds address a variable by a deterministic record id derived from that name. Older builds used a random id. If two devices each created a different id for the same name before they synchronized, both change records stay active and neither insert can land on the other device.
Sync resolves that pair without asking: the deterministic id owns the name when either row has one, otherwise the lexicographically smaller id does. The value, scope, and secret flag come from the row with the newer modifiedAt (a missing timestamp counts as zero; a tie keeps the identity winner's value). The other id is tombstoned, so every device ends on the same row. The rule only uses the two records every device already has, which is why the devices agree.
Observable shape
A PersistentVar<T> is an Observable<T> with two extra members:
loadingPromise— resolves when the initial database lookup has finished and subscriptions are wired. A missing row is allowed and is not created just by loading. Await this before callingdelete()or assuming persistence has caught up.delete()— removes the row and resets to the default value when a default was provided.
Reading and writing use the observable call form: myVar() to read, myVar(newValue) to write. Writes debounce through to PersistentVars save logic.
Defaults and initial writes
defaultValue is a non-authoritative in-memory fallback. Constructing or loading a pvar whose row does not exist does not persist the default by itself.
The first observable-driven save for a locally absent pvar is a weak insert. It still synchronizes, but any established normal write from another device has higher conflict priority. This prevents a newly connected device from replacing an existing userVar, groupVar, or groupUserVar value with locally initialized state before synchronization catches up. Once the row exists locally, subsequent writes are normal updates and take normal conflict priority.
Secrets
When isSecret: true, new or changed values are encrypted via rpcServerCalls.encryptData on the server path (not on a thin multi-process client that only proxies SQL). In single-process bundles (for example the PWA), encryption runs in-process when applicable.
Secret values are stored encrypted at rest and are never decrypted into the UI. The detail screen shows a masked field, and decryption only ever happens on the server/main process (see Copy Value below).
Variables screen (UI)
The Variables screen lists pvars and has two tabs:
- User — variables you created (
userCreated: true). Type a name and press Enter to create a new one. - System — everything else: system and package-owned pvars (
userCreatedunset orfalse). This tab is browse/search only (no create). Selecting any variable opens the same detail screen.
Copy Value
The variable detail screen has a Copy Value button. It copies the variable's value to the system clipboard entirely on the server/main process via rpcServerCalls.copyPvarValueToClipboard(persistentVarId, groupId?) — the same approach used by "Copy Secret Key". For secret variables, the value is decrypted server-side and written straight to the clipboard; the plaintext is never returned to or displayed in the UI.
- On desktop (Electron), a copied secret is automatically cleared from the clipboard after 30 seconds.
- On the PWA (single process), the copy uses the browser clipboard API. True process isolation isn't possible there, but decryption stays out of UI components and auto-clear is best-effort (it only clears if the clipboard still holds the copied value).
How updates reach the UI
When another process, sync, or tool updates a PersistentVars row, your in-memory observable still needs to update. Pvars subscribe to PersistentVars change notifications across data contexts (not only the default group). Those notifications ride the same named event pipeline as table dataChanged events.
For how named events are delivered in the desktop shell (including selective forwarding to the renderer), see Events.