api-client/packages/hoppscotch-desktop/src/services/persistence.service.ts
Shreyas 96ceb84df9
fix(desktop): unified store scope and migration reroute (#6238)
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
2026-04-28 14:59:10 +05:30

406 lines
15 KiB
TypeScript

import * as E from "fp-ts/Either"
import { invoke } from "@tauri-apps/api/core"
import { z } from "zod"
import { StoreError } from "@hoppscotch/kernel"
import {
DESKTOP_SETTINGS_SCHEMA,
DESKTOP_SETTINGS_STORE_KEY,
DESKTOP_SETTINGS_STORE_NAMESPACE,
type DesktopSettings,
} from "@hoppscotch/common/platform/desktop-settings"
import {
UPDATE_STATE_SCHEMA,
UPDATE_STATE_STORE_KEY,
} from "@hoppscotch/common/platform/update-state"
import type { Instance } from "@hoppscotch/common/platform/instance"
import { Log } from "@hoppscotch/common/kernel/log"
import { Store } from "~/kernel/store"
import {
createStoreResource,
type StoreResource,
} from "~/kernel/store-resource"
import type { UpdateState } from "~/types"
const LOG_TAG = "persistence"
// Shared namespace for every desktop-local store resource. Individual keys
// live in `STORE_KEYS` below. Exported for the small handful of callers
// that still touch the store directly.
export const STORE_NAMESPACE = DESKTOP_SETTINGS_STORE_NAMESPACE
export const STORE_KEYS = {
UPDATE_STATE: UPDATE_STATE_STORE_KEY,
CONNECTION_STATE: "connectionState",
RECENT_INSTANCES: "recentInstances",
SCHEMA_VERSION: "schema_version",
// Legacy key. Written by portable builds in schema v1. Read only by the
// v1 to v2 migration. All other code uses `DESKTOP_SETTINGS`.
PORTABLE_SETTINGS: "portableSettings",
DESKTOP_SETTINGS: DESKTOP_SETTINGS_STORE_KEY,
} as const
// Runtime validator for `Instance` values read from the store. The type
// annotation pins the Zod output to the canonical `Instance` in common,
// so any drift between the definition stored here and the definition
// consumed by the webview's instance service would fail typecheck
// rather than silently producing a mismatched runtime value.
export const INSTANCE_SCHEMA: z.ZodType<Instance> = z.object({
kind: z.enum(["on-prem", "cloud", "cloud-org", "vendored"]),
serverUrl: z.string(),
displayName: z.string(),
version: z.string(),
lastUsed: z.string(),
bundleName: z.string().optional(),
})
// Runtime definition of the persisted connection state. NOTE: the
// canonical `ConnectionState` type in
// `@hoppscotch/common/platform/instance.ts` is a discriminated union,
// while the persisted form here is flat with optional fields per
// variant. The two are semi-compatible (the union serializes cleanly
// into this flat form), but the reader loses type narrowing. A later
// migration can switch the persisted form to a discriminated union so
// common's type becomes the single source of truth end-to-end.
export const PERSISTED_CONNECTION_STATE_SCHEMA = z.object({
status: z.enum(["idle", "connecting", "connected", "error"]),
instance: INSTANCE_SCHEMA.optional(),
target: z.string().optional(),
message: z.string().optional(),
})
export type PersistedConnectionState = z.infer<
typeof PERSISTED_CONNECTION_STATE_SCHEMA
>
// Re-exported for callers that import from this service. The canonical type
// lives in `@hoppscotch/common/platform/desktop-settings`.
export type { DesktopSettings }
// Legacy `PortableSettings` interface. Kept as a local type (not
// exported, not re-exported from `~/types`) because the v2 migration
// is the only reader and no new code should produce this form. Once
// the migration is retired this type can be dropped entirely.
interface LegacyPortableSettings {
disableUpdateNotifications: boolean
autoSkipWelcome: boolean
}
interface Migration {
version: number
// Each migration returns its result as an `Either` so the surrounding
// `runMigrations` loop, and in turn `init`, can propagate the
// `StoreError` contract without falling back to throws.
migrate: () => Promise<E.Either<StoreError, void>>
}
const migrations: Migration[] = [
{
version: 1,
migrate: async () => E.right(undefined),
},
{
// v1 to v2. Introduces `DesktopSettings` as the single source of truth
// for all desktop builds. Portable users had `portableSettings` with two
// fields, standard-build users had nothing. Both land in `desktopSettings`
// with full defaults for any field not carried over.
//
// Legacy `portableSettings` is intentionally left in place. The key
// is cheap to keep, it preserves a rollback path, and a later
// migration can prune it once the v2 definition has stabilized.
version: 2,
migrate: async () => {
// Decide whether to skip based on the existing `desktopSettings`
// payload. Two paths can re-run this migration after it succeeded
// once. A user downgrades to a pre-v2 build, which resets
// `SCHEMA_VERSION` to "1" because the older code does not
// recognize "2" and rolls it back. A re-upgrade then sees v1
// again and tries to migrate. The other path is a corrupted
// `SCHEMA_VERSION` value, which the `runMigrations` parse-defense
// coerces to "1" so every migration reruns from scratch. In both
// cases, blindly running the legacy carry-forward would
// overwrite any user-set v2 fields with
// `disableUpdateNotifications` plus schema defaults, undoing the
// user's work.
//
// Three reachable cases here, each handled explicitly. A `Left`
// from `Store.get` means the store is degraded (file I/O
// failure, or not yet open) and there is no way to tell whether
// v2 already ran. Propagating the `Left` aborts the migration
// before `runMigrations` bumps `SCHEMA_VERSION`, so the next
// launch retries on a hopefully-recovered store. A `Right` with
// a present and schema-valid payload is the canonical "v2
// already happened" signal, since the migration itself is what
// writes a valid payload, so a stored value implies the
// migration ran successfully at least once. A `Right` with
// either no payload (fresh install) or a malformed payload
// (partial object, wrong field types) falls through to the
// legacy carry-forward, which writes a fresh schema-defaults
// `desktopSettings` and self-heals the corruption.
const existingResult = await Store.get<unknown>(
STORE_NAMESPACE,
STORE_KEYS.DESKTOP_SETTINGS
)
if (E.isLeft(existingResult)) {
return existingResult
}
if (
existingResult.right !== undefined &&
DESKTOP_SETTINGS_SCHEMA.safeParse(existingResult.right).success
) {
return E.right(undefined)
}
const legacyResult = await Store.get<Partial<LegacyPortableSettings>>(
STORE_NAMESPACE,
STORE_KEYS.PORTABLE_SETTINGS
)
const legacy =
E.isRight(legacyResult) && legacyResult.right
? legacyResult.right
: undefined
// Use `safeParse` with a defaults fallback. `Store.get` returns
// the raw JSON value cast to the generic without validation, so
// a corrupted legacy entry (for example a stringified boolean
// left behind by an older build) would throw from `.parse` and
// abort the full persistence init. Falling back to a fresh
// defaults parse keeps the migration progressing on bad data.
const parsed = DESKTOP_SETTINGS_SCHEMA.safeParse({
disableUpdateNotifications: legacy?.disableUpdateNotifications,
autoSkipWelcome: legacy?.autoSkipWelcome,
})
const migrated = parsed.success
? parsed.data
: DESKTOP_SETTINGS_SCHEMA.parse({})
const writeResult = await Store.set(
STORE_NAMESPACE,
STORE_KEYS.DESKTOP_SETTINGS,
migrated
)
if (E.isLeft(writeResult)) {
// Return Left so `runMigrations` aborts before recording the
// new `SCHEMA_VERSION`. Swallowing here would let the loop
// bump the version despite the failed write, and the next
// launch would treat the data as already migrated and never
// retry.
Log.error(
LOG_TAG,
"v2 migration failed to write desktopSettings",
writeResult.left
)
return writeResult
}
return E.right(undefined)
},
},
]
/**
* Facade over the desktop-local persistent store.
*
* Each persistent value is exposed as a `StoreResource<T>`, which
* carries a uniform `{ get, set, watch }` API validated through a Zod
* schema. The service itself is a thin orchestrator. It runs
* schema-version migrations on init, subscribes once to the
* desktop-settings resource so the Rust mailbox stays in sync with any
* writer, and exposes the resources as readonly members.
*
* Callers move from `persistence.setFoo(value)` to `persistence.foo.set(value)`
* and likewise for `get` / `watch`. Compound operations (e.g. adding to the
* recent-instances list) live as free functions over the resource, below.
*/
export class DesktopPersistenceService {
private static instance: DesktopPersistenceService
readonly desktopSettings: StoreResource<DesktopSettings>
readonly updateState: StoreResource<UpdateState | null>
readonly connectionState: StoreResource<PersistedConnectionState | null>
readonly recentInstances: StoreResource<Instance[]>
private constructor() {
this.desktopSettings = createStoreResource(
STORE_NAMESPACE,
STORE_KEYS.DESKTOP_SETTINGS,
DESKTOP_SETTINGS_SCHEMA,
() => DESKTOP_SETTINGS_SCHEMA.parse({})
)
this.updateState = createStoreResource(
STORE_NAMESPACE,
STORE_KEYS.UPDATE_STATE,
UPDATE_STATE_SCHEMA.nullable(),
() => null
)
this.connectionState = createStoreResource(
STORE_NAMESPACE,
STORE_KEYS.CONNECTION_STATE,
PERSISTED_CONNECTION_STATE_SCHEMA.nullable(),
() => null
)
this.recentInstances = createStoreResource(
STORE_NAMESPACE,
STORE_KEYS.RECENT_INSTANCES,
z.array(INSTANCE_SCHEMA),
() => []
)
}
public static getInstance(): DesktopPersistenceService {
if (!DesktopPersistenceService.instance) {
DesktopPersistenceService.instance = new DesktopPersistenceService()
}
return DesktopPersistenceService.instance
}
async init(): Promise<E.Either<StoreError, void>> {
const initResult = await Store.init()
if (E.isLeft(initResult)) {
Log.error(LOG_TAG, "Failed to initialize store", initResult.left)
return initResult
}
const migrationResult = await this.runMigrations()
if (E.isLeft(migrationResult)) {
return migrationResult
}
await this.setupRustSync()
return E.right(undefined)
}
/**
* Keep the Rust-side `DESKTOP_CONFIG` mailbox (see
* `src-tauri/src/config.rs`) in sync with the desktop settings resource.
*
* Two triggers. An initial push so Rust has the current value
* before any consumer reads it. A subscription to the resource's
* `watch` so every subsequent write from any writer (this shell,
* the webview, another process) gets mirrored. The write-side code
* path stays pure persistence, and the sync is a cross-cutting
* concern handled here.
*
* Both paths swallow failures. Rust already has a compile-time default
* for every field Rust cares about, so a failed sync degrades to
* "Rust reads stale value" rather than a user-visible error.
*/
private async setupRustSync(): Promise<void> {
try {
const initial = await this.desktopSettings.get()
await invoke("set_desktop_config", { config: initial })
} catch (err) {
Log.warn(LOG_TAG, "Initial DesktopSettings sync to Rust failed", err)
}
try {
await this.desktopSettings.watch((settings) => {
invoke("set_desktop_config", { config: settings }).catch((err) => {
Log.warn(LOG_TAG, "DesktopSettings sync to Rust failed", err)
})
})
} catch (err) {
Log.warn(
LOG_TAG,
"Failed to subscribe to DesktopSettings for Rust sync",
err
)
}
}
private async runMigrations(): Promise<E.Either<StoreError, void>> {
const versionResult = await Store.get<string>(
STORE_NAMESPACE,
STORE_KEYS.SCHEMA_VERSION
)
const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "1"
const rawVersion = perhapsVersion ?? "1"
// Coerce a corrupted or non-numeric stored value to the lowest known
// version. Without this, `parseInt("v2")` or `parseInt("")` would
// return NaN, every `migration.version > NaN` check below would
// evaluate to false, the loop would skip every migration, and the
// code would still write `SCHEMA_VERSION = "2"` at the end. That
// would mark migrations complete without ever running them.
// Falling back to "1" instead reruns every migration from scratch,
// which is safe because each migration is idempotent on a fresh
// store and a real fresh install lands here through the same path.
const parsedVersion = parseInt(rawVersion, 10)
const currentVersion = Number.isNaN(parsedVersion) ? "1" : rawVersion
const targetVersion = "2"
if (currentVersion === targetVersion) {
return E.right(undefined)
}
for (const migration of migrations) {
if (migration.version > parseInt(currentVersion, 10)) {
const result = await migration.migrate()
if (E.isLeft(result)) {
return result
}
}
}
// Record the new version only when the write succeeds. A silent
// failure here would leave the stored version stale, and the next
// launch would re-run every migration on already-migrated data.
const versionWrite = await Store.set(
STORE_NAMESPACE,
STORE_KEYS.SCHEMA_VERSION,
targetVersion
)
if (E.isLeft(versionWrite)) {
Log.error(
LOG_TAG,
"Failed to persist schema version after migrations",
versionWrite.left
)
return versionWrite
}
return E.right(undefined)
}
}
/**
* Adds an instance to the recent list, preserving the "most-recent-first,
* max 10, deduplicated by kind+serverUrl" invariants. Kept as a free
* function over the resource rather than a method on the service so the
* data-access concern (the resource) stays separate from the business
* rules (dedupe, sort, trim).
*/
export async function addRecentInstance(
recent: StoreResource<Instance[]>,
instance: Instance
): Promise<void> {
const current = await recent.get()
const now = new Date().toISOString()
const existingIndex = current.findIndex(
(i) => i.kind === instance.kind && i.serverUrl === instance.serverUrl
)
const merged =
existingIndex >= 0
? current.map((existing, index) =>
index === existingIndex ? { ...instance, lastUsed: now } : existing
)
: [{ ...instance, lastUsed: now }, ...current]
const next = [...merged]
.sort(
(a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
)
.slice(0, 10)
await recent.set(next)
}
/**
* Removes an instance from the recent list by `serverUrl`. Absent entries
* are silently ignored.
*/
export async function removeRecentInstance(
recent: StoreResource<Instance[]>,
serverUrl: string
): Promise<void> {
const current = await recent.get()
const filtered = current.filter((i) => i.serverUrl !== serverUrl)
await recent.set(filtered)
}