1import * as Effect from "effect/Effect";
2import { AsyncResult, Atom } from "effect/reactivity";
3
4import * as MobilePreferences from "../persistence/mobile-preferences";
5import * as Runtime from "../lib/runtime";
6
7interface OptimisticPreferences {
8 readonly values: Partial<MobilePreferences.Preferences>;
9 readonly versions: Partial<Record<keyof MobilePreferences.Preferences, number>>;
10}
11
12const editListeners = new Set<(patch: Partial<MobilePreferences.Preferences>) => void>();
13export function subscribeMobilePreferenceEdits(
14 listener: (patch: Partial<MobilePreferences.Preferences>) => void,
15) {
16 editListeners.add(listener);
17 return () => {
18 editListeners.delete(listener);
19 };
20}
21
22// A bare function updates the command result; preference transforms need a payload.
23type PreferencesUpdate =
24 | { readonly patch: Partial<MobilePreferences.Preferences>; readonly source: "sync" }
25 | Partial<MobilePreferences.Preferences>
26 | {
27 readonly transform: (
28 current: MobilePreferences.Preferences,
29 ) => Partial<MobilePreferences.Preferences>;
30 };
31
32/**
33 * Owns the device preference blob for the lifetime of the app registry.
34 * Optimistic patches are kept separately so writes made while persistence is
35 * still loading cannot be replaced by the eventual read result.
36 */
37export function createMobilePreferencesState(
38 runtime: Atom.AtomRuntime<MobilePreferences.MobilePreferencesStore>,
39) {
40 const storedPreferencesAtom = runtime
41 .atom(
42 MobilePreferences.MobilePreferencesStore.pipe(
43 Effect.flatMap((store) => store.load),
44 Effect.catch((error) =>
45 Effect.logWarning("Could not load mobile preferences.", error).pipe(
46 Effect.as<MobilePreferences.Preferences>({}),
47 ),
48 ),
49 ),
50 )
51 .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:stored"));
52
53 const optimisticPatchAtom = Atom.make<OptimisticPreferences>({ values: {}, versions: {} }).pipe(
54 Atom.keepAlive,
55 Atom.withLabel("mobile:preferences:optimistic-patch"),
56 );
57 const confirmedPreferencesAtom = Atom.make<MobilePreferences.Preferences>({}).pipe(
58 Atom.keepAlive,
59 Atom.withLabel("mobile:preferences:confirmed"),
60 );
61 let nextPatchVersion = 0;
62
63 const preferencesAtom = Atom.make((get) => {
64 const stored = get(storedPreferencesAtom);
65 const confirmed = get(confirmedPreferencesAtom);
66 const optimistic = get(optimisticPatchAtom);
67 return AsyncResult.map(stored, (preferences) => ({
68 ...preferences,
69 ...confirmed,
70 ...optimistic.values,
71 }));
72 }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences"));
73
74 const updatePreferencesAtom = runtime
75 .fn(
76 (update: PreferencesUpdate, get) => {
77 const currentPreferences = get(preferencesAtom);
78 const patch =
79 "source" in update
80 ? update.patch
81 : "transform" in update
82 ? update.transform(
83 AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {},
84 )
85 : update;
86 if (!("source" in update)) for (const listener of editListeners) listener(patch);
87 const version = ++nextPatchVersion;
88 const current = get(optimisticPatchAtom);
89 const versions = { ...current.versions };
90 for (const key of Object.keys(patch) as Array<keyof MobilePreferences.Preferences>) {
91 versions[key] = version;
92 }
93 get.set(optimisticPatchAtom, {
94 values: { ...current.values, ...patch },
95 versions,
96 });
97 return MobilePreferences.MobilePreferencesStore.pipe(
98 Effect.flatMap((store) =>
99 "transform" in update ? store.update(update.transform) : store.savePatch(patch),
100 ),
101 Effect.tap((saved) =>
102 Effect.sync(() => {
103 get.set(confirmedPreferencesAtom, saved);
104 const optimistic = get(optimisticPatchAtom);
105 const values = { ...optimistic.values } as Record<string, unknown>;
106 const currentVersions = { ...optimistic.versions } as Record<string, unknown>;
107 for (const key of Object.keys(patch) as Array<keyof MobilePreferences.Preferences>) {
108 if (optimistic.versions[key] === version) {
109 delete values[key];
110 delete currentVersions[key];
111 }
112 }
113 get.set(optimisticPatchAtom, {
114 values: values as Partial<MobilePreferences.Preferences>,
115 versions: currentVersions as Partial<
116 Record<keyof MobilePreferences.Preferences, number>
117 >,
118 });
119 }),
120 ),
121 Effect.tapError(() =>
122 Effect.sync(() => {
123 const optimistic = get(optimisticPatchAtom);
124 const values = { ...optimistic.values } as Record<string, unknown>;
125 const currentVersions = { ...optimistic.versions } as Record<string, unknown>;
126 for (const key of Object.keys(patch) as Array<keyof MobilePreferences.Preferences>) {
127 if (optimistic.versions[key] === version) {
128 delete values[key];
129 delete currentVersions[key];
130 }
131 }
132 get.set(optimisticPatchAtom, {
133 values: values as Partial<MobilePreferences.Preferences>,
134 versions: currentVersions as Partial<
135 Record<keyof MobilePreferences.Preferences, number>
136 >,
137 });
138 }),
139 ),
140 );
141 },
142 // The storage layer serializes preference read-modify-write operations.
143 // Keep every invocation alive so one preference update cannot interrupt
144 // another update to a different field in the shared blob.
145 { concurrent: true },
146 )
147 .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:update"));
148
149 return { preferencesAtom, updatePreferencesAtom } as const;
150}
151
152const mobilePreferencesRuntime = Atom.runtime(Runtime.layer);
153export const mobilePreferencesState = createMobilePreferencesState(mobilePreferencesRuntime);
154
155export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom;
156export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom;