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 { 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 {
|
||||
DESKTOP_SETTINGS_SCHEMA,
|
||||
DESKTOP_SETTINGS_STORE_KEY,
|
||||
|
|
@ -52,6 +58,16 @@ const loaded = ref(false)
|
|||
let initPromise: Promise<void> | undefined
|
||||
|
||||
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>(
|
||||
DESKTOP_SETTINGS_STORE_NAMESPACE,
|
||||
DESKTOP_SETTINGS_STORE_KEY
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import * as E from "fp-ts/Either"
|
|||
import { invoke } from "@tauri-apps/api/core"
|
||||
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 {
|
||||
UPDATE_STATE_SCHEMA,
|
||||
UPDATE_STATE_STORE_KEY,
|
||||
|
|
@ -251,6 +256,16 @@ function nextState(current: UpdateState, event: UpdateEvent): UpdateState {
|
|||
}
|
||||
|
||||
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>(
|
||||
UPDATE_STATE_STORE_NAMESPACE,
|
||||
UPDATE_STATE_STORE_KEY
|
||||
|
|
|
|||
|
|
@ -25,21 +25,29 @@ import { diag } from "./log"
|
|||
// a beforeEach guard in modules/router.ts, and survives full-page reloads
|
||||
// because Tauri sets it on the initial webview URL
|
||||
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`
|
||||
: `${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", "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.href:", window.location.href)
|
||||
|
||||
let cachedStorePath: string | undefined
|
||||
|
||||
// These are only defined functions if in desktop mode.
|
||||
// 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.
|
||||
// Lazy-loaded Tauri APIs. Module-scoped so every scoped store shares
|
||||
// the init step and the loaded modules. Web mode never resolves these
|
||||
// because `isInitd` returns early outside desktop.
|
||||
let invoke:
|
||||
| (<T>(cmd: string, args?: Record<string, unknown>) => Promise<T>)
|
||||
| undefined
|
||||
|
|
@ -102,9 +110,21 @@ export const getLogsDir = async (): Promise<string> => {
|
|||
return await invoke<string>("get_logs_dir")
|
||||
}
|
||||
|
||||
const getStorePath = async (): Promise<string> => {
|
||||
// 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> => {
|
||||
if (cachedStorePath) {
|
||||
diag("store", "getStorePath: returning cached:", cachedStorePath)
|
||||
diag(
|
||||
"store",
|
||||
`getStorePath(${staticPath}): returning cached:`,
|
||||
cachedStorePath
|
||||
)
|
||||
return cachedStorePath
|
||||
}
|
||||
|
||||
|
|
@ -113,29 +133,33 @@ const getStorePath = async (): Promise<string> => {
|
|||
if (join) {
|
||||
try {
|
||||
const storeDir = await getStoreDir()
|
||||
cachedStorePath = await join(storeDir, STORE_PATH)
|
||||
cachedStorePath = await join(storeDir, staticPath)
|
||||
diag(
|
||||
"store",
|
||||
"getStorePath: resolved desktop path:",
|
||||
cachedStorePath,
|
||||
"(STORE_PATH:",
|
||||
STORE_PATH,
|
||||
")"
|
||||
`getStorePath(${staticPath}): resolved desktop path:`,
|
||||
cachedStorePath
|
||||
)
|
||||
return cachedStorePath
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedStorePath = STORE_PATH
|
||||
diag("store", "getStorePath: using fallback STORE_PATH:", cachedStorePath)
|
||||
cachedStorePath = staticPath
|
||||
diag(
|
||||
"store",
|
||||
`getStorePath(${staticPath}): using fallback path:`,
|
||||
cachedStorePath
|
||||
)
|
||||
return cachedStorePath
|
||||
}
|
||||
}
|
||||
|
||||
export const Store = (() => {
|
||||
const module = () => getModule("store")
|
||||
|
||||
return {
|
||||
|
|
@ -143,9 +167,9 @@ export const Store = (() => {
|
|||
|
||||
init: async () => {
|
||||
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)
|
||||
diag("store", "Store.init() completed for path:", storePath)
|
||||
diag("store", `Store.init(${staticPath}) completed for path:`, storePath)
|
||||
return result
|
||||
},
|
||||
|
||||
|
|
@ -156,7 +180,11 @@ export const Store = (() => {
|
|||
options?: StorageOptions
|
||||
): Promise<E.Either<StoreError, void>> => {
|
||||
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)
|
||||
},
|
||||
|
||||
|
|
@ -165,7 +193,11 @@ export const Store = (() => {
|
|||
key: string
|
||||
): Promise<E.Either<StoreError, T | undefined>> => {
|
||||
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)
|
||||
if (E.isRight(result)) {
|
||||
const val = result.right
|
||||
|
|
@ -177,9 +209,16 @@ export const Store = (() => {
|
|||
: typeof val === "object"
|
||||
? `object(${Object.keys(val as Record<string, unknown>).length} keys)`
|
||||
: typeof val
|
||||
diag("store", `Store.get(${namespace}, ${key}) => Right(${shape})`)
|
||||
diag(
|
||||
"store",
|
||||
`Store.get(${namespace}, ${key}) on ${staticPath} => Right(${shape})`
|
||||
)
|
||||
} else {
|
||||
diag("store", `Store.get(${namespace}, ${key}) => Left:`, result.left)
|
||||
diag(
|
||||
"store",
|
||||
`Store.get(${namespace}, ${key}) on ${staticPath} => Left:`,
|
||||
result.left
|
||||
)
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
|
@ -230,4 +269,16 @@ export const Store = (() => {
|
|||
return extendStore(module(), storePath, namespace)
|
||||
},
|
||||
} 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.
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue