import * as Effect from "effect/Effect"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; import * as Runtime from "../lib/runtime"; export { MobilePreferencesLoadError, MobilePreferencesSaveError, MobilePreferencesStore, } from "../persistence/mobile-preferences"; interface OptimisticPreferences { readonly values: Partial; readonly versions: Partial>; } /** * Owns the device preference blob for the lifetime of the app registry. * Optimistic patches are kept separately so writes made while persistence is * still loading cannot be replaced by the eventual read result. */ export function createMobilePreferencesState(runtime: Atom.AtomRuntime) { const storedPreferencesAtom = runtime .atom( MobilePreferencesStore.pipe( Effect.flatMap((store) => store.load), Effect.catch((error) => Effect.logWarning("Could not load mobile preferences.", error).pipe( Effect.as({}), ), ), ), ) .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:stored")); const optimisticPatchAtom = Atom.make({ values: {}, versions: {} }).pipe( Atom.keepAlive, Atom.withLabel("mobile:preferences:optimistic-patch"), ); const confirmedPreferencesAtom = Atom.make({}).pipe( Atom.keepAlive, Atom.withLabel("mobile:preferences:confirmed"), ); let nextPatchVersion = 0; const preferencesAtom = Atom.make((get) => { const stored = get(storedPreferencesAtom); const confirmed = get(confirmedPreferencesAtom); const optimistic = get(optimisticPatchAtom); return AsyncResult.map(stored, (preferences) => ({ ...preferences, ...confirmed, ...optimistic.values, })); }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences")); const updatePreferencesAtom = runtime .fn( (patch: Partial, get) => { const version = ++nextPatchVersion; const current = get(optimisticPatchAtom); const versions = { ...current.versions }; for (const key of Object.keys(patch) as Array) { versions[key] = version; } get.set(optimisticPatchAtom, { values: { ...current.values, ...patch }, versions, }); return MobilePreferencesStore.pipe( Effect.flatMap((store) => store.savePatch(patch)), Effect.tap((saved) => Effect.sync(() => { get.set(confirmedPreferencesAtom, saved); const optimistic = get(optimisticPatchAtom); const values = { ...optimistic.values } as Record; const currentVersions = { ...optimistic.versions } as Record; for (const key of Object.keys(patch) as Array) { if (optimistic.versions[key] === version) { delete values[key]; delete currentVersions[key]; } } get.set(optimisticPatchAtom, { values: values as Partial, versions: currentVersions as Partial>, }); }), ), Effect.tapError(() => Effect.sync(() => { const optimistic = get(optimisticPatchAtom); const values = { ...optimistic.values } as Record; const currentVersions = { ...optimistic.versions } as Record; for (const key of Object.keys(patch) as Array) { if (optimistic.versions[key] === version) { delete values[key]; delete currentVersions[key]; } } get.set(optimisticPatchAtom, { values: values as Partial, versions: currentVersions as Partial>, }); }), ), ); }, // The storage layer serializes preference read-modify-write operations. // Keep every invocation alive so one preference update cannot interrupt // another update to a different field in the shared blob. { concurrent: true }, ) .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:update")); return { preferencesAtom, updatePreferencesAtom } as const; } const mobilePreferencesRuntime = Atom.runtime(Runtime.runtimeContextLayer); export const mobilePreferencesState = createMobilePreferencesState(mobilePreferencesRuntime); export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom;