fix(desktop): unified store scope and migration reroute (#6238)
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
df97d11753
commit
96ceb84df9
4 changed files with 170 additions and 47 deletions
|
|
@ -2,7 +2,13 @@ import { reactive, ref, readonly } from "vue"
|
||||||
import * as E from "fp-ts/Either"
|
import * as E from "fp-ts/Either"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
|
||||||
import { Store } from "~/kernel/store"
|
// Bind to the unified, process-wide store rather than the org-scoped
|
||||||
|
// default `Store`. Desktop settings are machine-level configuration
|
||||||
|
// (for example the "disable update checks" toggle), and the Tauri
|
||||||
|
// shell reads them through its own `kernel/store.ts` wrapper at the
|
||||||
|
// same physical path. Going through the org-scoped store would route
|
||||||
|
// writes to a different file and the shell would never see them.
|
||||||
|
import { UnifiedStore as Store } from "~/kernel/store"
|
||||||
import {
|
import {
|
||||||
DESKTOP_SETTINGS_SCHEMA,
|
DESKTOP_SETTINGS_SCHEMA,
|
||||||
DESKTOP_SETTINGS_STORE_KEY,
|
DESKTOP_SETTINGS_STORE_KEY,
|
||||||
|
|
@ -52,6 +58,16 @@ const loaded = ref(false)
|
||||||
let initPromise: Promise<void> | undefined
|
let initPromise: Promise<void> | undefined
|
||||||
|
|
||||||
async function loadInitial(): Promise<void> {
|
async function loadInitial(): Promise<void> {
|
||||||
|
// Open the unified store before reading. The shell already inits this
|
||||||
|
// path through its own `DesktopPersistenceService.init`, but the
|
||||||
|
// webview runs in a separate window with its own process state, so
|
||||||
|
// the underlying Tauri store still needs to be opened here. Repeat
|
||||||
|
// calls land on the same on-disk file and are harmless.
|
||||||
|
const initResult = await Store.init()
|
||||||
|
if (E.isLeft(initResult)) {
|
||||||
|
Log.warn(LOG_TAG, "Failed to init unified store", initResult.left)
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Store.get<unknown>(
|
const result = await Store.get<unknown>(
|
||||||
DESKTOP_SETTINGS_STORE_NAMESPACE,
|
DESKTOP_SETTINGS_STORE_NAMESPACE,
|
||||||
DESKTOP_SETTINGS_STORE_KEY
|
DESKTOP_SETTINGS_STORE_KEY
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ import * as E from "fp-ts/Either"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||||
|
|
||||||
import { Store } from "~/kernel/store"
|
// Bind to the unified, process-wide store rather than the org-scoped
|
||||||
|
// default `Store`. Persisted `UpdateState` is machine-level, not
|
||||||
|
// per-org, and the Tauri shell reads the same physical file through
|
||||||
|
// its own `kernel/store.ts` wrapper. Going through the org-scoped
|
||||||
|
// store would route writes to a file the shell never reads.
|
||||||
|
import { UnifiedStore as Store } from "~/kernel/store"
|
||||||
import {
|
import {
|
||||||
UPDATE_STATE_SCHEMA,
|
UPDATE_STATE_SCHEMA,
|
||||||
UPDATE_STATE_STORE_KEY,
|
UPDATE_STATE_STORE_KEY,
|
||||||
|
|
@ -251,6 +256,16 @@ function nextState(current: UpdateState, event: UpdateEvent): UpdateState {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPersistedState(): Promise<void> {
|
async function loadPersistedState(): Promise<void> {
|
||||||
|
// Open the unified store before reading. The shell already opens
|
||||||
|
// this path through `DesktopPersistenceService.init`, but the
|
||||||
|
// webview runs in a separate window with its own process state, so
|
||||||
|
// the underlying Tauri store still needs to be opened here. Repeat
|
||||||
|
// calls land on the same on-disk file and are harmless.
|
||||||
|
const initResult = await Store.init()
|
||||||
|
if (E.isLeft(initResult)) {
|
||||||
|
Log.warn(LOG_TAG, "Failed to init unified store", initResult.left)
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Store.get<PersistedUpdateState | null>(
|
const result = await Store.get<PersistedUpdateState | null>(
|
||||||
UPDATE_STATE_STORE_NAMESPACE,
|
UPDATE_STATE_STORE_NAMESPACE,
|
||||||
UPDATE_STATE_STORE_KEY
|
UPDATE_STATE_STORE_KEY
|
||||||
|
|
|
||||||
|
|
@ -25,21 +25,29 @@ import { diag } from "./log"
|
||||||
// a beforeEach guard in modules/router.ts, and survives full-page reloads
|
// a beforeEach guard in modules/router.ts, and survives full-page reloads
|
||||||
// because Tauri sets it on the initial webview URL
|
// because Tauri sets it on the initial webview URL
|
||||||
const orgParam = new URLSearchParams(window.location.search).get("org")
|
const orgParam = new URLSearchParams(window.location.search).get("org")
|
||||||
const STORE_PATH = orgParam
|
const HOST_SCOPED_STORE_PATH = orgParam
|
||||||
? `${orgParam.replace(/[^a-zA-Z0-9]/g, "_")}.hoppscotch.store`
|
? `${orgParam.replace(/[^a-zA-Z0-9]/g, "_")}.hoppscotch.store`
|
||||||
: `${window.location.host}.hoppscotch.store`
|
: `${window.location.host}.hoppscotch.store`
|
||||||
|
|
||||||
|
// process-wide store file shared across orgs. holds machine-level state
|
||||||
|
// (desktop settings, recent-instances list, update state) that should
|
||||||
|
// not vary per organization. file name matches the path each shell's
|
||||||
|
// own `kernel/store.ts` wrapper writes to and the path
|
||||||
|
// `DesktopPersistenceService` uses on the Tauri side, so common
|
||||||
|
// composables that bind here read/write the same physical file the
|
||||||
|
// shell does.
|
||||||
|
const UNIFIED_STORE_PATH = "hoppscotch-unified.store"
|
||||||
|
|
||||||
diag("store", "--- COMMON store.ts module evaluated ---")
|
diag("store", "--- COMMON store.ts module evaluated ---")
|
||||||
diag("store", "orgParam:", orgParam ?? "(none)")
|
diag("store", "orgParam:", orgParam ?? "(none)")
|
||||||
diag("store", "STORE_PATH:", STORE_PATH)
|
diag("store", "HOST_SCOPED_STORE_PATH:", HOST_SCOPED_STORE_PATH)
|
||||||
|
diag("store", "UNIFIED_STORE_PATH:", UNIFIED_STORE_PATH)
|
||||||
diag("store", "window.location.host:", window.location.host)
|
diag("store", "window.location.host:", window.location.host)
|
||||||
diag("store", "window.location.href:", window.location.href)
|
diag("store", "window.location.href:", window.location.href)
|
||||||
|
|
||||||
let cachedStorePath: string | undefined
|
// Lazy-loaded Tauri APIs. Module-scoped so every scoped store shares
|
||||||
|
// the init step and the loaded modules. Web mode never resolves these
|
||||||
// These are only defined functions if in desktop mode.
|
// because `isInitd` returns early outside desktop.
|
||||||
// For more context, take a look at how `hoppscotch-kernel/.../store/v1/` works
|
|
||||||
// and how the `web` mode store kernel ignores the first file directory input.
|
|
||||||
let invoke:
|
let invoke:
|
||||||
| (<T>(cmd: string, args?: Record<string, unknown>) => Promise<T>)
|
| (<T>(cmd: string, args?: Record<string, unknown>) => Promise<T>)
|
||||||
| undefined
|
| undefined
|
||||||
|
|
@ -102,9 +110,21 @@ export const getLogsDir = async (): Promise<string> => {
|
||||||
return await invoke<string>("get_logs_dir")
|
return await invoke<string>("get_logs_dir")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Factory for a Store wrapper bound to a specific store file. Each
|
||||||
|
// instance keeps its own resolved-path cache so two scoped stores
|
||||||
|
// never alias their absolute paths. Tauri-API loading and kernel
|
||||||
|
// module access are module-scoped above, so the factory only
|
||||||
|
// handles the per-store concerns.
|
||||||
|
function createScopedStore(staticPath: string) {
|
||||||
|
let cachedStorePath: string | undefined
|
||||||
|
|
||||||
const getStorePath = async (): Promise<string> => {
|
const getStorePath = async (): Promise<string> => {
|
||||||
if (cachedStorePath) {
|
if (cachedStorePath) {
|
||||||
diag("store", "getStorePath: returning cached:", cachedStorePath)
|
diag(
|
||||||
|
"store",
|
||||||
|
`getStorePath(${staticPath}): returning cached:`,
|
||||||
|
cachedStorePath
|
||||||
|
)
|
||||||
return cachedStorePath
|
return cachedStorePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,29 +133,33 @@ const getStorePath = async (): Promise<string> => {
|
||||||
if (join) {
|
if (join) {
|
||||||
try {
|
try {
|
||||||
const storeDir = await getStoreDir()
|
const storeDir = await getStoreDir()
|
||||||
cachedStorePath = await join(storeDir, STORE_PATH)
|
cachedStorePath = await join(storeDir, staticPath)
|
||||||
diag(
|
diag(
|
||||||
"store",
|
"store",
|
||||||
"getStorePath: resolved desktop path:",
|
`getStorePath(${staticPath}): resolved desktop path:`,
|
||||||
cachedStorePath,
|
cachedStorePath
|
||||||
"(STORE_PATH:",
|
|
||||||
STORE_PATH,
|
|
||||||
")"
|
|
||||||
)
|
)
|
||||||
return cachedStorePath
|
return cachedStorePath
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
diag("store", "getStorePath: failed to get store dir:", String(error))
|
diag(
|
||||||
|
"store",
|
||||||
|
`getStorePath(${staticPath}): failed to get store dir:`,
|
||||||
|
String(error)
|
||||||
|
)
|
||||||
console.error("Failed to get store directory:", error)
|
console.error("Failed to get store directory:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedStorePath = STORE_PATH
|
cachedStorePath = staticPath
|
||||||
diag("store", "getStorePath: using fallback STORE_PATH:", cachedStorePath)
|
diag(
|
||||||
|
"store",
|
||||||
|
`getStorePath(${staticPath}): using fallback path:`,
|
||||||
|
cachedStorePath
|
||||||
|
)
|
||||||
return cachedStorePath
|
return cachedStorePath
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Store = (() => {
|
|
||||||
const module = () => getModule("store")
|
const module = () => getModule("store")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -143,9 +167,9 @@ export const Store = (() => {
|
||||||
|
|
||||||
init: async () => {
|
init: async () => {
|
||||||
const storePath = await getStorePath()
|
const storePath = await getStorePath()
|
||||||
diag("store", "Store.init() called with path:", storePath)
|
diag("store", `Store.init(${staticPath}) called with path:`, storePath)
|
||||||
const result = await module().init(storePath)
|
const result = await module().init(storePath)
|
||||||
diag("store", "Store.init() completed for path:", storePath)
|
diag("store", `Store.init(${staticPath}) completed for path:`, storePath)
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -156,7 +180,11 @@ export const Store = (() => {
|
||||||
options?: StorageOptions
|
options?: StorageOptions
|
||||||
): Promise<E.Either<StoreError, void>> => {
|
): Promise<E.Either<StoreError, void>> => {
|
||||||
const storePath = await getStorePath()
|
const storePath = await getStorePath()
|
||||||
diag("store", `Store.set(${namespace}, ${key}) on path:`, storePath)
|
diag(
|
||||||
|
"store",
|
||||||
|
`Store.set(${namespace}, ${key}) on ${staticPath}:`,
|
||||||
|
storePath
|
||||||
|
)
|
||||||
return module().set(storePath, namespace, key, value, options)
|
return module().set(storePath, namespace, key, value, options)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -165,7 +193,11 @@ export const Store = (() => {
|
||||||
key: string
|
key: string
|
||||||
): Promise<E.Either<StoreError, T | undefined>> => {
|
): Promise<E.Either<StoreError, T | undefined>> => {
|
||||||
const storePath = await getStorePath()
|
const storePath = await getStorePath()
|
||||||
diag("store", `Store.get(${namespace}, ${key}) on path:`, storePath)
|
diag(
|
||||||
|
"store",
|
||||||
|
`Store.get(${namespace}, ${key}) on ${staticPath}:`,
|
||||||
|
storePath
|
||||||
|
)
|
||||||
const result = await module().get<T>(storePath, namespace, key)
|
const result = await module().get<T>(storePath, namespace, key)
|
||||||
if (E.isRight(result)) {
|
if (E.isRight(result)) {
|
||||||
const val = result.right
|
const val = result.right
|
||||||
|
|
@ -177,9 +209,16 @@ export const Store = (() => {
|
||||||
: typeof val === "object"
|
: typeof val === "object"
|
||||||
? `object(${Object.keys(val as Record<string, unknown>).length} keys)`
|
? `object(${Object.keys(val as Record<string, unknown>).length} keys)`
|
||||||
: typeof val
|
: typeof val
|
||||||
diag("store", `Store.get(${namespace}, ${key}) => Right(${shape})`)
|
diag(
|
||||||
|
"store",
|
||||||
|
`Store.get(${namespace}, ${key}) on ${staticPath} => Right(${shape})`
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
diag("store", `Store.get(${namespace}, ${key}) => Left:`, result.left)
|
diag(
|
||||||
|
"store",
|
||||||
|
`Store.get(${namespace}, ${key}) on ${staticPath} => Left:`,
|
||||||
|
result.left
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|
@ -230,4 +269,16 @@ export const Store = (() => {
|
||||||
return extendStore(module(), storePath, namespace)
|
return extendStore(module(), storePath, namespace)
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
})()
|
}
|
||||||
|
|
||||||
|
// Org-scoped store. Holds per-org state (auth tokens, collections,
|
||||||
|
// environments, settings that vary by organization). Default Store
|
||||||
|
// for almost every consumer in common.
|
||||||
|
export const Store = createScopedStore(HOST_SCOPED_STORE_PATH)
|
||||||
|
|
||||||
|
// Process-wide store shared across orgs. Holds machine-level state
|
||||||
|
// like desktop settings, the recent-instances list, and update state.
|
||||||
|
// Use this for anything that should persist regardless of which org
|
||||||
|
// the user is viewing, and for state that the desktop shell also
|
||||||
|
// reads or writes through its own kernel/store wrapper.
|
||||||
|
export const UnifiedStore = createScopedStore(UNIFIED_STORE_PATH)
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,47 @@ const migrations: Migration[] = [
|
||||||
// migration can prune it once the v2 definition has stabilized.
|
// migration can prune it once the v2 definition has stabilized.
|
||||||
version: 2,
|
version: 2,
|
||||||
migrate: async () => {
|
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>>(
|
const legacyResult = await Store.get<Partial<LegacyPortableSettings>>(
|
||||||
STORE_NAMESPACE,
|
STORE_NAMESPACE,
|
||||||
STORE_KEYS.PORTABLE_SETTINGS
|
STORE_KEYS.PORTABLE_SETTINGS
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue